diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 21e4f49dab..886a0dfe3e 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -5,7 +5,12 @@ Amethyst is a Nostr Client for Android that was made for Android-only and has been slowly switching over to a Kotlin Multiplatform project. The main modules are: `quartz`, `commons`, `commonsUI`, `amethyst`, `desktopApp`, `cli`, plus the audio-rooms transport stack `quic` + `nestsClient`. Quartz should -contain implementations of Nostr specifications and utilities to help implement them. Commons stores +contain implementations of Nostr specifications and utilities to help implement them — NIPs under +`nipXX` packages, and whole non-NIP protocol families beside them: `marmot/` (MLS over Nostr, +`mipXX`), `cordn/` (MLS over an MCP coordinator, `specXX`), `contextvm/` (MCP over Nostr, `cepXX`), +`concord/` (`cordXX`), `buzz/`, plus the binding-agnostic RFC 9420 engine in `mls/`. A new protocol +over Nostr belongs here as a package, not as a Gradle module; `quic`/`nestsClient`/`marmotQuic` are +modules because they are transports with no Nostr in them. Commons stores shared code between Amethyst Android (`amethyst`) and Amethyst Desktop (`desktopApp`). The Desktop App is designed to be mouse first and so uses a completely different screen and navigation architecture while sharing the back end components with the android counterpart. `cli` ships `amy`, @@ -280,6 +285,48 @@ Quartz uses expect/actual for platform-specific implementations (e.g. crypto backed by `secp256k1-kmp-jni-android` on Android and `secp256k1-kmp-jni-jvm` on JVM). See `/kotlin-multiplatform` for the expect/actual and source-set patterns. +## Strings + +**MANDATORY: every new user-visible string goes in `commonsUI` Compose resources**, +`commonsUI/src/commonMain/composeResources/values/strings.xml` — **never** in +`amethyst/src/main/res/values/strings.xml`, even for an Android-only screen. A +screen cannot move to `commonsUI` (or be rendered by Desktop) while its labels +live in Android `res/`, and every key added there has to be migrated back out +later. (The 2026-09-22 move emptied Android `res/` down to ~190 keys; the next two +features put 447 back.) + +- Reference it as `Res.string.my_key` / `Res.plurals.my_key`, importing + `com.vitorpamplona.amethyst.commons.resources.Res` **and** the per-key accessor + `com.vitorpamplona.amethyst.commons.resources.my_key` (Compose resources generates + each key as an extension property). +- Read it with `stringRes(Res.string.x)` / `pluralStringRes(Res.plurals.x, n, …)` + in composition — the app-side `ui.stringRes` overloads delegate to the + commonsUI bridge (`commons/ui/StringRes.kt`), so one import serves both kinds. +- Outside composition there is **no blocking accessor**: use `loadStringRes(…)` + from a coroutine (or make the function `suspend`), or resolve the label in + composition and pass it down as a `String`. A `runBlocking` bridge is ruled out. +- Hold a string reference as `StringResource` / `PluralStringResource`, not a + `@StringRes Int`. +- Format arguments must be positional (`%1$s`, `%2$d`) — compose-resources does + not format bare `%s`/`%d`. +- Escaping is not Android's: write `'` and `"` bare (no `\'`), and never wrap a + value in quotes to keep its whitespace. `.claude/hooks/compose_escaping_check.py` + checks this before a push. + +The **only** strings that stay in Android `res/` are the ones a platform API +reads synchronously under a deadline, where a `suspend` read cannot run: a +foreground service's `startForeground` notification, a notification channel +created during service startup, PiP `RemoteAction`s, napplet capability labels +read in the sandbox process, and `AndroidManifest.xml` / `res/xml` references. +Most of those are *copies* of a key that also lives in `commonsUI`. See +`commons/plans/2026-09-22-strings-to-compose-resources.md` ("the +synchronous-platform tier"). If you think a new string belongs in that tier, +say why in the PR. + +To move keys that already landed in Android `res/`, use +`tools/strings-migrate/migrate.py `. It moves every locale byte-for-byte, +so Crowdin sees a pure move. Then repoint `R.string.x` → `Res.string.x`. + ## Icons The Material Symbols font bundled at diff --git a/.claude/hooks/compose_escaping_check.py b/.claude/hooks/compose_escaping_check.py index cd9b054eef..b5721481ae 100755 --- a/.claude/hooks/compose_escaping_check.py +++ b/.claude/hooks/compose_escaping_check.py @@ -27,6 +27,9 @@ Repair with: idempotent, quote-unwrapping is not, and a second unwrap strips the real display quotes from values like `import_follows_tips`. +Raw line breaks and tabs inside a value are the whitespace half of the same +mismatch: aapt collapsed them, Compose renders them (the same repair fixes them). + Only the Compose catalog is scanned. In an Android res tree the same escaping is correct and must be left alone. """ @@ -41,6 +44,10 @@ CATALOG = "*/src/*/composeResources/values*/strings.xml" # Compose resolves those itself. ANDROID_ESCAPE = re.compile(r"(?]*>(.*?)", re.S) +LAYOUT_WS = re.compile(r"[\r\n\t]") def find_violations(root: Path): @@ -52,6 +59,13 @@ def find_violations(root: Path): n = len(TOOLS_ATTR.findall(text)) if n: found[path]["tools: attribute"] += n + n = sum( + 1 + for m in STRING_TEXT.finditer(text) + if "` derived from the note's metadata flow (decrypted through a per-feature `DecryptionCache`, with backup fallback from `AccountSettings`, `stateIn(scope, Eagerly, …)`), and offers suspend mutation helpers (e.g. `MuteListState.hideUser(pubkey)`) that build the updated signed event. Consumers read `account.muteList.flow`, never a raw `MutableStateFlow` on `Account`. -- Encrypted lists pair the state object with a `DecryptionCache` sibling (`muteListDecryptionCache`, `peopleListDecryptionCache`, …) so NIP-44 decryption results are cached per event. +- Encrypted lists pair the state object with a `DecryptionCache` sibling (`muteListDecryptionCache`, `followSetDecryptionCache`, …) so NIP-44 decryption results are cached per event. - UI reads via `collectAsStateWithLifecycle` on Android and `collectAsState` on Desktop. - Sibling files per feature live alongside: `AccountSettings.kt`, `AccountSyncedSettings.kt`, plus per-NIP state classes under `model/nip02FollowLists/`, `model/nip51Lists/`, `model/nip65RelayList/`, etc. diff --git a/.claude/skills/account-state/references/account-state-flow.md b/.claude/skills/account-state/references/account-state-flow.md index d7e37c5524..73a56cca1b 100644 --- a/.claude/skills/account-state/references/account-state-flow.md +++ b/.claude/skills/account-state/references/account-state-flow.md @@ -16,8 +16,8 @@ been renamed, grep `Account.kt` for the class name.) | `userMetadata` | `UserMetadataState` | 0 | `amethyst/.../model/nip01UserMetadata/` | | `kind3FollowList` | `Kind3FollowListState` | 3 | `model/nip02FollowLists/` | | `muteList` (+ `muteListDecryptionCache`) | `MuteListState` | 10000 | `model/nip51Lists/muteList/` | -| `blockPeopleList`, `peopleLists` | `BlockPeopleListState`, `PeopleListsState` | NIP-51 people sets | `model/nip51Lists/peopleList/` | -| `followLists` | `FollowListsState` | NIP-51 follow sets | `model/nip51Lists/peopleList/` | +| `blockPeopleList`, `peopleLists` | `BlockPeopleListState`, `FollowSetsState` | NIP-51 people sets | `model/nip51Lists/followSets/` | +| `followLists` | `StarterPacksState` | NIP-51 follow sets | `model/nip51Lists/followSets/` | | `hiddenUsers` | `HiddenUsersState` — derived from `muteList.flow` + `blockPeopleList.flow` | — | `model/nip51Lists/` | | `allFollows` | `MergedFollowListsState` — merges kind3 + people/follow/hashtag/geohash/community lists | — | `model/serverList/` | @@ -31,7 +31,7 @@ been renamed, grep `Account.kt` for the class name.) | `blockedRelayList` | `BlockedRelayListState` | 10006 | `model/nip51Lists/blockedRelays/` | | `localRelayList` | `LocalRelayListState` | local | `model/localRelays/` | | `privateStorageRelayList` | `PrivateStorageRelayListState` | private storage | `model/edits/` | -| `keyPackageRelayList`, `trustedRelayList`, `proxyRelayList`, `broadcastRelayList`, `indexerRelayList`, `relayFeedsList` | per-feature `…RelayListState` classes, each with a `DecryptionCache` sibling | custom relay sets | `model/nip51Lists/…` | +| `keyPackageRelayList`, `trustedRelayList`, `proxyRelayList`, `broadcastRelayList`, `indexerRelayList`, `favoriteRelayList` | per-feature `…RelayListState` classes, each with a `DecryptionCache` sibling | custom relay sets | `model/nip51Lists/…` | Derived relay views (merge several of the above): `homeRelays` (`AccountHomeRelayState`), `outboxRelays`, `dmRelays`, `notificationRelays`, @@ -43,10 +43,10 @@ Derived relay views (merge several of the above): `homeRelays` | Account property | State class | Kind | Package | |------------------|-------------|------|---------| | `bookmarkState` (and legacy `oldBookmarkState`) | `BookmarkListState` | 10003 | `model/nip51Lists/` | -| `labeledBookmarkLists` | `LabeledBookmarkListsState` | NIP-51 bookmark sets | `model/nip51Lists/labeledBookmarkLists/` | +| `bookmarkSets` | `BookmarkSetsState` | NIP-51 bookmark sets | `model/nip51Lists/bookmarkSets/` | | `pinState` | `PinListState` | NIP-51 | `model/nip51Lists/` | | `interestSets` | `InterestSetsState` | NIP-51 interest sets | `model/nip51Lists/interestSets/` | -| `hashtagList` / `geohashList` | `HashtagListState` / `GeohashListState` | NIP-51 | `model/nip51Lists/hashtagLists/`, `…/geohashLists/` | +| `hashtagList` / `geohashList` | `InterestListState` / `GeohashListState` | NIP-51 | `model/nip51Lists/interestLists/`, `…/geohashLists/` | | `communityList` | `CommunityListState` | NIP-72 communities | `model/nip72Communities/` | | `favoriteAlgoFeedsList` | `FavoriteAlgoFeedsListState` | NIP-51 | `model/nip51Lists/` | | `emoji`, `ownedEmojiPacks` | `EmojiPackState`, `OwnedEmojiPacksState` | 10030 | `commons/.../commons/model/nip30CustomEmojis/` | diff --git a/.claude/skills/android-expert/SKILL.md b/.claude/skills/android-expert/SKILL.md index e2483221d0..19b79ec6a1 100644 --- a/.claude/skills/android-expert/SKILL.md +++ b/.claude/skills/android-expert/SKILL.md @@ -875,18 +875,33 @@ fun ProfileScreen( ### 3. Resource Access +**Strings do not go in `amethyst/src/main/res`.** New user-visible strings live in +`commonsUI/src/commonMain/composeResources/values/strings.xml` and are read as +`Res.string.x`, even on an Android-only screen. See "Strings" in `.claude/CLAUDE.md` +for the rule and the small platform tier that is the only exception. + ```kotlin +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.button_clicked +import com.vitorpamplona.amethyst.commons.resources.button_label +import com.vitorpamplona.amethyst.commons.ui.loadStringRes +import com.vitorpamplona.amethyst.ui.stringRes + @Composable fun LocalizedButton() { val context = LocalContext.current + val scope = rememberCoroutineScope() Button( onClick = { - val message = context.getString(R.string.button_clicked) - Toast.makeText(context, message, Toast.LENGTH_SHORT).show() + // No blocking accessor outside composition: read it from a coroutine, + // or resolve it in composition and capture the String. + scope.launch { + Toast.makeText(context, loadStringRes(Res.string.button_clicked), Toast.LENGTH_SHORT).show() + } } ) { - Text(stringResource(R.string.button_label)) + Text(stringRes(Res.string.button_label)) } } ``` diff --git a/.claude/skills/auth-signers/SKILL.md b/.claude/skills/auth-signers/SKILL.md index 8b11a52d1b..0d32eedf8c 100644 --- a/.claude/skills/auth-signers/SKILL.md +++ b/.claude/skills/auth-signers/SKILL.md @@ -29,7 +29,7 @@ abstract class NostrSigner(val pubKey: HexKey) { abstract fun nip04Decrypt(ciphertext: String, fromPubKey: HexKey, onReady: (String) -> Unit) abstract fun nip44Encrypt(...) abstract fun nip44Decrypt(...) - abstract fun decryptZapEvent(event: LnZapRequestEvent, onReady: (LnZapRequestEvent) -> Unit) + abstract fun decryptZapEvent(event: ZapRequestEvent, onReady: (ZapRequestEvent) -> Unit) } ``` diff --git a/.claude/skills/event-store-semantics/SKILL.md b/.claude/skills/event-store-semantics/SKILL.md index 57a9e9a7d0..a98f901693 100644 --- a/.claude/skills/event-store-semantics/SKILL.md +++ b/.claude/skills/event-store-semantics/SKILL.md @@ -37,7 +37,7 @@ Executable spec: the test suites in `quartz/src/commonTest/.../store/sqlite/` (`BasicTest`, `ReplaceableTest`, `AddressableTest`, `DeletionTest`, `ExpirationTest`, `RightToVanishTest`, `SearchTest`, `SearchRelevanceOrderTest`, `MergeQueryCorrectnessTest`, `TagMergeCorrectnessTest`, `QueryAssemblerTest`, -`SnapshotIdsForNegentropyTest`, `FilterMatcherTest`, …). If a rule here ever contradicts a test, +`SnapshotIdsForNegentropyTest`, `FilterMatcherTest`, `InsertOutcomeClassificationTest`, …). If a rule here ever contradicts a test, the test wins — and this file has a bug to fix. ## Kind classes (used throughout) @@ -189,6 +189,21 @@ back alone and reports `Rejected(reason)`; the rest commit. If the **outer commi entry is treated as `Rejected` (the `IEventStore.batchInsert` contract). Outcomes are returned in input order; OK frames pair by event id, not order. +**STORE-W09 — a failed row is classified against the database, not against the driver's +exception text.** `SQLiteEventStore.classifyRowError` rolls the row's savepoint back and then +asks the connection (which now shows pre-insert state): id already present → `DUPLICATE`; +a stored version that beats this one at the replaceable/addressable coordinate (the exact +complement of the supersession predicate in W01/W02) → `SUPERSEDED`; otherwise `Failed`. +Message text is only a fast path and a fallback for trigger RAISEs (`blocked:`, `not allowed`), +which leave no database-visible trace. This matters because the message is driver-specific — +the bundled JVM driver writes `UNIQUE constraint failed: event_headers.id`, Android's throws an +`android.database.SQLException` with a **null** message — so a text-only classifier answered +`OK false` on Android for events the store already held. Corollary: re-offering a stored +replaceable/addressable event **byte-for-byte** is `DUPLICATE`, not `SUPERSEDED` (it violates +both indexes and only the id answer is driver-independent); a stale *different* version is +still `SUPERSEDED`. Both carry the `duplicate:` prefix, so the relay reply is `OK true` either +way. + --- ## Deletion lifecycle — NIP-09 / NIP-62 (STORE-D) @@ -323,6 +338,11 @@ non-itemizable cases). Add one line per behavior change, newest first: `YYYY-MM-DD — what changed`. +- 2026-09-18 (pending) W09, W01/W02 — insert-failure classification now queries the database + instead of parsing the driver's exception message (Android's is null, so duplicates were + reported as `Failed`/`OK false`). A byte-for-byte re-offer of a stored replaceable/addressable + event now reports `DUPLICATE` where the JVM driver previously reported `SUPERSEDED`; both are + `duplicate:` → `OK true`, so the wire answer is unchanged. - 2026-08-04 (baseline) — rules F01–F13, W01–W08, D01–D08, C01, S01–S06, N01 written from the code at the time this skill was introduced. Changes before this date are not itemized; archaeology starts at `git log` on `nip01Core/store/`. diff --git a/.claude/skills/find-missing-translations/SKILL.md b/.claude/skills/find-missing-translations/SKILL.md index b41533cf3e..7547c5c46d 100644 --- a/.claude/skills/find-missing-translations/SKILL.md +++ b/.claude/skills/find-missing-translations/SKILL.md @@ -26,6 +26,13 @@ There are two separate `strings.xml` trees, each with its own default `values/` | **amethyst** (Android app) | `amethyst/src/main/res/values/strings.xml` | `amethyst/src/main/res/values-/strings.xml` | | **commonsUI** (KMP Compose resources, shared by Android + Desktop) | `commonsUI/src/commonMain/composeResources/values/strings.xml` | `commonsUI/src/commonMain/composeResources/values-/strings.xml` | +**New keys go in the `commonsUI` tree, always.** The amethyst tree is frozen at +the small synchronous-platform tier (foreground-service and notification-channel +text, PiP actions, napplet capability labels, manifest references). If you find +a new feature key in it, it belongs in `commonsUI`. Move it with +`tools/strings-migrate/migrate.py` rather than translating it where it is. See +"Strings" in `.claude/CLAUDE.md`. + The `commonsUI` tree appeared when shared event-renderer composables were extracted out of `amethyst/` into `commons/` — now `commonsUI/` since the UI split (Compose Multiplatform `stringResource`). It is **not** a copy of the amethyst tree — the vast majority of its keys are commons-only; only a small handful overlap. Every diff/count/translate command below works on either tree by swapping the base path — **run the whole technique once per tree** and report them separately (each maps to its own Crowdin file, so the counts should reconcile against two different Crowdin UI numbers). **Locale-qualifier caveat:** `commons` uses the same region-qualified locale dirs as amethyst for our four targets (`values-cs`, `values-de-rDE`, `values-sv-rSE`, `values-pt-rBR`), but the *full* set of locale dirs differs between trees. Enumerate `values-*` under each tree's own base rather than assuming they match. diff --git a/.claude/skills/nip85-trusted-assertions/SKILL.md b/.claude/skills/nip85-trusted-assertions/SKILL.md index b0b17f22ea..935ea8e3d4 100644 --- a/.claude/skills/nip85-trusted-assertions/SKILL.md +++ b/.claude/skills/nip85-trusted-assertions/SKILL.md @@ -26,16 +26,16 @@ event signatures; the 10040→assertion link is **consumer-side convention** (se | Kind | Class | Kind class | d-tag = the subject | Content | |---|---|---|---|---| | 10040 | `list/TrustProviderListEvent` | replaceable | *(none — always `""`)* | NIP-44 private provider entries (optional) | -| 30382 | `users/ContactCardEvent` | addressable | **target user's pubkey** (hex) | NIP-44 private tags (petname/summary/emoji) | +| 30382 | `users/UserAssertionEvent` | addressable | **target user's pubkey** (hex) | NIP-44 private tags (petname/summary/emoji) | | 30383 | `events/EventAssertionEvent` | addressable | **target event id** (hex) | `""` | | 30384 | `addressables/AddressableAssertionEvent` | addressable | **target coordinate** `kind:pubkey:dtag` | `""` | | 30385 | `externalIds/ExternalIdAssertionEvent` | addressable | **external identifier** (e.g. `isbn:978-0-13-468599-1`) | `""` | -Addresses: `ContactCardEvent.createAddress(owner, target)` → `Address(30382, owner, target)` +Addresses: `UserAssertionEvent.createAddress(owner, target)` → `Address(30382, owner, target)` (owner = signer, target = subject). `TrustProviderListEvent.createAddress(pubKey)` uses `FIXED_D_TAG = ""`. `AssertionEventTest.eventKindsAreCorrect` pins all five numbers. -`ContactCardEvent` is also a `SearchableEvent` — it indexes only the **public** petname/summary +`UserAssertionEvent` is also a `SearchableEvent` — it indexes only the **public** petname/summary tags plus topics; the encrypted card content is intentionally never indexed. ## The 10040 provider entry (`ServiceProviderTag` / `ServiceType`) @@ -91,9 +91,9 @@ The same kind serves two roles, distinguished **by author**: `followers`, `hops`, …); this is what 10040 discovery points at. 2. **The account's own contact cards (nicknames, NIP-81-style)** — signed by the account, one per target user. The petname, summary, and their NIP-30 emoji mappings **always live in - the NIP-44 encrypted content, never in public tags** (`ContactCardEvent.build`/ - `updatePetNameAndSummary` strip stray public copies; asserted by `ContactCardPetNameTest`). - `commons/.../ContactCardsState.kt` keys everything on `author == account` and ignores + the NIP-44 encrypted content, never in public tags** (`UserAssertionEvent.build`/ + `updatePetNameAndSummary` strip stray public copies; asserted by `UserAssertionPetNameTest`). + `commons/.../UserAssertionsState.kt` keys everything on `author == account` and ignores provider cards. ## Tag vocabulary and value semantics @@ -104,7 +104,7 @@ value — a bad tag is *dropped*, never an error) + `assemble(value)` → `[name validation (rank isn't clamped, hours aren't checked against 0–23, counts may be negative) — consumers must defend. -**On 30382** (`users/tags/`, accessors on `ContactCardEvent` and as `TagArray` extensions in +**On 30382** (`users/tags/`, accessors on `UserAssertionEvent` and as `TagArray` extensions in `users/TagArrayExt.kt` so they also work on decrypted private arrays): | Tag name | Accessor | Type | Semantics | @@ -138,7 +138,7 @@ val providers: List = updated.serviceProviders() // val private = updated.privateTags(signer)?.serviceProviders() // private side // Provider-style contact card (public metrics) — the GrapeRankPublisher pattern: -val card = ContactCardEvent.create( +val card = UserAssertionEvent.create( targetUser = subjectPubkey, signer = providerSigner, publicInitializer = { @@ -225,8 +225,8 @@ card's `a`-tag (`30382::`). - **Publisher**: `quartz/.../experimental/graperank/GrapeRankPublisher.kt` (canonical 30382 writer), `cli/.../graperank/` (`amy graperank register|unregister|providers|publish`). -- **Client model**: `commons/.../model/nip85TrustedAssertions/` (`ContactCardsState`, - `UserCardsCache`, `ContactCardDecryptionCache`, `TrustProviderListDecryptionCache`), +- **Client model**: `commons/.../model/nip85TrustedAssertions/` (`UserAssertionsState`, + `UserCardsCache`, `UserAssertionDecryptionCache`, `TrustProviderListDecryptionCache`), `amethyst/.../model/trustedAssertions/TrustProviderListState.kt`. - **Relay plumbing**: `commons/.../relayClient/assemblers/ContactCardFilters.kt`, `amethyst/.../reqCommand/user/watchers/UserCardsSubAssembler.kt`. diff --git a/.claude/skills/nostr-expert/references/event-hierarchy.md b/.claude/skills/nostr-expert/references/event-hierarchy.md index 1ca0c0fa1c..a2c288c9de 100644 --- a/.claude/skills/nostr-expert/references/event-hierarchy.md +++ b/.claude/skills/nostr-expert/references/event-hierarchy.md @@ -79,30 +79,38 @@ companion object { ### Zap Request/Receipt (kinds 9734, 9735) ```kotlin -class LnZapRequestEvent(...) : Event(...) +class ZapRequestEvent(...) : Event(...) // Created by client, sent to Lightning Address -class LnZapEvent(...) : Event(...) +class ZapReceiptEvent(...) : Event(...) // Receipt from LSP, contains bolt11 + embedded zap request - val zapRequest: LnZapRequestEvent? by lazy { containedPost() } + val zapRequest: ZapRequestEvent? by lazy { containedPost() } val amount: BigDecimal? by lazy { /* parse from bolt11 */ } ``` ### Long-Form Content (kind 30023) ```kotlin -class LongTextNoteEvent(...) : BaseAddressableEvent(...) +class LongFormContentEvent(...) : BaseAddressableEvent(...) // Blog posts, articles // Addressable via kind:pubkey:d-tag ``` -### Lists (kinds 10000-30004) +### Lists (NIP-51) +Each list kind is its own class under `nip51Lists/`, not a subtype of a shared list event. +Lists with private (NIP-44 encrypted) items extend one of two bases: ```kotlin -sealed class PeopleListEvent : BaseAddressableEvent { - object MuteList : PeopleListEvent(10000) - object PinList : PeopleListEvent(10001) - object BookmarkList : PeopleListEvent(10003) - // ... 18 list types total -} +// Base for sets (kind 30000-39999); some replaceable lists use it too (MuteListEvent, InterestListEvent) +abstract class PrivateTagArrayEvent(...) : BaseAddressableEvent(...) +// Replaceable lists (kind 10000-19999) +abstract class PrivateReplaceableTagArrayEvent(...) : BaseReplaceableEvent(...) + +class MuteListEvent(...) : PrivateTagArrayEvent(...) // kind 10000 +class PinListEvent(...) : BaseReplaceableEvent(...) // kind 10001, public only +class BookmarkListEvent(...) : PrivateReplaceableTagArrayEvent(...) // kind 10003 +class InterestListEvent(...) : PrivateTagArrayEvent(...) // kind 10015 +class FollowSetEvent(...) : PrivateTagArrayEvent(...) // kind 30000 follow sets +class BookmarkSetEvent(...) : PrivateTagArrayEvent(...) // kind 30003 bookmark sets +class StarterPackEvent(...) : BaseAddressableEvent(...) // kind 39089 starter packs ``` ## Event Interfaces @@ -279,7 +287,7 @@ val metadata2 = MetadataEvent.createNew(name = "Alice Updated", picture = "url2" ### Event Deletion ```kotlin // Delete events -val deletion = DeletionEvent.create( +val deletion = DeletionRequestEvent.create( deleteEvents = listOf(eventId1, eventId2), reason = "Spam", signer = signer diff --git a/.claude/skills/nostr-expert/references/nip-catalog.md b/.claude/skills/nostr-expert/references/nip-catalog.md index 78ed47ee11..996addba10 100644 --- a/.claude/skills/nostr-expert/references/nip-catalog.md +++ b/.claude/skills/nostr-expert/references/nip-catalog.md @@ -15,7 +15,7 @@ under `experimental/`**. The categorized list below may lag behind — | 04 | `nip04Dm/` | EncryptedDmEvent.kt | Legacy encrypted DMs (deprecated for NIP-17) | | 05 | `nip05DnsIdentifiers/` | UserHexResolver.kt, Nip05Client.kt | Internet identifiers; `resolveUserHexOrNull` resolves hex/npub/nprofile/`name@domain` → pubkey (see references/nip05-identifiers.md) | | 06 | `nip06KeyDerivation/` | Mnemonic-related | BIP-39 key derivation | -| 09 | `nip09Deletions/` | DeletionEvent.kt | Event deletion requests (kind 5) | +| 09 | `nip09Deletions/` | DeletionRequestEvent.kt | Event deletion requests (kind 5) | | 11 | `nip11RelayInfo/` | RelayInformation.kt | Relay metadata | | 13 | `nip13Pow/` | ProofOfWork.kt | Proof of work | | 14 | `nip14Subject/` | Subject tags | Subject tags for text notes | @@ -33,7 +33,7 @@ ent for NIP-04) | | 10 | `nip10Notes/` | TextNoteEvent.kt | Text notes with threading (kind 1) | | 18 | `nip18Reposts/` | RepostEvent.kt, GenericRepostEvent.kt | Reposts (kind 6, 16) | | 22 | `nip22Comments/` | CommentEvent.kt | Comments (kind 1111) | -| 23 | `nip23LongContent/` | LongTextNoteEvent.kt | Long-form content (kind 30023) | +| 23 | `nip23LongContent/` | LongFormContentEvent.kt | Long-form content (kind 30023) | | 25 | `nip25Reactions/` | ReactionEvent.kt | Reactions (kind 7) | | 31 | `nip31Alts/` | Alt tags | Alt description tags | | 36 | `nip36SensitiveContent/` | Content warnings | Content warning tags | @@ -62,7 +62,7 @@ ent for NIP-04) | | 46 | `nip46RemoteSigner/` | NostrConnectEvent.kt | Remote signer protocol (bunker) | | 47 | `nip47WalletConnect/` | Nostr Wallet Connect | Wallet connection protocol | | 56 | `nip56Reports/` | ReportEvent.kt | Reports (kind 1984) | -| 57 | `nip57Zaps/` | LnZapEvent.kt, LnZapRequestEvent.kt | Lightning zaps (kinds 9734, 9735) | +| 57 | `nip57Zaps/` | ZapReceiptEvent.kt, ZapRequestEvent.kt | Lightning zaps (kinds 9734, 9735) | | 58 | `nip58Badges/` | Badge events | Badge definitions & awards (kinds 30009, 8) | | 59 | `nip59Giftwrap/` | GiftWrapEvent.kt | Gift-wrapped events for privacy | | 75 | `nip75ZapGoals/` | ZapGoalEvent.kt | Zap goals (kind 9041) | @@ -76,7 +76,7 @@ ent for NIP-04) | | 35 | `nip35Torrents/` | Torrent events | Torrent tracking | | 52 | `nip52Calendar/` | Calendar events | Calendar time-based/date-based (kinds 31922-31925) | | 53 | `nip53LiveActivities/` | LiveActivitiesEvent.kt | Live events/streaming (kind 30311) | -| 54 | `nip54Wiki/` | WikiNoteEvent.kt | Wiki pages (kind 30818) | +| 54 | `nip54Wiki/` | WikiArticleEvent.kt | Wiki pages (kind 30818) | | 68 | `nip68Picture/` | Picture metadata | Picture metadata | | 71 | `nip71Video/` | 7 video event types | Video events (kinds 34235, 35235, 1234, 1235) | | 72 | `nip72ModCommunities/` | Community events | Moderated communities (kinds 34550, 34551, 9041) | @@ -84,7 +84,7 @@ ent for NIP-04) | | 89 | `nip89AppHandlers/` | AppDefinitionEvent.kt | App recommendations (kinds 31990, 31989) | | 90 | `nip90Dvms/` | DVM job events | Data Vending Machines (DVMs) (kinds 5000-7000) | | 92 | `nip92IMeta/` | IMeta tags | Image metadata tags | -| 94 | `nip94FileMetadata/` | FileHeaderEvent.kt, FileStorageEvent.kt | File metadata (kind 1063) | +| 94 | `nip94FileMetadata/` | FileMetadataEvent.kt, FileStorageEvent.kt | File metadata (kind 1063) | | 96 | `nip96FileStorage/` | HTTP file storage | HTTP-based file storage | | 99 | `nip99Classifieds/` | ClassifiedsEvent.kt | Classifieds/marketplace (kind 30402) | | A0 | `nipA0VoiceMessages/` | Voice messages | Voice message events | @@ -93,7 +93,7 @@ ent for NIP-04) | ### Web/Storage/Other | NIP | Directory | Key Files | Description | |-----|-----------|-----------|-------------| -| 38 | `nip38UserStatus/` | StatusEvent.kt | User status (kind 30315) | +| 38 | `nip38UserStatus/` | UserStatusEvent.kt | User status (kind 30315) | | 60 | `nip60Payment/` | Wallet events | Wallet info (kind 13194) | | 61 | `nip61PaymentRequest/` | Nut zaps | Cashu payment requests | | 64 | `nip64Chess/` | Chess moves | Chess move events | diff --git a/.claude/skills/quartz-integration/SKILL.md b/.claude/skills/quartz-integration/SKILL.md index 55dc9ad12f..8bd65f92e1 100644 --- a/.claude/skills/quartz-integration/SKILL.md +++ b/.claude/skills/quartz-integration/SKILL.md @@ -650,15 +650,15 @@ val custom = ReactionEvent.build("🤙", targetEvent) ### NIP-57 — Zap request (kind 9734) ```kotlin -import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapRequestEvent -val template = LnZapRequestEvent.build( +val template = ZapRequestEvent.build( message = "Great post!", relays = listOf("wss://relay.damus.io"), target = targetEvent, - zapType = LnZapRequestEvent.ZapType.PUBLIC + zapType = ZapRequestEvent.ZapType.PUBLIC ) -val zapRequest: LnZapRequestEvent = signer.sign(template) +val zapRequest: ZapRequestEvent = signer.sign(template) ``` ### NIP-59 — Gift wrap / sealed DM (kind 1059 + 14) @@ -678,9 +678,9 @@ val (dmEvent, giftWrap) = NIP17Factory.create( ### NIP-23 — Long-form article (kind 30023) ```kotlin -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent -val template = LongTextNoteEvent.build( +val template = LongFormContentEvent.build( body = markdownContent, title = "My Article", image = "https://example.com/cover.jpg", @@ -882,16 +882,16 @@ use `Nip11RelayInformation.fromJson(json)`. | 0 | User metadata | 01 | `MetadataEvent` | | 1 | Text note | 10 | `TextNoteEvent` | | 3 | Follow list | 02 | `ContactListEvent` | -| 4 | Legacy DM | 04 | `PrivateDmEvent` | -| 5 | Deletion | 09 | `DeletionEvent` | +| 4 | Legacy DM | 04 | `EncryptedDmEvent` | +| 5 | Deletion | 09 | `DeletionRequestEvent` | | 6 | Repost | 18 | `RepostEvent` | | 7 | Reaction | 25 | `ReactionEvent` | | 14 | Chat message (sealed) | 17 | `NIP17GroupMessage` | | 1059 | Gift wrap | 59 | `GiftWrapEvent` | -| 9734 | Zap request | 57 | `LnZapRequestEvent` | -| 9735 | Zap receipt | 57 | `LnZapEvent` | +| 9734 | Zap request | 57 | `ZapRequestEvent` | +| 9735 | Zap receipt | 57 | `ZapReceiptEvent` | | 10002 | Relay list | 65 | `AdvertisedRelayListEvent` | -| 30023 | Long-form content | 23 | `LongTextNoteEvent` | +| 30023 | Long-form content | 23 | `LongFormContentEvent` | ## Related Skills diff --git a/.claude/skills/searchable-events/SKILL.md b/.claude/skills/searchable-events/SKILL.md index aad4744f68..f27492bf5f 100644 --- a/.claude/skills/searchable-events/SKILL.md +++ b/.claude/skills/searchable-events/SKILL.md @@ -45,10 +45,9 @@ Notables that surprise people: (`zapRequest?.content.orEmpty()`) — receipts are searchable by the zapper's comment. - **Kind 0 / 31990** index many profile fields space-joined (name, about, nip05, lud16, website, picture URL, …). -- **Kind 30063 is claimed twice** (`ReleaseArtifactSetEvent` in nip51Lists and the experimental - `SoftwareReleaseEvent`); `EventFactory` resolves 30063 to `ReleaseArtifactSetEvent`, so - `title()\ndescription()` is what actually gets indexed — `SoftwareReleaseEvent.indexableContent()` - is dead on the store path. +- **Kind 30063** (`ReleaseArtifactSetEvent`) serves both NIP-51 release artifact sets and NIP-82 + software releases: it indexes `title()\ndescription()`, plus the release notes in `content` only + for NIP-82 releases (NIP-51 `content` can be encrypted private items). - Poll kinds (1068, 6969) append each option label on its own line. ## MANDATORY maintenance when you touch this surface diff --git a/.claude/skills/searchable-events/references/searchable-kinds.md b/.claude/skills/searchable-events/references/searchable-kinds.md index b2e3348127..d9942a6530 100644 --- a/.claude/skills/searchable-events/references/searchable-kinds.md +++ b/.claude/skills/searchable-events/references/searchable-kinds.md @@ -5,7 +5,7 @@ expression. **Update this file in the same PR as any change to the searchable se `indexableContent()` body** (see SKILL.md). Verified against the code 2026-09-17. Counts: 137 concrete classes covering 140 kind values (`GitStatusEvent` spans 4 kinds; -kind 30063 has a collision — see the footnote). File paths are under +kind 30063 is shared by two NIPs — see the footnote). File paths are under `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/`. Separator legend: **NL** = `joinToString("\n")`, **SP** = `joinToString(" ")`. @@ -26,7 +26,7 @@ Separator legend: **NL** = `joinToString("\n")`, **SP** = `joinToString(" ")`. | 42 | ChannelMessageEvent | nip28PublicChat/message | `content` | | 54 | PodcastEpisodeEvent | nipF4Podcasts/episode | `listOfNotNull(title(), description(), content)` NL | | 1010 | TextNoteModificationEvent | experimental/edits | `listOfNotNull(content, summary())` NL (content first) | -| 1063 | FileHeaderEvent | nip94FileMetadata | `listOfNotNull(summary(), content)` NL | +| 1063 | FileMetadataEvent | nip94FileMetadata | `listOfNotNull(summary(), content)` NL | | 1065 | FileStorageHeaderEvent | experimental/nip95/header | `listOfNotNull(summary())` NL | | 1068 | PollEvent | nip88Polls/poll | `buildString { append(content); options().forEach { append('\n').append(it.label) } }` | | 1111 | CommentEvent | nip22Comments | `(listOf(content) + tags.hashtags())` NL | @@ -48,20 +48,20 @@ Separator legend: **NL** = `joinToString("\n")`, **SP** = `joinToString(" ")`. | 2004 | TorrentCommentEvent | nip35Torrents | `content` | | 2473 | BirdDetectionEvent | experimental/birdstar | `listOfNotNull(summary(), speciesName())` NL | | 3302 | ConcordChatEditEvent | concord/cord03Channels | `content` | -| 5050 | NIP90TextGenerationRequestEvent | nip90Dvms/textGeneration | `inputs().filter { it.type == "prompt" \|\| it.type == "text" }.joinToString(" ") { it.value }` (SP) | -| 5100 | NIP90ImageGenerationRequestEvent | nip90Dvms/imageGeneration | `listOfNotNull(prompt(), negativePrompt()).joinToString(" ")` (SP) | +| 5050 | DvmTextGenerationRequestEvent | nip90Dvms/textGeneration | `inputs().filter { it.type == "prompt" \|\| it.type == "text" }.joinToString(" ") { it.value }` (SP) | +| 5100 | DvmImageGenerationRequestEvent | nip90Dvms/imageGeneration | `listOfNotNull(prompt(), negativePrompt()).joinToString(" ")` (SP) | | 5129 | NappletSnapshotEvent | nip5dNapplets | `listOfNotNull(title(), description())` NL | -| 5250 | NIP90TextToSpeechRequestEvent | nip90Dvms/textToSpeech | `text() ?: ""` | -| 5302 | NIP90ContentSearchRequestEvent | nip90Dvms/contentSearch | `searchQuery() ?: ""` | -| 5303 | NIP90PeopleSearchRequestEvent | nip90Dvms/peopleSearch | `searchQuery() ?: ""` | +| 5250 | DvmTextToSpeechRequestEvent | nip90Dvms/textToSpeech | `text() ?: ""` | +| 5302 | DvmContentSearchRequestEvent | nip90Dvms/contentSearch | `searchQuery() ?: ""` | +| 5303 | DvmPeopleSearchRequestEvent | nip90Dvms/peopleSearch | `searchQuery() ?: ""` | | 6969 | ZapPollEvent | experimental/zapPolls | `buildString { append(content); pollOptionsArray().forEach { append('\n').append(it.descriptor) } }` | | 7516 | GeocacheFoundLogEvent | nipCCGeocaching/foundLog | `content` | | 8333 | OnchainZapEvent | nipBCOnchainZaps/zap | `content` | -| 9002 | EditMetadataEvent | nip29RelayGroups/moderation | `(listOfNotNull(name(), about()) + hashtags())` NL | -| 9041 | GoalEvent | nip75ZapGoals | `listOfNotNull(summary(), content)` NL | +| 9002 | GroupEditMetadataEvent | nip29RelayGroups/moderation | `(listOfNotNull(name(), about()) + hashtags())` NL | +| 9041 | ZapGoalEvent | nip75ZapGoals | `listOfNotNull(summary(), content)` NL | | 9321 | NutzapEvent | nip61Nutzaps/nutzap | `content` | -| 9734 | LnZapRequestEvent | nip57Zaps | `content` | -| 9735 | LnZapEvent | nip57Zaps | `zapRequest?.content.orEmpty()` — indexes the **embedded 9734's** content | +| 9734 | ZapRequestEvent | nip57Zaps | `content` | +| 9735 | ZapReceiptEvent | nip57Zaps | `zapRequest?.content.orEmpty()` — indexes the **embedded 9734's** content | | 9736 | Bolt12ZapEvent | nipB1Bolt12Zaps/zap | `content` | | 9737 | Bolt12ZapIntentEvent | nipB1Bolt12Zaps/intent | `content` | | 9802 | HighlightEvent | nip84Highlights | `listOfNotNull(comment(), context(), content)` NL | @@ -74,10 +74,10 @@ Separator legend: **NL** = `joinToString("\n")`, **SP** = `joinToString(" ")`. | 12473 | BirdexEvent | experimental/birdstar | `(listOfNotNull(summary()) + speciesNames())` NL | | 15128 | RootSiteEvent | nip5aStaticWebsites | `listOfNotNull(title(), description())` NL | | 15129 | RootNappletEvent | nip5dNapplets | `listOfNotNull(title(), description())` NL | -| 30000 | PeopleListEvent | nip51Lists/peopleList | `listOfNotNull(titleOrName(), description())` NL | +| 30000 | FollowSetEvent | nip51Lists/followSet | `listOfNotNull(titleOrName(), description())` NL | | 30001 | OldBookmarkListEvent | nip51Lists/bookmarkList | `listOfNotNull(title())` NL | | 30002 | RelaySetEvent | nip51Lists/relaySets | `listOfNotNull(title(), description())` NL | -| 30003 | LabeledBookmarkListEvent | nip51Lists/labeledBookmarkList | `listOfNotNull(titleOrName(), description())` NL | +| 30003 | BookmarkSetEvent | nip51Lists/bookmarkSet | `listOfNotNull(titleOrName(), description())` NL | | 30004 | ArticleCurationSetEvent | nip51Lists/articleCurationSet | `listOfNotNull(title(), description())` NL | | 30005 | VideoCurationSetEvent | nip51Lists/videoCurationSet | `listOfNotNull(title(), description())` NL | | 30006 | PictureCurationSetEvent | nip51Lists/pictureCurationSet | `listOfNotNull(title(), description())` NL | @@ -87,11 +87,11 @@ Separator legend: **NL** = `joinToString("\n")`, **SP** = `joinToString(" ")`. | 30018 | ProductEvent | nip15Marketplace/product | `productData()?.let { (listOfNotNull(it.name, it.description) + categories()).joinToString("\n") } ?: ""` | | 30019 | MarketplaceEvent | nip15Marketplace/marketplace | `marketplaceData()?.let { listOfNotNull(it.name, it.about).joinToString("\n") } ?: ""` | | 30020 | AuctionEvent | nip15Marketplace/auction | `auctionData()?.let { (listOfNotNull(it.name, it.description) + tags.hashtags()).joinToString("\n") } ?: ""` | -| 30023 | LongTextNoteEvent | nip23LongContent | `listOfNotNull(title(), summary(), content)` NL | +| 30023 | LongFormContentEvent | nip23LongContent | `listOfNotNull(title(), summary(), content)` NL | | 30030 | EmojiPackEvent | nip30CustomEmoji/pack | `listOfNotNull(titleOrName(), description(), content)` NL | | 30054 | Podcasting20EpisodeEvent | nipXXPodcasting20/episode | `(listOfNotNull(title(), description(), content) + topics())` NL | | 30055 | Podcasting20TrailerEvent | nipXXPodcasting20/trailer | `listOfNotNull(title(), content)` NL | -| 30063 | ReleaseArtifactSetEvent † | nip51Lists/releaseArtifactSet | `listOfNotNull(title(), description())` NL | +| 30063 | ReleaseArtifactSetEvent † | nip51Lists/releaseArtifactSet | `listOfNotNull(title(), description(), content if NIP-82)` NL | | 30175 | PersonaEvent | buzz/apPersonas | `personaOrNull()?.let { listOfNotNull(it.displayName, it.systemPrompt).joinToString("\n") } ?: ""` | | 30176 | TeamEvent | buzz/teams | `teamOrNull()?.let { listOfNotNull(it.name, it.description, it.instructions).joinToString("\n") } ?: ""` | | 30177 | ManagedAgentEvent | buzz/managedAgents | `agentOrNull()?.let { listOfNotNull(it.name, it.systemPrompt).joinToString("\n") } ?: ""` | @@ -101,8 +101,8 @@ Separator legend: **NL** = `joinToString("\n")`, **SP** = `joinToString(" ")`. | 30311 | LiveActivitiesEvent | nip53LiveActivities/streaming | `listOfNotNull(title(), summary(), content)` NL | | 30312 | MeetingSpaceEvent | nip53LiveActivities/meetingSpaces | `listOfNotNull(room(), summary(), content)` NL | | 30313 | MeetingRoomEvent | nip53LiveActivities/meetingSpaces | `listOfNotNull(title(), summary())` NL | -| 30315 | StatusEvent | nip38UserStatus | `content` | -| 30382 | ContactCardEvent | nip85TrustedAssertions/users | `(listOfNotNull(petName(), summary()) + topics())` NL — public tags only, never the NIP-44 content | +| 30315 | UserStatusEvent | nip38UserStatus | `content` | +| 30382 | UserAssertionEvent | nip85TrustedAssertions/users | `(listOfNotNull(petName(), summary()) + topics())` NL — public tags only, never the NIP-44 content | | 30392 | UserTrustedListEvent | experimental/trustedLists/users | inherited `TrustedListEvent`: `title() ?: ""` — the label only; `metric`/`d` are machine ids and `content` is a JSON echo of the membership | | 30393 | EventTrustedListEvent | experimental/trustedLists/events | inherited `TrustedListEvent`: `title() ?: ""` | | 30394 | AddressableTrustedListEvent | experimental/trustedLists/addressables | inherited `TrustedListEvent`: `title() ?: ""` | @@ -111,7 +111,7 @@ Separator legend: **NL** = `joinToString("\n")`, **SP** = `joinToString(" ")`. | 30617 | GitRepositoryEvent | nip34Git/repository | `listOfNotNull(name(), description(), content)` NL | | 30620 | WorkflowDefEvent | buzz/workflow | `listOfNotNull(name(), content)` NL | | 30817 | NipTextEvent | experimental/nipsOnNostr | `listOfNotNull(title(), content)` NL | -| 30818 | WikiNoteEvent | nip54Wiki | `listOfNotNull(title(), summary(), content)` NL | +| 30818 | WikiArticleEvent | nip54Wiki | `listOfNotNull(title(), summary(), content)` NL | | 31337 | AudioTrackEvent | experimental/audio/track | `listOfNotNull(subject())` NL | | 31871 | AttestationEvent | experimental/attestations/attestation | `content` | | 31872 | AttestationRequestEvent | experimental/attestations/request | `content` | @@ -119,15 +119,15 @@ Separator legend: **NL** = `joinToString("\n")`, **SP** = `joinToString(" ")`. | 31890 | FeedDefinitionEvent | feedDefinition | `title().orEmpty()` | | 31922 | CalendarDateSlotEvent | nip52Calendar/appt/day | `listOfNotNull(title(), summary(), content)` NL | | 31923 | CalendarTimeSlotEvent | nip52Calendar/appt/time | `listOfNotNull(title(), summary(), content)` NL | -| 31924 | CalendarEvent | nip52Calendar/calendar | `listOfNotNull(title(), content)` NL | +| 31924 | CalendarCollectionEvent | nip52Calendar/calendar | `listOfNotNull(title(), content)` NL | | 31925 | CalendarRSVPEvent | nip52Calendar/rsvp | `content` | | 31990 | AppDefinitionEvent | nip89AppHandlers/definition | `appMetaData()?.let { listOfNotNull(it.name, it.username, it.displayName, it.about, it.nip05, it.lud06, it.lud16, it.website, it.picture, it.banner, it.image).joinToString(" ") } ?: ""` (SP) | | 32267 | SoftwareApplicationEvent | experimental/nip82SoftwareApps/application | `listOfNotNull(name(), summary(), content)` NL | | 33401 | ExerciseTemplateEvent | experimental/fitness/workout | `listOfNotNull(title(), content)` NL | | 33863 | FundraiserEvent | experimental/agora | `listOfNotNull(title(), content)` NL | | 34139 | MusicPlaylistEvent | experimental/music/playlist | `listOfNotNull(title(), description(), content)` NL | -| 34235 | VideoHorizontalEvent | nip71Video | inherited `AddressableVideoEvent`: `listOfNotNull(title(), content)` NL | -| 34236 | VideoVerticalEvent | nip71Video | inherited `AddressableVideoEvent`: `listOfNotNull(title(), content)` NL | +| 34235 | AddressableNormalVideoEvent | nip71Video | inherited `AddressableVideoEvent`: `listOfNotNull(title(), content)` NL | +| 34236 | AddressableShortVideoEvent | nip71Video | inherited `AddressableVideoEvent`: `listOfNotNull(title(), content)` NL | | 34550 | CommunityDefinitionEvent | nip72ModCommunities/definition | `listOfNotNull(name(), description(), rules(), content)` NL | | 35128 | NamedSiteEvent | nip5aStaticWebsites | `listOfNotNull(title(), description())` NL | | 35129 | NamedNappletEvent | nip5dNapplets | `listOfNotNull(title(), description())` NL | @@ -138,7 +138,7 @@ Separator legend: **NL** = `joinToString("\n")`, **SP** = `joinToString(" ")`. | 38192 | Ps1SaveEvent | experimental/ps1saves | `listOfNotNull(summary(), saveTitle(), region(), filename())` NL | | 38383 | P2POrderEvent | nip69P2pOrderEvents | `(listOfNotNull(makerName(), currency()) + paymentMethods().orEmpty()).joinToString(" ")` (SP) | | 39000 | GroupMetadataEvent | nip29RelayGroups/metadata | `listOfNotNull(name(), about())` NL | -| 39089 | FollowListEvent | nip51Lists/followList | `listOfNotNull(title(), description())` NL | +| 39089 | StarterPackEvent | nip51Lists/starterPack | `listOfNotNull(title(), description())` NL | | 39092 | MediaStarterPackEvent | nip51Lists/mediaStarterPack | `listOfNotNull(title(), description())` NL | | 39307 | TextTrackEvent | nip71Video/textTrack | `content` (the WebVTT cue text) | | 39701 | WebBookmarkEvent | nipB0WebBookmarks | `listOfNotNull(title(), description())` NL | @@ -150,10 +150,10 @@ Separator legend: **NL** = `joinToString("\n")`, **SP** = `joinToString(" ")`. | 45003 | ForumCommentEvent | buzz/forum | `content` | | 48106 | HuddleGuidelinesEvent | buzz/huddles | `content` | -† **Kind 30063 collision:** `experimental/nip82SoftwareApps/release/SoftwareReleaseEvent` also -declares `KIND = 30063` and implements `SearchableEvent` (`content`), but `EventFactory` maps -30063 to `ReleaseArtifactSetEvent`, so on every store path kind 30063 indexes -`title()\ndescription()`. If the factory mapping ever changes, this table changes with it. +† **Kind 30063 is shared** by NIP-51 release artifact sets and NIP-82 software releases, and +`ReleaseArtifactSetEvent` parses both. It indexes `title()` and `description()`, plus `content` +(the release notes) only when the event carries the NIP-82 `i` + `version` tags — a NIP-51 set +may hold encrypted private items in `content`, which must never be indexed. ## Abstract bases (no kind of their own) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 25df64d47d..32fad8469a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -335,10 +335,29 @@ jobs: name: Quartz iOS Test Reports path: quartz/build/reports - test-and-build-android: + # Android Lint, split out of test-and-build-android. + # + # That job died with "the runner has received a shutdown signal" (SIGTERM, + # exit 143) seven times across this workflow's history — the OOM killer taking + # the runner agent on a 16GB box. Two attempts to fix it by tuning numbers + # have now been spent: capping both daemons to 4g traded the runner OOM for an + # R8/lintAnalyze "Java heap space", and --max-workers=3 survives a warm cache + # but still dies on a cold one. + # + # This is the structural fix rather than a third number. lintAnalyze is the + # single heaviest step in that job — measured at 13 of its 35 minutes on one + # cold run — and it holds a large analysis graph while the Kotlin daemon, a + # forked test JVM and R8 are all still resident. Giving the two lint tasks + # their own runner removes that peak from the critical job instead of trying + # to squeeze everything under one ceiling, and the two now run concurrently. + # + # The cost is honest: both jobs restore the same read-only Gradle cache and so + # repeat some module compilation. That buys back more than it spends here, + # because the duplicated work is parallel while the memory pressure was not. + lint-android: needs: lint runs-on: ubuntu-latest - timeout-minutes: 60 + timeout-minutes: 90 steps: - name: Checkout code uses: actions/checkout@v7 @@ -354,12 +373,61 @@ jobs: with: cache-read-only: ${{ github.ref != 'refs/heads/main' }} - # Lint + focused unit tests + benchmark assembly in one Gradle invocation. - # Previously: one invocation for lint, one for `test` (which compiled all - # six amethyst variants × all flavors), one for `assembleBenchmark` - # (re-walking the same task graph). Combining them keeps the daemon hot - # across phases and lets task-level dedup (e.g. compileKotlin) only - # happen once. + # Same daemon cap as the sibling job: lintAnalyze draws on the Gradle + # daemon's heap, which is why the earlier 4g experiment broke it. Only the + # Kotlin daemon is trimmed. + - name: Lint Android (gradle) + run: | + ./gradlew \ + -Dkotlin.daemon.jvmargs="-Xmx4g -XX:MaxMetaspaceSize=1g" \ + --max-workers=3 \ + :amethyst:lintFdroidBenchmark \ + :amethyst:lintPlayBenchmark + + - name: Upload Android Lint Reports + uses: actions/upload-artifact@v7 + if: always() + with: + name: Android Lint Reports + path: amethyst/build/reports/lint-results-*.html + + test-and-build-android: + needs: lint + runs-on: ubuntu-latest + # 90, not 60. On main this job's Gradle step takes ~46 minutes, which used + # 76% of a 60-minute budget — fine for an incremental run, but PR runs set + # `cache-read-only` (below), so they restore main's Gradle cache and never + # save. A PR that touches `quartz` or `commons` invalidates most of what + # that cache holds for everything downstream, and the job then rebuilds it + # from cold: measured 2-3x the main-branch time across every job in the + # workflow, which puts this one past the cap and gets it killed mid-step + # with no test report. Raising the cap costs nothing on runs that finish + # early — `timeout-minutes` bounds a job, it does not reserve the time. + timeout-minutes: 120 + steps: + - name: Checkout code + uses: actions/checkout@v7 + + - name: Set up JDK 21 + uses: actions/setup-java@v6.0.0 + with: + distribution: 'temurin' + java-version: 21 + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v6 + with: + cache-read-only: ${{ github.ref != 'refs/heads/main' }} + + # Focused unit tests + benchmark assembly in one Gradle invocation. + # Previously: one invocation for `test` (which compiled all six amethyst + # variants × all flavors) and one for `assembleBenchmark` (re-walking the + # same task graph). Combining them keeps the daemon hot across phases and + # lets task-level dedup (e.g. compileKotlin) only happen once. + # + # Lint used to run here too and now has its own job (lint-android above) — + # see the note there for why. What remains is still the heaviest job in + # the workflow, so the memory notes below continue to apply. # # `-PdisableAbiSplits=true` produces a single non-split APK per # (flavor, buildType) instead of 5 (4 ABIs + universal). The CI only @@ -371,11 +439,47 @@ jobs: # variants are compile-equivalent for unit-test purposes; running all six # adds ~5× the kotlinc work without catching new defects on PRs. Push to # main still gets the full test matrix via the production-build path. + # Memory, CI-only. gradle.properties asks for -Xmx6g (Gradle) plus -Xmx8g + # and 2g metaspace (Kotlin daemon) — about 16GB of ceiling on a 16GB + # ubuntu-latest runner, before the launcher JVM, Lint's fork and the KSP + # workers. This job is the only one heavy enough to reach it, and it died + # five times mid-compile with "the runner has received a shutdown signal", + # which is the OOM killer taking the runner agent. + # + # Only the Kotlin daemon is cut. An earlier attempt capped BOTH to 4g and + # traded one OOM for another: the runner survived and the job ran to + # completion, but R8 and lintAnalyze then failed with + # "java.lang.OutOfMemoryError: Java heap space" — they draw on the Gradle + # daemon's heap, and 4g is not enough for them on this app. 6g always was, + # so it stays; 8g + 2g for kotlinc is the part that did not fit. + # + # Overridden here rather than in gradle.properties so local builds on + # bigger machines keep the headroom. + # + # `--max-workers` is the second half, and it is about concurrency rather + # than ceilings. The heap numbers above are per-JVM limits; what actually + # tips a 16GB runner over is how many heavy JVMs are live at once. Gradle + # defaults max-workers to the core count (4 on ubuntu-latest) and + # org.gradle.parallel is on, so a cold run can have several kotlinc + # workers, a lint fork and a forked test JVM resident alongside the two + # daemons. + # + # Cold is the case that matters. The 4g cap was first validated on a run + # that only changed this file, so it restored main's Gradle cache and + # built almost nothing; the next PR run that touched `commons` + # invalidated that cache, rebuilt from cold, and died the same way at + # ~20 minutes. Fewer workers is what makes the cold path fit — dropping + # heap further would start starving R8 again, which is the trade the + # previous attempt already lost. + # + # 3 rather than 2: this job is mostly a chain of single-task module + # compiles, so the parallelism it loses is small, and halving it risks + # the timeout on a cold run. The cap goes to 120 for the same reason. - name: Test + Build Android (gradle) run: | ./gradlew \ - :amethyst:lintFdroidBenchmark \ - :amethyst:lintPlayBenchmark \ + -Dkotlin.daemon.jvmargs="-Xmx4g -XX:MaxMetaspaceSize=1g" \ + --max-workers=3 \ :quartz:jvmTest \ :commons:jvmTest \ :commonsUI:jvmTest \ @@ -386,13 +490,6 @@ jobs: :amethyst:assembleBenchmark \ -PdisableAbiSplits=true - - name: Upload Android Lint Reports - uses: actions/upload-artifact@v7 - if: always() - with: - name: Android Lint Reports - path: amethyst/build/reports/lint-results-*.html - # Publishes the JUnit XML produced by the unit-test tasks above as inline # annotations plus a job summary. Replaces asadmansr/android-test-report-action, # which was abandoned (last release 2020) and rebuilt an EOL Ubuntu 18.04 + diff --git a/amethyst/plans/2026-07-01-auth-permission-architecture.md b/amethyst/plans/2026-07-01-auth-permission-architecture.md index f3d431047a..17dce367c7 100644 --- a/amethyst/plans/2026-07-01-auth-permission-architecture.md +++ b/amethyst/plans/2026-07-01-auth-permission-architecture.md @@ -68,7 +68,7 @@ The NIP-42 plumbing and a first-cut permission gate are already in place: | Settings screen (global policy + per-relay list) | `amethyst/.../ui/screen/loggedIn/relayauth/RelayAuthSettingsScreen.kt` | | Global policy setting, persisted local-only | `AccountSettings.defaultRelayAuthPolicy`, `LocalPreferences` key `DEFAULT_RELAY_AUTH_POLICY` | | Blocked-relay list (kind 10006) | `amethyst/.../model/nip51Lists/blockedRelays/BlockedRelayListState.kt` (`.flow`) | -| Follow checks | `Account.isFollowing(...)`, `Account.allFollows.flow.value.authors`, `FollowListsState.isUserInFollowSets(...)` | +| Follow checks | `Account.isFollowing(...)`, `Account.allFollows.flow.value.authors`, `StarterPacksState.isUserInFollowSets(...)` | | DM / NIP-65 relay lookups | `DmRelayListState`, `Nip65RelayListState` (+ per-user via `LocalCache`) | ## The gap @@ -180,7 +180,7 @@ class RelayAuthContext(val relayUrl: String, val purposes: List) - `IF_IN_MY_LIST` → `ALLOW` if relay ∈ my relay lists, else fall through - `TRUSTED_FOLLOWS` *(new — see idea A below)* → `ALLOW` if relay ∈ my lists **or** any counterparty in `ctx.purposes` is followed (`Account.allFollows` - / `FollowListsState.isUserInFollowSets`) and the purpose permits it; else + / `StarterPacksState.isUserInFollowSets`) and the purpose permits it; else fall through. 4. **Fall-through**: `ASK` if the purpose is attributable (we can show a reason); otherwise `DENY` silently (don't prompt for anonymous stranger challenges). diff --git a/amethyst/plans/2026-07-07-nip29-armada-study.md b/amethyst/plans/2026-07-07-nip29-armada-study.md index 4d8e0aa3cf..7c430631de 100644 --- a/amethyst/plans/2026-07-07-nip29-armada-study.md +++ b/amethyst/plans/2026-07-07-nip29-armada-study.md @@ -392,11 +392,11 @@ can be a group event if it carries `h` and the relay accepts the kind." has: - `metadata/` — `GroupMetadataEvent` (kind **39000**), `GroupAdminsEvent` - (39001), `GroupMembersEvent` (39002), `SupportedRolesEvent` (39003). -- `moderation/` — `CreateGroupEvent` (9007), `EditMetadataEvent` (9002), - `PutUserEvent` (9000), `RemoveUserEvent` (9001), `DeleteEventEvent` (9005), - `DeleteGroupEvent` (9008), `CreateInviteEvent` (9009), plus tag helpers. -- `request/` — `JoinRequestEvent` (9021), `LeaveRequestEvent` (9022). + (39001), `GroupMembersEvent` (39002), `GroupRolesEvent` (39003). +- `moderation/` — `CreateGroupEvent` (9007), `GroupEditMetadataEvent` (9002), + `GroupPutUserEvent` (9000), `GroupRemoveUserEvent` (9001), `GroupDeleteEventEvent` (9005), + `DeleteGroupEvent` (9008), `GroupCreateInviteEvent` (9009), plus tag helpers. +- `request/` — `GroupJoinRequestEvent` (9021), `GroupLeaveRequestEvent` (9022). - `tags/` — `GroupIdTag` (the `h` tag), `CodeTag`, `GroupAdminTag`, `RoleTag`, and even a `PreviousTag`. @@ -427,7 +427,7 @@ NIP-04/17 DMs, NIP-28 public channels, and NIP-C7 ephemeral chats — all under ### 5.2 The main protocol gap There is **no kind-9 group chat message event** in `nip29RelayGroups/` -(`CreateInviteEvent` at 9009 is the highest kind present; nothing for kind 9). +(`GroupCreateInviteEvent` at 9009 is the highest kind present; nothing for kind 9). The 9xxx moderation, 39xxx metadata, and 9021/9022 request events exist, but the actual message carrier does not. This is the first thing to build: diff --git a/amethyst/plans/2026-07-13-chat-scroll-performance.md b/amethyst/plans/2026-07-13-chat-scroll-performance.md index 4b066c6858..dd7de42179 100644 --- a/amethyst/plans/2026-07-13-chat-scroll-performance.md +++ b/amethyst/plans/2026-07-13-chat-scroll-performance.md @@ -71,7 +71,7 @@ Feed-level: marker update (only the newest visible timestamp matters; the marker is monotonic). One coroutine per scroll session instead of one per row. 6. **Jumbo without a coroutine (#4)**: only launch the decrypt effect for - encrypted kinds (`PrivateDmEvent`, sealed rumors not yet in the decrypt + encrypted kinds (`EncryptedDmEvent`, sealed rumors not yet in the decrypt cache). Plaintext kinds (public chats, NIP-17 rumors already unwrapped — the vast majority) take the synchronous path only. 7. **Retire settled delivery ticks (#6)**: once a message is fully accepted (or diff --git a/amethyst/plans/2026-07-15-bitchat-geohash-interop.md b/amethyst/plans/2026-07-15-bitchat-geohash-interop.md index 2722de432e..09af8f3a48 100644 --- a/amethyst/plans/2026-07-15-bitchat-geohash-interop.md +++ b/amethyst/plans/2026-07-15-bitchat-geohash-interop.md @@ -55,7 +55,7 @@ Bitchat's Nostr side has two chat features. Amethyst is a pure-Nostr client 1. **Encrypted DMs (Phase 2).** Port the `bitchat1:` binary packet (`BitchatPacket` TLV + `NoisePayloadType`) into quartz, wrap/unwrap it in the - existing NIP-17 stack (`GiftWrapEvent`/`SealedRumorEvent`/`ChatMessageEvent`), + existing NIP-17 stack (`GiftWrapEvent`/`SealEvent`/`ChatMessageEvent`), handle geohash DMs (to a per-geohash pubkey) and delivery/read receipts. Add `amy geochat dm` for interop testing. 2. **Desktop UI.** The shared pieces (quartz events, `GeoRelayDirectory`) are diff --git a/amethyst/plans/2026-07-23-push-notification-redesign.md b/amethyst/plans/2026-07-23-push-notification-redesign.md index 2e0acac758..a2f8d535ee 100644 --- a/amethyst/plans/2026-07-23-push-notification-redesign.md +++ b/amethyst/plans/2026-07-23-push-notification-redesign.md @@ -178,10 +178,10 @@ This keeps the *policy* (who gets notified) exactly where it is and isolates the | Kind(s) | Category | Style | Accent / icon | Title → Body | Actions | | --- | --- | --- | --- | --- | --- | -| NIP-17 `ChatMessageEvent` (14), file (15), NIP-04 `PrivateDmEvent` (4), Marmot group | DIRECT_MESSAGE | `MessagingStyle` + Conversation shortcut + Bubble | blue-green / `chat` | sender → message | Reply, Mark-read | +| NIP-17 `ChatMessageEvent` (14), file (15), NIP-04 `EncryptedDmEvent` (4), Marmot group | DIRECT_MESSAGE | `MessagingStyle` + Conversation shortcut + Bubble | blue-green / `chat` | sender → message | Reply, Mark-read | | `TextNoteEvent`(1)/`CommentEvent`(1111)/`ChannelMessageEvent`(42) **reply to me** | REPLY | `MessagingStyle` (parent as prior message) | purple / `reply` | "X replied" → excerpt (+ quoted parent) | Reply, View thread, Mute thread | | Same kinds, **mention/quote only** | MENTION | plain (BigText) | purple / `alternate_email` | "X mentioned you" → excerpt | View, Mute thread | -| `LnZapEvent`(9735) | ZAP | **colorized** amount card (BigText) | gold / `bolt` | "⚡ 2,100 sats from X" → zap comment + zapped-note excerpt | Zap back, View | +| `ZapReceiptEvent`(9735) | ZAP | **colorized** amount card (BigText) | gold / `bolt` | "⚡ 2,100 sats from X" → zap comment + zapped-note excerpt | Zap back, View | | `NutzapEvent`(9321) *(new)* | ZAP | colorized amount card | gold / `bolt` (cashu tint) | "🥜 X nutzapped you 2,100 sats" → … | Zap back, View | | `OnchainZapEvent`(8333) *(new)* | ZAP | colorized amount card | gold / `bolt` | "⛓ X sent an onchain zap" → … | View | | `ReactionEvent`(7) | REACTION | aggregated `InboxStyle` | heart-red / `favorite` | "🤙 X & N others liked your post" → note excerpt | Like back, View | @@ -189,7 +189,7 @@ This keeps the *policy* (who gets notified) exactly where it is and isolates the | `PictureEvent`(20)/`VideoNormal`/`Short`/`Vertical`/`Horizontal` | MEDIA | `BigPictureStyle` | purple / `image` | "X shared a photo/video" → caption, image inline | View | | `PollEvent`/`ZapPollEvent` | MENTION | plain | purple / `ballot` | "X asked a question" → poll title | Vote, View | | `HighlightEvent`(9802) | ARTICLE | plain | purple / `format_quote` | "X highlighted your article" → highlighted text | View | -| `LongTextNoteEvent`(30023)/`WikiNoteEvent`(30818) | ARTICLE | plain | purple / `article` | "X mentioned you in an article" → title | View | +| `LongFormContentEvent`(30023)/`WikiArticleEvent`(30818) | ARTICLE | plain | purple / `article` | "X mentioned you in an article" → title | View | | `GitIssueEvent`/`GitPatchEvent`/`GitPullRequestEvent`/`…Update` | CODE | plain | slate / `merge` | "X opened an issue/PR" → subject | View | | `BadgeAwardEvent`(8) *(new)* | BADGE | `BigPictureStyle` (badge art) | gold / `award_star` | "You earned a badge" → badge name, image | View | | `LiveChessGameAcceptEvent`/`LiveChessMoveEvent` | CHESS | plain | brown / `chess` | "X accepted your challenge" / "your turn" | Open board | diff --git a/amethyst/plans/2026-07-24-nwc-bolt12-pay.md b/amethyst/plans/2026-07-24-nwc-bolt12-pay.md index e42b00a729..4f4e0ba89a 100644 --- a/amethyst/plans/2026-07-24-nwc-bolt12-pay.md +++ b/amethyst/plans/2026-07-24-nwc-bolt12-pay.md @@ -59,7 +59,7 @@ and dispatches the decrypted `Response`, so a new method needs no changes there: decrypt-on-arrival. - `NwcPaymentTracker` (`commons/…/service/nwc/`) — request↔response match with the author-spoof gate. -- `LocalCache.consume(LnZapPaymentResponseEvent)` — routes 23195 back to the callback. +- `LocalCache.consume(NwcResponseEvent)` — routes 23195 back to the callback. - Wallet storage: `AccountSettings.nwcWallets` + `defaultPaymentSourceId`; `PaymentSourceResolver`. - Pay rail entry: `ZapPaymentHandler.zap()` → `payViaNWC()` (the `PaymentSource.Nwc` diff --git a/amethyst/plans/2026-07-31-shortpost-notify-list-picker.md b/amethyst/plans/2026-07-31-shortpost-notify-list-picker.md index e7a382b5d3..0e8cfe0552 100644 --- a/amethyst/plans/2026-07-31-shortpost-notify-list-picker.md +++ b/amethyst/plans/2026-07-31-shortpost-notify-list-picker.md @@ -41,9 +41,9 @@ storm. |---|---| | The chip row + "Add" chip | `ui/note/creators/notify/Notifying.kt` (`Notifying`, `NotifyUserChip`, `AddUserChip`) | | Audience state | `ShortNotePostViewModel.pTags: List?`, `mutedNotifies: Set`, `activeNotifies()`, `addToReplyList(user)` | -| My NIP-51 people lists (kind 30000, public **and** decrypted private members) | `account.peopleLists.uiListFlow: StateFlow>` (`model/nip51Lists/peopleList/PeopleListsState.kt`) | -| My follow packs (kind 39089, public members) | `account.followLists.uiListFlow` (`model/nip51Lists/peopleList/FollowListsState.kt`) | -| `PeopleList` UI model (`title`, `image`, `publicMembers`, `privateMembers` as `Set`) | `model/nip51Lists/peopleList/PeopleList.kt` | +| My NIP-51 people lists (kind 30000, public **and** decrypted private members) | `account.peopleLists.uiListFlow: StateFlow>` (`model/nip51Lists/followSets/FollowSetsState.kt`) | +| My follow packs (kind 39089, public members) | `account.followLists.uiListFlow` (`model/nip51Lists/followSets/StarterPacksState.kt`) | +| `PeopleList` UI model (`title`, `image`, `publicMembers`, `privateMembers` as `Set`) | `model/nip51Lists/followSets/PeopleList.kt` | | Two-column list catalog rendering | `ui/screen/loggedIn/lists/memberEdit/FollowListAndPackAndUserView.kt` — same "Follow sets" + "Discover follows" sectioning | | Multi-select member review (count header, select-all checkbox, per-user row, confirm button) | `ui/screen/loggedIn/newUser/ImportFollowListPickFollowsScreen.kt` (`PreviewList` / `FollowEntryRow`) | | Bottom-sheet picker shell w/ search field | `ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupParentPicker.kt` | diff --git a/amethyst/plans/2026-09-10-dvm-heartbeat-liveness-plan.md b/amethyst/plans/2026-09-10-dvm-heartbeat-liveness-plan.md index dfe5996c2d..d56b7fd2e5 100644 --- a/amethyst/plans/2026-09-10-dvm-heartbeat-liveness-plan.md +++ b/amethyst/plans/2026-09-10-dvm-heartbeat-liveness-plan.md @@ -281,7 +281,7 @@ In `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt` ```kotlin import com.vitorpamplona.quartz.nip90Dvms.dvmHeartbeat.DvmHeartbeatEvent ``` -2. Add a dispatch branch inside the `when (kind)` near the other NIP-90 kinds (next to line 736 `NIP90StatusEvent.KIND -> ...`): +2. Add a dispatch branch inside the `when (kind)` near the other NIP-90 kinds (next to line 736 `DvmStatusEvent.KIND -> ...`): ```kotlin DvmHeartbeatEvent.KIND -> DvmHeartbeatEvent(id, pubKey, createdAt, tags, content, sig) ``` @@ -304,7 +304,7 @@ private val knownDTagReaders = - [ ] **Step 6: Update the design doc §2** -In `amethyst/plans/2026-09-10-dvm-heartbeat-liveness.md`, replace the §2 bullet that says the class "extends `BaseReplaceableEvent`" / "Overrides `dTag()`" with the as-built wording: extends `BaseAddressableEvent` (the codebase convention for 10xxx events with real `d` tags, e.g. `FollowListEvent`), so `dTag()`/`address()`/`addressTag()` come from the base; note `MAX_AGE_SECONDS`, `isFreshAt`, and the `knownDTagReaders` entry live in quartz too (commons imports them). +In `amethyst/plans/2026-09-10-dvm-heartbeat-liveness.md`, replace the §2 bullet that says the class "extends `BaseReplaceableEvent`" / "Overrides `dTag()`" with the as-built wording: extends `BaseAddressableEvent` (the codebase convention for 10xxx events with real `d` tags, e.g. `StarterPackEvent`), so `dTag()`/`address()`/`addressTag()` come from the base; note `MAX_AGE_SECONDS`, `isFreshAt`, and the `knownDTagReaders` entry live in quartz too (commons imports them). - [ ] **Step 7: Run the tests to verify they pass** diff --git a/amethyst/plans/2026-09-10-dvm-heartbeat-liveness.md b/amethyst/plans/2026-09-10-dvm-heartbeat-liveness.md index b67a5cd4bc..3e76ed1eb6 100644 --- a/amethyst/plans/2026-09-10-dvm-heartbeat-liveness.md +++ b/amethyst/plans/2026-09-10-dvm-heartbeat-liveness.md @@ -45,7 +45,7 @@ feeds and the detail surface show an offline state instead. New `quartz/.../nip90Dvms/dvmHeartbeat/DvmHeartbeatEvent.kt`: - `class DvmHeartbeatEvent(...) : BaseAddressableEvent(...)`, `KIND = 11998` — the codebase - convention for 10xxx events with real `d` tags (e.g. `FollowListEvent`), so `dTag()` / + convention for 10xxx events with real `d` tags (e.g. `StarterPackEvent`), so `dTag()` / `address()` / `addressTag()` come from the base. The cache address is `Address(11998, dvmPubkey, dTag)`, the exact mirror of the announcement's `Address(31990, dvmPubkey, dTag)`. diff --git a/amethyst/plans/2026-09-17-nip-cc-geocaching.md b/amethyst/plans/2026-09-17-nip-cc-geocaching.md index 837d0de01c..ea2a24df5a 100644 --- a/amethyst/plans/2026-09-17-nip-cc-geocaching.md +++ b/amethyst/plans/2026-09-17-nip-cc-geocaching.md @@ -83,7 +83,7 @@ The listing event is mostly existing tags wearing a new kind number: | `r` | `nip51Lists/tags/RelayTag` (`"r"`) | | `a` | `nip01Core/tags/aTag/ATag` (curation list + found log) | | `title`, `description` (37517) | `nip51Lists/tags/TitleTag`, `.../DescriptionTag` | -| embedded 7517 | `Event.fromJson(...)` (same trick `LnZapEvent.zapRequest()` uses) | +| embedded 7517 | `Event.fromJson(...)` (same trick `ZapReceiptEvent.zapRequest()` uses) | | signature check | `Event.verifySignature()` in `nip01Core/crypto/EventExt.kt` | | kind 1111 logs | `nip22Comments/CommentEvent` **as-is** — it already models `A/K/P` + `a/k/p` addressable root+parent (`rootAddress()`, `replyAddress()`, `hasRootAddress()`). Only the `t` log-type vocabulary is new. | | required-tag gate | `containsAllTagNamesWithValues(REQUIRED_FIELDS)`, as `ClassifiedsEvent.isWellFormed()` does | diff --git a/amethyst/plans/2026-09-19-cordn-ui.md b/amethyst/plans/2026-09-19-cordn-ui.md new file mode 100644 index 0000000000..db83c49efa --- /dev/null +++ b/amethyst/plans/2026-09-19-cordn-ui.md @@ -0,0 +1,527 @@ +# Cordn UI: every feature their client has, and what it costs us + +Status: proposed. No code yet. + +Companion to `quartz/plans/2026-09-17-cordn-interop.md`, which covers the protocol. That plan's +Stage 4 shipped the headless layer (`commons/…/cordn/`) and the §8 disclosure surface +(`commonsUI/…/cordn/ui/`, `Settings → Cordn group link`). This plan covers the rest: the group UI, +and every other user-facing feature the reference client has. + +Sources read on 2026-09-19: + +- `Cordn-msg/cordn-web` @ `c38e307` (2026-09-16), version `0.4.0` — **MIT**, the client at + . Read for its feature surface; nothing is translated from it. +- `Cordn-msg/cordn` @ `b465df0` — `spec/applications/*` for the normative half. + +Read the licensing correction in the interop plan's §7 before touching anything outside +`packages/core` and `packages/cli`: the rest of that repo is unlicensed. + +## 1. Executive summary + +**Marmot is frozen. Nothing in this plan changes how Marmot works.** The two protocols do not +interoperate, neither is a layer of the other, and the design requirement is that **either side +can walk away from the other** without the other noticing. That is a standing constraint, not a +stage, and §3 is what it means in practice. + +`amethyst/…/chats/marmotGroup/` is 3,308 lines covering the same *kinds* of screen cordn needs — +group list, chat view, group info, create-group. None of it is a reuse candidate. cordn gets its +own screens, and the only thing the two share is ordinary app furniture (§3.2). + +**The gating dependency is not a screen.** There is no account-level cordn runtime: nothing +constructs a `CordnGroupManager` for the logged-in account, wires it to a relay pool, publishes +key packages, or persists state. Until that exists, every group screen has nothing to render. +That is Stage A, and it is most of the risk. + +**One feature is an explicit non-goal** (multi-device, §5.3), and several are cordn.net product +features rather than protocol (§6). + +## 2. What their client actually has + +From `cordn-web`'s routes and components, not from the spec. Anything marked ✅ we already have in +some form; ⚠️ means we have a Marmot-shaped equivalent that does not transfer as-is. + +### 2.1 Coordinators (`/chat/coordinators`, `config/`) + +| Feature | Their file | Ours | +| ------- | ---------- | ---- | +| Coordinator list + per-coordinator page | `coordinators/[coordinatorKey]` | ✅ `CoordinatorConfig` model, no screen | +| Add a coordinator (pubkey + relays) | `CoordinatorAddForm` | model only | +| "Add default coordinator" | `coordinators/+page` | — | +| Health | `coordinatorHealth.svelte.ts` | ✅ `CoordinatorHealth` + `CoordinatorHealthRow` | +| Server info | `coordinatorServerInfo.svelte.ts` | ContextVM `initialize()` exists; unsurfaced | +| Purge a coordinator and its groups | `CoordinatorPurgeDialog` | — | + +### 2.2 Key packages (`config/key-packages`) + +| Feature | Their file | Ours | +| ------- | ---------- | ---- | +| Create a key package, with a label | `config/key-packages` | engine has `createKeyPackage`; no lifecycle | +| Publish to a coordinator | `chatCoordinatorActions` | ✅ `CordnGroupManager.publishKeyPackage` | +| Stored (local) key packages | `KeyPackageCard` | ⚠️ Marmot has `KeyPackageBundleStore` | +| Browse a coordinator's directory | `AvailableKeyPackageDirectory`, `VirtualKeyPackageList` | `kp_list` client exists; no screen | +| Last-resort conflict resolution | `LastResortConflictDialog` | — | + +Note §4.2 of the interop plan: cordn has **no key-package event kind**, so none of Marmot's +kind-443 machinery (rotation manager, relay list, publish obligations) applies. This is new work, +not an extraction. + +### 2.3 Group lifecycle + +| Feature | Their file | Ours | +| ------- | ---------- | ---- | +| Create group (name, description, icon, image, coordinator, key package) | `create-group` | ✅ `createGroup` + ⚠️ `CreateGroupScreen` (Marmot) | +| Group list / sidebar / tab bar | `ChatSidebar`, `ChatTabBar` | ⚠️ `MarmotGroupListScreen` | +| Group info: metadata, admins, participants, created, **current epoch**, coordinator, group id, **MLS snapshots**, **sync issues** | `chat/[id]/info` | ⚠️ `MarmotGroupInfoScreen` (1,154 lines) | +| Share link + QR | `share`, `QrShareDialog`, `QrScanner`, `GroupLinkInput` | ✅ `shareRef`, ✅ link screen; no QR | +| Join requests (accept / decline) | `JoinRequestCard` | client calls exist; no flow, no screen | +| Welcome notifications ("invited you", "accepted") | `WelcomeNotificationCard` | ✅ `joinPendingWelcomes`; no surface | +| New conversation (pick pubkeys, max 50) | `NewConversationDialog`, `ChatPubkeyMultiSelect` | ⚠️ Marmot equivalent | +| Admin policy gate | `chatAdminPolicy.ts` | ✅ `CordnGroupPolicy` (egalitarian-when-empty) | +| Delete local group / mark as read | `ChatGroupActions` | ⚠️ Marmot equivalent | + +### 2.4 Messaging + +Their kind catalog is `src/lib/chat/kinds.ts`, and it is the sharpest divergence in this document: + +| Purpose | cordn-web | Marmot (ours) | Spec says | +| ------- | --------- | ------------- | --------- | +| Chat message | 9 | ✅ 9 | NIP-C7, `spec/02.md` §6 | +| Threaded reply | 1111 | ✅ 1111 | NIP-22, §6 | +| Reaction | 7 | ✅ 7 | NIP-25, §6 | +| Edit | **1010** | **1009** | nothing | +| Delete | 5 | 5 | nothing | +| Pin / unpin | **1011** (`op` tag, LWW) | — | nothing | +| System rows | synthetic **client-side** `-1`, derived from Commits | kind **1210** on the wire | nothing | + +`spec/02.md` §6 is explicit that there is "no required set of `kind` values". So edits, pins and +system rows are **app conventions, not protocol** — and the two apps picked differently. §5.1 +decides what we do. + +Message-level features: reply, react (quick + custom emoji), edit, delete, copy, download, pin, +"message info", a pinned ribbon, unread chips, drafts, mentions, presence, and a per-message +route with a rich renderer beside the compact stream row (`src/lib/chat/README.md` documents that +two-renderer contract — worth reading; Amethyst's feed has the same shape implicitly). + +Media: `ChatMessageMedia`, `InlineMediaUrl`, `MediaLightbox`, Blossom upload, plus **voice notes** +(`voiceRecorder` / `voicePlayback`). Encrypted per `spec/applications/encrypted-media.md`, which +§4.5 of the interop plan already records as **diverging from our MIP-04 v2**: cordn uses the +exporter output directly as the file key with `aad = mime‖0x00‖filename‖0x00‖sha256(plaintext)`, +where MIP-04 v2 does `HKDF-Expand(exporter, context)` with a different AAD. Separate codec, +shared primitives (ChaCha20-Poly1305, NIP-92 `imeta`, Blossom) that Amethyst already has. + +### 2.5 Settings + +Backup & recovery (passphrase-encrypted export/restore, optional message history), media +(upload server, auto-load policy), notifications (background poll interval), appearance, and +multi-device. + +## 3. Independence is the design constraint + +### 3.1 The rule + +Marmot and cordn are two bindings of RFC 9420 onto two unrelated delivery models. They share no +groups, no messages, no identities and no servers. So: + +- **Marmot is frozen.** No cordn work may edit, refactor or generalise Marmot code. A cordn + requirement is never a reason to touch `marmotGroup/`, `commons/…/marmot/` or + `quartz/…/marmot/`. +- **Neither may import the other.** Not models, not state holders, not screens, not helpers. +- **Either must be deletable.** Removing cordn entirely should not compile-break Marmot, and the + reverse should hold too. + +This is now **enforced, not documented**: + +| Guard | Covers | +| ----- | ------ | +| `quartz/…/BindingIsolationTest` | `mls/` imports neither binding nor Nostr; `cordn/` ⊥ `marmot/` both ways | +| `commons/…/cordn/CordnIndependenceTest` | `commons/…/cordn/` ⊥ `commons/…/marmot/` both ways | + +Both scan shipped source sets only — an interop test that drives both profiles through one engine +is how we *demonstrate* they cannot collide, and a fixture is data, not a dependency. Both also +assert that the scan found real files and real imports, because an architecture test that passes +for the wrong reason is worse than none. + +Writing the first one immediately found a leak: **`mls/group/MlsGroup.kt` imported +`nip01Core.core.toHexKey`** — a Nostr import in an engine whose own README says it "knows nothing +about Marmot, Nostr, or cordn". The README only ever grepped for `marmot`, so the stated invariant +was wider than the check. `toHexKey()` is defined as `Hex.encode(this)`, so the fix was a +one-for-one swap to the binding-neutral `quartz/utils/Hex` with no behaviour change. + +**When the Android screens land, add the third guard** over `chats/cordnGroup/` ⊥ +`chats/marmotGroup/`, in `amethyst/src/test`. It is the one that matters most, because screens are +where "just reuse that composable" is most tempting. + +### 3.2 What cordn may share + +App furniture, and nothing that knows what a group is: + +- The theme, typography and colour scheme. +- Generic components — buttons, text fields, avatars, dialogs, QR encode/scan, image and video + viewers, the media upload pipeline, rich-text rendering, icons. +- Platform services — Blossom, the relay pool, the signer, notifications. + +Not shareable, by the rule above: chatroom models, message view models, feed filters, group list +rows, chat composers, group info screens, system-row renderers, or anything named `Marmot*`. + +### 3.3 The one thing this rules out + +There is no `Note`/`LocalCache` adaptation. Beyond the coupling, it would be the wrong call +anyway: `LocalCache` is the note graph for relay events, cordn envelopes are unsigned +(`spec/02.md` forbids `sig`) with no relay provenance, and one escaping into relay-bound code — +an outbox calculation, a NIP-65 decision, a relay-facing query — is a confidentiality bug rather +than a rendering bug. cordn gets its own store, keyed by envelope id. + +**Cost, stated plainly:** roughly 2,000–3,000 lines of screen code that resembles Marmot's, and +chat fixes that have to be made twice when they apply to both. That is the price of the +constraint, and it is the constraint that was asked for. The compensation is that cordn can move +at its own pace — it is the less settled protocol, with an unlicensed reference coordinator and +several unspecified conventions (§5.1) — without any of that reaching a shipped feature. + +## 4. Plan + +Each stage lists how it is verified. Note throughout: **Tier B is blocked** (interop plan §7 — +the reference coordinator is unlicensed), so nothing below can be checked against a live cordn +deployment. What *can* be checked: the in-memory coordinator (`FakeCoordinator`), the ContextVM +Tier C fixture server, and the ts-mls/`@cordn/core` vectors. Every stage below is verifiable that +way; none of them is verified against cordn.net until §7 of the interop plan is resolved. + +### Stage A — the account-level runtime (blocks everything) — **LANDED** + +No UI. This is the gap between "`CordnGroupManager` has tests" and "an account has cordn groups". + +1. **Wire the transport.** ✅ **LANDED.** `CvmTransport` over Amethyst's relay pool, with the + account signer as the stable identity and a per-session ephemeral signer (`spec/00.md` §8; the + split is already enforced by `CoordinatorMethod`). Encryption pinned `REQUIRED` — + `:contextvm`'s `CvmGiftWrap` defaults that way and nothing here may undo it (§8.6). + `CordnTransportHarness` + `CordnTransportIntegrationTest` drive the whole lifecycle over it. +2. **A per-account, per-coordinator manager registry.** ✅ **LANDED** as + `CordnCoordinatorRegistry` + `CordnSession`, opened through a `CordnCoordinatorScopeFactory` + seam so production passes relay-backed transport and encrypted stores while tests pass the + fixture. Three rules it enforces, each with a test that dies without it: + - **Idempotent per coordinator, under a mutex.** A second `CordnGroupManager` would + deserialise its own copy of every `MlsGroup` from the same store and commit against the same + epoch; MLS has no recovery from a forked ratchet tree. The UI and the sync loop both open a + session at launch, so concurrent opens are ordinary, not a race to wave off. + - **Never merged across coordinators.** A `gid` is unique only within one (§4), so two + coordinators serving `gid = "abc"` are unrelated groups. + - **Editing relays reopens the transport but keeps the stores**, and `forget` closes the wire + without wiping state — removing a coordinator is not purging it (cordn-web draws the same + line in `CoordinatorPurgeDialog`). + + A session also restores its own groups and KeyPackages on open rather than leaving it to the + caller: a session that reports no groups is indistinguishable from a failure, and its first + commit for a group it forgot would start at epoch zero. +3. **Persistence.** ✅ **LANDED** as `FileCordnGroupStore` / `FileCordnKeyPackageStore` in + `commons/jvmAndroid`, behind a `CordnBlobCipher` seam whose Android implementation is + AES-GCM under the Android KeyStore (`KeyStoreCordnBlobCipher`). + `FileBackedCordnScopeFactory` assembles them into a `CordnCoordinatorScope`, leaving only the + transport for the front end to supply (`CordnCoordinatorLinkFactory`). + + The cipher is an interface for one reason: the KeyStore cannot run in a JVM unit test, and + everything below it can fail silently. Marmot's equivalent store is welded to the KeyStore and + has no unit test at all; this one has sixteen, including that **the plaintext never reaches the + disk**. The rules they cover: + - **Scoped per account AND per coordinator.** Two accounts on a device must not read each + other's groups, and two coordinators can both serve `gid = "abc"` as unrelated groups (§4) — + a layout ignoring either would have one overwrite the other's ratchet tree. + - **A `gid` is caller-chosen, so it is encoded, not validated.** Marmot's store demands hex + because a Marmot group id is a hash; a cordn `gid` is whatever its creator picked and may + contain `..` or `/`. Base64url accepts every legal gid, keeps the write inside the store + directory, and still reverses — which is what lets `listGroups` return the real gid. + - **Atomic writes.** A truncated `MlsGroupState` is not a stale group but an unreadable one, + and it cannot be re-derived from anywhere else on the device. + - **Deleting a group takes its cursor.** Otherwise a later re-join of the same gid resumes from + a cursor belonging to a group it is no longer in and silently skips everything before it. + + The Android cipher **serialises** its calls: `KeyStoreEncryption` keeps one `Cipher` in a field + and `init` + `doFinal` is not atomic, while the stores run on `Dispatchers.IO`. Left alone, + two groups saving at once corrupts one of them. (The underlying class is shared with Marmot and + account storage and was not touched — the locking is in the cordn wrapper.) +4. **Key-package lifecycle.** ✅ **LANDED** as `CordnKeyPackages` + `CordnKeyPackageStore`, with + `KeyPackageBundleCodec` in `mls/` for the private half. cordn has no event kind for this + (§4.2), so it is all coordinator calls plus local storage — none of Marmot's rotation + machinery transferred, and none of it was reused. The ordering rules are the load-bearing + part and each has a test: + - **Publish stores the private half first.** A publish that lands on the coordinator and then + fails locally leaves a KeyPackage others can invite us with and we cannot open. Storing + first makes the failure the harmless direction — an unused bundle on disk, cleaned up when + the call fails. + - **Withdraw tells the coordinator first**, for the mirror reason: while it still serves the + package, we still need the key. + - **Top-up counts what the coordinator holds**, not what we published — `kp_take` consumes a + single-use package, so our count never falls. Last-resort packages do not fill pool slots. + - **`kp_ref` is the RFC 9420 KeyPackageRef.** Getting it wrong fails silently in the worst + way: every take misses, every Welcome lands at an address nobody listens on, no group forms. + - **A second device publishes its own last-resort package.** `kp_list` is per account, not per + device, so adopting the other device's reusable key would send every fallback Welcome + somewhere this device cannot open. +5. **A foreground sync loop.** ✅ **LANDED** as `CordnSyncLoop` over a `CordnSyncSource` seam + (which `CordnGroupManager` implements). `catch_up` drains history, then a `subscribe` is held + open and re-opened when it closes; the cursor makes the seam safe, so the two phases cannot + leave a hole. + + Wrapping two calls would be pointless if the happy path were the story. It is not — three + things separate an unattended loop from a call, and each has a test that dies without it: + - **A failure must not end the loop.** A coordinator down for a minute is ordinary; an + exception that escapes stops syncing for the session and looks exactly like a quiet group. + Every attempt is caught, recorded on `CoordinatorHealth`, and retried with a backoff that + **resets on success** — without the reset one bad stretch pins the loop at its cap forever. + - **An account with no groups must not spin.** Both calls return immediately when the manager + holds nothing, so the obvious `while (true)` burns a core on every new account, silently. + The loop parks on `gids` instead. + - **A group joined mid-subscription must not wait.** A subscription is opened for a fixed set + of gids, so a group joined a moment later is not in it; a change to the set cancels and + re-opens rather than leaving the new room empty until the stream times out. + + A closed stream is a re-subscribe, not a failure — counting a normal timeout as an error would + put a healthy loop into permanent backoff. `start()` is idempotent: two loops on one session + would both deliver, and every message would appear twice. + +Nothing in Stage A may touch Marmot. Where a cordn need resembles a Marmot one — an encrypted +state store, a key-package store — cordn gets its own, because §3.1 forbids a shared abstraction +and §3.1's guards will say so. + +*Verified by:* an integration test driving two accounts through the Tier C fixture end to end — +publish, invite, join, send, receive — with the real transport rather than `FakeCoordinator`; +plus store and loop suites over their own seams, because the failures they guard against +(a silent spin, an outage that ends sync, a plaintext write) cannot be provoked on a schedule +through a transport. + +**What Stage A still does not do:** nothing constructs a `FileBackedCordnScopeFactory` yet, because +its `CordnCoordinatorLinkFactory` needs Amethyst's relay pool and account signers. That wiring is +the first thing Stage B does, and it is one function. + +### Stage B — groups exist and are visible — **LANDED** + +6. **A cordn chatroom model + ViewModel** in `commons` (`model/cordnGroups/`, beside + `marmotGroups/`), fed by `CordnGroupManager.Delivery`. Messages keyed by envelope id + (`spec/02.md` §7 — the cursor is a delivery primitive, never a message identity). +7. **Group list**, with `CordnGroupBadge` (built, unused) on every row. +8. **Minimal chat screen**: ordered messages, sender, send a kind-9. No reactions, no media yet. +9. **Group info**: metadata, members, admins, epoch, coordinator, `gid`, share ref, and the + **`CordnExposureCard`** (built, and this is its real home — the link screen was the pre-join + half of the same disclosure). + +Add the **third isolation guard** here, over `chats/cordnGroup/` ⊥ `chats/marmotGroup/` (§3.1). +It belongs with the first screen, not after the fifth. + +*Verified by:* commons tests on the model; render tests on the new composables, the way +`CordnExposureRenderTest` does it; the new guard. + +### Stage C — joining and being joined — **LANDED** + +10. **Welcome inbox surface** — "X invited you to join", accept/decline, wired to + `joinPendingWelcomes`. The skip reasons that method already returns are user-facing text. +11. **Join requests** — `join_request_store` from a share link; the admin side lists pending + requests and completes them (take key package → verify → Add+Commit → `welcome_store`). +12. **QR share + scan**, reusing Amethyst's existing QR components. +13. **Create group**, including choosing a coordinator and the `gid` (their client uses a random + UUID; anything unique works and it must not be derived from the MLS `group_id`, which is + secret). + +*Verified by:* a two-account fixture test covering link → request → accept → first message. + +### Stage D — message features — **LANDED** + +14. Reply (1111), reaction (7), delete (5) — the three that are spec-suggested and match Marmot. +15. **Edit and pin** — only after §5.1 is decided. +16. Drafts, unread state, mentions, pinned ribbon. +17. **Encrypted media**: the cordn codec (§4.5), then image/file send and view over Blossom. +18. Voice notes, if we want parity; Amethyst has audio recording already. + +*Verified by:* codec vectors for media (generated the way `cordn-vector-gen` does), fixture tests +for the rest. + +### Stage E — settings — **LANDED** + +19. Coordinator management screen (add, remove, purge, health, server info). +20. Key-package screen (create, publish, directory, last-resort). +21. Backup/restore — **only if** we decide cordn state belongs in Amethyst's existing backup + story rather than a cordn-specific one. + +## 5. Decisions this plan needs + +### 5.1 Edit and pin kinds — DECIDED: follow cordn-web, and LANDED + +Marmot edits are **1009**, cordn-web edits are **1010**, and pins (**1011**) exist only in +cordn-web. `spec/02.md` §6 blesses none of them — it names chat/reply/reaction and then says there +is "no required set of kind values", so these are app conventions two apps picked differently. + +**Decision: follow cordn-web.** Being the second implementation of an unspecified convention is +how it becomes a specification; a third numbering would leave two clients that silently no-op on +each other's edits. Amethyst carries one kind table per binding, which it needs anyway because +system rows already differ (§5.2). + +Landed 2026-09-19 in `quartz/…/cordn/spec02Envelopes/`: + +- `CordnMessageKinds` — the catalog, with the divergence table in its KDoc. +- `CordnMessageReferences` — parse **and** build in one file, because they are two halves of one + format and the failure of splitting them is a client that emits tags its own parser rejects. + A test asserts exactly that round-trip. +- `CordnAnnotationIndex` — the fold, and the reason any of this is protocol code rather than view + model: **the authorization rules live here.** + +The rules, each with a test that fails if it is loosened: + +| Annotation | Who may | Ordering | +| ---------- | ------- | -------- | +| Reaction | anyone | n/a — a set per emoji | +| Edit | **author only** | newest `created_at`, ties on cursor | +| Deletion | **author only**, and `k` must match the target | n/a | +| Pin | **any member** | newest wins, ties on cursor | + +Two of those are load-bearing and easy to get wrong: + +- **Deletion is resolved before edits**, so a late edit cannot resurrect text its author already + withdrew. The pass order in the fold is not incidental. +- **Ties break on the coordinator's cursor**, which is the only total order two clients both see + (`spec/00.md` §4). Without it, two edits in the same second are a coin flip that two clients + could call differently — and §7 of `spec/02.md` still holds: the cursor breaks ties, it is never + an identity. + +Mutation-checked: dropping the edit author check, the delete author check, the cursor tie-break, +or the delete-before-edit ordering each kills its own test. + +Still worth asking upstream to write 1010, 1011 and the derived system row into a spec. + +### 5.2 System rows are structurally different + +Marmot puts system events **on the wire** (kind 1210, a real envelope). cordn-web **derives** them +client-side from Commits and never transmits them (synthetic kind `-1`). Deriving is strictly +better here — it cannot disagree with the MLS state it describes, and it costs no bytes — and it +is what a cordn peer will do anyway. Take theirs for cordn. Do not change Marmot. + +### 5.3 Multi-device — the fleet stays a non-goal; migration LANDED + +This section twice recorded multi-device as a non-goal, and twice for a reason +that was narrower than stated. The decision below supersedes both. + +**What is still a non-goal: a live fleet.** Two devices of one identity staying +in step is what `multi-device.md` §10 leaves unresolved — committing inside one +delivery round-trip lands them on the same epoch with different states, and §15 +concedes *equal-epoch MLS states have no merge function*. Nothing here changes +that, and nothing should ship that depends on it. + +**What landed: migration.** Moving an account from one Amethyst phone to +another is the same documents, the same seal and the same tip, minus the thing +that makes a fleet hard — a handoff has **one writer**. The old phone publishes +a snapshot and stands down; the new one seeds from it (§9) and starts. The +equal-epoch race is out of reach by construction rather than by mitigation. + +Built: + +| What | Where | +| ---- | ----- | +| §4/§5/§6/§7 documents, seal, tip inventory | `quartz/…/cordn/appMultiDevice/` | +| §6/§11 handoff code (`cordndev1…`) | `CordnHandoffCode` | +| publish / fetch | `commons/…/cordn/CordnMigration` | +| snapshot read + write over the stores | `CordnMigrationStores` | +| the fork guard | `CordnHandoffState`, enforced at `CordnRuntime.session()` | +| Blossom, two platforms | `AndroidCordnBlobStore`, `HttpCordnBlobStore` | +| `amy cordn migrate export\|import` | `cli/…/CordnMigrateCommands` | +| live end-to-end harness | `cli/tests/cordn/migrate.sh` | +| screens | `CordnMigrateScreen`, `CordnHubScreen` | + +Deliberately **not** built, because each exists to keep two *live* devices in +step: §8.5 `prev` chains, §10 sibling-Commit convergence, §10.5 +publish-on-every-Commit, §8 tombstone reconciliation. + +Five additive fields beyond the spec's documents, each because the spec has +nowhere to say it and a migration loses something real otherwise: +`clientStateFormat` (so a ts-mls document is refused with a reason rather than +crashing inside our MLS engine), `amethystRoomState` / `amethystEchoState` / +`amethystJoinedViaRequest` (drafts, read positions and echo bookkeeping — +`CordnBackup` loses all three today), `amethystCoordinatorRelays` (§8.5 of +`spec/00.md` gives a coordinator no address but its pubkey), and the full +key-package list on the meta document (§11.5 carries only the last-resort one, +which is right for a fleet and wrong for a phone that is going away). + +**Cross-client migration remains impossible**, unchanged: `clientState` is +library-private by design (§4.2), so an Amethyst device and a cordn-web device +can never share a leaf. What landed is Amethyst-to-Amethyst, which is what was +asked for. + +**The cost that was weighed and accepted:** the sealed documents leave the +device for a storage server. They are NIP-44 v2 under a DEK reachable only +through a seal to the owner's own npub, so the server sees size and timing and +nothing else — but group state including leaf private keys is on someone else's +disk until the blobs are deleted. The export screen says so before the button. + +Revisit the *fleet* question if upstream specifies the equal-epoch tiebreaker. +A library-neutral `clientState` would separately unlock cross-client migration. +Neither has happened. + +### 5.4 Which backup story — DECIDED: our own format, and LANDED + +cordn-web has its own passphrase-encrypted backup. Amethyst has account backup already. Folding +cordn group state into Amethyst's backup is better for users and worse for portability (their +file will not import). Needs a call; no strong opinion here. + +**Answered while building it, and neither half of the trade survived contact.** + +- *Portability was never available.* The valuable content of any cordn backup is MLS group + state, which is an engine's internal serialization rather than a wire format — the same + reasoning §4.6 of the interop plan uses to rule out *cross-client* multi-device. Theirs is ts-mls's + `ClientState`, ours is `MlsGroupState`, and neither reads the other whatever container wraps + it. A byte-compatible file would buy a restore that cannot restore. Their client also sits + outside the two MIT packages, and the spec prose is unlicensed. +- *There was nothing to fold into.* "Amethyst has account backup already" means **key** backup. + A relay-backed account rebuilds itself from an nsec; a cordn group does not, because the key + alone restores nothing. + +So: `CordnBackup` — our own versioned format, scrypt at NIP-49's cost plus ChaCha20-Poly1305. +Restoring **replaces** a device rather than merging, because an MLS state export is a cloneable +identity and two devices committing from one state fork the ratchet tree — the same equal-epoch +divergence §5.3 records as unresolved in `multi-device.md` §10, reached here by restore instead of +by a race. + +## 6. Out of scope, and why + +- **News feed, donations, supporters** — cordn.net product surface, not protocol. +- **Theme editor / gallery** — Amethyst has its own theming. +- **App update banners, native bridge/shims** — packaging concerns of a web app shipped as a + native wrapper. +- **Profile pages, "why" page** — Amethyst has these. +- **Multi-device** — §5.3. + +## 7. Risks + +1. **Stage A is most of the work and all of the uncertainty.** Every screen is cheap once an + account really holds cordn groups; nothing is possible before that. +2. **No live verification.** Tier B is blocked on licensing, so "works against the fixture" is the + strongest claim any stage below can make. Shipping a chat feature to users on that basis is a + product decision, not a technical one — it should be made deliberately, and probably means + shipping behind a flag until a licensed coordinator exists to test against. +3. **Two group-chat UIs, permanently.** This is the accepted cost of §3, not a risk to mitigate — + but it has a failure mode worth naming: a chat bug fixed in one and forgotten in the other. + The mitigation is not a shared abstraction (§3.1 forbids it); it is that cordn's screens stay + small enough to hold in one head, which means resisting the urge to port Marmot's feature set + wholesale. Build what cordn users need, not what Marmot happens to have. +4. **§8 exposure must ride along.** The badge and the card exist; if the group list and info + screens ship without them the disclosure requirement quietly regresses to what it was before. + +## 8. Open questions + +1. Do we ship cordn to users at all before a licensed coordinator exists to test against (risk 2)? + **Still open, and still the only thing between this plan and users — but the question has + narrowed.** Every stage below it has landed, and the protocol layer is no longer + fixture-only: Tier B ran on 2026-09-22 against the reference coordinator and the full + lifecycle passes end to end (interop plan §7.1). So "untested against a real peer" is no + longer an argument against shipping. + + What has *not* changed is the licensing, and that was always the sharper half of risk 2. A + coordinator we may not depend on is one we cannot put in CI, cannot regression-test against + on every release, and cannot point a user at. Tier B is a diagnostic someone runs by hand, + which is worth a great deal at this stage and nothing at all as a shipping guarantee. + + Worth separating when this is decided: the two bugs Tier B found were both invisible to five + tiers of our own tests, which is evidence about how much fixture-only verification is worth + — in either direction, depending on whether you read it as "the live tier works" or "we do + not know what else is hiding". +2. ~~§5.1 — follow cordn-web's 1010/1011, or wait for a spec?~~ **Answered: follow cordn-web.** + Landed; see §5.1. +3. ~~§5.4 — one backup story or two?~~ **Answered: our own.** Landed; see §5.4. +4. ~~Is there appetite for a shared chat surface?~~ **Answered: no.** Marmot is frozen and the + two must stay independently deletable; §3 is the standing constraint and the guards enforce it. +5. Whose coordinator do we default to, if any? The interop plan's open question 5 applies harder + once there is a UI with a "use default" button: shipping a default is an endorsement. diff --git a/amethyst/plans/2026-09-23-encrypted-storage-retirement.md b/amethyst/plans/2026-09-23-encrypted-storage-retirement.md new file mode 100644 index 0000000000..b2eccd2986 --- /dev/null +++ b/amethyst/plans/2026-09-23-encrypted-storage-retirement.md @@ -0,0 +1,140 @@ +# Retiring EncryptedStorage + +Status: **migrated, not yet deleted.** Every key has a home in the new stores. +The legacy files are still written, so they are still there — and the reader +can never go. + +## The constraint + +Every migration in the preference layer is *lazy*: it reads the legacy store +when it runs, not when the app is installed. Ten come through +`EncryptedStorage` — the eight `LegacyKeyTable` copies on the per-account +DataStore plus the key, secret and roster stores. Only the Cashu counters and +calendar reminders read a plain (non-encrypted) source and would survive its +removal. + +So deleting `EncryptedStorage` does not merely affect installs that have not +upgraded yet. It strands anyone who **skips** the release introducing the new +stores: a pre-migration build upgrading straight to a post-deletion build runs +its migration against a reader that no longer exists. Keys, accounts, wallets +and settings stay encrypted on disk with nothing able to read them, and the app +opens as a fresh install. Auto-update off, the F-Droid cadence and restoring +from a backup all skip releases. + +**The reader is permanent.** What a later release can retire is the legacy +*write*, which stops new data landing there while old data stays readable. +`androidx.security.crypto` has to stay for as long as the reader does. That is +an unmaintained-library risk, not an active vulnerability, and a much smaller +cost than stranding users. + +## Where each key went + +`LegacyKeyCoverageTest` holds this to being exhaustive: every constant in +`PrefKeys` is either claimed by a migration table, one of the secrets, on the +accepted-loss list, or a key of the global file. A key added to `PrefKeys` and +to none of those fails that test at the commit that adds it. + +| group | destination | legacy write | +|---|---|---| +| follow lists, cached events, upload, dialogs, relay auth, feed visibility, notifications | the account's plain DataStore | already retired | +| identity — pubkey, signer, local relays, backup conflicts, backup flag | the account's plain DataStore | **kept** | +| private key | `SecureKeyStorage` | **kept** | +| NIP-46 material, wallets, payment source | the account's encrypted DataStore | **kept** | +| current account, saved accounts | the encrypted roster store | **kept** | +| UI settings (`shared_settings`) | `UiSharedPreferences`' own DataStore | none left | +| location-chat identity — seed, nickname | the account's encrypted DataStore, as its own `GeohashIdentitySecrets` group | **kept** | + +The stores that still mirror are the ones whose loss is not an annoyance: an +account that cannot be listed, signed with, or paid from. They keep the +rollback window open until the device pass below has happened. + +The location-chat identity is the odd one, in two ways worth knowing before +step 4. It is its **own** group rather than fields on `AccountSecrets`: every +account save mirrors a whole `AccountSecrets` built from `AccountSettings`, +which does not hold these, and that group save removes keys whose value is +null — folded in, the seed would be deleted by the next unrelated save and +every geohash identity the user has would silently change. And its legacy home +is a **different file**, `secret_keeper_`, because its writer +passed `signer.pubKey` where every other caller passes an npub. + +The UI settings copy is **guarded** where the others are not. That store has +been the real home of these settings for a while, so most installs already +have a populated one and copying the old blob over it would undo every UI +change since. The copy only runs into a store that has never been saved +(`ui.theme` absent, which `save` always writes). + +## Deliberately not migrated + +| key | what is lost | +|---|---| +| `PENDING_ATTESTATIONS` | queued OTS attestations are not published | +| `NOTIF_GLOBAL_TO_CURATED_MIGRATED` | the one-shot notification filter migration runs once more | +| `LAST_READ_PER_ROUTE` | every feed reads as unread once | +| `USE_PROXY`, `PROXY_PORT` | nothing — only ever removed, never read | +| `TOR_SETTINGS` | nothing — no reader left anywhere | + +These are listed in `LegacyAccountKeys.accepted`, which is what lets the +cleanup treat any *other* unclaimed key as a reason to keep the file. + +## Deleting a legacy file + +`LegacyPreferenceCleanup` runs after every successful account load and deletes +that account's files — `secret_keeper_` **and** the location-chat +identity's `secret_keeper_` — only when it can prove nothing would +be lost. Both, because nothing else would ever remove the second one: the +cleanup enumerates npub-keyed files, so left out of this it would sit on disk +holding a seed forever. + +1. **Every key in the file is accounted for** — claimed by a table, one of the + secrets, or on the accepted list. Driven from the file's own keys, not from + a checklist, because a checklist fails silently in the one direction that + matters. +2. **Every copy that had something to copy has run.** Marker-based, not a value + comparison: those groups stopped being legacy-written when they moved, so + the file is a frozen snapshot and the two are *expected* to diverge as soon + as the user changes a setting. `CopyOnceMigration` commits the values and + its marker as one `Preferences`, so the marker cannot be set without them. +3. **The secrets and the private key read back identical** from the current + stores. Those *are* still dual-written, so the stronger question is + available and is asked. +4. **The location-chat identity has been copied**, when its file holds one. + Read from the hex-keyed file, not the npub one — these two keys were never + in that one, so a check pointed at it would never fire. An account that + never opened a location chat holds neither key, which is a real answer and + must not hold the file hostage. +5. A store that cannot be read is a reason, never a pass. + +It refuses today, and says so, because of the last condition: +`LEGACY_WRITES_RETIRED` is false. While the app still mirrors into the file, +deleting it achieves nothing — the next save recreates it — and would look +like it had worked. + +## Order of work + +1. ~~Migrate the remaining keys.~~ Done. +2. ~~Gate deletion on a per-account read-back.~~ Done. +3. Do the device pass below. +4. Flip `LEGACY_WRITES_RETIRED` and drop the legacy writes for the identity, + key, secret and roster stores. This ends the rollback window, so it is a + release of its own. The flag is `internal`, not private, so the + location-chat mirror in `GeohashChatIdentityState` reads the same switch — + flipping it stops that write too, and the cleanup then removes both of the + account's legacy files. One flip, nothing left behind. +5. Keep the reader, and `androidx.security.crypto`, indefinitely. + +## Verification this needs and has not had + +None of the AndroidKeyStore paths have executed: this environment has no device +or emulator, and `commons` has no Robolectric. What is tested is the decision +logic against fakes — which is why step 3 is not optional, and why deletion is +the one irreversible step in the whole series. + +On a real device, before step 4 ships: upgrade an install holding accounts and +confirm they all list; open one and sign; force-stop and relaunch; add and +remove an account; pair a NIP-46 signer; pay from a wallet; check the +key-backup nudge stays dismissed; confirm UI settings survive the upgrade; +open a location chat under a bunker or external signer and confirm the +throwaway identity and nickname are the same ones as before the upgrade — the +seed is the one migrated value whose loss is silent rather than visible. +Then let the cleanup run with the flag flipped, and confirm the files are gone +and everything above still holds on the next cold start. diff --git a/amethyst/plans/README.md b/amethyst/plans/README.md index ca667f7c01..e96cd495cb 100644 --- a/amethyst/plans/README.md +++ b/amethyst/plans/README.md @@ -11,6 +11,7 @@ _Audited 2026-06-30. 21 plans: 19 shipped (archived), 1 in-progress, 1 queued, 0 ## Queued | Plan | Summary | | ---- | ------- | +| [2026-09-19-cordn-ui.md](2026-09-19-cordn-ui.md) | Every user-facing feature cordn-web has, mapped onto Amethyst — the account-level cordn runtime (the real gate), group list/chat/info screens, welcomes, join requests, message features and settings. Marmot is frozen and the two bindings must stay independently deletable, enforced by isolation tests rather than a README; flags the 1009-vs-1010 edit-kind divergence. | | [2026-09-15-qr-reader-overhaul.md](2026-09-15-qr-reader-overhaul.md) | QR reader rebuilt on CameraX + zxing-cpp in-app (replacing the Camera1 zxing-android-embedded activity) — continuous AF, zoom, torch, full-frame decode, explicit failure feedback, and gallery/clipboard import. | | [2026-07-23-push-notification-redesign.md](2026-07-23-push-notification-redesign.md) | Per-kind tray notification redesign — accent colors, status-bar icons, MessagingStyle/BigPictureStyle/colorized zap cards, aggregation, Conversations/Bubbles; closes nutzap/onchain/repost/badge parity gaps. | | [2026-06-20-napplet-inter-applet.md](2026-06-20-napplet-inter-applet.md) | NAP-INC / NAP-INTENT inter-applet messaging — deferred; prerequisites (multi-applet hosting, archetype registry, `MESSAGING` capability) not yet built. | diff --git a/amethyst/plans/archive/2026-05-14-onchain-zaps.md b/amethyst/plans/archive/2026-05-14-onchain-zaps.md index 4e06e084b3..9a449c7120 100644 --- a/amethyst/plans/archive/2026-05-14-onchain-zaps.md +++ b/amethyst/plans/archive/2026-05-14-onchain-zaps.md @@ -94,7 +94,7 @@ Section labels: "Bitcoin" and "Lightning". ## Subscription edits — extend existing kind lists No new assemblers. Add `OnchainZapEvent.KIND` (8333) to the existing -`LnZapEvent.KIND` (9735) sites: +`ZapReceiptEvent.KIND` (9735) sites: | File | Edit | |---|---| @@ -109,7 +109,7 @@ No new assemblers. Add `OnchainZapEvent.KIND` (8333) to the existing ## Display path — fold into `Note.zapsAmount` -Today: `LocalCache.consume(LnZapEvent)` → `Note.addZap()` → `Note.updateZapTotal()` +Today: `LocalCache.consume(ZapReceiptEvent)` → `Note.addZap()` → `Note.updateZapTotal()` sums lightning amounts into `Note.zapsAmount`, which `ReactionsRow` / `ObserveZapAmountText` / `SlidingAnimationAmount` render. We add onchain zap sats to the same `Note.zapsAmount` — no UI changes required. @@ -118,7 +118,7 @@ sats to the same `Note.zapsAmount` — no UI changes required. - Add `var onchainZaps = mapOf<...>()` (separate map from `zaps`). - Extend `updateZapTotal()` to add **verified** onchain sats. Unverified or pending tx amounts are NOT counted. -- `amethyst/.../model/LocalCache.kt` (after the `consume(LnZapEvent)` block ~line 1667) +- `amethyst/.../model/LocalCache.kt` (after the `consume(ZapReceiptEvent)` block ~line 1667) - New `consume(event: OnchainZapEvent)` handler. - Reject self-zap. - Enqueue verification against the configured `OnchainBackend`. diff --git a/amethyst/plans/archive/2026-06-22-napplet-nsite-security.md b/amethyst/plans/archive/2026-06-22-napplet-nsite-security.md index d9a14ff27d..d6236690cf 100644 --- a/amethyst/plans/archive/2026-06-22-napplet-nsite-security.md +++ b/amethyst/plans/archive/2026-06-22-napplet-nsite-security.md @@ -34,7 +34,7 @@ and what remains as future work. 2. **Private mute/block leak via `identity.getMutes`/`getBlocked`.** These read `muteList.flow` / `blockPeopleList.flow`, which contain **decrypted private** entries. Now they read the events' **public** tags only (`MuteListEvent.publicMutes()`, - `PeopleListEvent.publicUsersIdSet()`). + `FollowSetEvent.publicUsersIdSet()`). 3. **Silent "allow-always" actions + UI-redress.** Added persistent **trusted chrome** (a sandbox bar the applet can't draw over: shield + name + tap-to-see "what it can diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/NotificationFeedFilterModeOverrideTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/NotificationFeedFilterModeOverrideTest.kt index d51726d393..8c8bf58097 100644 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/NotificationFeedFilterModeOverrideTest.kt +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/NotificationFeedFilterModeOverrideTest.kt @@ -25,10 +25,10 @@ import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuMintDirectoryFilterAssembler import com.vitorpamplona.amethyst.commons.relayClient.nip47WalletConnect.NWCPaymentFilterAssembler +import com.vitorpamplona.amethyst.commons.service.http.OkHttpWebSocket import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.service.location.LocationState -import com.vitorpamplona.amethyst.service.okhttp.OkHttpWebSocket import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.dal.NotificationFeedFilter import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ThreadDualAxisChartAssemblerTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ThreadDualAxisChartAssemblerTest.kt index 1a205eaf1b..06c0b9ba0c 100644 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ThreadDualAxisChartAssemblerTest.kt +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ThreadDualAxisChartAssemblerTest.kt @@ -24,11 +24,11 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuMintDirectoryFilterAssembler import com.vitorpamplona.amethyst.commons.relayClient.nip47WalletConnect.NWCPaymentFilterAssembler +import com.vitorpamplona.amethyst.commons.service.http.OkHttpWebSocket import com.vitorpamplona.amethyst.commons.viewmodels.thread.ThreadFeedFilter import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.service.location.LocationState -import com.vitorpamplona.amethyst.service.okhttp.OkHttpWebSocket import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.crypto.verify diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AccountKeyStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AccountKeyStore.kt new file mode 100644 index 0000000000..5ef8b4af68 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AccountKeyStore.kt @@ -0,0 +1,191 @@ +/* + * 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 + +import com.vitorpamplona.amethyst.commons.keystorage.SecureKeyStorage +import com.vitorpamplona.quartz.utils.Log + +/** + * The narrow slice of a key store this needs. + * + * An interface rather than [SecureKeyStorage] directly so the decision logic + * below — which store wins, what happens when one fails — is testable without + * an AndroidKeyStore, which no unit test can reach. + */ +interface PrivateKeyVault { + /** The stored key, or null only when genuinely absent. Throws when the store cannot be read. */ + suspend fun get(npub: String): String? + + suspend fun save( + npub: String, + privKeyHex: String, + ) + + suspend fun delete(npub: String) +} + +/** [PrivateKeyVault] over the real [SecureKeyStorage]. */ +class SecureKeyStorageVault( + private val storage: SecureKeyStorage, +) : PrivateKeyVault { + override suspend fun get(npub: String): String? = storage.getPrivateKeyOrThrow(npub) + + override suspend fun save( + npub: String, + privKeyHex: String, + ) = storage.savePrivateKey(npub, privKeyHex) + + override suspend fun delete(npub: String) { + storage.deletePrivateKey(npub) + } +} + +/** + * Moves account private keys off `androidx.security.crypto` without ever + * leaving one only in a place the running build cannot read. + * + * The old home is `secret_keeper_`, an EncryptedSharedPreferences file. + * The new one is [SecureKeyStorage], which on Android is now an encrypted + * DataStore sealed by the AndroidKeyStore directly. Both are written on every + * save, and reads prefer the new store but fall back to the old one, so: + * + * - an install that has never run this build still finds its key, and is + * migrated the first time the account loads; + * - a build rolled back to reading only the old store still finds every key, + * including ones added after the upgrade; + * - a new store that cannot be read — a wiped AndroidKeyStore after a device + * credential reset, say — falls back rather than presenting the account as + * having no key, which would silently demote it to read-only. + * + * Nothing is deleted here, and the legacy *reader* is permanent — see + * [EncryptedStorage]. The migration is lazy, so an install that skips the + * release introducing this store still needs the old file readable when it + * finally arrives. What a later release can drop is the legacy **write**, once + * every key in that file has a new home; several still do not. + * + * The two stores cannot legitimately disagree: an npub is derived from its + * private key, so the key for a given npub never changes. A mismatch means + * corruption, and is resolved in favour of the older, proven store. + */ +class AccountKeyStore( + private val vault: PrivateKeyVault, +) { + companion object { + private const val TAG = "AccountKeyStore" + } + + /** + * The account's private key, or null when it genuinely has none — an + * external-signer account, or a watch-only npub. + * + * @param legacyValue what the legacy store holds, read by the caller that + * already has the file open. + */ + suspend fun read( + npub: String, + legacyValue: String?, + ): String? { + val fromSecure = + try { + vault.get(npub) + } catch (e: Exception) { + // Unreadable, not absent. Fall back, and do not migrate into a + // store that just failed. + Log.w(TAG, "Could not read the key store for $npub; using the legacy store", e) + return legacyValue + } + + if (fromSecure != null) { + if (legacyValue != null && legacyValue != fromSecure) { + Log.e(TAG, "Key mismatch for $npub between the legacy and current stores; keeping the legacy value", null) + return legacyValue + } + return fromSecure + } + + // Absent from the new store: first load since the upgrade. + if (legacyValue != null) migrate(npub, legacyValue) + return legacyValue + } + + private suspend fun migrate( + npub: String, + privKeyHex: String, + ) { + try { + vault.save(npub, privKeyHex) + Log.i(TAG) { "Migrated the private key for $npub into the current store" } + } catch (e: Exception) { + // The legacy store still has it and is still read, so this is + // recoverable — the next load tries again. + Log.w(TAG, "Could not migrate the private key for $npub; it stays in the legacy store", e) + } + } + + /** + * Mirrors a save into the new store. The legacy write stays where it is, + * inside the caller's existing edit block, so a rollback keeps working. + * + * The three cases match the legacy write exactly, including the one that is + * easy to get wrong: with no external signer and no private key in hand, + * the legacy store *leaves the stored key alone* rather than clearing it, + * so this must not clear it either. Deleting here would drop the key on + * every save from a session that never decrypted it. + */ + suspend fun mirrorSave( + npub: String, + usesExternalSigner: Boolean, + privKeyHex: String?, + ) { + try { + when { + usesExternalSigner -> vault.delete(npub) + privKeyHex != null -> vault.save(npub, privKeyHex) + else -> Unit + } + } catch (e: Exception) { + // Never fatal: the legacy store still loads the account, and the + // next save or load repairs this one. + Log.w(TAG, "Could not write the private key for $npub to the current store", e) + } + } + + /** + * What the current store holds, with no fallback to the legacy value. + * + * For [LegacyPreferenceCleanup]; [read] deliberately hides this distinction. + */ + suspend fun stored(npub: String): String? = vault.get(npub) + + /** Drops the key from the new store; the caller clears the legacy file itself. */ + suspend fun delete(npub: String) { + try { + vault.delete(npub) + } catch (e: Exception) { + Log.w(TAG, "Could not delete the private key for $npub from the current store", e) + } + } +} + +/** The production instance, over the app's [SecureKeyStorage]. */ +val accountKeyStore: AccountKeyStore by lazy { + AccountKeyStore(SecureKeyStorageVault(SecureKeyStorage.create(Amethyst.instance.appContext))) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AccountRoster.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AccountRoster.kt new file mode 100644 index 0000000000..012bd535b9 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AccountRoster.kt @@ -0,0 +1,183 @@ +/* + * 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 + +import com.vitorpamplona.amethyst.commons.model.preferences.AccountRosterStore +import com.vitorpamplona.amethyst.commons.model.preferences.SecretEncryption +import com.vitorpamplona.quartz.utils.Log + +/** + * The slice of the roster store this needs. + * + * An interface so the fallback decisions below are testable without an + * AndroidKeyStore, which no unit test can reach. + */ +interface RosterStorage { + suspend fun hasMigrated(): Boolean + + suspend fun markMigrated() + + suspend fun currentAccount(): String? + + suspend fun setCurrentAccount(npub: String?) + + suspend fun allAccountInfoJson(): String? + + suspend fun setAllAccountInfoJson(json: String?) + + suspend fun clear() +} + +/** [RosterStorage] over the real encrypted store. */ +class EncryptedRosterStorage( + private val store: AccountRosterStore, +) : RosterStorage { + override suspend fun hasMigrated() = store.hasMigrated() + + override suspend fun markMigrated() = store.markMigrated() + + override suspend fun currentAccount() = store.currentAccount() + + override suspend fun setCurrentAccount(npub: String?) = store.setCurrentAccount(npub) + + override suspend fun allAccountInfoJson() = store.allAccountInfoJson() + + override suspend fun setAllAccountInfoJson(json: String?) = store.setAllAccountInfoJson(json) + + override suspend fun clear() = store.clear() +} + +/** + * Moves the account index — which accounts exist, which one is in front — out + * of the global `secret_keeper` EncryptedSharedPreferences file, on the same + * terms as the keys and secrets before it: both stores written, new store + * preferred on read, nothing deleted. + * + * This one is the most consequential to get wrong. Every private key can be + * perfectly intact and, if the roster reads empty, the app still opens as a + * fresh install with no way back to the accounts that are sitting on disk. + * So a read that fails or comes back empty falls through to the legacy file + * rather than being taken at face value. + * + * The legacy reader stays for good; see [EncryptedStorage] for why a lazy + * migration cannot have its source deleted. + */ +class AccountRoster( + private val store: RosterStorage, +) { + companion object { + private const val TAG = "AccountRoster" + } + + /** + * Runs the one-off copy if it has not run, and reports whether the new + * store can be trusted for this read. + * + * Returns false when anything goes wrong, which sends the caller to the + * legacy file. + */ + private suspend fun ready( + legacyCurrent: () -> String?, + legacyAll: () -> String?, + ): Boolean = + try { + if (!store.hasMigrated()) { + store.setCurrentAccount(legacyCurrent()) + store.setAllAccountInfoJson(legacyAll()) + // Marker last: a crash midway leaves this unmigrated, so the + // next read copies again rather than trusting a half-written + // roster. + store.markMigrated() + } + true + } catch (e: Exception) { + Log.w(TAG, "Could not prepare the roster store; using the legacy file", e) + false + } + + suspend fun currentAccount( + legacyCurrent: () -> String?, + legacyAll: () -> String?, + ): String? { + if (!ready(legacyCurrent, legacyAll)) return legacyCurrent() + + return try { + store.currentAccount() ?: legacyCurrent() + } catch (e: Exception) { + Log.w(TAG, "Could not read the current account; using the legacy file", e) + legacyCurrent() + } + } + + /** + * The saved-account list as JSON. + * + * An empty or absent value falls through to the legacy file rather than + * being reported as "no accounts": the two are indistinguishable here, and + * only one of them is safe to act on. + */ + suspend fun allAccountInfoJson( + legacyCurrent: () -> String?, + legacyAll: () -> String?, + ): String? { + if (!ready(legacyCurrent, legacyAll)) return legacyAll() + + return try { + store.allAccountInfoJson()?.takeIf { it.isNotBlank() && it != "[]" } ?: legacyAll() + } catch (e: Exception) { + Log.w(TAG, "Could not read the saved accounts; using the legacy file", e) + legacyAll() + } + } + + suspend fun mirrorCurrentAccount(npub: String?) = guard { store.setCurrentAccount(npub) } + + suspend fun mirrorAllAccountInfoJson(json: String?) = guard { store.setAllAccountInfoJson(json) } + + /** Matches the legacy `clear()` on the global file when the last account goes. */ + suspend fun clear() = guard { store.clear() } + + private suspend fun guard(block: suspend () -> Unit) { + try { + block() + } catch (e: Exception) { + // Never fatal: the legacy file is still written and still read. + Log.w(TAG, "Could not write the roster store", e) + } + } +} + +val accountRoster: AccountRoster by lazy { + AccountRoster( + EncryptedRosterStorage( + AccountRosterStore( + // Through the holder rather than a DataStore built here: it is the + // one registry that knows which files already have a live store, + // and `roster` sits in the same directory as every other one. + Amethyst.instance.appStores.getDataStore(ROSTER_FILE_NAME), + SecretEncryption(), + Amethyst.instance.applicationIOScope, + ), + ), + ) +} + +private const val ROSTER_FILE_NAME = "roster" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AccountSecretsStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AccountSecretsStore.kt new file mode 100644 index 0000000000..aaf46b8726 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AccountSecretsStore.kt @@ -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 + +import com.vitorpamplona.amethyst.commons.model.preferences.AccountSecrets +import com.vitorpamplona.amethyst.commons.model.preferences.AccountSecretsEncryptedStores +import com.vitorpamplona.amethyst.commons.model.preferences.GeohashIdentitySecrets +import com.vitorpamplona.quartz.utils.Log +import okio.Path.Companion.toOkioPath + +/** + * Moves the per-account secrets — NIP-46 bunker material, wallet connection + * strings — out of the `secret_keeper_` EncryptedSharedPreferences file + * and into an encrypted DataStore, on the same terms as the private key: both + * stores written, new store preferred on read, nothing deleted. + * + * The copy is lazy rather than a DataMigration, and that is not a style + * choice. A DataMigration writes values as-is, while this store decrypts on + * read, so plaintext placed there by one cannot be read back — the attempt + * raises. `EncryptedDataStoreTest` pins that behaviour. Copying through the + * store's own `save` is what keeps the values readable. + * + * Losing these is recoverable — the user re-pairs a signer or re-adds a wallet + * — but it is not something to spend, so a read that fails falls back to the + * legacy values rather than reporting the account as having none. + * + * The legacy reader stays for good; see [EncryptedStorage] for why a lazy + * migration cannot have its source deleted. + */ +class AccountSecretsStore( + private val stores: AccountSecretsEncryptedStores, +) { + companion object { + private const val TAG = "AccountSecretsStore" + } + + /** + * The account's secrets, migrating out of the legacy file on first use. + * + * @param legacy what the legacy encrypted file holds, read by the caller + * that already has it open. + */ + suspend fun read( + npub: String, + legacy: AccountSecrets, + ): AccountSecrets { + val stored = + try { + stores.loadSecrets(npub) + } catch (e: Exception) { + Log.w(TAG, "Could not read the secrets store for $npub; using the legacy file", e) + return legacy + } + + if (stored != null) return stored + + // Not migrated yet: copy the legacy values across and use them. + mirror(npub, legacy) + return legacy + } + + /** Mirrors a save into the new store. The legacy write stays where it is. */ + suspend fun mirror( + npub: String, + value: AccountSecrets, + ) { + try { + stores.saveSecrets(npub, value) + } catch (e: Exception) { + // Never fatal: the legacy file still has them, and the next save or + // load tries again. + Log.w(TAG, "Could not write the secrets for $npub to the current store", e) + } + } + + /** + * What the current store holds, with no fallback to the legacy file. + * + * For [LegacyPreferenceCleanup], which has to tell "migrated" from + * "falling back and looking migrated" — the read above deliberately cannot. + */ + suspend fun stored(npub: String): AccountSecrets? = stores.loadSecrets(npub) + + // ── the location-chat identity ──────────────────────────────────── + + /** + * The account's location-chat identity, migrating out of the legacy file on + * first use, on the same terms as [read]. + * + * @param legacy opens and reads `secret_keeper_`. Note the + * *hex*: this group's legacy file is keyed by the signer's pubkey rather + * than the npub every other group uses, so it is a different file. + * + * A lambda, not a value, because opening that file **creates** it — an + * `EncryptedSharedPreferences` writes its Tink keyset on construction. An + * eager read would resurrect the file on the load after the cleanup + * deleted it, and would cost a Keystore-backed open per account on every + * cold start. Called only when the store has nothing yet. + */ + suspend fun readGeohashIdentity( + npub: String, + legacy: suspend () -> GeohashIdentitySecrets, + ): GeohashIdentitySecrets { + val stored = + try { + stores.loadGeohashIdentity(npub) + } catch (e: Exception) { + Log.w(TAG, "Could not read the location-chat identity for $npub; using the legacy file", e) + return legacy() + } + + if (stored != null) return stored + + val fromLegacy = legacy() + mirrorGeohashIdentity(npub, fromLegacy) + return fromLegacy + } + + /** + * What the current store holds for the location-chat identity, with no + * fallback to the legacy file — the same distinction [stored] draws, and + * for the same reader: [LegacyPreferenceCleanup] has to tell "migrated" + * from "falling back and looking migrated". + */ + suspend fun storedGeohashIdentity(npub: String): GeohashIdentitySecrets? = stores.loadGeohashIdentity(npub) + + /** Mirrors a save into the new store. The legacy write stays where it is. */ + suspend fun mirrorGeohashIdentity( + npub: String, + value: GeohashIdentitySecrets, + ) { + try { + stores.saveGeohashIdentity(npub, value) + } catch (e: Exception) { + Log.w(TAG, "Could not write the location-chat identity for $npub to the current store", e) + } + } + + suspend fun delete(npub: String) { + try { + stores.removeAccount(npub) + } catch (e: Exception) { + Log.w(TAG, "Could not drop the secrets store for $npub", e) + } + } +} + +val accountSecretsStore: AccountSecretsStore by lazy { + AccountSecretsStore( + AccountSecretsEncryptedStores( + rootFilesDir = { + Amethyst.instance.appContext.filesDir + .toOkioPath() + }, + scope = Amethyst.instance.applicationIOScope, + ), + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt index ebe76d1a34..8332fb8ed8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt @@ -26,9 +26,6 @@ import android.os.Build import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp import com.vitorpamplona.amethyst.commons.service.http.HttpClientEnvironment import com.vitorpamplona.amethyst.commons.service.http.MediaCallEventListener -import com.vitorpamplona.amethyst.favorites.BrowserHistoryRegistry -import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry -import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry import com.vitorpamplona.amethyst.favorites.WebShortcuts import com.vitorpamplona.amethyst.napplet.WebAppNetworkRegistry import com.vitorpamplona.amethyst.napplet.WebSitePermissionRegistry @@ -148,24 +145,24 @@ class Amethyst : Application() { WorkerThreadPriorityGovernor.start(this) // Hydrate the device-local favorite-apps list (main process only; the sandbox never reads it). - FavoriteAppsRegistry.init(this) + instance.favoriteApps.init() // Mirror web-app favorites into the launcher's long-press shortcuts (open in Amethyst's browser). CoroutineScope(Dispatchers.Default).launch { - FavoriteAppsRegistry.favorites + instance.favoriteApps.favorites .map { apps -> apps.filterIsInstance().map { it.url to it.label } } .distinctUntilChanged() - .collect { WebShortcuts.publishFavorites(this@Amethyst, FavoriteAppsRegistry.favorites.value) } + .collect { WebShortcuts.publishFavorites(this@Amethyst, instance.favoriteApps.favorites.value) } } // Hydrate the per-site camera/microphone/location answers the browser asks about. WebSitePermissionRegistry.init(this) // Hydrate the device-local browser visit history (main process only; feeds the omnibox suggestions). - BrowserHistoryRegistry.init(this) + instance.browserHistory.init() // Index device-local captured favicons (main process only; decorates favorites + suggestions). - BrowserIconRegistry.init(this) + instance.browserIcons.init() // 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. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 4ffb52fa28..934226f2c8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -27,12 +27,27 @@ import android.os.SystemClock import androidx.security.crypto.EncryptedSharedPreferences import coil3.disk.DiskCache import coil3.memory.MemoryCache +import com.vitorpamplona.amethyst.commons.browser.BrowserHistoryRegistry +import com.vitorpamplona.amethyst.commons.browser.BrowserIconRegistry +import com.vitorpamplona.amethyst.commons.connectedApps.DataStoreNostrSignerPermissionStore +import com.vitorpamplona.amethyst.commons.connectedApps.nip46.DataStoreNip46ClientStore +import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppsRegistry import com.vitorpamplona.amethyst.commons.model.NoteState import com.vitorpamplona.amethyst.commons.model.UiSettings import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.model.nip03Timestamp.BitcoinExplorerEndpoint import com.vitorpamplona.amethyst.commons.model.nip03Timestamp.IncomingOtsEventVerifier import com.vitorpamplona.amethyst.commons.model.nip03Timestamp.TorAwareOkHttpOtsResolverBuilder +import com.vitorpamplona.amethyst.commons.model.preferences.AppPreferenceStores +import com.vitorpamplona.amethyst.commons.model.preferences.BuzzAttestationStore +import com.vitorpamplona.amethyst.commons.model.preferences.BuzzChannelStarStore +import com.vitorpamplona.amethyst.commons.model.preferences.BuzzWorkspaceStore +import com.vitorpamplona.amethyst.commons.model.preferences.DrawerSectionCollapsePreferences +import com.vitorpamplona.amethyst.commons.model.preferences.NamecoinSettingsStore +import com.vitorpamplona.amethyst.commons.model.preferences.OtsSettingsStore +import com.vitorpamplona.amethyst.commons.model.preferences.RelayGroupDeletionStore +import com.vitorpamplona.amethyst.commons.model.preferences.TorSettingsStore +import com.vitorpamplona.amethyst.commons.model.preferences.UiSettingsStore import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger import com.vitorpamplona.amethyst.commons.relayClient.BlockedRelayFilteringClient import com.vitorpamplona.amethyst.commons.relayClient.diagnostics.BootRelayDiagnostics @@ -40,6 +55,7 @@ import com.vitorpamplona.amethyst.commons.relayClient.event.EventFinderQueryStat import com.vitorpamplona.amethyst.commons.relayClient.speedLogger.RelaySpeedLogger import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderQueryState import com.vitorpamplona.amethyst.commons.relays.health.TorCircuitHealthTracker +import com.vitorpamplona.amethyst.commons.relays.nip11RelayInfo.Nip11CachedRetriever import com.vitorpamplona.amethyst.commons.richtext.CachedAsciiDocToMarkdown import com.vitorpamplona.amethyst.commons.richtext.CachedRichTextParser import com.vitorpamplona.amethyst.commons.robohash.CachedRobohash @@ -52,33 +68,28 @@ import com.vitorpamplona.amethyst.commons.service.http.DualHttpClientManager import com.vitorpamplona.amethyst.commons.service.http.DualHttpClientManagerForRelays import com.vitorpamplona.amethyst.commons.service.http.EncryptionKeyCache import com.vitorpamplona.amethyst.commons.service.http.LocalBlossomMediaCallFactory +import com.vitorpamplona.amethyst.commons.service.http.OkHttpWebSocket import com.vitorpamplona.amethyst.commons.service.http.OnionLocationCache import com.vitorpamplona.amethyst.commons.service.lnurl.OkHttpLnurlEndpointResolver +import com.vitorpamplona.amethyst.commons.service.pow.PoWJobStore import com.vitorpamplona.amethyst.commons.service.pow.PoWPolicy import com.vitorpamplona.amethyst.commons.service.pow.PoWPublishQueue import com.vitorpamplona.amethyst.commons.state.UiSettingsState +import com.vitorpamplona.amethyst.commons.tor.TorRelayState import com.vitorpamplona.amethyst.commons.tor.TorSettings -import com.vitorpamplona.amethyst.connectedApps.DataStoreNostrSignerPermissionStore -import com.vitorpamplona.amethyst.connectedApps.nip46.DataStoreNip46ClientStore import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState -import com.vitorpamplona.amethyst.model.nip11RelayInfo.Nip11CachedRetriever -import com.vitorpamplona.amethyst.model.preferences.BuzzAttestationPreferences -import com.vitorpamplona.amethyst.model.preferences.BuzzChannelStarPreferences -import com.vitorpamplona.amethyst.model.preferences.BuzzWorkspacePreferences -import com.vitorpamplona.amethyst.model.preferences.DrawerSectionCollapsePreferences -import com.vitorpamplona.amethyst.model.preferences.NamecoinSharedPreferences -import com.vitorpamplona.amethyst.model.preferences.OtsSharedPreferences -import com.vitorpamplona.amethyst.model.preferences.RelayGroupDeletionPreferences -import com.vitorpamplona.amethyst.model.preferences.TorSharedPreferences +import com.vitorpamplona.amethyst.model.nip60Cashu.CashuPreferences import com.vitorpamplona.amethyst.model.preferences.UiSharedPreferences -import com.vitorpamplona.amethyst.model.preferences.sharedPreferencesDataStore import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilder import com.vitorpamplona.amethyst.model.torState.AccountsTorStateConnector -import com.vitorpamplona.amethyst.model.torState.TorRelayState import com.vitorpamplona.amethyst.napplet.DataStoreNappletPermissionStore -import com.vitorpamplona.amethyst.service.calendar.CalendarReminderPrefs +import com.vitorpamplona.amethyst.service.calendar.CALENDAR_REMINDER_LOG_STORE +import com.vitorpamplona.amethyst.service.calendar.CALENDAR_REMINDER_SETTINGS_STORE import com.vitorpamplona.amethyst.service.calendar.CalendarReminderWorker +import com.vitorpamplona.amethyst.service.calendar.calendarReminderLogMigrations +import com.vitorpamplona.amethyst.service.calendar.calendarReminderSettings +import com.vitorpamplona.amethyst.service.calendar.calendarReminderSettingsMigrations import com.vitorpamplona.amethyst.service.cast.CastRegistry import com.vitorpamplona.amethyst.service.connectivity.ConnectivityManager import com.vitorpamplona.amethyst.service.crashreports.CrashReportCache @@ -93,13 +104,11 @@ import com.vitorpamplona.amethyst.service.notifications.AlwaysOnNotificationServ import com.vitorpamplona.amethyst.service.notifications.NotificationDispatcher import com.vitorpamplona.amethyst.service.notifications.NwcPaymentNotificationWatcher import com.vitorpamplona.amethyst.service.notifications.PokeyReceiver -import com.vitorpamplona.amethyst.service.okhttp.OkHttpWebSocket import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCache import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCacheFactory import com.vitorpamplona.amethyst.service.playback.pip.BackgroundMedia import com.vitorpamplona.amethyst.service.playback.service.PlaybackServiceClient import com.vitorpamplona.amethyst.service.pow.PowJobRestorer -import com.vitorpamplona.amethyst.service.pow.PowJobStore import com.vitorpamplona.amethyst.service.pow.PowMiningForegroundService import com.vitorpamplona.amethyst.service.relayClient.CacheClientConnector import com.vitorpamplona.amethyst.service.relayClient.RelayProxyClientConnector @@ -198,6 +207,7 @@ import kotlinx.coroutines.flow.transform import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking +import okio.Path.Companion.toOkioPath import java.io.File class AppModules( @@ -229,19 +239,55 @@ class AppModules( private val _trimLevelEvents = MutableSharedFlow(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) val trimLevelEvents = _trimLevelEvents.asSharedFlow() + /** + * The app-wide DataStore files — the ones that belong to the install rather + * than to an account. [AccountPreferenceStores] is the same idea keyed by + * npub. + * + * This replaces the `Context.preferencesDataStore` delegate these stores + * used to share. Same paths — `AppPreferenceStores.file` reproduces + * `filesDir/datastore/.preferences_pb` exactly — so nothing migrates + * and a rollback finds its data where it left it. What it buys is that the + * stores themselves live in `commonMain`, where a desktop or CLI front end + * can say where its data lives instead of needing a `Context`. + * + * The shared_settings migration is attached here, to the file, because + * eight stores share it and DataStore runs a file's migrations once, on + * whichever store opens it first. + */ + val appStores by lazy { + AppPreferenceStores( + rootFilesDir = { appContext.filesDir.toOkioPath() }, + migrations = { name -> + when { + name == AppPreferenceStores.SHARED_SETTINGS -> UiSettingsStore.migrations { LocalPreferences.loadSharedSettings() } + // One file per account, so the migration is per name rather than a constant. + name.startsWith(CashuPreferences.FILE_PREFIX) -> + listOf(CashuPreferences.legacyMigration(appContext, name.removePrefix(CashuPreferences.FILE_PREFIX))) + name == CALENDAR_REMINDER_SETTINGS_STORE -> calendarReminderSettingsMigrations(appContext) + name == CALENDAR_REMINDER_LOG_STORE -> calendarReminderLogMigrations(appContext) + else -> emptyList() + } + }, + ) + } + + /** The file UI, Tor, Namecoin, OTS and the Buzz stores all share. */ + val sharedSettingsStore get() = appStores.sharedSettings() + // Pre-load both preference DataStores in parallel on IO threads. // Both constructors use runBlocking internally, so starting them concurrently // reduces total blocking time from (torPrefs + uiPrefs) to ~max(torPrefs, uiPrefs). private val uiPrefsDeferred = applicationIOScope.async { - val prefs = UiSharedPreferences.uiPreferences(appContext) ?: UiSettings() - UiSharedPreferences(prefs, appContext, applicationIOScope) + val prefs = UiSharedPreferences.uiPreferences(sharedSettingsStore) ?: UiSettings() + UiSharedPreferences(prefs, sharedSettingsStore, appContext, applicationIOScope) } private val torPrefsDeferred = applicationIOScope.async { - val prefs = TorSharedPreferences.torPreferences(appContext) ?: TorSettings() - TorSharedPreferences(prefs, appContext, applicationIOScope) + val prefs = TorSettingsStore.torPreferences(sharedSettingsStore) ?: TorSettings() + TorSettingsStore(prefs, sharedSettingsStore, applicationIOScope) } // Blocking load of UI Preferences to avoid theme/language blinking @@ -252,20 +298,26 @@ class AppModules( // Blocking load of Tor Settings to avoid connection leaks val torPrefs by lazy { - Log.d("AppModules", "TorSharedPreferences Init") + Log.d("AppModules", "TorSettingsStore Init") runBlocking { torPrefsDeferred.await() } } // Namecoin ElectrumX server preferences (global, like Tor settings) val namecoinPrefs by lazy { - Log.d("AppModules", "NamecoinSharedPreferences Init") - NamecoinSharedPreferences(appContext, applicationIOScope) + Log.d("AppModules", "NamecoinSettingsStore Init") + NamecoinSettingsStore(sharedSettingsStore, applicationIOScope) } // OTS blockchain explorer preferences (global, like Tor settings) + // + // The blocking load is the one the store used to do inside its own + // constructor: `current` has to answer synchronously for the resolver + // builder, so somebody has to wait. It is explicit here rather than hidden + // in commonMain, which has no runBlocking to hide it behind. val otsPrefs by lazy { - Log.d("AppModules", "OtsSharedPreferences Init") - OtsSharedPreferences(appContext, applicationIOScope) + Log.d("AppModules", "OtsSettingsStore Init") + val store = sharedSettingsStore + OtsSettingsStore(store, runBlocking { OtsSettingsStore.load(store) }) } // App services that should be run as soon as there are subscribers to their @@ -308,12 +360,13 @@ class AppModules( // Restore + persist the set of relay-group channels deleted (kind-9008) on this device, so a // deleted channel stays hidden across a restart even if the host relay re-announces a stale // kind-44100 for it (device-global; a delete is authoritative and terminal for everyone). - val relayGroupDeletionPrefs = RelayGroupDeletionPreferences(appContext, applicationIOScope) + val relayGroupDeletionPrefs = + RelayGroupDeletionStore(sharedSettingsStore, applicationIOScope) // Restore + persist which drawer section headings the user has folded away, so the side menu // opens the way they left it (device-global: a collapsed heading is a per-device view choice, // not an account setting worth syncing, unlike the hidden rows beside it in the drawer). - val drawerSectionCollapsePrefs = DrawerSectionCollapsePreferences(appContext.sharedPreferencesDataStore, applicationIOScope) + val drawerSectionCollapsePrefs = DrawerSectionCollapsePreferences(sharedSettingsStore, applicationIOScope) // Service that will run at all times to receive events from Pokey val pokeyReceiver = PokeyReceiver() @@ -811,8 +864,10 @@ class AppModules( */ val nappletAccountScope: () -> String = { sessionManager.loggedInAccount()?.pubKey ?: "" } - // Singleton stores for napplet permissions — DataStore v1 enforces one instance per file. - val nappletPermissionStore by lazy { DataStoreNappletPermissionStore(appContext, nappletAccountScope) } + // Singleton stores for napplet permissions. The holder is what enforces + // DataStore's one-instance-per-file rule now; this stays a lazy val so the + // ledger below and the broker share one object. + val nappletPermissionStore by lazy { DataStoreNappletPermissionStore(appStores.getDataStore("napplet_permissions"), nappletAccountScope) } /** * The one napplet permission ledger for the main process. Its persistent half is just the store @@ -830,10 +885,26 @@ class AppModules( // carry their owning account (`nip46::`) 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) } + val signerPermissionStore by lazy { DataStoreNostrSignerPermissionStore(appStores) } // Display + relay info for connected NIP-46 remote-signer clients. - val nip46ClientStore by lazy { DataStoreNip46ClientStore(appContext) } + val nip46ClientStore by lazy { DataStoreNip46ClientStore(appStores.getDataStore(DataStoreNip46ClientStore.FILE_NAME)) } + + // The device-local favorite-apps list behind the bottom bar, the Favorite Apps grid and the + // browser launcher, plus the browser's visit history behind the omnibox suggestions. Both live in + // commons and take their store and scope from here — that is the whole of their Android binding. + // + // Main process only: the keyless `:napplet` sandbox never builds AppModules, so it never builds + // these either. One instance each, so DataStore only ever sees one live reader per file. + val favoriteApps by lazy { FavoriteAppsRegistry(appStores.getDataStore(FavoriteAppsRegistry.FILE_NAME), applicationIOScope) } + + val browserHistory by lazy { BrowserHistoryRegistry(appStores.getDataStore(BrowserHistoryRegistry.FILE_NAME), applicationIOScope) } + + // Favicons captured by the browser host, one PNG per host. Not a DataStore — it takes the directory + // to keep them in, the same way AppPreferenceStores takes rootFilesDir. + val browserIcons by lazy { + BrowserIconRegistry({ appContext.filesDir.toOkioPath() / BrowserIconRegistry.DIR }, applicationIOScope) + } // Authenticates with relays. val authCoordinator = AuthCoordinator(client, applicationIOScope) @@ -912,7 +983,7 @@ class AppModules( // and every enqueue raises the shortService shield so backgrounding // doesn't freeze a miner. val powJobStore by lazy { - PowJobStore(File(appContext.filesDir, PowJobStore.FILE_NAME), applicationIOScope) + PoWJobStore(File(appContext.filesDir, PoWJobStore.FILE_NAME), applicationIOScope) } val powPublishQueue by lazy { @@ -969,11 +1040,11 @@ class AppModules( // start — Buzz membership is server-side) and the starred channels. Per account: the // joined set makes a relay first-party for NIP-42, and a star is personal. startBuzzPersistence = { account -> - BuzzWorkspacePreferences(appContext, account.scope, account.pubKey, account.buzzWorkspaces) - BuzzChannelStarPreferences(appContext, account.scope, account.pubKey, account.buzzChannelStars) + BuzzWorkspaceStore(sharedSettingsStore, account.scope, account.pubKey, account.buzzWorkspaces) + BuzzChannelStarStore(sharedSettingsStore, account.scope, account.pubKey, account.buzzChannelStars) // Eager like the rest, so a held NIP-OA attestation is loaded before this account's // first Buzz-relay AUTH rather than after it. - BuzzAttestationPreferences(appContext, account.scope, account.pubKey, account.buzzAttestation) + BuzzAttestationStore(sharedSettingsStore, account.scope, account.pubKey, account.buzzAttestation) }, ) @@ -1083,7 +1154,7 @@ class AppModules( // Local store for posts the user has scheduled to publish later. Backed by a // single JSON file under the app's private filesDir; read by ScheduledPostWorker. val scheduledPostStore = - ScheduledPostStore(File(appContext.filesDir, ScheduledPostStore.FILE_NAME)) + ScheduledPostStore(File(appContext.filesDir, ScheduledPostStore.FILE_NAME).path) // Organizes cache clearing val trimmingService by @@ -1303,7 +1374,7 @@ class AppModules( Filter(kinds = listOf(CalendarDateSlotEvent.KIND, CalendarTimeSlotEvent.KIND)), ).conflate() .collect { - if (CalendarReminderPrefs(appContext).isEnabled() && + if (calendarReminderSettings().load().enabled && CalendarReminderWorker.couldStillFire(CalendarReminderWorker.acceptedRsvpsInCache(), TimeUtils.now()) ) { CalendarReminderWorker.schedule(appContext) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/EncryptedStorage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/EncryptedStorage.kt index 324abea1e1..2802812ca9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/EncryptedStorage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/EncryptedStorage.kt @@ -24,6 +24,39 @@ import android.content.Context import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.MasterKey +/** + * The legacy encrypted preference files, and a permanent read-only migration + * source. + * + * # This class cannot be deleted + * + * Every migration in the preference layer is *lazy*: it reads the legacy store + * at the moment it runs, not when the app is installed. Nine of them come + * through here — the seven CopyOnceMigrations under LocalPreferences plus the + * key, secret and roster stores. + * + * So deleting this class does not only affect installs that have not upgraded + * yet. It strands anyone who **skips** the release that introduced the new + * stores: they move from a pre-migration build straight to a post-deletion one, + * the migration runs against a reader that no longer exists, and their keys, + * accounts, wallets and settings sit encrypted on disk with nothing able to + * read them. The app opens as a fresh install. That is not rare — auto-update + * off, the F-Droid cadence, or a restore from backup all skip releases. + * + * What *can* go, once the new path has shipped and held, is the legacy + * **writes**. Dropping those stops new data landing here while this stays able + * to read what is already here. The `androidx.security.crypto` dependency has + * to stay for as long as this does; it is an unmaintained-library risk rather + * than an active vulnerability, and a far smaller cost than stranding users. + * + * # Before any legacy file is deleted + * + * Deletion is only safe for an account whose every key has been migrated, and + * that is not yet true — see `amethyst/plans/2026-09-23-encrypted-storage-retirement.md` + * for what is still outstanding. NOSTR_PUBKEY is the one to watch: without it + * `loadAccountConfigFromEncryptedStorage` returns null and the account + * disappears whether or not its private key survived. + */ class EncryptedStorage { companion object { private const val PREFERENCES_NAME = "secret_keeper" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LegacyPreferenceCleanup.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LegacyPreferenceCleanup.kt new file mode 100644 index 0000000000..7a271b02ee --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LegacyPreferenceCleanup.kt @@ -0,0 +1,342 @@ +/* + * 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 + +import androidx.datastore.preferences.core.Preferences +import com.vitorpamplona.amethyst.commons.model.preferences.AccountIdentityStore +import com.vitorpamplona.amethyst.commons.model.preferences.AccountSecrets +import com.vitorpamplona.amethyst.commons.model.preferences.DialogDismissalStore +import com.vitorpamplona.amethyst.commons.model.preferences.FeedVisibilityStore +import com.vitorpamplona.amethyst.commons.model.preferences.GeohashIdentitySecrets +import com.vitorpamplona.amethyst.commons.model.preferences.LatestEventCacheStore +import com.vitorpamplona.amethyst.commons.model.preferences.LegacyAccountSecretNames +import com.vitorpamplona.amethyst.commons.model.preferences.LegacyKeyTable +import com.vitorpamplona.amethyst.commons.model.preferences.LegacyPreferenceSource +import com.vitorpamplona.amethyst.commons.model.preferences.NotificationPrefsStore +import com.vitorpamplona.amethyst.commons.model.preferences.RelayAuthStore +import com.vitorpamplona.amethyst.commons.model.preferences.TopNavFollowListStore +import com.vitorpamplona.amethyst.commons.model.preferences.UploadSettingsStore +import com.vitorpamplona.amethyst.commons.model.preferences.readLegacyGeohashIdentity +import com.vitorpamplona.quartz.utils.Log + +/** + * How every key that can appear in a `secret_keeper_` file is accounted + * for. + * + * Together with [LegacyAccountSecretNames], these two lists are what let + * [LegacyPreferenceCleanup] treat any *other* key in the file as a reason not + * to delete it. `LegacyKeyCoverageTest` holds them to covering all of + * `PrefKeys`, so a key added later cannot quietly fall outside both. + */ +internal object LegacyAccountKeys { + /** + * The one-shot copies out of the account's legacy file. + * + * Each store owns the table of legacy names it came from, so the copy and + * the check that the copy happened read the same list — see [LegacyKeyTable]. + */ + val tables = + listOf( + TopNavFollowListStore.legacyTable, + LatestEventCacheStore.legacyTable, + UploadSettingsStore.legacyTable, + DialogDismissalStore.legacyTable, + RelayAuthStore.legacyTable, + FeedVisibilityStore.legacyTable, + NotificationPrefsStore.legacyTable, + AccountIdentityStore.legacyTable, + ) + + /** + * Keys that are deliberately not carried across. + * + * Each costs something once and nothing after, and none is worth the code + * to move it: queued attestations go unpublished, the one-shot + * Global -> Curated notification rewrite runs one more time, and every feed + * reads as unread once. `use_proxy` and `proxy_port` are only ever removed, + * never read, and `tor_settings` has no reader left at all. + */ + val accepted = + setOf( + PrefKeys.PENDING_ATTESTATIONS, + PrefKeys.NOTIF_GLOBAL_TO_CURATED_MIGRATED, + PrefKeys.LAST_READ_PER_ROUTE, + PrefKeys.USE_PROXY, + PrefKeys.PROXY_PORT, + PrefKeys.TOR_SETTINGS, + ) +} + +/** What [LegacyPreferenceCleanup] did, and why. */ +sealed interface LegacyCleanupResult { + /** There was no legacy file for this account. */ + data object NothingToDelete : LegacyCleanupResult + + data object Deleted : LegacyCleanupResult + + /** Nothing was touched. Each reason names one thing that would have been lost. */ + data class Kept( + val reasons: List, + ) : LegacyCleanupResult +} + +/** The per-account legacy file, as this needs it. */ +interface LegacyAccountFiles { + fun source(npub: String): LegacyPreferenceSource + + /** + * The account's OTHER legacy file: the location-chat identity, which lives + * in `secret_keeper_` rather than `secret_keeper_` + * because that is the key its writer passed. + * + * Separate from [source] because [delete] removes both, and a check that + * read the npub file for these keys would never find them — they are not + * in it. That mistake was made once already. + */ + fun geohashSource(npub: String): LegacyPreferenceSource + + fun exists(npub: String): Boolean + + /** Returns false when there was nothing to delete. */ + suspend fun delete(npub: String): Boolean +} + +/** What the current, encrypted stores hold for an account. */ +interface MigratedSecrets { + /** Null when this account has not been copied across yet. */ + suspend fun secrets(npub: String): AccountSecrets? + + /** Null only when the account genuinely has no private key. Throws when the store is unreadable. */ + suspend fun privateKey(npub: String): String? + + /** + * The location-chat identity, or null when it has not been copied across. + * + * Its own question because it migrates out of its own file: [AccountSecrets] + * being present says nothing about whether this was carried over. + */ + suspend fun geohashIdentity(npub: String): GeohashIdentitySecrets? +} + +/** + * Deletes an account's `secret_keeper_` file, but only once it can prove + * nothing in it would be lost. + * + * # Why the check is not one rule + * + * The two halves of the migration are in different states, and asking the same + * question of both would give the wrong answer for one of them. + * + * The plain per-account groups — settings, dialogs, feeds, cached events — + * stopped being written to the legacy file when they moved, so that file is a + * frozen snapshot of the day they migrated. Comparing values would flag every + * setting the user has changed since. What is actually being asked of them is + * "did the copy run", and [LegacyKeyTable.hasRun] answers it exactly: + * `CopyOnceMigration` writes the values and its marker as a single + * `Preferences`, committed atomically, so the marker cannot be set without them. + * + * The private key takes the strongest form: read it back and require it to + * equal the legacy one. That comparison stays valid forever, because an npub is + * derived from its private key, so the key for a given npub can never change. + * + * The secrets cannot be compared, and the reason is worth stating because the + * obvious reading is wrong. They *are* dual-written today — but this whole + * check only runs once [legacyWritesRetired] is true, and from that release on + * the legacy copy is frozen while the live one keeps moving. An account that + * re-pairs a bunker or adds a wallet after upgrading would then differ from the + * file forever and never have it deleted. So they are gated the same way as the + * plain groups: on the copy having run, which + * [AccountSecretsEncryptedStores.loadSecrets] reports by returning non-null + * only once its marker is set, and it writes that marker last. + * + * # Why an unrecognised key blocks + * + * A list of keys to check, maintained by hand, fails silently in the one + * direction that matters: a key added later that no migration carries. So the + * check runs the other way round — every key *in the file* must be claimed by + * a table, be one of the secrets, or be on [accepted], the short list of + * deliberate losses. Anything else stops the deletion and says so by name. + * + * # Cost + * + * [LegacyPreferenceSource.keys] goes through `EncryptedSharedPreferences.all`, + * which decrypts every value in the file — there is no keys-only API. It is + * called from the one place an account load is not already cached, so it costs + * at most once per account per process, and nothing at all while + * [legacyWritesRetired] is false. + * + * # Why deletion also waits on the legacy writes + * + * [legacyWritesRetired] is the other half. While the app still mirrors into + * this file on every save, deleting it achieves nothing — the next save + * recreates it, with a subset of what was there. Worse, it would look like it + * had worked. So the file is only removed once it is no longer being written, + * which is a separate release from this one. + */ +class LegacyPreferenceCleanup( + private val tables: List, + private val accepted: Set, + private val files: LegacyAccountFiles, + private val currentStore: suspend (String) -> Preferences, + private val secrets: MigratedSecrets, + private val legacyWritesRetired: Boolean, +) { + companion object { + private const val TAG = "LegacyPreferenceCleanup" + + const val STILL_WRITTEN = "the legacy file is still written on every save" + } + + private val claimed: Set = tables.flatMapTo(mutableSetOf()) { it.legacyNames } + LegacyAccountSecretNames.all + + /** + * Everything that would be lost by deleting this account's legacy file. + * Empty means nothing would be. + * + * A store that cannot be read is a reason, never a pass: the whole point is + * to be sure, and "the check itself failed" is not sure. + */ + suspend fun verify(npub: String): List { + val legacy = + try { + files.source(npub) + } catch (e: Exception) { + Log.w(TAG, "Could not open the legacy file for $npub", e) + return listOf("the legacy file could not be read") + } + + val reasons = mutableListOf() + + val present = legacy.keys() + (present - claimed - accepted).sorted().forEach { + reasons += "no migration claims '$it'" + } + + val current = + try { + currentStore(npub) + } catch (e: Exception) { + Log.w(TAG, "Could not read the current store for $npub", e) + return reasons + "the current store could not be read" + } + + tables.forEach { table -> + // A table whose keys the file never held has nothing to prove. + if (present.none { it in table.legacyNames }) return@forEach + if (!table.hasRun(current)) reasons += "the '${table.markerName}' copy has not run" + } + + reasons += secretMismatches(npub, legacy) + + return reasons + } + + private suspend fun secretMismatches( + npub: String, + legacy: LegacyPreferenceSource, + ): List { + val reasons = mutableListOf() + + try { + if (secrets.secrets(npub) == null) reasons += "the secrets have not been copied across" + } catch (e: Exception) { + Log.w(TAG, "Could not read the secrets store for $npub", e) + reasons += "the secrets store could not be read" + } + + val legacyKey = legacy.getString(LegacyAccountSecretNames.NOSTR_PRIVKEY) + if (legacyKey != null) { + try { + when (secrets.privateKey(npub)) { + null -> reasons += "the private key has not been copied across" + legacyKey -> Unit + else -> reasons += "the stored private key differs from the legacy file" + } + } catch (e: Exception) { + Log.w(TAG, "Could not read the key store for $npub", e) + reasons += "the key store could not be read" + } + } + + reasons += geohashMismatches(npub) + + return reasons + } + + /** + * Whether deleting this account's `secret_keeper_` file would + * lose its location-chat identity. + * + * Read from [LegacyAccountFiles.geohashSource], not from the npub file the + * rest of [verify] walks: these two keys were never in that one. An account + * that never opened a location chat holds neither, and needs no copy. + */ + private suspend fun geohashMismatches(npub: String): List { + val legacy = + try { + readLegacyGeohashIdentity(files.geohashSource(npub)) + } catch (e: Exception) { + Log.w(TAG, "Could not read the location-chat identity file for $npub", e) + return listOf("the location-chat identity file could not be read") + } + + if (legacy == GeohashIdentitySecrets()) return emptyList() + + return try { + if (secrets.geohashIdentity(npub) == null) { + listOf("the location-chat identity has not been copied across") + } else { + emptyList() + } + } catch (e: Exception) { + Log.w(TAG, "Could not read the location-chat identity store for $npub", e) + listOf("the location-chat identity store could not be read") + } + } + + /** + * Deletes the account's legacy file if — and only if — [verify] comes back + * empty and the app has stopped writing to it. + */ + suspend fun deleteIfVerified(npub: String): LegacyCleanupResult { + if (!files.exists(npub)) return LegacyCleanupResult.NothingToDelete + + if (!legacyWritesRetired) return LegacyCleanupResult.Kept(listOf(STILL_WRITTEN)) + + val reasons = verify(npub) + if (reasons.isNotEmpty()) { + Log.i(TAG) { "Keeping the legacy file for $npub: ${reasons.joinToString("; ")}" } + return LegacyCleanupResult.Kept(reasons) + } + + return try { + if (files.delete(npub)) { + Log.i(TAG) { "Deleted the migrated legacy file for $npub" } + LegacyCleanupResult.Deleted + } else { + LegacyCleanupResult.NothingToDelete + } + } catch (e: Exception) { + Log.w(TAG, "Could not delete the legacy file for $npub", e) + LegacyCleanupResult.Kept(listOf("the legacy file could not be deleted")) + } + } +} diff --git a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessDismissedGamesStorage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LegacySharedPreferences.kt similarity index 51% rename from commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessDismissedGamesStorage.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/LegacySharedPreferences.kt index 7ea69cdd61..a49c151e9e 100644 --- a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessDismissedGamesStorage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LegacySharedPreferences.kt @@ -18,34 +18,28 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.commons.nip64Chess +package com.vitorpamplona.amethyst -import java.util.prefs.Preferences +import android.content.SharedPreferences +import com.vitorpamplona.amethyst.commons.model.preferences.LegacyPreferenceSource -actual class ChessDismissedGamesStorage private actual constructor() { - private val prefs: Preferences = Preferences.userNodeForPackage(ChessDismissedGamesStorage::class.java) +/** + * [LegacyPreferenceSource] over the `secret_keeper` files. + * + * Every getter reports absence as null rather than as a default, which + * `SharedPreferences` itself cannot do — that distinction is what keeps a + * migration from writing "false" over a key the user never set. + */ +class LegacySharedPreferences( + private val prefs: SharedPreferences, +) : LegacyPreferenceSource { + override fun keys(): Set = prefs.all.keys - actual companion object { - private const val NODE_PREFIX = "chess_dismissed_" - private const val DELIMITER = "," + override fun getBoolean(name: String): Boolean? = if (prefs.contains(name)) prefs.getBoolean(name, false) else null - actual fun create(context: Any?): ChessDismissedGamesStorage = ChessDismissedGamesStorage() - } + override fun getString(name: String): String? = prefs.getString(name, null) - actual fun load(userPubkey: String): Set { - val raw = prefs.get("$NODE_PREFIX$userPubkey", "") - if (raw.isEmpty()) return emptySet() - return raw.split(DELIMITER).toSet() - } - - actual fun save( - userPubkey: String, - ids: Set, - ) { - if (ids.isEmpty()) { - prefs.remove("$NODE_PREFIX$userPubkey") - } else { - prefs.put("$NODE_PREFIX$userPubkey", ids.joinToString(DELIMITER)) - } - } + // SharedPreferences hands back the live set and documents that mutating it + // corrupts the file, so this copies before anything downstream can hold it. + override fun getStringSet(name: String): Set? = prefs.getStringSet(name, null)?.toSet() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index a0acb8bad9..256a27ed35 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -35,11 +35,35 @@ import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerName import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupViewMode import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntry import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntryNorm +import com.vitorpamplona.amethyst.commons.model.preferences.AccountIdentity +import com.vitorpamplona.amethyst.commons.model.preferences.AccountIdentityStore +import com.vitorpamplona.amethyst.commons.model.preferences.AccountPreferenceStores +import com.vitorpamplona.amethyst.commons.model.preferences.AccountSecrets +import com.vitorpamplona.amethyst.commons.model.preferences.CopyOnceMigration +import com.vitorpamplona.amethyst.commons.model.preferences.DialogDismissal +import com.vitorpamplona.amethyst.commons.model.preferences.DialogDismissalStore +import com.vitorpamplona.amethyst.commons.model.preferences.FeedVisibility +import com.vitorpamplona.amethyst.commons.model.preferences.FeedVisibilityStore +import com.vitorpamplona.amethyst.commons.model.preferences.FollowListSlot +import com.vitorpamplona.amethyst.commons.model.preferences.LatestEventCacheStore +import com.vitorpamplona.amethyst.commons.model.preferences.LatestEventSlot +import com.vitorpamplona.amethyst.commons.model.preferences.LegacyPreferenceSource +import com.vitorpamplona.amethyst.commons.model.preferences.NotificationPrefs +import com.vitorpamplona.amethyst.commons.model.preferences.NotificationPrefsStore +import com.vitorpamplona.amethyst.commons.model.preferences.RelayAuth +import com.vitorpamplona.amethyst.commons.model.preferences.RelayAuthStore +import com.vitorpamplona.amethyst.commons.model.preferences.TopNavFollowListStore +import com.vitorpamplona.amethyst.commons.model.preferences.UploadSettings +import com.vitorpamplona.amethyst.commons.model.preferences.UploadSettingsStore +import com.vitorpamplona.amethyst.commons.model.preferences.orIfUnusable +import com.vitorpamplona.amethyst.commons.model.preferences.readLegacyAccountSecrets +import com.vitorpamplona.amethyst.commons.model.preferences.readLegacyGeohashIdentity import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.model.backups.BackupConflictStorage import com.vitorpamplona.amethyst.model.nip60Cashu.CashuPreferences +import com.vitorpamplona.amethyst.model.preferences.UiSharedPreferences import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent @@ -54,19 +78,20 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent -import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.nip17Dm.settings.DmRelayListEvent +import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes import com.vitorpamplona.quartz.nip19Bech32.toNpub -import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent +import com.vitorpamplona.quartz.nip28PublicChat.list.PublicChatListEvent import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayListEvent import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListEvent import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent -import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent +import com.vitorpamplona.quartz.nip51Lists.interestList.InterestListEvent import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.FavoriteRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent -import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.SimpleGroupListEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent @@ -81,10 +106,12 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable +import okio.Path.Companion.toOkioPath import java.io.File // Release mode (!BuildConfig.DEBUG) always uses encrypted preferences @@ -102,7 +129,7 @@ data class AccountInfo( val isTransient: Boolean = false, ) -private object PrefKeys { +internal object PrefKeys { const val CURRENT_ACCOUNT = "currently_logged_in_account" // Global (non-account) master switch for the always-on notification service. @@ -249,6 +276,220 @@ object LocalPreferences { private val savedAccountsMutex = Mutex() private val cachedAccounts: MutableMap = mutableMapOf() + /** + * The per-account DataStore: every non-secret setting this account has. + * + * Each account's store carries one [CopyOnceMigration] per group in + * [LegacyAccountKeys.tables], lifting that group out of the account's legacy + * encrypted SharedPreferences the first time the store is read. The copies + * leave the legacy keys in place, so a build that reads the old location + * still works — see [CopyOnceMigration]. + */ + private val accountStores: AccountPreferenceStores by lazy { + AccountPreferenceStores( + rootFilesDir = { + Amethyst.instance.appContext.filesDir + .toOkioPath() + }, + migrations = { npub -> + LegacyAccountKeys.tables.map { it.migration { legacySource(npub) } } + }, + ) + } + + private fun followListStore(npub: String) = TopNavFollowListStore(accountStores.getDataStore(npub)) + + private fun latestEventStore(npub: String) = LatestEventCacheStore(accountStores.getDataStore(npub)) + + private fun uploadSettingsStore(npub: String) = UploadSettingsStore(accountStores.getDataStore(npub)) + + private fun dialogDismissalStore(npub: String) = DialogDismissalStore(accountStores.getDataStore(npub)) + + private fun relayAuthStore(npub: String) = RelayAuthStore(accountStores.getDataStore(npub)) + + private fun feedVisibilityStore(npub: String) = FeedVisibilityStore(accountStores.getDataStore(npub)) + + private fun notificationPrefsStore(npub: String) = NotificationPrefsStore(accountStores.getDataStore(npub)) + + private fun identityStore(npub: String) = AccountIdentityStore(accountStores.getDataStore(npub)) + + private fun legacySource(npub: String): LegacyPreferenceSource = LegacySharedPreferences(encryptedPreferences(npub)) + + /** + * Whether the app has stopped mirroring into `secret_keeper_`. + * + * False, and deliberately so: the private key, the secrets and the identity + * group are all still written there, so that a build rolled back to reading + * only the legacy file still finds a complete account. Deleting the file + * while that is true would achieve nothing — the next save recreates it — + * so [legacyCleanup] refuses to. + * + * Flipping this is a release of its own, and it ends the rollback window. + * It waits on the device pass in + * `amethyst/plans/2026-09-23-encrypted-storage-retirement.md`. + * + * `internal` rather than private because the mirror is not all in this + * file: [com.vitorpamplona.amethyst.model.GeohashChatIdentityState] writes + * the location-chat identity into its own legacy file and reads this to + * know when to stop. Private, it would have kept writing after the flip + * and the switch would only half work. + */ + internal const val LEGACY_WRITES_RETIRED = false + + private val legacyCleanup: LegacyPreferenceCleanup by lazy { + LegacyPreferenceCleanup( + tables = LegacyAccountKeys.tables, + accepted = LegacyAccountKeys.accepted, + files = + object : LegacyAccountFiles { + override fun source(npub: String) = legacySource(npub) + + // Either file: once the npub one is gone, the hex one still + // has to be reachable or it can never be removed. + override fun exists(npub: String) = legacyAccountFile(npub).exists() || legacyAccountFile(geohashLegacyKey(npub)).exists() + + override fun geohashSource(npub: String) = LegacySharedPreferences(encryptedPreferences(geohashLegacyKey(npub))) + + override suspend fun delete(npub: String): Boolean { + // Clear before unlinking, as deleteAccount does: the live + // SharedPreferences still holds the values in memory and + // would write them straight back out. + encryptedPreferences(npub).edit(commit = true) { clear() } + val removedAccountFile = legacyAccountFile(npub).delete() + + // The location-chat identity is in a SECOND file, keyed by the + // pubkey hex rather than the npub, because that is the key its + // writer passed. Nothing else would ever remove it, so it goes + // with the account's own file rather than being left as an + // orphan holding a seed forever. + val removedGeohashFile = deleteGeohashLegacyFile(npub) + + return removedAccountFile || removedGeohashFile + } + }, + currentStore = { npub -> accountStores.getDataStore(npub).data.first() }, + secrets = + object : MigratedSecrets { + override suspend fun secrets(npub: String) = accountSecretsStore.stored(npub) + + override suspend fun privateKey(npub: String) = accountKeyStore.stored(npub) + + override suspend fun geohashIdentity(npub: String) = accountSecretsStore.storedGeohashIdentity(npub) + }, + legacyWritesRetired = LEGACY_WRITES_RETIRED, + ) + } + + /** + * The file behind [encryptedPreferences], following the same branch it + * does — a name taken from the other side of that `if` would have the + * cleanup checking for, and deleting, a file that is not the one being + * read. + */ + private fun legacyAccountFile(npub: String): File { + val name = if (BuildConfig.DEBUG && DEBUG_PLAINTEXT_PREFERENCES) "${DEBUG_PREFERENCES_NAME}_$npub" else EncryptedStorage.prefsFileName(npub) + return File(prefsDirPath, "$name.xml") + } + + /** + * The key the location-chat identity's legacy file is named by. + * + * [GeohashChatIdentityState] passed `signer.pubKey` — hex — where every + * other caller of [encryptedPreferences] passes an npub, so that material + * sits in `secret_keeper_`, a different file from the account's own + * `secret_keeper_`. Converting here keeps that quirk in one place. + */ + private fun geohashLegacyKey(npub: String): String = npub.bechToBytes("npub").toHexKey() + + /** + * Clears and unlinks `secret_keeper_`. + * + * Clear before unlinking, as everything else here does: the live + * SharedPreferences still holds the values in memory and would write them + * straight back out. + */ + private fun deleteGeohashLegacyFile(npub: String): Boolean { + val hex = geohashLegacyKey(npub) + encryptedPreferences(hex).edit(commit = true) { clear() } + return legacyAccountFile(hex).delete() + } + + /** + * Copies the location-chat identity out of `secret_keeper_` on + * the first load after the upgrade. + * + * Eager, not lazy. [GeohashChatIdentityState] also copies on first use, but + * only a user who opens a location chat ever reaches it — and the cleanup + * refuses to delete an account's legacy files while that file still holds + * an identity the current store does not. Left to the lazy path alone, a + * user who never opens another location chat would keep both files + * forever, which is the opposite of what the migration is for. + * + * Idempotent, and cheap after the first run: the store's marker short-circuits + * it, so re-running on each load cannot overwrite a later edit and — because + * the legacy read is a lambda — does not open `secret_keeper_` + * either. That matters beyond speed: opening an `EncryptedSharedPreferences` + * writes its Tink keyset, so an eager read would recreate the file on the + * load right after the cleanup deleted it, permanently. + */ + private suspend fun copyGeohashIdentity(npub: String) { + accountSecretsStore.readGeohashIdentity(npub) { + readLegacyGeohashIdentity(LegacySharedPreferences(encryptedPreferences(geohashLegacyKey(npub)))) + } + } + + /** + * Everything the account's DataStore holds, read in one hop. + * + * Loaded as a group rather than store by store because + * [innerLoadCurrentAccountFromEncryptedStorage] is already near the JVM's + * 64KB method limit: every suspend call inside it adds a state to the + * generated coroutine state machine, and seven separate loads pushed it + * over. One call, one state. + */ + private class AccountStoreData( + val identity: AccountIdentity, + val followLists: Map, + val latestEvents: Map, + val uploadSettings: UploadSettings, + val dialogDismissal: DialogDismissal, + val relayAuth: RelayAuth, + val feedVisibility: FeedVisibility, + val notificationPrefs: NotificationPrefs, + ) + + private suspend fun loadAccountStores( + npub: String, + legacy: SharedPreferences, + ) = AccountStoreData( + identity = + identityStore(npub).load().orIfUnusable { + AccountIdentity( + pubKeyHex = legacy.getString(PrefKeys.NOSTR_PUBKEY, null), + loginWithExternalSigner = legacy.getBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, false), + externalSignerPackageName = legacy.getString(PrefKeys.SIGNER_PACKAGE_NAME, null), + localRelayServers = legacy.getStringSet(PrefKeys.LOCAL_RELAY_SERVERS, null) ?: setOf(), + openBackupConflictsJson = legacy.getString(PrefKeys.OPEN_BACKUP_CONFLICTS, null), + ) + }, + followLists = migrateNotificationFilter(npub, legacy, followListStore(npub).load()), + latestEvents = latestEventStore(npub).load(), + uploadSettings = uploadSettingsStore(npub).load(), + dialogDismissal = dialogDismissalStore(npub).load(), + relayAuth = relayAuthStore(npub).load(), + feedVisibility = feedVisibilityStore(npub).load(), + notificationPrefs = notificationPrefsStore(npub).load(), + ) + + // NOT migrated to DataStore, and cannot be: DataStore is suspend-only, while + // NotificationRelayService.isEnabled(context) is a synchronous Boolean read + // from Service and BroadcastReceiver entry points in freshly started + // processes (boot, watchdog, WorkManager). Making it suspend would mean the + // restart layers could not consult it at all, and a saved OFF would be + // missed on cold boot — the service would resurrect itself. Plain + // SharedPreferences is the only store here that answers synchronously on + // any thread, so this key stays on it deliberately. + // // Global master switch for the always-on notification service ("Background // notification service"). Default ON: existing users keep current behavior, and // per-account participation decides who actually stays active. @@ -292,11 +533,15 @@ object LocalPreferences { globalSettingsPrefs().edit { putBoolean(PrefKeys.NOTIFICATION_SERVICE_ENABLED, enabled) } } + private fun legacyCurrentAccount(): String? = encryptedPreferences().getString(PrefKeys.CURRENT_ACCOUNT, null) + + private fun legacyAllAccountInfo(): String? = encryptedPreferences().getString(PrefKeys.ALL_ACCOUNT_INFO, null) + suspend fun currentAccount(): String? { if (currentAccount == null) { currentAccount = withContext(Dispatchers.IO) { - encryptedPreferences().getString(PrefKeys.CURRENT_ACCOUNT, null) + accountRoster.currentAccount(::legacyCurrentAccount, ::legacyAllAccountInfo) } } return currentAccount @@ -307,12 +552,14 @@ object LocalPreferences { currentAccount = null withContext(Dispatchers.IO) { encryptedPreferences().edit { clear() } + accountRoster.clear() } } else if (currentAccount != info.npub) { currentAccount = info.npub if (!info.isTransient) { withContext(Dispatchers.IO) { encryptedPreferences().edit { putString(PrefKeys.CURRENT_ACCOUNT, info.npub) } + accountRoster.mirrorCurrentAccount(info.npub) } } } @@ -332,7 +579,7 @@ object LocalPreferences { withContext(Dispatchers.IO) { with(encryptedPreferences()) { val newSystemOfAccounts = - getString(PrefKeys.ALL_ACCOUNT_INFO, "[]")?.let { + (accountRoster.allAccountInfoJson(::legacyCurrentAccount, ::legacyAllAccountInfo) ?: "[]").let { JsonMapper.fromJson>(it) } @@ -354,9 +601,17 @@ object LocalPreferences { ) } + val json = JsonMapper.toJson(migrated) edit { - putString(PrefKeys.ALL_ACCOUNT_INFO, JsonMapper.toJson(migrated)) + putString(PrefKeys.ALL_ACCOUNT_INFO, json) } + // Mirrored as well, exactly as updateSavedAccounts does. The + // roster's own copy has already run by this point, against an + // ALL_ACCOUNT_INFO that did not exist yet, and its marker is + // set — so without this the roster store stays permanently + // empty for these installs and they open as a fresh install + // the moment the legacy write goes. + accountRoster.mirrorAllAccountInfoJson(json) migrated } @@ -367,16 +622,15 @@ object LocalPreferences { private suspend fun updateSavedAccounts(accounts: List) = withContext(Dispatchers.IO) { - if (savedAccounts != accounts) { + // .value, not the flow: StateFlow does not override equals, so + // comparing the holder to a List was unconditionally true and every + // call rewrote both stores. + if (savedAccounts.value != accounts) { savedAccounts.emit(accounts) - encryptedPreferences() - .edit { - putString( - PrefKeys.ALL_ACCOUNT_INFO, - JsonMapper.toJson(accounts.filter { !it.isTransient }), - ) - } + val json = JsonMapper.toJson(accounts.filter { !it.isTransient }) + encryptedPreferences().edit { putString(PrefKeys.ALL_ACCOUNT_INFO, json) } + accountRoster.mirrorAllAccountInfoJson(json) } } @@ -448,6 +702,19 @@ object LocalPreferences { // would resurrect the deleted settings from this cache. mutex.withLock { cachedAccounts.remove(accountInfo.npub) } encryptedPreferences(accountInfo.npub).edit(commit = true) { clear() } + // The location-chat identity's own file, keyed by pubkey hex. Without + // this the anonymous device seed outlives the account that owned it + // and comes back if the same npub is re-added — the opposite of what + // an unlinkable per-cell identity is for. + deleteGeohashLegacyFile(accountInfo.npub) + accountKeyStore.delete(accountInfo.npub) + accountSecretsStore.delete(accountInfo.npub) + // The account's plain DataStore, which deleteUserPreferenceFile cannot + // reach: that sweeps shared_prefs/, this lives in filesDir/datastore/. + // Left behind it would keep the deleted account's pubkey, signer and + // cached events on disk — and re-adding the same npub would find a + // live identity there and resurrect the account that was just deleted. + accountStores.removeAccount(accountInfo.npub) removeAccount(accountInfo) deleteUserPreferenceFile(accountInfo.npub) @@ -510,54 +777,13 @@ object LocalPreferences { } settings.keyPair.pubKey.let { putString(PrefKeys.NOSTR_PUBKEY, it.toHexKey()) } - putString( - PrefKeys.DEFAULT_FILE_SERVER, - JsonMapper.toJson(settings.defaultFileServer), - ) - - putBoolean(PrefKeys.STRIP_LOCATION_ON_UPLOAD, settings.stripLocationOnUpload) - putBoolean(PrefKeys.USE_LOCAL_BLOSSOM_CACHE, settings.useLocalBlossomCache.value) - putBoolean(PrefKeys.LOCAL_BLOSSOM_CACHE_PROFILE_PICTURES_ONLY, settings.localBlossomCacheProfilePicturesOnly.value) - putBoolean(PrefKeys.MIRROR_UPLOADS_TO_ALL_SERVERS, settings.mirrorUploadsToAllServers.value) - putBoolean(PrefKeys.OPTIMIZE_MEDIA_ON_UPLOAD, settings.optimizeMediaOnUpload.value) - putBoolean(PrefKeys.HIDE_COMMUNITY_RULES_VIOLATIONS, settings.hideCommunityRulesViolations.value) putBoolean(PrefKeys.NIP46_SIGNER_ENABLED, settings.nip46SignerEnabled.value) putString(PrefKeys.NIP46_BUNKER_SECRET, settings.nip46BunkerSecret.value) putString(PrefKeys.NIP46_TRANSPORT_KEY, settings.nip46TransportKey.value) putStringSet(PrefKeys.NIP46_SEEN_IDS, settings.nip46SeenRequestIds.value) - putString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, JsonMapper.toJson(settings.defaultHomeFollowList.value)) - putString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultStoriesFollowList.value)) - putString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, JsonMapper.toJson(settings.defaultNotificationFollowList.value)) - putString(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, JsonMapper.toJson(settings.defaultDiscoveryFollowList.value)) - - putString(PrefKeys.DEFAULT_POLLS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultPollsFollowList.value)) - putString(PrefKeys.DEFAULT_PICTURES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultPicturesFollowList.value)) - putString(PrefKeys.DEFAULT_RELAY_GROUPS_DISCOVERY_FOLLOW_LIST, JsonMapper.toJson(settings.defaultRelayGroupsDiscoveryFollowList.value)) - putString(PrefKeys.DEFAULT_NAPPLETS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultNappletsFollowList.value)) - putString(PrefKeys.DEFAULT_NSITES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultNsitesFollowList.value)) - putString(PrefKeys.DEFAULT_WORKOUTS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultWorkoutsFollowList.value)) - putString(PrefKeys.DEFAULT_GIT_REPOSITORIES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultGitRepositoriesFollowList.value)) - putString(PrefKeys.DEFAULT_HIGHLIGHTS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultHighlightsFollowList.value)) - putString(PrefKeys.DEFAULT_CALENDARS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultCalendarsFollowList.value)) - putString(PrefKeys.DEFAULT_PRODUCTS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultProductsFollowList.value)) - putString(PrefKeys.DEFAULT_GEOCACHES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultGeocachesFollowList.value)) - putString(PrefKeys.DEFAULT_SHORTS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultShortsFollowList.value)) - putString(PrefKeys.DEFAULT_PUBLIC_CHATS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultPublicChatsFollowList.value)) - putString(PrefKeys.DEFAULT_LIVE_STREAMS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultLiveStreamsFollowList.value)) - putString(PrefKeys.DEFAULT_NESTS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultNestsFollowList.value)) - putString(PrefKeys.DEFAULT_LONGS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultLongsFollowList.value)) - putString(PrefKeys.DEFAULT_ARTICLES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultArticlesFollowList.value)) - putString(PrefKeys.DEFAULT_MUSIC_TRACKS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultMusicTracksFollowList.value)) - putString(PrefKeys.DEFAULT_MUSIC_PLAYLISTS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultMusicPlaylistsFollowList.value)) - putString(PrefKeys.DEFAULT_PODCAST_EPISODES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultPodcastEpisodesFollowList.value)) - putString(PrefKeys.DEFAULT_PODCASTS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultPodcastsFollowList.value)) - putString(PrefKeys.DEFAULT_SOFTWARE_APPS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultSoftwareAppsFollowList.value)) - putString(PrefKeys.DEFAULT_BADGES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultBadgesFollowList.value)) - putString(PrefKeys.DEFAULT_BROWSE_EMOJI_SETS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultBrowseEmojiSetsFollowList.value)) - putString(PrefKeys.DEFAULT_COMMUNITIES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultCommunitiesFollowList.value)) - putString(PrefKeys.DEFAULT_FOLLOW_PACKS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultFollowPacksFollowList.value)) - putString(PrefKeys.DEFAULT_APP_RECOMMENDATIONS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultAppRecommendationsFollowList.value)) + // top-nav filters now live in the account's DataStore; written below, + // outside this edit block, because that write is suspend. val walletEntries = settings.nwcWallets.value.mapNotNull { it.denormalize() } if (walletEntries.isNotEmpty()) { @@ -582,8 +808,6 @@ object LocalPreferences { // Remove legacy key after migration remove(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER) - putOrRemove(PrefKeys.LATEST_CONTACT_LIST, settings.backupContactList) - // The undecided conflicts themselves, not just the backups they hold back. // Without these the card vanishes on the next launch and the user never // answers the question the backup is still waiting on. @@ -594,56 +818,12 @@ object LocalPreferences { putString(PrefKeys.OPEN_BACKUP_CONFLICTS, BackupConflictStorage.encode(openConflicts)) } - putOrRemove(PrefKeys.LATEST_USER_METADATA, settings.backupUserMetadata) - putOrRemove(PrefKeys.LATEST_DM_RELAY_LIST, settings.backupDMRelayList) - putOrRemove(PrefKeys.LATEST_NIP65_RELAY_LIST, settings.backupNIP65RelayList) - putOrRemove(PrefKeys.LATEST_SEARCH_RELAY_LIST, settings.backupSearchRelayList) - putOrRemove(PrefKeys.LATEST_INDEX_RELAY_LIST, settings.backupIndexRelayList) - putOrRemove(PrefKeys.LATEST_RELAY_FEEDS_LIST, settings.backupRelayFeedsList) - putOrRemove(PrefKeys.LATEST_BLOCKED_RELAY_LIST, settings.backupBlockedRelayList) - putOrRemove(PrefKeys.LATEST_TRUSTED_RELAY_LIST, settings.backupTrustedRelayList) - if (settings.localRelayServers.value.isNotEmpty()) { putStringSet(PrefKeys.LOCAL_RELAY_SERVERS, settings.localRelayServers.value) } else { remove(PrefKeys.LOCAL_RELAY_SERVERS) } - putOrRemove(PrefKeys.LATEST_MUTE_LIST, settings.backupMuteList) - putOrRemove(PrefKeys.LATEST_PRIVATE_HOME_RELAY_LIST, settings.backupPrivateHomeRelayList) - putOrRemove(PrefKeys.LATEST_APP_SPECIFIC_DATA, settings.backupAppSpecificData) - - putOrRemove(PrefKeys.LATEST_CHANNEL_LIST, settings.backupChannelList) - putOrRemove(PrefKeys.LATEST_COMMUNITY_LIST, settings.backupCommunityList) - putOrRemove(PrefKeys.LATEST_HASHTAG_LIST, settings.backupHashtagList) - putOrRemove(PrefKeys.LATEST_GEOHASH_LIST, settings.backupGeohashList) - putOrRemove(PrefKeys.LATEST_EPHEMERAL_LIST, settings.backupEphemeralChatList) - putOrRemove(PrefKeys.LATEST_RELAY_GROUP_LIST, settings.backupRelayGroupList) - putOrRemove(PrefKeys.LATEST_CONCORD_LIST, settings.backupConcordList) - putOrRemove(PrefKeys.LATEST_TRUST_PROVIDER_LIST, settings.backupTrustProviderList) - putOrRemove(PrefKeys.LATEST_KEY_PACKAGE_RELAY_LIST, settings.backupKeyPackageRelayList) - putOrRemove(PrefKeys.LATEST_FAVORITE_ALGO_FEEDS_LIST, settings.backupFavoriteAlgoFeedsList) - putOrRemove(PrefKeys.LATEST_PAYMENT_TARGETS, settings.backupNipA3PaymentTargets) - putOrRemove(PrefKeys.LATEST_BOLT12_OFFERS, settings.backupBolt12Offers) - putOrRemove(PrefKeys.LATEST_CASHU_WALLET, settings.backupCashuWallet) - putOrRemove(PrefKeys.LATEST_NUTZAP_INFO, settings.backupNutzapInfo) - - putBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, settings.hideDeleteRequestDialog) - putBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, settings.hideNIP17WarningDialog) - putBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, settings.hideBlockAlertDialog) - putBoolean(PrefKeys.CALLS_ENABLED, settings.callsEnabled.value) - putBoolean(PrefKeys.ALWAYS_ON_NOTIFICATION_SERVICE, settings.alwaysOnNotificationService.value) - putString(PrefKeys.DEFAULT_RELAY_AUTH_POLICY, settings.defaultRelayAuthPolicy.value.name) - putString(PrefKeys.RELAY_GROUP_VIEW_MODE, settings.relayGroupViewMode.value.name) - putString(PrefKeys.CONCORD_VIEW_MODE, settings.concordViewMode.value.name) - putString(PrefKeys.DISABLED_CHAT_FEEDS, ChatFeedType.encode(ChatFeedType.ALL - settings.enabledChatFeeds.value)) - putString(PrefKeys.DISABLED_HOME_FEED_TYPES, HomeFeedType.encode(HomeFeedType.ALL - settings.enabledHomeFeedTypes.value)) - putBoolean(PrefKeys.RELAY_AUTH_TRUST_MY_RELAYS, settings.relayAuthTrustMyRelaysAndVenues.value) - putBoolean(PrefKeys.RELAY_AUTH_TRUST_READ_FOLLOWS, settings.relayAuthTrustReadFollows.value) - putBoolean(PrefKeys.RELAY_AUTH_TRUST_MESSAGE_FOLLOWS, settings.relayAuthTrustMessageFollows.value) - putBoolean(PrefKeys.RELAY_AUTH_TRUST_MESSAGE_STRANGERS, settings.relayAuthTrustMessageStrangers.value) - putBoolean(PrefKeys.SPLIT_NOTIFICATIONS_ENABLED, settings.splitNotificationsEnabled.value) - putBoolean(PrefKeys.SHOW_MESSAGES_IN_NOTIFICATIONS, settings.showMessagesInNotifications.value) // Any account that reaches a save has its notification filter in its // post-split meaning, so stamp it as migrated. This keeps the one-shot // Global -> Selected rewrite from ever touching it again and preserves a @@ -663,37 +843,187 @@ object LocalPreferences { PrefKeys.LAST_READ_PER_ROUTE, JsonMapper.toJson(regularMap), ) - putStringSet(PrefKeys.HAS_DONATED_IN_VERSION, settings.hasDonatedInVersion.value) - putStringSet(PrefKeys.DISMISSED_POLL_NOTE_IDS, settings.dismissedPollNoteIds.value) - putStringSet(PrefKeys.DISMISSED_CHANNEL_INVITES, settings.dismissedChannelInvites.value) - putStringSet(PrefKeys.MUTED_PUBLIC_CHATS, settings.mutedPublicChats.value) - putString( - PrefKeys.VIEWED_POLL_RESULT_NOTE_IDS, - JsonMapper.toJson(settings.viewedPollResultNoteIds.value), - ) putString( PrefKeys.PENDING_ATTESTATIONS, JsonMapper.toJson(settings.pendingAttestations.value), ) } + + // Mirrored into the key store after the legacy write, not + // instead of it: both stores carry the key during the + // transition so a rollback still loads the account. + accountSecretsStore.mirror( + npub = settings.keyPair.pubKey.toNpub(), + value = + AccountSecrets( + nip46SignerEnabled = settings.nip46SignerEnabled.value, + nip46BunkerSecret = settings.nip46BunkerSecret.value, + nip46TransportKey = settings.nip46TransportKey.value, + nip46SeenRequestIds = settings.nip46SeenRequestIds.value, + nwcWalletsJson = + settings.nwcWallets.value + .mapNotNull { it.denormalize() } + .takeIf { it.isNotEmpty() } + ?.let { JsonMapper.toJson(it) }, + // .map { denormalize() } and not the raw wallets: the legacy + // write stores the denormalized shape, and the read path parses + // that shape. Serializing the raw value here would write JSON + // the loader cannot understand. + clinkDebitWalletsJson = + settings.clinkDebitWallets.value + .map { it.denormalize() } + .takeIf { it.isNotEmpty() } + ?.let { JsonMapper.toJson(it) }, + defaultPaymentSourceId = settings.defaultPaymentSourceId.value, + ), + ) + accountKeyStore.mirrorSave( + npub = settings.keyPair.pubKey.toNpub(), + usesExternalSigner = settings.externalSignerPackageName != null, + privKeyHex = settings.keyPair.privKey?.toHexKey(), + ) } + // Mirrored, not moved: NOSTR_PUBKEY is the one key whose loss empties + // the app, so the legacy write above stays until a release has + // proved this one — see [EncryptedStorage]. + identityStore(settings.keyPair.pubKey.toNpub()).save( + AccountIdentity( + pubKeyHex = settings.keyPair.pubKey.toHexKey(), + loginWithExternalSigner = settings.externalSignerPackageName != null, + externalSignerPackageName = settings.externalSignerPackageName, + localRelayServers = settings.localRelayServers.value, + openBackupConflictsJson = settings.openBackupConflicts().takeIf { it.isNotEmpty() }?.let { BackupConflictStorage.encode(it) }, + ), + ) + uploadSettingsStore(settings.keyPair.pubKey.toNpub()).save( + UploadSettings( + stripLocationOnUpload = settings.stripLocationOnUpload, + optimizeMediaOnUpload = settings.optimizeMediaOnUpload.value, + mirrorUploadsToAllServers = settings.mirrorUploadsToAllServers.value, + useLocalBlossomCache = settings.useLocalBlossomCache.value, + localBlossomCacheProfilePicturesOnly = settings.localBlossomCacheProfilePicturesOnly.value, + defaultFileServerJson = JsonMapper.toJson(settings.defaultFileServer), + ), + ) + dialogDismissalStore(settings.keyPair.pubKey.toNpub()).save( + DialogDismissal( + hideDeleteRequestDialog = settings.hideDeleteRequestDialog, + hideBlockAlertDialog = settings.hideBlockAlertDialog, + hideNip17WarningDialog = settings.hideNIP17WarningDialog, + hideCommunityRulesViolations = settings.hideCommunityRulesViolations.value, + dismissedPollNoteIds = settings.dismissedPollNoteIds.value, + dismissedChannelInvites = settings.dismissedChannelInvites.value, + mutedPublicChats = settings.mutedPublicChats.value, + hasDonatedInVersion = settings.hasDonatedInVersion.value, + viewedPollResultNoteIdsJson = JsonMapper.toJson(settings.viewedPollResultNoteIds.value), + ), + ) + relayAuthStore(settings.keyPair.pubKey.toNpub()).save( + RelayAuth( + policyName = settings.defaultRelayAuthPolicy.value.name, + trustMyRelays = settings.relayAuthTrustMyRelaysAndVenues.value, + trustReadFollows = settings.relayAuthTrustReadFollows.value, + trustMessageFollows = settings.relayAuthTrustMessageFollows.value, + trustMessageStrangers = settings.relayAuthTrustMessageStrangers.value, + ), + ) + feedVisibilityStore(settings.keyPair.pubKey.toNpub()).save( + FeedVisibility( + disabledChatFeeds = ChatFeedType.encode(ChatFeedType.ALL - settings.enabledChatFeeds.value), + disabledHomeFeedTypes = HomeFeedType.encode(HomeFeedType.ALL - settings.enabledHomeFeedTypes.value), + relayGroupViewMode = settings.relayGroupViewMode.value.name, + concordViewMode = settings.concordViewMode.value.name, + callsEnabled = settings.callsEnabled.value, + ), + ) + notificationPrefsStore(settings.keyPair.pubKey.toNpub()).save( + NotificationPrefs( + alwaysOnService = settings.alwaysOnNotificationService.value, + showMessagesInNotifications = settings.showMessagesInNotifications.value, + splitNotificationsEnabled = settings.splitNotificationsEnabled.value, + ), + ) + latestEventStore(settings.keyPair.pubKey.toNpub()).saveAll( + mapOf( + LatestEventSlot.CONTACT_LIST to settings.backupContactList?.let { OptimizedJsonMapper.toJson(it) }, + LatestEventSlot.USER_METADATA to settings.backupUserMetadata?.let { OptimizedJsonMapper.toJson(it) }, + LatestEventSlot.DM_RELAY_LIST to settings.backupDMRelayList?.let { OptimizedJsonMapper.toJson(it) }, + LatestEventSlot.NIP65_RELAY_LIST to settings.backupNIP65RelayList?.let { OptimizedJsonMapper.toJson(it) }, + LatestEventSlot.SEARCH_RELAY_LIST to settings.backupSearchRelayList?.let { OptimizedJsonMapper.toJson(it) }, + LatestEventSlot.INDEX_RELAY_LIST to settings.backupIndexRelayList?.let { OptimizedJsonMapper.toJson(it) }, + LatestEventSlot.RELAY_FEEDS_LIST to settings.backupFavoriteRelayList?.let { OptimizedJsonMapper.toJson(it) }, + LatestEventSlot.BLOCKED_RELAY_LIST to settings.backupBlockedRelayList?.let { OptimizedJsonMapper.toJson(it) }, + LatestEventSlot.TRUSTED_RELAY_LIST to settings.backupTrustedRelayList?.let { OptimizedJsonMapper.toJson(it) }, + LatestEventSlot.MUTE_LIST to settings.backupMuteList?.let { OptimizedJsonMapper.toJson(it) }, + LatestEventSlot.PRIVATE_HOME_RELAY_LIST to settings.backupPrivateHomeRelayList?.let { OptimizedJsonMapper.toJson(it) }, + LatestEventSlot.APP_SPECIFIC_DATA to settings.backupAppSpecificData?.let { OptimizedJsonMapper.toJson(it) }, + LatestEventSlot.CHANNEL_LIST to settings.backupPublicChatList?.let { OptimizedJsonMapper.toJson(it) }, + LatestEventSlot.COMMUNITY_LIST to settings.backupCommunityList?.let { OptimizedJsonMapper.toJson(it) }, + LatestEventSlot.HASHTAG_LIST to settings.backupInterestList?.let { OptimizedJsonMapper.toJson(it) }, + LatestEventSlot.GEOHASH_LIST to settings.backupGeohashList?.let { OptimizedJsonMapper.toJson(it) }, + LatestEventSlot.EPHEMERAL_LIST to settings.backupEphemeralChatList?.let { OptimizedJsonMapper.toJson(it) }, + LatestEventSlot.RELAY_GROUP_LIST to settings.backupRelayGroupList?.let { OptimizedJsonMapper.toJson(it) }, + LatestEventSlot.CONCORD_LIST to settings.backupConcordList?.let { OptimizedJsonMapper.toJson(it) }, + LatestEventSlot.TRUST_PROVIDER_LIST to settings.backupTrustProviderList?.let { OptimizedJsonMapper.toJson(it) }, + LatestEventSlot.KEY_PACKAGE_RELAY_LIST to settings.backupKeyPackageRelayList?.let { OptimizedJsonMapper.toJson(it) }, + LatestEventSlot.FAVORITE_ALGO_FEEDS_LIST to settings.backupFavoriteAlgoFeedsList?.let { OptimizedJsonMapper.toJson(it) }, + LatestEventSlot.PAYMENT_TARGETS to settings.backupNipA3PaymentTargets?.let { OptimizedJsonMapper.toJson(it) }, + LatestEventSlot.BOLT12_OFFERS to settings.backupBolt12Offers?.let { OptimizedJsonMapper.toJson(it) }, + LatestEventSlot.CASHU_WALLET to settings.backupCashuWallet?.let { OptimizedJsonMapper.toJson(it) }, + LatestEventSlot.NUTZAP_INFO to settings.backupNutzapInfo?.let { OptimizedJsonMapper.toJson(it) }, + ), + ) + followListStore(settings.keyPair.pubKey.toNpub()).saveAll( + mapOf( + FollowListSlot.HOME to settings.defaultHomeFollowList.value, + FollowListSlot.STORIES to settings.defaultStoriesFollowList.value, + FollowListSlot.NOTIFICATION to settings.defaultNotificationFollowList.value, + FollowListSlot.DISCOVERY to settings.defaultDiscoveryFollowList.value, + FollowListSlot.POLLS to settings.defaultPollsFollowList.value, + FollowListSlot.PICTURES to settings.defaultPicturesFollowList.value, + FollowListSlot.RELAY_GROUPS_DISCOVERY to settings.defaultRelayGroupsDiscoveryFollowList.value, + FollowListSlot.NAPPLETS to settings.defaultNappletsFollowList.value, + FollowListSlot.NSITES to settings.defaultNsitesFollowList.value, + FollowListSlot.WORKOUTS to settings.defaultWorkoutsFollowList.value, + FollowListSlot.GIT_REPOSITORIES to settings.defaultGitRepositoriesFollowList.value, + FollowListSlot.HIGHLIGHTS to settings.defaultHighlightsFollowList.value, + FollowListSlot.CALENDARS to settings.defaultCalendarsFollowList.value, + FollowListSlot.PRODUCTS to settings.defaultProductsFollowList.value, + FollowListSlot.GEOCACHES to settings.defaultGeocachesFollowList.value, + FollowListSlot.SHORTS to settings.defaultShortsFollowList.value, + FollowListSlot.PUBLIC_CHATS to settings.defaultPublicChatsFollowList.value, + FollowListSlot.LIVE_STREAMS to settings.defaultLiveStreamsFollowList.value, + FollowListSlot.NESTS to settings.defaultNestsFollowList.value, + FollowListSlot.LONGS to settings.defaultLongsFollowList.value, + FollowListSlot.ARTICLES to settings.defaultArticlesFollowList.value, + FollowListSlot.MUSIC_TRACKS to settings.defaultMusicTracksFollowList.value, + FollowListSlot.MUSIC_PLAYLISTS to settings.defaultMusicPlaylistsFollowList.value, + FollowListSlot.PODCAST_EPISODES to settings.defaultPodcastEpisodesFollowList.value, + FollowListSlot.PODCASTS to settings.defaultPodcastsFollowList.value, + FollowListSlot.SOFTWARE_APPS to settings.defaultSoftwareAppsFollowList.value, + FollowListSlot.BADGES to settings.defaultBadgesFollowList.value, + FollowListSlot.BROWSE_EMOJI_SETS to settings.defaultBrowseEmojiSetsFollowList.value, + FollowListSlot.COMMUNITIES to settings.defaultCommunitiesFollowList.value, + FollowListSlot.FOLLOW_PACKS to settings.defaultFollowPacksFollowList.value, + FollowListSlot.APP_RECOMMENDATIONS to settings.defaultAppRecommendationsFollowList.value, + ), + ) } Log.d("LocalPreferences", "Saved to encrypted storage") } suspend fun loadAccountConfigFromEncryptedStorage(): AccountSettings? = currentAccount()?.let { loadAccountConfigFromEncryptedStorage(it) } - fun saveSharedSettings( - sharedSettings: UiSettings, - prefs: SharedPreferences = encryptedPreferences(), - ) { - Log.d("LocalPreferences", "Saving to shared settings") - prefs.edit { - putString(PrefKeys.SHARED_SETTINGS, JsonMapper.toJson(sharedSettings)) - } - } - + /** + * The UI settings as the global `secret_keeper` file holds them. + * + * A migration source only: [UiSharedPreferences] owns these now and writes + * them to its own DataStore, which carries a one-shot copy out of this blob + * for installs that predate it. Nothing writes here any more — the matching + * `saveSharedSettings` was removed once it had no callers — but the read + * stays for good, like every other legacy reader; see [EncryptedStorage]. + */ fun loadSharedSettings(prefs: SharedPreferences = encryptedPreferences()): UiSettings? { Log.d("LocalPreferences", "Load shared settings") with(prefs) { @@ -722,10 +1052,9 @@ object LocalPreferences { private suspend fun hasBackedUpKeysFlow(npub: String): MutableStateFlow = hasBackedUpKeysMutex.withLock { hasBackedUpKeysFlows.getOrPut(npub) { - val stored = - withContext(Dispatchers.IO) { - encryptedPreferences(npub).getBoolean(PrefKeys.HAS_BACKED_UP_KEYS, true) - } + // Absent reads as true in both stores, so a store that cannot be + // read leaves the nudge off rather than showing it to everyone. + val stored = withContext(Dispatchers.IO) { identityStore(npub).hasBackedUpKeys() } MutableStateFlow(stored) } } @@ -738,7 +1067,10 @@ object LocalPreferences { npub: String, ) { withContext(Dispatchers.IO) { + // Legacy write kept alongside the new one, as for the rest of the + // identity group — see [EncryptedStorage]. encryptedPreferences(npub).edit { putBoolean(PrefKeys.HAS_BACKED_UP_KEYS, value) } + identityStore(npub).setHasBackedUpKeys(value) } hasBackedUpKeysFlow(npub).value = value } @@ -750,103 +1082,166 @@ object LocalPreferences { cachedAccounts[npub]?.let { return it } return withContext(Dispatchers.IO) { - mutex.withLock { - cachedAccounts[npub]?.let { return@withContext it } + var loadedHere = false - val accountSettings = innerLoadCurrentAccountFromEncryptedStorage(npub) + val accountSettings = + mutex.withLock { + cachedAccounts[npub]?.let { return@withLock it } - // Only cache successful loads. Caching null would leave the account - // permanently unreachable for the rest of the session if a reader - // raced in before the per-npub file finished being written. - if (accountSettings != null) { - cachedAccounts.put(npub, accountSettings) + val loaded = innerLoadCurrentAccountFromEncryptedStorage(npub) + + // Only cache successful loads. Caching null would leave the account + // permanently unreachable for the rest of the session if a reader + // raced in before the per-npub file finished being written. + if (loaded != null) { + cachedAccounts.put(npub, loaded) + loadedHere = true + } + + loaded } - return@withContext accountSettings + // Outside the lock, and only for the call that did the loading. + // Verifying decrypts the whole legacy file and reads three stores, + // while `mutex` serialises every account load — under the lock, each + // account on a multi-account cold start would wait for the previous + // one's full cleanup pass. Nothing here feeds the load. + if (loadedHere) { + // Before the cleanup, which refuses to delete this account's files + // while the location-chat identity has not been copied. Here rather + // than inside the loader for the reason [AccountStoreData] gives: + // that method is at the JVM's 64KB limit and one more suspend call + // inside it does not fit. + // Never fatal, like every other legacy step here: this runs on + // the account-load path, and an EncryptedSharedPreferences that + // cannot be opened must not take the whole load — and with it the + // per-account loops in the notification consumers — down with it. + try { + copyGeohashIdentity(npub) + } catch (e: Exception) { + Log.w("LocalPreferences", "Could not copy the location-chat identity for $npub", e) + } + legacyCleanup.deleteIfVerified(npub) } + + accountSettings } } - private suspend fun innerLoadCurrentAccountFromEncryptedStorage(npub: String?): AccountSettings? { + private suspend fun innerLoadCurrentAccountFromEncryptedStorage(npub: String): AccountSettings? { Log.d("LocalPreferences") { "Load account from file $npub" } val startedAtMs = TimeUtils.nowMillis() val result = withContext(Dispatchers.IO) { return@withContext with(encryptedPreferences(npub)) { Log.d("LocalPreferences") { "Load account from file $npub - opened file" } - val privKey = getString(PrefKeys.NOSTR_PRIVKEY, null) - val pubKey = getString(PrefKeys.NOSTR_PUBKEY, null) ?: return@with null - val externalSignerPackageName = getString(PrefKeys.SIGNER_PACKAGE_NAME, null) ?: if (getBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, false)) "com.greenart7c3.nostrsigner" else null + // Every store this account has, read in one hop — including + // the identity the rest of this function is derived from, so + // that read does not cost its own state in the generated + // coroutine state machine (see [AccountStoreData]). + // + // Keyed by the npub handed in, which is the npub the save side + // writes under and the name of the legacy file just opened. The + // identity falls back to that file when its store cannot + // produce a pubkey: an account without one vanishes from the + // app entirely, private key intact. + val stores = loadAccountStores(npub, this) + val identity = stores.identity + val pubKey = identity.pubKeyHex ?: return@with null + val privKey = + accountKeyStore.read( + npub = pubKey.hexToByteArray().toNpub(), + legacyValue = getString(PrefKeys.NOSTR_PRIVKEY, null), + ) + val externalSignerPackageName = identity.externalSignerPackageName ?: if (identity.loginWithExternalSigner) "com.greenart7c3.nostrsigner" else null val keyPair = KeyPair(privKey = privKey?.hexToByteArray(), pubKey = pubKey.hexToByteArray()) + // The npub handed in names the file just read, and the save + // side writes every store under the npub derived from the + // pubkey inside it, so the two are the same by construction. + // Say so if they ever are not: it would mean this load is + // reading stores that a save never wrote. + if (keyPair.pubKey.toNpub() != npub) { + Log.e("LocalPreferences", "Account file $npub holds pubkey ${keyPair.pubKey.toNpub()}; its stores were read under the file's name", null) + } + Log.d("LocalPreferences") { "Load account from file $npub - keys ready" } - val stripLocationOnUpload = getBoolean(PrefKeys.STRIP_LOCATION_ON_UPLOAD, true) - val useLocalBlossomCache = getBoolean(PrefKeys.USE_LOCAL_BLOSSOM_CACHE, true) - val localBlossomCacheProfilePicturesOnly = getBoolean(PrefKeys.LOCAL_BLOSSOM_CACHE_PROFILE_PICTURES_ONLY, false) - val mirrorUploadsToAllServers = getBoolean(PrefKeys.MIRROR_UPLOADS_TO_ALL_SERVERS, true) - val optimizeMediaOnUpload = getBoolean(PrefKeys.OPTIMIZE_MEDIA_ON_UPLOAD, false) - val hideCommunityRulesViolations = getBoolean(PrefKeys.HIDE_COMMUNITY_RULES_VIOLATIONS, false) - val nip46SignerEnabled = getBoolean(PrefKeys.NIP46_SIGNER_ENABLED, false) - val nip46BunkerSecret = getString(PrefKeys.NIP46_BUNKER_SECRET, "") ?: "" - val nip46TransportKey = getString(PrefKeys.NIP46_TRANSPORT_KEY, "") ?: "" - val nip46SeenRequestIds = getStringSet(PrefKeys.NIP46_SEEN_IDS, null) ?: setOf() - val hideDeleteRequestDialog = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false) - val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false) - val hideNIP17WarningDialog = getBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, false) - val callsEnabled = getBoolean(PrefKeys.CALLS_ENABLED, true) - val alwaysOnNotificationService = getBoolean(PrefKeys.ALWAYS_ON_NOTIFICATION_SERVICE, false) + val stripLocationOnUpload = stores.uploadSettings.stripLocationOnUpload + val useLocalBlossomCache = stores.uploadSettings.useLocalBlossomCache + val localBlossomCacheProfilePicturesOnly = stores.uploadSettings.localBlossomCacheProfilePicturesOnly + val mirrorUploadsToAllServers = stores.uploadSettings.mirrorUploadsToAllServers + val optimizeMediaOnUpload = stores.uploadSettings.optimizeMediaOnUpload + val hideCommunityRulesViolations = stores.dialogDismissal.hideCommunityRulesViolations + val hideDeleteRequestDialog = stores.dialogDismissal.hideDeleteRequestDialog + val hideBlockAlertDialog = stores.dialogDismissal.hideBlockAlertDialog + val hideNIP17WarningDialog = stores.dialogDismissal.hideNip17WarningDialog + val callsEnabled = stores.feedVisibility.callsEnabled + val alwaysOnNotificationService = stores.notificationPrefs.alwaysOnService // Read as a group via a helper: this load lambda sits right at the JVM's // per-method bytecode limit (see the note above the awaits below), so keeping // these heavy string/enum decodes out of it preserves headroom. - val inboxPrefs = readInboxPrefs() - val splitNotificationsEnabled = getBoolean(PrefKeys.SPLIT_NOTIFICATIONS_ENABLED, false) - val showMessagesInNotifications = getBoolean(PrefKeys.SHOW_MESSAGES_IN_NOTIFICATIONS, true) - val hasDonatedInVersion = getStringSet(PrefKeys.HAS_DONATED_IN_VERSION, null) ?: setOf() - val dismissedPollNoteIds = getStringSet(PrefKeys.DISMISSED_POLL_NOTE_IDS, null) ?: setOf() - val dismissedChannelInvites = getStringSet(PrefKeys.DISMISSED_CHANNEL_INVITES, null) ?: setOf() - val mutedPublicChats = getStringSet(PrefKeys.MUTED_PUBLIC_CHATS, null) ?: setOf() - val viewedPollResultNoteIdsStr = getString(PrefKeys.VIEWED_POLL_RESULT_NOTE_IDS, null) - val localRelayServers = getStringSet(PrefKeys.LOCAL_RELAY_SERVERS, null) ?: setOf() + val inboxPrefs = readInboxPrefs(stores.relayAuth, stores.feedVisibility) + val splitNotificationsEnabled = stores.notificationPrefs.splitNotificationsEnabled + val showMessagesInNotifications = stores.notificationPrefs.showMessagesInNotifications + val hasDonatedInVersion = stores.dialogDismissal.hasDonatedInVersion + val dismissedPollNoteIds = stores.dialogDismissal.dismissedPollNoteIds + val dismissedChannelInvites = stores.dialogDismissal.dismissedChannelInvites + val mutedPublicChats = stores.dialogDismissal.mutedPublicChats + val viewedPollResultNoteIdsStr = stores.dialogDismissal.viewedPollResultNoteIdsJson + val localRelayServers = identity.localRelayServers - val followListPrefs = loadFollowListPrefs() + val followListPrefs = toFollowListPrefs(stores.followLists) - val zapPaymentRequestServerStr = getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null) - val nwcWalletsStr = getString(PrefKeys.NWC_WALLETS, null) - val defaultNwcWalletIdStr = getString(PrefKeys.DEFAULT_NWC_WALLET_ID, null) - val clinkDebitWalletsStr = getString(PrefKeys.CLINK_DEBIT_WALLETS, null) - val defaultPaymentSourceIdStr = getString(PrefKeys.DEFAULT_PAYMENT_SOURCE_ID, null) - val defaultFileServerStr = getString(PrefKeys.DEFAULT_FILE_SERVER, null) + // The secrets that used to live in this file now come from the + // encrypted DataStore, falling back to what is still here. + val secrets = + accountSecretsStore.read( + npub = keyPair.pubKey.toNpub(), + // Through the shared reader, so the loader and the check + // that gates deleting this file read the same keys. + legacy = readLegacyAccountSecrets(LegacySharedPreferences(this)), + ) + val nip46SignerEnabled = secrets.nip46SignerEnabled + val nip46BunkerSecret = secrets.nip46BunkerSecret + val nip46TransportKey = secrets.nip46TransportKey + val nip46SeenRequestIds = secrets.nip46SeenRequestIds + val zapPaymentRequestServerStr = secrets.legacyZapPaymentRequestServer + val nwcWalletsStr = secrets.nwcWalletsJson + val defaultNwcWalletIdStr = secrets.legacyDefaultNwcWalletId + val clinkDebitWalletsStr = secrets.clinkDebitWalletsJson + val defaultPaymentSourceIdStr = secrets.defaultPaymentSourceId + val defaultFileServerStr = stores.uploadSettings.defaultFileServerJson val pendingAttestationsStr = getString(PrefKeys.PENDING_ATTESTATIONS, null) - val openBackupConflictsStr = getString(PrefKeys.OPEN_BACKUP_CONFLICTS, null) - val latestUserMetadataStr = getString(PrefKeys.LATEST_USER_METADATA, null) - val latestContactListStr = getString(PrefKeys.LATEST_CONTACT_LIST, null) - val latestDmRelayListStr = getString(PrefKeys.LATEST_DM_RELAY_LIST, null) - val latestNip65RelayListStr = getString(PrefKeys.LATEST_NIP65_RELAY_LIST, null) - val latestSearchRelayListStr = getString(PrefKeys.LATEST_SEARCH_RELAY_LIST, null) - val latestIndexRelayListStr = getString(PrefKeys.LATEST_INDEX_RELAY_LIST, null) - val latestRelayFeedsListStr = getString(PrefKeys.LATEST_RELAY_FEEDS_LIST, null) - val latestBlockedRelayListStr = getString(PrefKeys.LATEST_BLOCKED_RELAY_LIST, null) - val latestTrustedRelayListStr = getString(PrefKeys.LATEST_TRUSTED_RELAY_LIST, null) - val latestMuteListStr = getString(PrefKeys.LATEST_MUTE_LIST, null) - val latestPrivateHomeRelayListStr = getString(PrefKeys.LATEST_PRIVATE_HOME_RELAY_LIST, null) - val latestAppSpecificDataStr = getString(PrefKeys.LATEST_APP_SPECIFIC_DATA, null) - val latestChannelListStr = getString(PrefKeys.LATEST_CHANNEL_LIST, null) - val latestCommunityListStr = getString(PrefKeys.LATEST_COMMUNITY_LIST, null) - val latestHashtagListStr = getString(PrefKeys.LATEST_HASHTAG_LIST, null) - val latestGeohashListStr = getString(PrefKeys.LATEST_GEOHASH_LIST, null) - val latestEphemeralListStr = getString(PrefKeys.LATEST_EPHEMERAL_LIST, null) - val latestRelayGroupListStr = getString(PrefKeys.LATEST_RELAY_GROUP_LIST, null) - val latestConcordListStr = getString(PrefKeys.LATEST_CONCORD_LIST, null) - val latestTrustProviderListStr = getString(PrefKeys.LATEST_TRUST_PROVIDER_LIST, null) - val latestKeyPackageRelayListStr = getString(PrefKeys.LATEST_KEY_PACKAGE_RELAY_LIST, null) - val latestFavoriteAlgoFeedsListStr = getString(PrefKeys.LATEST_FAVORITE_ALGO_FEEDS_LIST, null) - val latestPaymentTargetsStr = getString(PrefKeys.LATEST_PAYMENT_TARGETS, null) - val latestBolt12OffersStr = getString(PrefKeys.LATEST_BOLT12_OFFERS, null) - val latestCashuWalletStr = getString(PrefKeys.LATEST_CASHU_WALLET, null) - val latestNutzapInfoStr = getString(PrefKeys.LATEST_NUTZAP_INFO, null) + val openBackupConflictsStr = identity.openBackupConflictsJson + val latestUserMetadataStr = stores.latestEvents[LatestEventSlot.USER_METADATA] + val latestContactListStr = stores.latestEvents[LatestEventSlot.CONTACT_LIST] + val latestDmRelayListStr = stores.latestEvents[LatestEventSlot.DM_RELAY_LIST] + val latestNip65RelayListStr = stores.latestEvents[LatestEventSlot.NIP65_RELAY_LIST] + val latestSearchRelayListStr = stores.latestEvents[LatestEventSlot.SEARCH_RELAY_LIST] + val latestIndexRelayListStr = stores.latestEvents[LatestEventSlot.INDEX_RELAY_LIST] + val latestFavoriteRelayListStr = stores.latestEvents[LatestEventSlot.RELAY_FEEDS_LIST] + val latestBlockedRelayListStr = stores.latestEvents[LatestEventSlot.BLOCKED_RELAY_LIST] + val latestTrustedRelayListStr = stores.latestEvents[LatestEventSlot.TRUSTED_RELAY_LIST] + val latestMuteListStr = stores.latestEvents[LatestEventSlot.MUTE_LIST] + val latestPrivateHomeRelayListStr = stores.latestEvents[LatestEventSlot.PRIVATE_HOME_RELAY_LIST] + val latestAppSpecificDataStr = stores.latestEvents[LatestEventSlot.APP_SPECIFIC_DATA] + val latestPublicChatListStr = stores.latestEvents[LatestEventSlot.CHANNEL_LIST] + val latestCommunityListStr = stores.latestEvents[LatestEventSlot.COMMUNITY_LIST] + val latestInterestListStr = stores.latestEvents[LatestEventSlot.HASHTAG_LIST] + val latestGeohashListStr = stores.latestEvents[LatestEventSlot.GEOHASH_LIST] + val latestEphemeralListStr = stores.latestEvents[LatestEventSlot.EPHEMERAL_LIST] + val latestRelayGroupListStr = stores.latestEvents[LatestEventSlot.RELAY_GROUP_LIST] + val latestConcordListStr = stores.latestEvents[LatestEventSlot.CONCORD_LIST] + val latestTrustProviderListStr = stores.latestEvents[LatestEventSlot.TRUST_PROVIDER_LIST] + val latestKeyPackageRelayListStr = stores.latestEvents[LatestEventSlot.KEY_PACKAGE_RELAY_LIST] + val latestFavoriteAlgoFeedsListStr = stores.latestEvents[LatestEventSlot.FAVORITE_ALGO_FEEDS_LIST] + val latestPaymentTargetsStr = stores.latestEvents[LatestEventSlot.PAYMENT_TARGETS] + val latestBolt12OffersStr = stores.latestEvents[LatestEventSlot.BOLT12_OFFERS] + val latestCashuWalletStr = stores.latestEvents[LatestEventSlot.CASHU_WALLET] + val latestNutzapInfoStr = stores.latestEvents[LatestEventSlot.NUTZAP_INFO] val lastReadPerRouteStr = getString(PrefKeys.LAST_READ_PER_ROUTE, null) Log.d("LocalPreferences") { "Load account from file $npub - before parsing events" } @@ -887,19 +1282,19 @@ object LocalPreferences { val pendingAttestations = async { parseOrNull>(pendingAttestationsStr) ?: mapOf() } val latestUserMetadata = async { parseEventOrNull(latestUserMetadataStr) } val latestContactList = async { parseEventOrNull(latestContactListStr) } - val latestDmRelayList = async { parseEventOrNull(latestDmRelayListStr) } + val latestDmRelayList = async { parseEventOrNull(latestDmRelayListStr) } val latestNip65RelayList = async { parseEventOrNull(latestNip65RelayListStr) } val latestSearchRelayList = async { parseEventOrNull(latestSearchRelayListStr) } val latestIndexRelayList = async { parseEventOrNull(latestIndexRelayListStr) } - val latestRelayFeedsList = async { parseEventOrNull(latestRelayFeedsListStr) } + val latestFavoriteRelayList = async { parseEventOrNull(latestFavoriteRelayListStr) } val latestBlockedRelayList = async { parseEventOrNull(latestBlockedRelayListStr) } val latestTrustedRelayList = async { parseEventOrNull(latestTrustedRelayListStr) } val latestMuteList = async { parseEventOrNull(latestMuteListStr) } val latestPrivateHomeRelayList = async { parseEventOrNull(latestPrivateHomeRelayListStr) } val latestAppSpecificData = async { parseEventOrNull(latestAppSpecificDataStr) } - val latestChannelList = async { parseEventOrNull(latestChannelListStr) } + val latestPublicChatList = async { parseEventOrNull(latestPublicChatListStr) } val latestCommunityList = async { parseEventOrNull(latestCommunityListStr) } - val latestHashtagList = async { parseEventOrNull(latestHashtagListStr) } + val latestInterestList = async { parseEventOrNull(latestInterestListStr) } val latestGeohashList = async { parseEventOrNull(latestGeohashListStr) } val latestEphemeralList = async { parseEventOrNull(latestEphemeralListStr) } val latestRelayGroupList = async { parseEventOrNull(latestRelayGroupListStr) } @@ -947,15 +1342,15 @@ object LocalPreferences { val latestNip65RelayListResolved = latestNip65RelayList.await() val latestSearchRelayListResolved = latestSearchRelayList.await() val latestIndexRelayListResolved = latestIndexRelayList.await() - val latestRelayFeedsListResolved = latestRelayFeedsList.await() + val latestFavoriteRelayListResolved = latestFavoriteRelayList.await() val latestBlockedRelayListResolved = latestBlockedRelayList.await() val latestTrustedRelayListResolved = latestTrustedRelayList.await() val latestMuteListResolved = latestMuteList.await() val latestPrivateHomeRelayListResolved = latestPrivateHomeRelayList.await() val latestAppSpecificDataResolved = latestAppSpecificData.await() - val latestChannelListResolved = latestChannelList.await() + val latestPublicChatListResolved = latestPublicChatList.await() val latestCommunityListResolved = latestCommunityList.await() - val latestHashtagListResolved = latestHashtagList.await() + val latestInterestListResolved = latestInterestList.await() val latestGeohashListResolved = latestGeohashList.await() val latestEphemeralListResolved = latestEphemeralList.await() val latestRelayGroupListResolved = latestRelayGroupList.await() @@ -1049,15 +1444,15 @@ object LocalPreferences { backupDMRelayList = latestDmRelayListResolved, backupSearchRelayList = latestSearchRelayListResolved, backupIndexRelayList = latestIndexRelayListResolved, - backupRelayFeedsList = latestRelayFeedsListResolved, + backupFavoriteRelayList = latestFavoriteRelayListResolved, backupBlockedRelayList = latestBlockedRelayListResolved, backupTrustedRelayList = latestTrustedRelayListResolved, backupPrivateHomeRelayList = latestPrivateHomeRelayListResolved, backupMuteList = latestMuteListResolved, backupAppSpecificData = latestAppSpecificDataResolved, - backupChannelList = latestChannelListResolved, + backupPublicChatList = latestPublicChatListResolved, backupCommunityList = latestCommunityListResolved, - backupHashtagList = latestHashtagListResolved, + backupInterestList = latestInterestListResolved, backupGeohashList = latestGeohashListResolved, backupEphemeralChatList = latestEphemeralListResolved, backupRelayGroupList = latestRelayGroupListResolved, @@ -1147,52 +1542,68 @@ object LocalPreferences { * deliberate raw-Global choice is never reverted. Accounts created after the * split are stamped at save time, so they are never touched here. */ - private fun SharedPreferences.migrateNotificationFilter(current: TopFilter): TopFilter { - if (getBoolean(PrefKeys.NOTIF_GLOBAL_TO_CURATED_MIGRATED, false)) return current + private suspend fun migrateNotificationFilter( + npub: String, + legacy: SharedPreferences, + filters: Map, + ): Map { + if (legacy.getBoolean(PrefKeys.NOTIF_GLOBAL_TO_CURATED_MIGRATED, false)) return filters + val current = filters.getValue(FollowListSlot.NOTIFICATION) val migrated = if (current is TopFilter.Global) TopFilter.Selected else current - edit { - if (migrated !== current) { - putString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, JsonMapper.toJson(migrated)) - } - putBoolean(PrefKeys.NOTIF_GLOBAL_TO_CURATED_MIGRATED, true) - } - return migrated + + // Into the store the loader reads, not the legacy key it no longer does. + // Writing it to the legacy file and stamping anyway left the account on + // raw Global for good: the stamp survives, the corrected value does not, + // and the next launch reads Global back out of the DataStore. + if (migrated !== current) followListStore(npub).save(FollowListSlot.NOTIFICATION, migrated) + + // Stamped only once the value is actually stored, so a failed write + // means the migration runs again rather than being lost. + legacy.edit { putBoolean(PrefKeys.NOTIF_GLOBAL_TO_CURATED_MIGRATED, true) } + + return if (migrated === current) filters else filters + (FollowListSlot.NOTIFICATION to migrated) } - private fun SharedPreferences.loadFollowListPrefs(): FollowListPrefs = + /** + * Maps the store's slot table onto the named fields [AccountSettings] + * still expects. `getValue` is intentional: [TopNavFollowListStore.load] + * returns every slot, so a missing one is a bug in this mapping rather + * than a user with no saved filter, and should fail loudly. + */ + private fun toFollowListPrefs(filters: Map): FollowListPrefs = FollowListPrefs( - home = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, null), TopFilter.AllFollows), - stories = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, null), TopFilter.Global), - notification = migrateNotificationFilter(parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, null), TopFilter.Selected)), - discovery = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, null), TopFilter.Global), - polls = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_POLLS_FOLLOW_LIST, null), TopFilter.Global), - pictures = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_PICTURES_FOLLOW_LIST, null), TopFilter.Global), - relayGroupsDiscovery = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_RELAY_GROUPS_DISCOVERY_FOLLOW_LIST, null), TopFilter.Mine), - napplets = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_NAPPLETS_FOLLOW_LIST, null), TopFilter.Global), - nsites = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_NSITES_FOLLOW_LIST, null), TopFilter.Global), - workouts = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_WORKOUTS_FOLLOW_LIST, null), TopFilter.Global), - gitRepositories = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_GIT_REPOSITORIES_FOLLOW_LIST, null), TopFilter.Global), - highlights = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_HIGHLIGHTS_FOLLOW_LIST, null), TopFilter.Global), - calendars = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_CALENDARS_FOLLOW_LIST, null), TopFilter.Global), - products = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_PRODUCTS_FOLLOW_LIST, null), TopFilter.AroundMe), - geocaches = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_GEOCACHES_FOLLOW_LIST, null), TopFilter.AroundMe), - shorts = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_SHORTS_FOLLOW_LIST, null), TopFilter.Global), - publicChats = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_PUBLIC_CHATS_FOLLOW_LIST, null), TopFilter.Global), - liveStreams = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_LIVE_STREAMS_FOLLOW_LIST, null), TopFilter.Global), - nests = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_NESTS_FOLLOW_LIST, null), TopFilter.Global), - longs = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_LONGS_FOLLOW_LIST, null), TopFilter.Global), - articles = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_ARTICLES_FOLLOW_LIST, null), TopFilter.AllFollows), - musicTracks = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_MUSIC_TRACKS_FOLLOW_LIST, null), TopFilter.Global), - musicPlaylists = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_MUSIC_PLAYLISTS_FOLLOW_LIST, null), TopFilter.Global), - podcastEpisodes = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_PODCAST_EPISODES_FOLLOW_LIST, null), TopFilter.Global), - podcasts = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_PODCASTS_FOLLOW_LIST, null), TopFilter.Global), - softwareApps = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_SOFTWARE_APPS_FOLLOW_LIST, null), TopFilter.Global), - badges = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_BADGES_FOLLOW_LIST, null), TopFilter.Mine), - browseEmojiSets = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_BROWSE_EMOJI_SETS_FOLLOW_LIST, null), TopFilter.Global), - communities = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_COMMUNITIES_FOLLOW_LIST, null), TopFilter.AllFollows), - followPacks = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_FOLLOW_PACKS_FOLLOW_LIST, null), TopFilter.Global), - appRecommendations = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_APP_RECOMMENDATIONS_FOLLOW_LIST, null), TopFilter.Global), + home = filters.getValue(FollowListSlot.HOME), + stories = filters.getValue(FollowListSlot.STORIES), + notification = filters.getValue(FollowListSlot.NOTIFICATION), + discovery = filters.getValue(FollowListSlot.DISCOVERY), + polls = filters.getValue(FollowListSlot.POLLS), + pictures = filters.getValue(FollowListSlot.PICTURES), + relayGroupsDiscovery = filters.getValue(FollowListSlot.RELAY_GROUPS_DISCOVERY), + napplets = filters.getValue(FollowListSlot.NAPPLETS), + nsites = filters.getValue(FollowListSlot.NSITES), + workouts = filters.getValue(FollowListSlot.WORKOUTS), + gitRepositories = filters.getValue(FollowListSlot.GIT_REPOSITORIES), + highlights = filters.getValue(FollowListSlot.HIGHLIGHTS), + calendars = filters.getValue(FollowListSlot.CALENDARS), + products = filters.getValue(FollowListSlot.PRODUCTS), + geocaches = filters.getValue(FollowListSlot.GEOCACHES), + shorts = filters.getValue(FollowListSlot.SHORTS), + publicChats = filters.getValue(FollowListSlot.PUBLIC_CHATS), + liveStreams = filters.getValue(FollowListSlot.LIVE_STREAMS), + nests = filters.getValue(FollowListSlot.NESTS), + longs = filters.getValue(FollowListSlot.LONGS), + articles = filters.getValue(FollowListSlot.ARTICLES), + musicTracks = filters.getValue(FollowListSlot.MUSIC_TRACKS), + musicPlaylists = filters.getValue(FollowListSlot.MUSIC_PLAYLISTS), + podcastEpisodes = filters.getValue(FollowListSlot.PODCAST_EPISODES), + podcasts = filters.getValue(FollowListSlot.PODCASTS), + softwareApps = filters.getValue(FollowListSlot.SOFTWARE_APPS), + badges = filters.getValue(FollowListSlot.BADGES), + browseEmojiSets = filters.getValue(FollowListSlot.BROWSE_EMOJI_SETS), + communities = filters.getValue(FollowListSlot.COMMUNITIES), + followPacks = filters.getValue(FollowListSlot.FOLLOW_PACKS), + appRecommendations = filters.getValue(FollowListSlot.APP_RECOMMENDATIONS), ) private inline fun parseOrNull(value: String?): T? { @@ -1276,20 +1687,22 @@ private class InboxPrefs( val relayAuthTrustMessageStrangers: Boolean, ) -private fun SharedPreferences.readInboxPrefs() = - InboxPrefs( - // Missing key = an account saved before this setting existed. Those keep CUSTOM; only - // brand-new logins get the ALWAYS default from AccountSettings' constructor. - defaultRelayAuthPolicy = - getString(PrefKeys.DEFAULT_RELAY_AUTH_POLICY, null) - ?.let { runCatching { RelayAuthPolicy.valueOf(it) }.getOrNull() } - ?: RelayAuthPolicy.CUSTOM, - relayGroupViewMode = RelayGroupViewMode.fromName(getString(PrefKeys.RELAY_GROUP_VIEW_MODE, null)), - concordViewMode = ConcordViewMode.fromName(getString(PrefKeys.CONCORD_VIEW_MODE, null)), - enabledChatFeeds = ChatFeedType.ALL - ChatFeedType.decode(getString(PrefKeys.DISABLED_CHAT_FEEDS, null)), - enabledHomeFeedTypes = HomeFeedType.ALL - HomeFeedType.decode(getString(PrefKeys.DISABLED_HOME_FEED_TYPES, null)), - relayAuthTrustMyRelays = getBoolean(PrefKeys.RELAY_AUTH_TRUST_MY_RELAYS, true), - relayAuthTrustReadFollows = getBoolean(PrefKeys.RELAY_AUTH_TRUST_READ_FOLLOWS, true), - relayAuthTrustMessageFollows = getBoolean(PrefKeys.RELAY_AUTH_TRUST_MESSAGE_FOLLOWS, true), - relayAuthTrustMessageStrangers = getBoolean(PrefKeys.RELAY_AUTH_TRUST_MESSAGE_STRANGERS, false), - ) +private fun readInboxPrefs( + relayAuth: RelayAuth, + feedVisibility: FeedVisibility, +) = InboxPrefs( + // Missing key = an account saved before this setting existed. Those keep CUSTOM; only + // brand-new logins get the ALWAYS default from AccountSettings' constructor. + defaultRelayAuthPolicy = + relayAuth.policyName + ?.let { runCatching { RelayAuthPolicy.valueOf(it) }.getOrNull() } + ?: RelayAuthPolicy.CUSTOM, + relayGroupViewMode = RelayGroupViewMode.fromName(feedVisibility.relayGroupViewMode), + concordViewMode = ConcordViewMode.fromName(feedVisibility.concordViewMode), + enabledChatFeeds = ChatFeedType.ALL - ChatFeedType.decode(feedVisibility.disabledChatFeeds), + enabledHomeFeedTypes = HomeFeedType.ALL - HomeFeedType.decode(feedVisibility.disabledHomeFeedTypes), + relayAuthTrustMyRelays = relayAuth.trustMyRelays, + relayAuthTrustReadFollows = relayAuth.trustReadFollows, + relayAuthTrustMessageFollows = relayAuth.trustMessageFollows, + relayAuthTrustMessageStrangers = relayAuth.trustMessageStrangers, +) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/BrowserIconRegistry.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/BrowserIconRegistry.kt deleted file mode 100644 index f2d37b5453..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/BrowserIconRegistry.kt +++ /dev/null @@ -1,124 +0,0 @@ -/* - * 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.favorites - -import android.content.Context -import com.vitorpamplona.quartz.utils.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 - -/** - * Device-local favicon store for browsed sites, keyed by host. Favicons are **captured from the WebView - * that already loaded the page** in the keyless `:napplet` browser host (where they ride the page's own — - * Tor-routed — network path) and relayed here as PNG bytes over IPC; this is the privacy-preserving - * alternative to the main app fetching `host/favicon.ico` itself, which would bypass Tor and leak the - * visit. Used to decorate favorite cards and omnibox suggestion rows. - * - * Lives only in the **main process**. Bytes are persisted as one small PNG per host under - * `filesDir/browser_icons`; the deterministic path means the only in-memory state is [keys] — the set of - * hosts that currently have an icon — which exists purely to drive Compose recomposition (and to keep - * `File.exists()` disk checks out of composition). - */ -object BrowserIconRegistry { - private const val DIR = "browser_icons" - - private val _keys = MutableStateFlow>(emptySet()) - - /** Sanitized host keys that currently have a stored icon. Observe to recompose when an icon arrives. */ - val keys: StateFlow> = _keys.asStateFlow() - - @Volatile private var iconDir: File? = null - - // 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) - iconDir = dir - 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. */ - fun record( - host: String, - bytes: ByteArray, - ) { - val dir = iconDir ?: return - if (host.isBlank() || bytes.isEmpty()) return - val key = sanitize(host) - // 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) - } - } - } - - /** - * A Coil model (`file://…`) for [host]'s favicon, or null when none is stored. Reads [keys] so callers - * that observe the flow recompose as icons arrive — pass [keys]'s value as a `remember` key. - */ - fun iconModelFor(host: String): String? { - val dir = iconDir ?: return null - val key = sanitize(host) - if (key !in _keys.value) return null - return "file://" + File(dir, key + PNG).absolutePath - } - - // Hosts map to a flat, filesystem-safe filename. Collisions (two hosts → one key) only mean a shared - // icon file, which is harmless for a decoration. - private fun sanitize(host: String): String = - host - .lowercase() - .map { if (it.isLetterOrDigit() || it == '.' || it == '-') it else '_' } - .joinToString("") - .take(120) - - private const val PNG = ".png" -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppLauncher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppLauncher.kt index cd6fb36b17..45cfd84f05 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppLauncher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppLauncher.kt @@ -97,7 +97,7 @@ object FavoriteAppLauncher { if (nightMask == Configuration.UI_MODE_NIGHT_YES) "DARK" else "LIGHT" } } - val isFavorite = FavoriteAppsRegistry.isFavorite("url:$url") + val isFavorite = Amethyst.instance.favoriteApps.isFavorite("url:$url") val intent = NappletBrowserActivity .intent( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/NappletFavoriteIcon.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/NappletFavoriteIcon.kt index 330f648f09..f6b1e609e7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/NappletFavoriteIcon.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/NappletFavoriteIcon.kt @@ -111,7 +111,7 @@ private fun resolveIconBlob(event: Event?): IconBlob? = /** * A Coil model (`file://…`) for the cached favicon of [url]'s host, or null when no favicon - * has been captured yet. The favicon is stored by [BrowserIconRegistry] at browse time (the + * has been captured yet. The favicon is stored by [com.vitorpamplona.amethyst.commons.browser.BrowserIconRegistry] at browse time (the * WebView captures it in the sandboxed `:napplet` process); this composable just reads the cache. * * Early-returns null when [url] is blank or has no parseable host — this early return is stable @@ -121,8 +121,9 @@ private fun resolveIconBlob(event: Event?): IconBlob? = @Composable fun rememberWebAppIconModel(url: String): String? { val host = remember(url) { OmniboxInput.hostOf(url) } ?: return null - val iconKeys by BrowserIconRegistry.keys.collectAsStateWithLifecycle() - return remember(host, iconKeys) { BrowserIconRegistry.iconModelFor(host) } + val iconKeys by Amethyst.instance.browserIcons.keys + .collectAsStateWithLifecycle() + return remember(host, iconKeys) { Amethyst.instance.browserIcons.iconModelFor(host) } } /** diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/WebShortcuts.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/WebShortcuts.kt index b21d8c8755..991c66dc63 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/WebShortcuts.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/WebShortcuts.kt @@ -27,6 +27,7 @@ import androidx.core.content.pm.ShortcutInfoCompat import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.graphics.drawable.IconCompat import androidx.core.graphics.scale +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.browser.BrowserChrome import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp @@ -107,7 +108,7 @@ object WebShortcuts { url: String, ): IconCompat { val bitmap = - BrowserIconRegistry + Amethyst.instance.browserIcons .iconModelFor(BrowserChrome.displayHost(url)) ?.removePrefix("file://") ?.let { path -> runCatching { BitmapFactory.decodeFile(File(path).absolutePath) }.getOrNull() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 85b79bd3c9..fe03751d12 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -75,21 +75,21 @@ import com.vitorpamplona.amethyst.commons.model.nip51Lists.blockPeopleList.Block import com.vitorpamplona.amethyst.commons.model.nip51Lists.blockedRelays.BlockedRelayListDecryptionCache import com.vitorpamplona.amethyst.commons.model.nip51Lists.broadcastRelays.BroadcastRelayListDecryptionCache import com.vitorpamplona.amethyst.commons.model.nip51Lists.favoriteAlgoFeedsLists.FavoriteAlgoFeedsListDecryptionCache +import com.vitorpamplona.amethyst.commons.model.nip51Lists.favoriteRelays.FavoriteRelayListDecryptionCache +import com.vitorpamplona.amethyst.commons.model.nip51Lists.followSets.FollowSetDecryptionCache import com.vitorpamplona.amethyst.commons.model.nip51Lists.geohashLists.GeohashListDecryptionCache -import com.vitorpamplona.amethyst.commons.model.nip51Lists.hashtagLists.HashtagListDecryptionCache import com.vitorpamplona.amethyst.commons.model.nip51Lists.indexerRelays.IndexerRelayListDecryptionCache +import com.vitorpamplona.amethyst.commons.model.nip51Lists.interestLists.InterestListDecryptionCache import com.vitorpamplona.amethyst.commons.model.nip51Lists.muteList.MuteListDecryptionCache -import com.vitorpamplona.amethyst.commons.model.nip51Lists.peopleList.PeopleListDecryptionCache import com.vitorpamplona.amethyst.commons.model.nip51Lists.proxyRelays.ProxyRelayListDecryptionCache -import com.vitorpamplona.amethyst.commons.model.nip51Lists.relayFeeds.RelayFeedsListDecryptionCache import com.vitorpamplona.amethyst.commons.model.nip51Lists.searchRelays.SearchRelayListDecryptionCache import com.vitorpamplona.amethyst.commons.model.nip51Lists.trustedRelays.TrustedRelayListDecryptionCache import com.vitorpamplona.amethyst.commons.model.nip56Reports.ReportAction import com.vitorpamplona.amethyst.commons.model.nip62Vanish.VanishRequestsState import com.vitorpamplona.amethyst.commons.model.nip72Communities.CommunityListDecryptionCache -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.nip85TrustedAssertions.UserAssertionDecryptionCache +import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.UserAssertionsState import com.vitorpamplona.amethyst.commons.model.nipBCOnchainZaps.OnchainWalletState import com.vitorpamplona.amethyst.commons.model.privateChatLastReadRoute import com.vitorpamplona.amethyst.commons.model.privateChats.hasEncryptedContent @@ -121,6 +121,7 @@ import com.vitorpamplona.amethyst.logTime import com.vitorpamplona.amethyst.model.algoFeeds.FavoriteAlgoFeedsOrchestrator import com.vitorpamplona.amethyst.model.bolt12Offers.Bolt12OfferListState import com.vitorpamplona.amethyst.model.buzz.ChannelInvitesState +import com.vitorpamplona.amethyst.model.cordn.CordnRuntime import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListState import com.vitorpamplona.amethyst.model.localRelays.ForwardKind0ToLocalRelayState import com.vitorpamplona.amethyst.model.localRelays.LocalRelayListState @@ -144,18 +145,18 @@ import com.vitorpamplona.amethyst.model.nip46Signer.Nip46SignerState import com.vitorpamplona.amethyst.model.nip47WalletConnect.NwcSignerState import com.vitorpamplona.amethyst.model.nip51Lists.HiddenUsersState import com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays.BlockedRelayListState +import com.vitorpamplona.amethyst.model.nip51Lists.bookmarkSets.BookmarkSetsState import com.vitorpamplona.amethyst.model.nip51Lists.broadcastRelays.BroadcastRelayListState import com.vitorpamplona.amethyst.model.nip51Lists.favoriteAlgoFeedsLists.FavoriteAlgoFeedsListState +import com.vitorpamplona.amethyst.model.nip51Lists.favoriteRelays.FavoriteRelayListState +import com.vitorpamplona.amethyst.model.nip51Lists.followSets.FollowSetsState +import com.vitorpamplona.amethyst.model.nip51Lists.followSets.StarterPacksState import com.vitorpamplona.amethyst.model.nip51Lists.geohashLists.GeohashListState -import com.vitorpamplona.amethyst.model.nip51Lists.hashtagLists.HashtagListState import com.vitorpamplona.amethyst.model.nip51Lists.indexerRelays.IndexerRelayListState +import com.vitorpamplona.amethyst.model.nip51Lists.interestLists.InterestListState import com.vitorpamplona.amethyst.model.nip51Lists.interestSets.InterestSetsState -import com.vitorpamplona.amethyst.model.nip51Lists.labeledBookmarkLists.LabeledBookmarkListsState import com.vitorpamplona.amethyst.model.nip51Lists.muteList.MuteListState -import com.vitorpamplona.amethyst.model.nip51Lists.peopleList.FollowListsState -import com.vitorpamplona.amethyst.model.nip51Lists.peopleList.PeopleListsState import com.vitorpamplona.amethyst.model.nip51Lists.proxyRelays.ProxyRelayListState -import com.vitorpamplona.amethyst.model.nip51Lists.relayFeeds.RelayFeedListState import com.vitorpamplona.amethyst.model.nip51Lists.searchRelays.SearchRelayListState import com.vitorpamplona.amethyst.model.nip51Lists.trustedRelays.TrustedRelayListState import com.vitorpamplona.amethyst.model.nip65RelayList.Nip65RelayListState @@ -207,8 +208,8 @@ import com.vitorpamplona.quartz.experimental.profileGallery.hash import com.vitorpamplona.quartz.experimental.profileGallery.image import com.vitorpamplona.quartz.experimental.profileGallery.mimeType import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.transport.MarmotQuicTransport +import com.vitorpamplona.quartz.marmot.groups.MlsGroupStateStore import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -237,8 +238,8 @@ import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUserIds import com.vitorpamplona.quartz.nip01Core.tags.references.references import com.vitorpamplona.quartz.nip03Timestamp.OtsResolver import com.vitorpamplona.quartz.nip04Dm.PrivateDMCache -import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent -import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip04Dm.messages.EncryptedDmEvent +import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip10Notes.content.findHashtags import com.vitorpamplona.quartz.nip10Notes.content.findNostrUris @@ -280,10 +281,10 @@ import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark import com.vitorpamplona.quartz.nip56Reports.ReportEvent import com.vitorpamplona.quartz.nip56Reports.ReportType -import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent import com.vitorpamplona.quartz.nip57Zaps.PrivateZapCache +import com.vitorpamplona.quartz.nip57Zaps.PrivateZapEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapRequestEvent import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetup import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplits import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiser @@ -319,10 +320,10 @@ import com.vitorpamplona.quartz.nip88Polls.poll.tags.PollType import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.NostrSignerWithClientTag import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.withoutClientTag -import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryRequest.NIP90ContentDiscoveryRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryRequest.DvmContentDiscoveryRequestEvent import com.vitorpamplona.quartz.nip92IMeta.IMetaTag import com.vitorpamplona.quartz.nip92IMeta.imetas -import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent +import com.vitorpamplona.quartz.nip94FileMetadata.FileMetadataEvent import com.vitorpamplona.quartz.nip94FileMetadata.blurhash import com.vitorpamplona.quartz.nip94FileMetadata.dimension import com.vitorpamplona.quartz.nip94FileMetadata.fileSize @@ -377,8 +378,17 @@ class Account( val cache: LocalCache, val client: INostrClient, val scope: CoroutineScope, + /** + * Where cordn keeps its encrypted group state, or null to run without it. + * + * A directory rather than a built runtime, because `CordnRuntime` needs + * this account's [scope] and that only exists once the account does. + * Nothing about it is shared with Marmot's stores above — cordn has its + * own, by the §3.1 rule in `amethyst/plans/2026-09-19-cordn-ui.md`. + */ + val cordnFilesDir: java.io.File? = null, val mlsGroupStateStore: MlsGroupStateStore? = null, - val marmotMessageStore: com.vitorpamplona.quartz.marmot.mls.group.MarmotMessageStore? = null, + val marmotMessageStore: com.vitorpamplona.quartz.marmot.groups.MarmotMessageStore? = null, val marmotKeyPackageStore: com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore? = null, /** * Durable publish obligations. Null means publish-before-apply does not @@ -457,17 +467,17 @@ class Account( // redeemed by this key and the relay grants membership to it alone — and this set makes the // relay first-party for NIP-42 (see AuthCoordinator.isFirstParty), so a device-global set would // hand every other logged-in account an automatic login on a workspace it never joined. - // Restored/persisted per account by BuzzWorkspacePreferences (see AccountCacheState). + // Restored/persisted per account by BuzzWorkspaceStore (see AccountCacheState). val buzzWorkspaces = BuzzWorkspaces() // The Buzz channels THIS account pinned. A star says which channels this user wants at the top // of the community view, so a shared set let one account reorder and badge every other one's - // channel list. Restored/persisted per account by BuzzChannelStarPreferences. + // channel list. Restored/persisted per account by BuzzChannelStarStore. val buzzChannelStars = BuzzChannelStars() // The NIP-OA attestation an owner issued to THIS account's key, attached to its Buzz-relay // AUTH so the relay grants virtual membership. Restored/persisted per account by - // BuzzAttestationPreferences. + // BuzzAttestationStore. val buzzAttestation = BuzzHeldAttestations(pubKey) // The relays this account approved by answering the NIP-42 prompt *without* the "remember" @@ -629,8 +639,8 @@ class Account( val indexerRelayListDecryptionCache = IndexerRelayListDecryptionCache(signer) val indexerRelayList = IndexerRelayListState(signer, cache, indexerRelayListDecryptionCache, scope, settings) - val relayFeedsListDecryptionCache = RelayFeedsListDecryptionCache(signer) - val relayFeedsList = RelayFeedListState(signer, cache, relayFeedsListDecryptionCache, scope, settings) + val favoriteRelayListDecryptionCache = FavoriteRelayListDecryptionCache(signer) + val favoriteRelayList = FavoriteRelayListState(signer, cache, favoriteRelayListDecryptionCache, scope, settings) val blockedRelayListDecryptionCache = BlockedRelayListDecryptionCache(signer) val blockedRelayList = BlockedRelayListState(signer, cache, blockedRelayListDecryptionCache, scope, settings) @@ -748,8 +758,8 @@ class Account( val communityListDecryptionCache = CommunityListDecryptionCache(signer) val communityList = CommunityListState(signer, cache, communityListDecryptionCache, scope, settings) - val hashtagListDecryptionCache = HashtagListDecryptionCache(signer) - val hashtagList = HashtagListState(signer, cache, hashtagListDecryptionCache, scope, settings) + val interestListDecryptionCache = InterestListDecryptionCache(signer) + val interestList = InterestListState(signer, cache, interestListDecryptionCache, scope, settings) val favoriteAlgoFeedsListDecryptionCache = FavoriteAlgoFeedsListDecryptionCache(signer) val favoriteAlgoFeedsList = FavoriteAlgoFeedsListState(signer, cache, favoriteAlgoFeedsListDecryptionCache, scope, settings) @@ -759,7 +769,7 @@ class Account( val geohashList = GeohashListState(signer, cache, geohashListDecryptionCache, scope, settings) // Anonymous, per-geohash throwaway identities for Bitchat-interoperable location chats. - val geohashIdentity = GeohashChatIdentityState(signer) + val geohashIdentity = GeohashChatIdentityState(signer, scope) val muteListDecryptionCache = MuteListDecryptionCache(signer) val muteList = MuteListState(signer, cache, muteListDecryptionCache, scope, settings) @@ -767,14 +777,14 @@ class Account( val trustProviderListDecryptionCache = TrustProviderListDecryptionCache(signer) val trustProviderList = TrustProviderListState(signer, cache, trustProviderListDecryptionCache, scope, settings) - val peopleListDecryptionCache = PeopleListDecryptionCache(signer) - val blockPeopleList = BlockPeopleListState(signer, cache, peopleListDecryptionCache, scope) - val peopleLists = PeopleListsState(signer, cache, peopleListDecryptionCache, scope) - val followLists = FollowListsState(signer, cache, scope) + val followSetDecryptionCache = FollowSetDecryptionCache(signer) + val blockPeopleList = BlockPeopleListState(signer, cache, followSetDecryptionCache, scope) + val followSets = FollowSetsState(signer, cache, followSetDecryptionCache, scope) + val starterPacks = StarterPacksState(signer, cache, scope) val hiddenUsers = HiddenUsersState(muteList.flow, blockPeopleList.flow, scope, settings) - val labeledBookmarkLists = LabeledBookmarkListsState(signer, cache, scope) + val bookmarkSets = BookmarkSetsState(signer, cache, scope) val interestSets = InterestSetsState(signer, cache, scope) val appRecommendations = AppRecommendationsState(signer, cache, scope) val oldBookmarkState = OldBookmarkListState(signer, cache, scope) @@ -785,8 +795,8 @@ class Account( val ownedEmojiPacks = OwnedEmojiPacksState(signer, cache, scope) // needs `emoji` above: nickname edits resolve :shortcodes: against the account's packs - val contactCardDecryptionCache = ContactCardDecryptionCache(signer) - val contactCards = ContactCardsState(signer, cache, contactCardDecryptionCache, emoji) + val userAssertionDecryptionCache = UserAssertionDecryptionCache(signer) + val userAssertions = UserAssertionsState(signer, cache, userAssertionDecryptionCache, emoji) val vanish = VanishRequestsState(signer, cache, client, scope) @@ -883,7 +893,7 @@ class Account( val followsPerRelay = FollowsPerOutboxRelay(kind3FollowList, blockedRelayList, proxyRelayList, cache, scope).flow // Merges all follow lists to create a single All Follows feed. - val allFollows = MergedFollowListsState(kind3FollowList, peopleLists, followLists, hashtagList, geohashList, communityList, scope) + val allFollows = MergedFollowListsState(kind3FollowList, followSets, starterPacks, interestList, geohashList, communityList, scope) val privateDMDecryptionCache = PrivateDMCache(signer) override val privateZapsDecryptionCache = PrivateZapCache(signer) @@ -945,6 +955,24 @@ class Account( val otsState = OtsState(signer, cache, otsResolverBuilder, scope, settings) + /** + * cordn, for this account, or null when no directory was supplied. + * + * Built here for the same reason [marmotManager] is: it needs [scope]. + * Opening coordinators and starting their sync loops is a separate, + * suspending step ([CordnRuntime.start]) — constructing this touches no + * network and no coordinator learns anything from it. + */ + val cordnRuntime: CordnRuntime? = + cordnFilesDir?.let { + CordnRuntime( + accountSigner = signer, + client = client, + filesDir = it, + scope = scope, + ) + } + val marmotManager: MarmotManager? = mlsGroupStateStore?.let { MarmotManager( @@ -1002,10 +1030,10 @@ class Account( val feedDecryptionCaches = FeedDecryptionCaches( - peopleListCache = peopleListDecryptionCache, + peopleListCache = followSetDecryptionCache, muteListCache = muteListDecryptionCache, communityListCache = communityListDecryptionCache, - hashtagCache = hashtagListDecryptionCache, + hashtagCache = interestListDecryptionCache, geohashCache = geohashListDecryptionCache, ) @@ -1019,7 +1047,7 @@ class Account( blockedRelays = blockedRelayList.flow, proxyRelays = proxyRelayList.flow, mineRelays = mineRelays.flow, - relayFeeds = relayFeedsList.flow, + relayFeeds = favoriteRelayList.flow, caches = feedDecryptionCaches, signer = signer, scope = scope, @@ -1255,7 +1283,7 @@ class Account( suspend fun updateZapAmounts( amountSet: List, - selectedZapType: LnZapEvent.ZapType, + selectedZapType: ZapReceiptEvent.ZapType, nip47Update: Nip47WalletConnect.Nip47URINorm?, ) { var changed = false @@ -1679,7 +1707,7 @@ class Account( if (myNotes.isNotEmpty()) { // chunks in 200 elements to avoid going over the 65KB limit for events. myNotes.chunked(200).forEach { chunkedList -> - val template = DeletionEvent.build(chunkedList.mapNotNull { it.event }) + val template = DeletionRequestEvent.build(chunkedList.mapNotNull { it.event }) val deletionEvent = signer.sign(template) val myRelayList = outboxRelays.flow.value.toMutableSet() chunkedList.forEach { @@ -1710,7 +1738,7 @@ class Account( val recipients = (targetEvent.taggedUserIds() + targetEvent.pubKey).distinct().minus(signer.pubKey) broadcastPrivately( - NIP17Factory().createDeletionNIP17(DeletionEvent.build(myRumors), recipients, signer), + NIP17Factory().createDeletionNIP17(DeletionRequestEvent.build(myRumors), recipients, signer), ) } @@ -1721,7 +1749,7 @@ class Account( if (!isWriteable()) return if (event.pubKey != signer.pubKey) return - val deletionEvent = signer.sign(DeletionEvent.build(listOf(event))) + val deletionEvent = signer.sign(DeletionRequestEvent.build(listOf(event))) client.publish(deletionEvent, outboxRelays.flow.value + additionalRelays) cache.justConsumeMyOwnEvent(deletionEvent) } @@ -2098,9 +2126,9 @@ class Account( suspend fun unfollow(community: AddressableNote) = sendMyPublicAndPrivateOutbox(communityList.unfollow(community)) - suspend fun followHashtag(tag: String) = sendMyPublicAndPrivateOutbox(hashtagList.follow(tag)) + suspend fun followHashtag(tag: String) = sendMyPublicAndPrivateOutbox(interestList.follow(tag)) - suspend fun unfollowHashtag(tag: String) = sendMyPublicAndPrivateOutbox(hashtagList.unfollow(tag)) + suspend fun unfollowHashtag(tag: String) = sendMyPublicAndPrivateOutbox(interestList.unfollow(tag)) suspend fun followFavoriteAlgoFeed(dvm: AddressBookmark) = sendMyPublicAndPrivateOutbox(favoriteAlgoFeedsList.follow(dvm)) @@ -2148,7 +2176,7 @@ class Account( suspend fun deleteWebBookmark(event: WebBookmarkEvent) { if (!isWriteable()) return - val template = DeletionEvent.build(listOf(event)) + val template = DeletionRequestEvent.build(listOf(event)) val signedEvent = signer.sign(template) cache.justConsumeMyOwnEvent(signedEvent) @@ -2184,7 +2212,7 @@ class Account( if (!isWriteable()) return if (event.pubKey != signer.pubKey) return - val template = DeletionEvent.build(listOf(event)) + val template = DeletionRequestEvent.build(listOf(event)) val signedEvent = signer.sign(template) cache.justConsumeMyOwnEvent(signedEvent) @@ -2569,7 +2597,7 @@ class Account( } } } else { - FileHeaderEvent.build(url, alt) { + FileMetadataEvent.build(url, alt) { hash(headerInfo.hash) fileSize(headerInfo.size) @@ -2683,7 +2711,7 @@ class Account( val extraRelays = draftNote.relays val deletedDraft = DraftWrapEvent.createDeletedEvent(draftTag, signer) - val deletionEvent = signer.sign(DeletionEvent.build(listOf(deletedDraft))) + val deletionEvent = signer.sign(DeletionRequestEvent.build(listOf(deletedDraft))) val relayList = (privateStorageRelayList.flow.value + localRelayList.flow.value + extraRelays).toSet() @@ -2909,7 +2937,7 @@ class Account( broadcast.forEach { client.publish(it, relayList) } } - override suspend fun sendNip04PrivateMessage(eventTemplate: EventTemplate) { + override suspend fun sendNip04PrivateMessage(eventTemplate: EventTemplate) { if (!isWriteable()) return val newEvent = signer.sign(eventTemplate) @@ -3412,11 +3440,11 @@ class Account( * encrypted in the content. `null` clears a field. Goes out through the * account's extended outbox relays. */ - suspend fun updateContactCardPetName( + suspend fun updateUserAssertionPetName( pubkeyHex: HexKey, petName: String?, summary: String?, - ) = sendMyPublicAndPrivateOutbox(contactCards.updatePetNameAndSummary(pubkeyHex, petName, summary)) + ) = sendMyPublicAndPrivateOutbox(userAssertions.updatePetNameAndSummary(pubkeyHex, petName, summary)) suspend fun showUser(pubkeyHex: HexKey) { sendMyPublicAndPrivateOutbox(blockPeopleList.showUser(pubkeyHex)) @@ -3453,10 +3481,10 @@ class Account( suspend fun requestDVMContentDiscovery( dvmPublicKey: User, - onReady: (event: NIP90ContentDiscoveryRequestEvent, relays: Set) -> Unit, + onReady: (event: DvmContentDiscoveryRequestEvent, relays: Set) -> Unit, ) { val relays = nip65RelayList.inboxFlow.value.toSet() - val request = signer.sign(NIP90ContentDiscoveryRequestEvent.build(dvmPublicKey.pubkeyHex, signer.pubKey, relays)) + val request = signer.sign(DvmContentDiscoveryRequestEvent.build(dvmPublicKey.pubkeyHex, signer.pubKey, relays)) val relayList = dvmPublicKey.inboxRelays()?.toSet()?.ifEmpty { null } @@ -3475,8 +3503,8 @@ class Account( return if (isWriteable()) { when { - event is PrivateDmEvent -> privateDMDecryptionCache.cachedDM(event) - event is LnZapRequestEvent && event.isPrivateZap() -> privateZapsDecryptionCache.cachedPrivateZap(event)?.content + event is EncryptedDmEvent -> privateDMDecryptionCache.cachedDM(event) + event is ZapRequestEvent && event.isPrivateZap() -> privateZapsDecryptionCache.cachedPrivateZap(event)?.content event is DraftWrapEvent -> draftsDecryptionCache.preCachedDraft(event)?.content else -> event.content } @@ -3491,11 +3519,11 @@ class Account( suspend fun decryptContent(note: Note): String? { val event = note.event return when { - event is PrivateDmEvent && isWriteable() -> { + event is EncryptedDmEvent && isWriteable() -> { privateDMDecryptionCache.decryptDM(event) } - event is LnZapRequestEvent && isWriteable() -> { + event is ZapRequestEvent && isWriteable() -> { if (event.isPrivateZap()) { if (isWriteable()) { privateZapsDecryptionCache.decryptPrivateZap(event)?.content @@ -3522,7 +3550,7 @@ class Account( } } - suspend fun decryptZapOrNull(event: LnZapRequestEvent): LnZapPrivateEvent? = if (event.isPrivateZap() && isWriteable()) privateZapsDecryptionCache.decryptPrivateZap(event) else null + suspend fun decryptZapOrNull(event: ZapRequestEvent): PrivateZapEvent? = if (event.isPrivateZap() && isWriteable()) privateZapsDecryptionCache.decryptPrivateZap(event) else null fun isAllHidden(users: Set): Boolean = users.all { isHidden(it) } @@ -3574,7 +3602,7 @@ class Account( note.countReportAuthorsBy(followingKeySet()) < reportWarningThreshold } - fun isDecryptedContentHidden(noteEvent: PrivateDmEvent): Boolean = + fun isDecryptedContentHidden(noteEvent: EncryptedDmEvent): Boolean = if (hiddenUsers.flow.value.hiddenWordsCase .isNotEmpty() ) { @@ -3719,11 +3747,11 @@ class Account( suspend fun saveTrustedRelayList(trustedRelays: List) = sendMyPublicAndPrivateOutbox(trustedRelayList.saveRelayList(trustedRelays)) - suspend fun saveRelayFeedsList(trustedRelays: List) = sendMyPublicAndPrivateOutbox(relayFeedsList.saveRelayList(trustedRelays)) + suspend fun saveFavoriteRelayList(trustedRelays: List) = sendMyPublicAndPrivateOutbox(favoriteRelayList.saveRelayList(trustedRelays)) - suspend fun followRelayFeed(url: NormalizedRelayUrl) = sendMyPublicAndPrivateOutbox(relayFeedsList.addRelay(url)) + suspend fun followRelayFeed(url: NormalizedRelayUrl) = sendMyPublicAndPrivateOutbox(favoriteRelayList.addRelay(url)) - suspend fun unfollowRelayFeed(url: NormalizedRelayUrl) = sendMyPublicAndPrivateOutbox(relayFeedsList.removeRelay(url)) + suspend fun unfollowRelayFeed(url: NormalizedRelayUrl) = sendMyPublicAndPrivateOutbox(favoriteRelayList.removeRelay(url)) suspend fun saveBlockedRelayList(blockedRelays: List) = sendMyPublicAndPrivateOutbox(blockedRelayList.saveRelayList(blockedRelays)) @@ -3755,7 +3783,7 @@ class Account( proxyRelayList.getProxyRelayList(), broadcastRelayList.getBroadcastRelayList(), indexerRelayList.getIndexerRelayList(), - relayFeedsList.getRelayFeedsList(), + favoriteRelayList.getFavoriteRelayList(), blockedRelayList.getBlockedRelayList(), muteList.getMuteList(), bookmarkState.getBookmarkList(), @@ -3885,6 +3913,19 @@ class Account( // the race where a publish would land on a half-built Account. cashuWalletState.start { event -> sendLiterallyEverywhere(event) } + // Reopen the cordn coordinators this account used last time. + // + // Deliberately its own launch rather than a branch of Marmot's block + // below: the two protocols are independent by design (§3.1 of + // amethyst/plans/2026-09-19-cordn-ui.md), and a cordn coordinator that + // is down must not delay Marmot's restore, or the reverse. Failures + // are already contained per coordinator inside start(). + cordnRuntime?.let { runtime -> + scope.launch(Dispatchers.IO) { + runtime.restore() + } + } + // Restore Marmot MLS group state on startup if (marmotManager != null) { // Derived kind:1210 rows go straight into the conversation. Only @@ -4015,9 +4056,9 @@ class Account( logTime("Account ${userProfile().toBestDisplayName()} newEventBundle Update with ${newNotes.size} new notes") { upgradeAttestations() newNotesPreProcessor.runNew(newNotes) - peopleLists.newNotes(newNotes) - followLists.newNotes(newNotes) - labeledBookmarkLists.newNotes(newNotes) + followSets.newNotes(newNotes) + starterPacks.newNotes(newNotes) + bookmarkSets.newNotes(newNotes) interestSets.newNotes(newNotes) ownedEmojiPacks.newNotes(newNotes) } @@ -4028,9 +4069,9 @@ class Account( cache.live.deletedEventBundles.collect { deletedNotes -> logTime("Account ${userProfile().toBestDisplayName()} deletedEventBundle Update with ${deletedNotes.size} new notes") { newNotesPreProcessor.runDeleted(deletedNotes) - peopleLists.deletedNotes(deletedNotes) - followLists.deletedNotes(deletedNotes) - labeledBookmarkLists.deletedNotes(deletedNotes) + followSets.deletedNotes(deletedNotes) + starterPacks.deletedNotes(deletedNotes) + bookmarkSets.deletedNotes(deletedNotes) interestSets.deletedNotes(deletedNotes) ownedEmojiPacks.deletedNotes(deletedNotes) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt index 0a4001414f..68c223da21 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt @@ -931,7 +931,7 @@ class AccountMarmotActions( /** * Revoke admin privileges from [targetPubKey]. Rejects any change that * would leave the group with zero admins — MIP-03's admin-depletion guard - * in [com.vitorpamplona.quartz.marmot.mls.group.MlsGroup] would otherwise + * in [com.vitorpamplona.quartz.mls.group.MlsGroup] would otherwise * throw at commit time. */ suspend fun revokeMarmotGroupAdmin( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountRelayGroupActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountRelayGroupActions.kt index 33612b11ef..884213d17f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountRelayGroupActions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountRelayGroupActions.kt @@ -58,15 +58,15 @@ import com.vitorpamplona.quartz.nip29RelayGroups.GroupId import com.vitorpamplona.quartz.nip29RelayGroups.hTag import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateGroupEvent -import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateInviteEvent import com.vitorpamplona.quartz.nip29RelayGroups.moderation.DeleteGroupEvent -import com.vitorpamplona.quartz.nip29RelayGroups.moderation.EditMetadataEvent -import com.vitorpamplona.quartz.nip29RelayGroups.moderation.PutUserEvent -import com.vitorpamplona.quartz.nip29RelayGroups.moderation.RemoveUserEvent -import com.vitorpamplona.quartz.nip29RelayGroups.moderation.UpdatePinListEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.GroupCreateInviteEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.GroupEditMetadataEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.GroupPutUserEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.GroupRemoveUserEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.GroupUpdatePinListEvent import com.vitorpamplona.quartz.nip29RelayGroups.moderation.previous -import com.vitorpamplona.quartz.nip29RelayGroups.request.JoinRequestEvent -import com.vitorpamplona.quartz.nip29RelayGroups.request.LeaveRequestEvent +import com.vitorpamplona.quartz.nip29RelayGroups.request.GroupJoinRequestEvent +import com.vitorpamplona.quartz.nip29RelayGroups.request.GroupLeaveRequestEvent import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent import com.vitorpamplona.quartz.utils.RandomInstance @@ -92,7 +92,7 @@ class AccountRelayGroupActions( channel: RelayGroupChannel, code: String? = null, ) { - val template = JoinRequestEvent.build(channel.groupId.id, inviteCode = code) + val template = GroupJoinRequestEvent.build(channel.groupId.id, inviteCode = code) account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } account.follow(channel) } @@ -295,7 +295,7 @@ class AccountRelayGroupActions( /** Send a kind 9022 leave request to the host relay and drop it from our list. */ suspend fun leaveRelayGroup(channel: RelayGroupChannel) { - val template = LeaveRequestEvent.build(channel.groupId.id) + val template = GroupLeaveRequestEvent.build(channel.groupId.id) account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } account.unfollow(channel) } @@ -351,7 +351,7 @@ class AccountRelayGroupActions( ) { listOf(relay) } val edit = - EditMetadataEvent.build( + GroupEditMetadataEvent.build( groupId, name = name, about = about, @@ -405,7 +405,7 @@ class AccountRelayGroupActions( channel: RelayGroupChannel, code: String, ) { - val template = CreateInviteEvent.build(channel.groupId.id, code) + val template = GroupCreateInviteEvent.build(channel.groupId.id, code) account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } } @@ -418,7 +418,7 @@ class AccountRelayGroupActions( channel: RelayGroupChannel, pinnedEventIds: List, ) { - val template = UpdatePinListEvent.build(channel.groupId.id, pinnedEventIds) + val template = GroupUpdatePinListEvent.build(channel.groupId.id, pinnedEventIds) account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } } @@ -445,7 +445,7 @@ class AccountRelayGroupActions( channel: RelayGroupChannel, pubkey: HexKey, ) { - val template = RemoveUserEvent.build(channel.groupId.id, listOf(pubkey)) + val template = GroupRemoveUserEvent.build(channel.groupId.id, listOf(pubkey)) account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } } @@ -470,7 +470,7 @@ class AccountRelayGroupActions( } else { null } - val template = PutUserEvent.build(channel.groupId.id, listOf(pubkey to roles), buzzRole = buzzRole) + val template = GroupPutUserEvent.build(channel.groupId.id, listOf(pubkey to roles), buzzRole = buzzRole) account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } } @@ -525,7 +525,7 @@ class AccountRelayGroupActions( // edit when we send that tag. A plain NIP-29 relay ignores it and honours the status flag. val isBuzz = BuzzRelayDialect.isBuzz(channel.groupId.relayUrl) val template = - EditMetadataEvent.build( + GroupEditMetadataEvent.build( channel.groupId.id, name = name, about = about, @@ -549,7 +549,7 @@ class AccountRelayGroupActions( channel: RelayGroupChannel, archived: Boolean, ) { - val template = EditMetadataEvent.build(channel.groupId.id, archived = archived) + val template = GroupEditMetadataEvent.build(channel.groupId.id, archived = archived) account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index e2613fbe36..81932f49de 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -59,8 +59,8 @@ import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey -import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent -import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent +import com.vitorpamplona.quartz.nip17Dm.settings.DmRelayListEvent +import com.vitorpamplona.quartz.nip28PublicChat.list.PublicChatListEvent import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayListEvent import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent @@ -68,16 +68,16 @@ import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListEvent import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent -import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent +import com.vitorpamplona.quartz.nip51Lists.interestList.InterestListEvent import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.FavoriteRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent -import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.SimpleGroupListEvent import com.vitorpamplona.quartz.nip55AndroidSigner.api.CommandType import com.vitorpamplona.quartz.nip55AndroidSigner.api.permission.Permission -import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent import com.vitorpamplona.quartz.nip60Cashu.wallet.CashuWalletEvent import com.vitorpamplona.quartz.nip61Nutzaps.info.NutzapInfoEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent @@ -203,20 +203,20 @@ class AccountSettings( val showMessagesInNotifications: MutableStateFlow = MutableStateFlow(true), var backupUserMetadata: MetadataEvent? = null, var backupContactList: ContactListEvent? = null, - var backupDMRelayList: ChatMessageRelayListEvent? = null, + var backupDMRelayList: DmRelayListEvent? = null, var backupKeyPackageRelayList: KeyPackageRelayListEvent? = null, var backupNIP65RelayList: AdvertisedRelayListEvent? = null, var backupSearchRelayList: SearchRelayListEvent? = null, var backupIndexRelayList: IndexerRelayListEvent? = null, - var backupRelayFeedsList: RelayFeedsListEvent? = null, + var backupFavoriteRelayList: FavoriteRelayListEvent? = null, var backupBlockedRelayList: BlockedRelayListEvent? = null, var backupTrustedRelayList: TrustedRelayListEvent? = null, var backupMuteList: MuteListEvent? = null, var backupPrivateHomeRelayList: PrivateOutboxRelayListEvent? = null, var backupAppSpecificData: AppSpecificDataEvent? = null, - var backupChannelList: ChannelListEvent? = null, + var backupPublicChatList: PublicChatListEvent? = null, var backupCommunityList: CommunityListEvent? = null, - var backupHashtagList: HashtagListEvent? = null, + var backupInterestList: InterestListEvent? = null, var backupFavoriteAlgoFeedsList: FavoriteAlgoFeedsListEvent? = null, var backupGeohashList: GeohashListEvent? = null, var backupEphemeralChatList: EphemeralChatListEvent? = null, @@ -375,7 +375,7 @@ class AccountSettings( // Zaps and Reactions // --- - fun changeDefaultZapType(zapType: LnZapEvent.ZapType): Boolean { + fun changeDefaultZapType(zapType: ZapReceiptEvent.ZapType): Boolean { if (syncedSettings.zaps.defaultZapType.value != zapType) { syncedSettings.zaps.defaultZapType.tryEmit(zapType) saveAccountSettings() @@ -1075,7 +1075,7 @@ class AccountSettings( when (incoming) { is MetadataEvent -> updateUserMetadata(incoming) is ContactListEvent -> updateContactListTo(incoming) - is ChatMessageRelayListEvent -> updateDMRelayList(incoming) + is DmRelayListEvent -> updateDMRelayList(incoming) is KeyPackageRelayListEvent -> updateKeyPackageRelayList(incoming) is AdvertisedRelayListEvent -> updateNIP65RelayList(incoming) is CashuWalletEvent -> updateCashuWallet(incoming) @@ -1084,13 +1084,13 @@ class AccountSettings( is Bolt12OfferListEvent -> updateBolt12Offers(incoming) is SearchRelayListEvent -> updateSearchRelayList(incoming) is IndexerRelayListEvent -> updateIndexRelayList(incoming) - is RelayFeedsListEvent -> updateRelayFeedList(incoming) + is FavoriteRelayListEvent -> updateFavoriteRelayList(incoming) is BlockedRelayListEvent -> updateBlockedRelayList(incoming) is TrustedRelayListEvent -> updateTrustedRelayList(incoming) is PrivateOutboxRelayListEvent -> updatePrivateHomeRelayList(incoming) - is ChannelListEvent -> updateChannelListTo(incoming) + is PublicChatListEvent -> updatePublicChatListTo(incoming) is GeohashListEvent -> updateGeohashListTo(incoming) - is HashtagListEvent -> updateHashtagListTo(incoming) + is InterestListEvent -> updateInterestListTo(incoming) is FavoriteAlgoFeedsListEvent -> updateFavoriteAlgoFeedsListTo(incoming) is CommunityListEvent -> updateCommunityListTo(incoming) is EphemeralChatListEvent -> updateEphemeralChatListTo(incoming) @@ -1140,7 +1140,7 @@ class AccountSettings( } } - fun updateDMRelayList(newDMRelayList: ChatMessageRelayListEvent?) { + fun updateDMRelayList(newDMRelayList: DmRelayListEvent?) { if (newDMRelayList == null) return if (backupGuard.accept(backupDMRelayList, newDMRelayList, isEmpty = newDMRelayList.tags.isEmpty()) { updateDMRelayList(newDMRelayList) }) { @@ -1221,7 +1221,8 @@ class AccountSettings( * Reserve [count] consecutive NUT-13 counters for [keysetId], * returning the first one. Caller derives `(secret, r)` from * `(seed, keysetId, i)` for `i in [returned .. returned+count-1]`. - * Persisted synchronously before returning — see [CashuKeysetCounterStore]. + * Persisted before returning — see [CashuKeysetCounterStore]. Suspends + * because that write is what stands between a crash and a reused counter. * * One-time migration: when this keyset has a non-zero value in the * legacy [cashuKeysetCounters] map (from a build that persisted @@ -1229,7 +1230,7 @@ class AccountSettings( * still at zero, the legacy value is copied over before we reserve * so an upgrade doesn't reset the counter. */ - fun reserveCashuCounters( + suspend fun reserveCashuCounters( keysetId: String, count: Int, ): Long { @@ -1238,12 +1239,12 @@ class AccountSettings( } /** Inspect the next counter for [keysetId] without consuming any. */ - fun peekCashuCounter(keysetId: String): Long { + suspend fun peekCashuCounter(keysetId: String): Long { migrateLegacyCashuCounter(keysetId) return cashuCounters.peek(keysetId) } - private fun migrateLegacyCashuCounter(keysetId: String) { + private suspend fun migrateLegacyCashuCounter(keysetId: String) { val legacy = cashuKeysetCounters[keysetId] ?: return cashuCounters.seedIfMissing(keysetId, legacy) } @@ -1284,11 +1285,11 @@ class AccountSettings( } } - fun updateRelayFeedList(newRelayFeedList: RelayFeedsListEvent?) { - if (newRelayFeedList == null) return + fun updateFavoriteRelayList(newFavoriteRelayList: FavoriteRelayListEvent?) { + if (newFavoriteRelayList == null) return - if (backupGuard.accept(backupRelayFeedsList, newRelayFeedList, isEmpty = newRelayFeedList.tags.isEmpty()) { updateRelayFeedList(newRelayFeedList) }) { - backupRelayFeedsList = newRelayFeedList + if (backupGuard.accept(backupFavoriteRelayList, newFavoriteRelayList, isEmpty = newFavoriteRelayList.tags.isEmpty()) { updateFavoriteRelayList(newFavoriteRelayList) }) { + backupFavoriteRelayList = newFavoriteRelayList saveAccountSettings() } } @@ -1320,13 +1321,13 @@ class AccountSettings( } } - override fun channelList() = backupChannelList + override fun publicChatList() = backupPublicChatList - override fun updateChannelListTo(newChannelList: ChannelListEvent?) { - if (newChannelList == null) return + override fun updatePublicChatListTo(newPublicChatList: PublicChatListEvent?) { + if (newPublicChatList == null) return - if (backupGuard.accept(backupChannelList, newChannelList, isEmpty = newChannelList.tags.isEmpty()) { updateChannelListTo(newChannelList) }) { - backupChannelList = newChannelList + if (backupGuard.accept(backupPublicChatList, newPublicChatList, isEmpty = newPublicChatList.tags.isEmpty()) { updatePublicChatListTo(newPublicChatList) }) { + backupPublicChatList = newPublicChatList saveAccountSettings() } } @@ -1340,11 +1341,11 @@ class AccountSettings( } } - fun updateHashtagListTo(newHashtagList: HashtagListEvent?) { - if (newHashtagList == null) return + fun updateInterestListTo(newInterestList: InterestListEvent?) { + if (newInterestList == null) return - if (backupGuard.accept(backupHashtagList, newHashtagList, isEmpty = newHashtagList.tags.isEmpty()) { updateHashtagListTo(newHashtagList) }) { - backupHashtagList = newHashtagList + if (backupGuard.accept(backupInterestList, newInterestList, isEmpty = newInterestList.tags.isEmpty()) { updateInterestListTo(newInterestList) }) { + backupInterestList = newInterestList saveAccountSettings() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt index 3d1047d25a..3acb2ed75a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt @@ -31,7 +31,7 @@ import com.vitorpamplona.amethyst.commons.service.pow.PoWCategory import com.vitorpamplona.amethyst.commons.service.pow.PoWPolicy import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey -import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.MutableStateFlow @@ -265,7 +265,7 @@ class AccountVideoPlayerPreferences( @Stable class AccountZapPreferences( var zapAmountChoices: MutableStateFlow>, - val defaultZapType: MutableStateFlow, + val defaultZapType: MutableStateFlow, ) /** diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt index b167b7ba62..de721aefbe 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt @@ -25,7 +25,7 @@ import androidx.core.os.ConfigurationCompat import com.vitorpamplona.amethyst.commons.model.navigation.BottomBarEntry import com.vitorpamplona.amethyst.commons.service.pow.PoWCategory import com.vitorpamplona.amethyst.ui.navigation.bottombars.DefaultBottomBarEntries -import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent import kotlinx.serialization.Serializable import java.util.Locale @@ -207,7 +207,7 @@ class AccountZapPreferencesInternal( // and so the on-chain-eligible subset round-trips back for them; on load it // is unioned into [zapAmountChoices]. See AccountSyncedSettings. var onchainZapAmountChoices: List = DefaultOnchainZapAmounts, - val defaultZapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PUBLIC, + val defaultZapType: ZapReceiptEvent.ZapType = ZapReceiptEvent.ZapType.PUBLIC, ) @Serializable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountZapActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountZapActions.kt index 4bcfcaef46..cf994956f6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountZapActions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountZapActions.kt @@ -48,8 +48,8 @@ import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayMethod import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaySuccessResponse import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response -import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapRequestEvent import com.vitorpamplona.quartz.nipB1Bolt12Zaps.builder.Bolt12ZapBuilder import com.vitorpamplona.quartz.nipB1Bolt12Zaps.verify.Bolt12ZapValidation import com.vitorpamplona.quartz.utils.Log @@ -74,12 +74,12 @@ class AccountZapActions( event: Event, pollOption: Int?, message: String = "", - zapType: LnZapEvent.ZapType, + zapType: ZapReceiptEvent.ZapType, toUser: User?, additionalRelays: Set? = null, amountMillisats: Long? = null, lnurl: String? = null, - ) = LnZapRequestEvent.create( + ) = ZapRequestEvent.create( zappedEvent = event, // Where the provider should publish the receipt. Zapping group content pins that to the room's // host relay: the receipt belongs where the message it pays for lives, so the room can show it @@ -205,7 +205,7 @@ class AccountZapActions( offer: String, amountMillisats: Long, message: String, - zapType: LnZapEvent.ZapType, + zapType: ZapReceiptEvent.ZapType, // (messageResId, detail) — the caller localizes; detail carries a wallet error, if any. onError: (StringResource, String?) -> Unit, // (code, detail) — the wallet refused or failed the payment; no funds moved. @@ -215,7 +215,7 @@ class AccountZapActions( ) { // NONZAP means "pay, but publish no receipt" — settle the offer without binding // a zap intent or emitting a 9736, matching the privacy of a bolt11 NONZAP. - if (zapType == LnZapEvent.ZapType.NONZAP) { + if (zapType == ZapReceiptEvent.ZapType.NONZAP) { sendNwcRequest(PayMethod.create("bitcoin:?lno=$offer", amountMillisats), onTimeout) { response -> account.scope.launch { try { @@ -228,7 +228,7 @@ class AccountZapActions( return } - val anonymous = zapType == LnZapEvent.ZapType.ANONYMOUS + val anonymous = zapType == ZapReceiptEvent.ZapType.ANONYMOUS // The 9737 intent and the 9736 zap MUST be signed by the same key. An anonymous // zap uses a fresh ephemeral key so it carries no `P` tag and isn't traceable. val zapSigner = if (anonymous) NostrSignerInternal(KeyPair()) else account.signer @@ -284,12 +284,12 @@ class AccountZapActions( suspend fun createZapRequestFor( user: User, message: String = "", - zapType: LnZapEvent.ZapType, + zapType: ZapReceiptEvent.ZapType, amountMillisats: Long? = null, lnurl: String? = null, - ): LnZapRequestEvent { + ): ZapRequestEvent { val zapRequest = - LnZapRequestEvent.create( + ZapRequestEvent.create( userHex = user.pubkeyHex, relays = account.nip65RelayList.inboxFlow.value + (user.inboxRelays() ?: emptyList()), signer = account.signer, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/EventBroadcaster.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/EventBroadcaster.kt index 298fff1dff..c5a8d6737c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/EventBroadcaster.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/EventBroadcaster.kt @@ -40,16 +40,16 @@ import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip17Dm.base.BaseDMGroupEvent -import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.nip17Dm.settings.DmRelayListEvent import com.vitorpamplona.quartz.nip29RelayGroups.isGroupScoped import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent -import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.LabeledBookmarkListEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkSet.BookmarkSetEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent -import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent +import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip60Cashu.wallet.CashuWalletEvent import com.vitorpamplona.quartz.nip61Nutzaps.info.NutzapInfoEvent @@ -121,7 +121,7 @@ class EventBroadcaster( event is AppSpecificDataEvent || event is BookmarkListEvent || event is OldBookmarkListEvent || - event is LabeledBookmarkListEvent + event is BookmarkSetEvent ) { return false } @@ -166,7 +166,7 @@ class EventBroadcaster( } // Seals, inner DM messages, and unsigned rumors never get broadcast // relays: they only travel inside gift wraps. - if (event is SealedRumorEvent || event is BaseDMGroupEvent || event.sig.isEmpty()) { + if (event is SealEvent || event is BaseDMGroupEvent || event.sig.isEmpty()) { return emptySet() } @@ -322,7 +322,7 @@ class EventBroadcaster( .toList() } val filter = - if (host.kind == SealedRumorEvent.KIND) { + if (host.kind == SealEvent.KIND) { Filter( kinds = listOf(host.kind), ids = listOf(host.id), @@ -383,7 +383,7 @@ class EventBroadcaster( fun sendRestoredVersion(event: Event) { when (event) { is AdvertisedRelayListEvent -> sendEverywhereAnd(event, event.relays().mapTo(mutableSetOf()) { it.relayUrl }) - is ChatMessageRelayListEvent -> sendEverywhereAnd(event, event.relays().toSet()) + is DmRelayListEvent -> sendEverywhereAnd(event, event.relays().toSet()) is KeyPackageRelayListEvent -> sendEverywhereAnd(event, event.relays().toSet()) is CashuWalletEvent -> sendLiterallyEverywhere(event) // Profiles and nutzap info are normally saved everywhere so others can find them; diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/GeohashChatIdentityState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/GeohashChatIdentityState.kt index 9517a3b62b..e45bc638b7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/GeohashChatIdentityState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/GeohashChatIdentityState.kt @@ -22,13 +22,24 @@ package com.vitorpamplona.amethyst.model import androidx.core.content.edit import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.LegacySharedPreferences +import com.vitorpamplona.amethyst.LocalPreferences +import com.vitorpamplona.amethyst.accountSecretsStore +import com.vitorpamplona.amethyst.commons.model.preferences.GeohashIdentitySecrets +import com.vitorpamplona.amethyst.commons.model.preferences.readLegacyGeohashIdentity import com.vitorpamplona.quartz.experimental.bitchat.identity.GeohashKeyDerivation import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip19Bech32.toNpub import com.vitorpamplona.quartz.utils.RandomInstance +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import java.util.concurrent.ConcurrentHashMap /** * The account's anonymous, per-geohash chat identities. @@ -52,68 +63,122 @@ import com.vitorpamplona.quartz.utils.RandomInstance */ class GeohashChatIdentityState( private val signer: NostrSigner, + private val scope: CoroutineScope, ) { - private val lock = Any() - private val cache = HashMap() + /** + * Guards seed creation as well as the key cache: two callers racing into + * [deviceSeed] must not mint two different seeds, or the loser's cells get + * identities the next launch cannot reproduce. A [Mutex] rather than + * `synchronized`, because the store reads it protects are suspending. + */ + private val mutex = Mutex() + private val cache = ConcurrentHashMap() - @Volatile private var cachedDeviceSeed: ByteArray? = null + /** + * The npub the current store is keyed by. + * + * The legacy file is keyed by the pubkey *hex* — the old code passed + * `signer.pubKey` where every other caller passes an npub, so the identity + * lived in `secret_keeper_`, a different file from the account's own + * `secret_keeper_`. The copy below reads that file and writes the + * npub-keyed store, which is what folds this orphan back in with the rest. + */ + private val npub by lazy { signer.pubKey.hexToByteArray().toNpub() } - @Volatile private var cachedNickname: String? = null + @Volatile private var loaded: GeohashIdentitySecrets? = null + + /** + * What `secret_keeper_` holds. + * + * Only called when the store has nothing yet: opening this file creates it, + * so reading it unconditionally would resurrect it after the cleanup has + * deleted it. Touches disk; callers are off the main thread. + */ + private fun legacy(): GeohashIdentitySecrets = readLegacyGeohashIdentity(LegacySharedPreferences(Amethyst.instance.encryptedStorage(signer.pubKey))) + + /** + * The stored identity, copying it out of the legacy file the first time. + * + * **Call under [mutex].** Not self-locking, because [keyPair] already holds + * the lock when it reaches here and [Mutex] is not reentrant. + */ + private suspend fun current(): GeohashIdentitySecrets { + loaded?.let { return it } + return accountSecretsStore.readGeohashIdentity(npub) { legacy() }.also { loaded = it } + } + + /** Call under [mutex], for the reason [current] gives. */ + private suspend fun persist(value: GeohashIdentitySecrets) { + loaded = value + accountSecretsStore.mirrorGeohashIdentity(npub, value) + } /** * The user's display handle for location chats: a single global nickname, persisted per account. * Bitchat carries this as the per-message `["n", …]` tag rather than a kind-0 profile, and kind-20000 * messages are ephemeral (relays needn't store them), so the only durable home for it is the device. - * Kept in this account's encrypted storage, so it survives restarts and switches with the account. - * Empty string means "no nickname set". Reads touch disk on first call — invoke off the main thread. + * Empty string means "no nickname set". */ - fun nickname(): String { - cachedNickname?.let { return it } - synchronized(lock) { - cachedNickname?.let { return it } - val value = Amethyst.instance.encryptedStorage(signer.pubKey).getString(PREF_NICKNAME, "") ?: "" - cachedNickname = value - return value - } - } + suspend fun nickname(): String = mutex.withLock { current().nickname ?: "" } - /** Persists the global location-chat nickname (trimmed) for this account. */ + /** + * Persists the global location-chat nickname (trimmed) for this account. + * + * Fire-and-forget on the account scope, which is what the SharedPreferences + * `edit {}` this replaced already did — the caller is a click handler on the + * main thread and the write is not something it waits for. + */ fun setNickname(value: String) { val trimmed = value.trim() - synchronized(lock) { - cachedNickname = trimmed - Amethyst.instance.encryptedStorage(signer.pubKey).edit { putString(PREF_NICKNAME, trimmed) } + scope.launch { + // Under the lock: this is a read-modify-write of the same group + // deviceSeed() writes. Racing the first seed mint, an unlocked copy + // would persist the nickname over a null deviceSeed, putOrRemove + // would delete the seed, and every per-cell identity minted that + // session would be unreproducible on the next launch. + mutex.withLock { + persist(current().copy(nickname = trimmed)) + // Mirrored, not moved: the legacy file stays readable until the + // legacy writes are retired app-wide, so a rollback keeps the handle. + // Gated on the same switch as every other mirror — otherwise flipping + // it would retire the documented four and leave this one writing. + if (!LocalPreferences.LEGACY_WRITES_RETIRED) { + Amethyst.instance.encryptedStorage(signer.pubKey).edit { putString(PREF_NICKNAME, trimmed) } + } + } } } - /** The Nostr key pair to use inside [geohash]. Derivation is cheap but cached; call off the main thread. */ - fun keyPair(geohash: String): KeyPair = - synchronized(lock) { - cache.getOrPut(geohash) { GeohashKeyDerivation.deriveKeyPair(seed(), geohash) } - } + /** The Nostr key pair to use inside [geohash]. Derivation is cheap but cached. */ + suspend fun keyPair(geohash: String): KeyPair { + cache[geohash]?.let { return it } - private fun seed(): ByteArray = accountPrivKey()?.let { GeohashKeyDerivation.accountSeed(it) } ?: deviceSeed() + return mutex.withLock { + cache[geohash] ?: GeohashKeyDerivation.deriveKeyPair(seed(), geohash).also { cache[geohash] = it } + } + } + + /** Call under [mutex]. */ + private suspend fun seed(): ByteArray = accountPrivKey()?.let { GeohashKeyDerivation.accountSeed(it) } ?: deviceSeed() private fun accountPrivKey(): ByteArray? = (signer as? NostrSignerInternal)?.keyPair?.privKey - /** Random per-account seed, used only when the account key is unreachable (bunker / external signer). */ - private fun deviceSeed(): ByteArray { - cachedDeviceSeed?.let { return it } - synchronized(lock) { - cachedDeviceSeed?.let { return it } - val prefs = Amethyst.instance.encryptedStorage(signer.pubKey) - val existing = prefs.getString(PREF_KEY, null) - val seed = - if (existing != null && existing.length == GeohashKeyDerivation.SEED_SIZE * 2) { - existing.hexToByteArray() - } else { - val fresh = RandomInstance.bytes(GeohashKeyDerivation.SEED_SIZE) - prefs.edit { putString(PREF_KEY, fresh.toHexKey()) } - fresh - } - cachedDeviceSeed = seed - return seed + /** + * Random per-account seed, used only when the account key is unreachable (bunker / external signer). + * + * Call under [mutex]: minting a second seed for an account that already has + * one would change every throwaway identity it has ever used. + */ + private suspend fun deviceSeed(): ByteArray { + val stored = current().deviceSeed + if (stored != null && stored.length == GeohashKeyDerivation.SEED_SIZE * 2) return stored.hexToByteArray() + + val fresh = RandomInstance.bytes(GeohashKeyDerivation.SEED_SIZE) + persist(current().copy(deviceSeed = fresh.toHexKey())) + if (!LocalPreferences.LEGACY_WRITES_RETIRED) { + Amethyst.instance.encryptedStorage(signer.pubKey).edit { putString(PREF_KEY, fresh.toHexKey()) } } + return fresh } companion object { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt index 62672c0ce9..0a711f801f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt @@ -26,20 +26,21 @@ import com.vitorpamplona.amethyst.commons.connectedApps.nip46.InMemoryNip46Clien import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46ClientStore import com.vitorpamplona.amethyst.commons.connectedApps.signers.InMemoryNostrSignerPermissionStore import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionStore +import com.vitorpamplona.amethyst.commons.marmot.EncryptedKeyPackageBundleStore +import com.vitorpamplona.amethyst.commons.marmot.EncryptedMarmotMessageStore +import com.vitorpamplona.amethyst.commons.marmot.EncryptedMlsGroupStateStore +import com.vitorpamplona.amethyst.commons.marmot.EncryptedPublishObligationStore import com.vitorpamplona.amethyst.commons.marmot.InMemoryMlsGroupStateStore import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.model.marmot.AndroidIngestDedupStore import com.vitorpamplona.amethyst.commons.model.marmot.AndroidPushStateStore +import com.vitorpamplona.amethyst.commons.model.preferences.AppPreferenceStores import com.vitorpamplona.amethyst.commons.relayClient.nip47WalletConnect.NWCPaymentFilterAssembler +import com.vitorpamplona.amethyst.commons.relayauth.DataStoreRelayAuthPermissionStore import com.vitorpamplona.amethyst.commons.service.pow.PoWPublishQueue import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AccountSettings -import com.vitorpamplona.amethyst.model.marmot.AndroidKeyPackageBundleStore -import com.vitorpamplona.amethyst.model.marmot.AndroidMarmotMessageStore -import com.vitorpamplona.amethyst.model.marmot.AndroidMlsGroupStateStore -import com.vitorpamplona.amethyst.model.marmot.AndroidPublishObligationStore import com.vitorpamplona.amethyst.service.location.LocationState -import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.DataStoreRelayAuthPermissionStore import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient @@ -49,6 +50,7 @@ import com.vitorpamplona.quartz.nip03Timestamp.OtsResolver import com.vitorpamplona.quartz.nip55AndroidSigner.client.NostrSignerExternal import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.NostrSignerWithClientTag import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.cache.LargeCache import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -57,6 +59,7 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update +import okio.Path.Companion.toOkioPath import java.io.File class AccountCacheState( @@ -89,6 +92,23 @@ class AccountCacheState( /** Guards [loadAccount]'s check-then-create so concurrent callers can't build twin Accounts. */ private val loadLock = Any() + /** + * One [AppPreferenceStores] per account directory, kept for the life of the + * process. + * + * [buildAccount] runs again for the same account on re-login and on cache + * races, and DataStore throws if a second instance is ever live on a file + * that already has one. Caching the holder — rather than the store — keeps + * that guarantee for every per-account store that gets added here later, + * not just the relay-auth one. + */ + private val accountStoreHolders = LargeCache() + + private fun storesFor(accountDir: File): AppPreferenceStores = + accountStoreHolders.getOrCreate(accountDir.absolutePath) { + AppPreferenceStores(rootFilesDir = { accountDir.toOkioPath() }) + } + fun removeAccount(pubkey: HexKey) { accounts.update { existingAccounts -> val oldValue = existingAccounts[pubkey] @@ -230,13 +250,13 @@ class AccountCacheState( val mlsStore = try { Log.d("AccountCacheState") { - "Initializing AndroidMlsGroupStateStore for ${signer.pubKey.take(8)}… at ${accountDir.absolutePath}" + "Initializing EncryptedMlsGroupStateStore for ${signer.pubKey.take(8)}… at ${accountDir.absolutePath}" } - AndroidMlsGroupStateStore(accountDir) + EncryptedMlsGroupStateStore(accountDir) } catch (e: Exception) { Log.e( "AccountCacheState", - "Failed to initialize AndroidMlsGroupStateStore, falling back to in-memory store (Marmot groups will NOT persist across restarts)", + "Failed to initialize EncryptedMlsGroupStateStore, falling back to in-memory store (Marmot groups will NOT persist across restarts)", e, ) InMemoryMlsGroupStateStore() @@ -247,11 +267,11 @@ class AccountCacheState( val marmotMessageStore = try { - AndroidMarmotMessageStore(accountDir) + EncryptedMarmotMessageStore(accountDir) } catch (e: Exception) { Log.e( "AccountCacheState", - "Failed to initialize AndroidMarmotMessageStore (Marmot messages will NOT persist across restarts)", + "Failed to initialize EncryptedMarmotMessageStore (Marmot messages will NOT persist across restarts)", e, ) null @@ -259,11 +279,11 @@ class AccountCacheState( val marmotKeyPackageStore = try { - AndroidKeyPackageBundleStore(accountDir) + EncryptedKeyPackageBundleStore(accountDir) } catch (e: Exception) { Log.e( "AccountCacheState", - "Failed to initialize AndroidKeyPackageBundleStore (Marmot KeyPackages will NOT persist across restarts)", + "Failed to initialize EncryptedKeyPackageBundleStore (Marmot KeyPackages will NOT persist across restarts)", e, ) null @@ -271,11 +291,11 @@ class AccountCacheState( val marmotPublishObligationStore = try { - AndroidPublishObligationStore(accountDir) + EncryptedPublishObligationStore(accountDir) } catch (e: Exception) { Log.e( "AccountCacheState", - "Failed to initialize AndroidPublishObligationStore " + + "Failed to initialize EncryptedPublishObligationStore " + "(a Marmot commit interrupted mid-publish will NOT be retried after a restart)", e, ) @@ -310,7 +330,10 @@ class AccountCacheState( // Per-account NIP-42 ALLOW/DENY overrides live in this account's own dir, so a DENY for one // account never leaks into another (the store used to be a single app-wide file). - val relayAuthPermissionStore = DataStoreRelayAuthPermissionStore(accountDir) + val relayAuthPermissionStore = + DataStoreRelayAuthPermissionStore( + storesFor(accountDir).getDataStore(DataStoreRelayAuthPermissionStore.FILE_NAME), + ) return Account( settings = accountSettings, @@ -330,6 +353,10 @@ class AccountCacheState( Log.e("AccountCacheState", "Account ${signer.pubKey} caught exception", throwable) }, ), + // The same per-account directory the Marmot stores use. cordn + // scopes itself further by coordinator underneath it, because a + // gid is unique only within one (spec/00.md §4). + cordnFilesDir = accountDir, mlsGroupStateStore = mlsStore, marmotMessageStore = marmotMessageStore, marmotKeyPackageStore = marmotKeyPackageStore, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/algoFeeds/FavoriteAlgoFeedsOrchestrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/algoFeeds/FavoriteAlgoFeedsOrchestrator.kt index 0496ad62dd..e8aa1a078b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/algoFeeds/FavoriteAlgoFeedsOrchestrator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/algoFeeds/FavoriteAlgoFeedsOrchestrator.kt @@ -25,8 +25,8 @@ import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent -import com.vitorpamplona.quartz.nip90Dvms.status.NIP90StatusEvent +import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.DvmContentDiscoveryResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.status.DvmStatusEvent import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope @@ -60,7 +60,7 @@ data class FavoriteAlgoFeedsSnapshot( val responseRelays: Set = emptySet(), val ids: Set = emptySet(), val addresses: Set = emptySet(), - val latestStatus: NIP90StatusEvent? = null, + val latestStatus: DvmStatusEvent? = null, val errorMessage: String? = null, ) @@ -139,9 +139,9 @@ class FavoriteAlgoFeedsOrchestrator( launch { account.cache - .observeLatestEvent( + .observeLatestEvent( Filter( - kinds = listOf(NIP90ContentDiscoveryResponseEvent.KIND), + kinds = listOf(DvmContentDiscoveryResponseEvent.KIND), tags = mapOf("e" to listOf(requestId)), limit = 1, ), @@ -159,9 +159,9 @@ class FavoriteAlgoFeedsOrchestrator( launch { account.cache - .observeLatestEvent( + .observeLatestEvent( Filter( - kinds = listOf(NIP90StatusEvent.KIND), + kinds = listOf(DvmStatusEvent.KIND), tags = mapOf("e" to listOf(requestId)), limit = 1, ), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/cordn/AndroidCordnBlobStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/cordn/AndroidCordnBlobStore.kt new file mode 100644 index 0000000000..39b8dad7a5 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/cordn/AndroidCordnBlobStore.kt @@ -0,0 +1,114 @@ +/* + * 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.cordn + +import android.content.Context +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.commons.cordn.CordnBlobStore +import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent +import com.vitorpamplona.quartz.utils.sha256.sha256 +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.Request +import java.io.ByteArrayInputStream + +/** + * Where a migration's sealed documents are stored, on Android. + * + * ## The authorization is signed by a throwaway, and that is the point + * + * `multi-device.md` §12 requires the BUD-01 upload authorization to be signed + * by an ephemeral key and **never** the owner `npub` — the same rule as the + * tip. Signing as the owner would tell the storage server, in the clear and + * under the account's own name, that this person is uploading right now. That + * is precisely the linkage the opaque tip is built to avoid, and it would + * arrive by a side door. + * + * So this does NOT reuse `account.createBlossomUploadAuth` the way + * [CordnMediaService] does for message attachments. Those are already + * attributable — they ride inside a group the coordinator can see traffic for + * — whereas the point of a migration blob is that nothing links it to anyone. + * [signer] is minted per instance and derived from nothing. + */ +class AndroidCordnBlobStore( + private val servers: List, + private val context: Context, +) : CordnBlobStore { + private val signer = NostrSignerInternal(KeyPair()) + + override suspend fun put(blob: ByteArray): List { + val hash = sha256(blob).toHexKey() + + return servers.filter { server -> + runCatching { + BlossomUploader() + .upload( + inputStream = ByteArrayInputStream(blob), + hash = hash, + length = blob.size.toLong(), + baseFileName = hash, + // Opaque on purpose: the server learns a size and a + // hash, and nothing about what kind of thing this is. + contentType = OPAQUE, + alt = null, + sensitiveContent = null, + serverBaseUrl = server, + okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads, + httpAuth = { h, size, alt -> BlossomAuthorizationEvent.createUploadAuth(h, size, alt ?: "", signer) }, + context = context, + useMediaEndpoint = false, + ).url != null + }.getOrDefault(false) + } + } + + override suspend fun get( + address: String, + servers: List, + ): ByteArray? = + withContext(Dispatchers.IO) { + // Ordered: §6 has the reader try the tip's servers as listed, most + // reliable first. + servers.firstNotNullOfOrNull { server -> + runCatching { + val url = "${server.trimEnd('/')}/$address" + Amethyst.instance.roleBasedHttpClientBuilder + .okHttpClientForImage(url) + .newCall( + Request + .Builder() + .url(url) + .get() + .build(), + ).execute() + .use { if (it.isSuccessful) it.body?.bytes() else null } + }.getOrNull() + } + } + + companion object { + private const val OPAQUE = "application/octet-stream" + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/cordn/CordnMediaService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/cordn/CordnMediaService.kt new file mode 100644 index 0000000000..678f62e288 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/cordn/CordnMediaService.kt @@ -0,0 +1,194 @@ +/* + * 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.cordn + +import android.content.Context +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader +import com.vitorpamplona.quartz.cordn.appEncryptedMedia.CordnBlobUpload +import com.vitorpamplona.quartz.cordn.appEncryptedMedia.CordnEncryptedMedia +import com.vitorpamplona.quartz.cordn.appEncryptedMedia.CordnMediaAttachment +import com.vitorpamplona.quartz.cordn.appEncryptedMedia.CordnMediaEncryption +import com.vitorpamplona.quartz.cordn.appEncryptedMedia.CordnMediaTag +import com.vitorpamplona.quartz.mls.group.MlsGroup +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.Request +import java.io.ByteArrayInputStream + +/** + * Sending and fetching cordn attachments. + * + * ## What each party sees + * + * | | sees | + * | --- | --- | + * | the blob host | opaque bytes, their size, and who uploaded them | + * | the coordinator | a sealed payload; not the URL, not the type, not the name | + * | the group | everything, because the key rides in the sealed descriptor | + * + * Getting that split right is most of this file. The `imeta` descriptor — + * URL, MIME type, filename, plaintext hash, nonce — rides **inside** the MLS + * envelope, so it is as private as the message it belongs to. + * + * ## Three deliberate choices on the upload + * + * 1. **`application/octet-stream`, always.** The real MIME type goes in the + * encrypted descriptor. Declaring `image/jpeg` to the host would tell it + * what kind of file this is for no benefit to anyone. + * 2. **No `alt` text, no content warning.** Both are plaintext on a Blossom + * upload. An alt string describing a private photo is the photo's caption, + * handed to a server that was supposed to see nothing. + * 3. **`/upload`, never `/media`.** The `/media` endpoint asks the server to + * re-encode. Re-encoding ciphertext destroys it, and an account with + * "optimize uploads" on would otherwise silently break every attachment. + * + * Blossom addresses a blob by the SHA-256 of the bytes it stores — the + * ciphertext. The `imeta` descriptor carries the hash of the **plaintext**. + * Two different hashes on purpose: the host needs one to name the blob, the + * group needs the other to know it got the file that was sent, and neither + * can be derived from the other. + */ +class CordnMediaService( + private val account: Account, +) { + /** + * Encrypts [bytes] under a fresh per-file key and uploads the ciphertext. + * + * @return the `imeta` tag to put on the message, or null when the account + * has no Blossom server configured — there is nowhere to put a file and + * saying so beats a failure deeper in. + */ + suspend fun upload( + group: MlsGroup, + bytes: ByteArray, + mimeType: String, + filename: String, + context: Context, + /** The host to put it on. Defaults to the account's, which is what the + * upload dialog's server spinner starts on. */ + serverBaseUrl: String = account.settings.defaultFileServer.baseUrl, + /** + * Display hints for the `imeta`. All optional and none authenticated: + * they are the spec's "passed through unchanged" fields, so they cost + * nothing to omit and are worth a great deal to include — without + * [dimensions] the bubble has no aspect ratio to reserve and the list + * jumps when the picture lands, and without [waveform] a voice note the + * recorder already measured comes back as bare bars. + */ + dimensions: String? = null, + blurhash: String? = null, + alt: String? = null, + waveform: List? = null, + ): Array? = + withContext(Dispatchers.IO) { + val server = serverBaseUrl.ifBlank { return@withContext null } + + // Derived from the group's epoch exporter, never sent: that is what + // spec/applications/encrypted-media.md §3.1 requires, and what lets + // any cordn client open the blob from group state alone. + val fileKey = CordnMediaEncryption.mediaKey(group) + val sealed = CordnMediaEncryption.encrypt(bytes, fileKey, mimeType, filename) + CordnMediaTag.build( + media = sealed, + url = put(sealed, server, context), + dimensions = dimensions, + blurhash = blurhash, + alt = alt, + waveform = waveform, + ) + } + + /** + * Uploads the ciphertext and returns the URL the host gave it. + * + * Every privacy decision is in [CordnBlobUpload]; this forwards it. Read + * that KDoc before changing an argument here — the wrong constant does not + * fail, it just tells a server something. + */ + private suspend fun put( + sealed: CordnEncryptedMedia, + server: String, + context: Context, + ): String { + val blob = CordnBlobUpload.of(sealed) + + val result = + BlossomUploader().upload( + inputStream = ByteArrayInputStream(blob.bytes), + hash = blob.hash, + length = blob.length, + baseFileName = blob.baseFileName, + contentType = blob.contentType, + alt = blob.alt, + sensitiveContent = blob.sensitiveContent, + serverBaseUrl = server, + okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads, + httpAuth = { hash, size, alt -> account.createBlossomUploadAuth(hash, size, alt) }, + context = context, + useMediaEndpoint = blob.useMediaEndpoint, + ) + + return result.url ?: throw IllegalStateException("the blob server returned no URL") + } + + /** + * Fetches [attachment] and opens it with the key it carries. + * + * Throws if the bytes do not authenticate. That is the right outcome and + * not a rare one: a blob host can serve anything it likes for a URL, and + * the AEAD tag plus the plaintext hash are the only reasons to believe + * what came back is what was sent. + */ + suspend fun download( + group: MlsGroup, + attachment: CordnMediaAttachment, + ): ByteArray = + withContext(Dispatchers.IO) { + val request = + Request + .Builder() + .url(attachment.url) + .get() + .build() + val response = + Amethyst.instance.roleBasedHttpClientBuilder + .okHttpClientForImage(attachment.url) + .newCall(request) + .execute() + + val body = + response.use { + check(it.isSuccessful) { "the blob server answered ${it.code}" } + it.body.bytes() + } + + CordnMediaEncryption.decrypt( + ciphertext = body, + fileKey = CordnMediaEncryption.mediaKey(group), + nonce = attachment.nonceBytes, + plaintextHash = attachment.hashBytes, + mimeType = attachment.mimeType, + filename = attachment.filename, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/cordn/CordnRuntime.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/cordn/CordnRuntime.kt new file mode 100644 index 0000000000..934a3f383a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/cordn/CordnRuntime.kt @@ -0,0 +1,1132 @@ +/* + * 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.cordn + +import com.vitorpamplona.amethyst.commons.cordn.CoordinatorConfig +import com.vitorpamplona.amethyst.commons.cordn.CoordinatorHealth +import com.vitorpamplona.amethyst.commons.cordn.CordnBackup +import com.vitorpamplona.amethyst.commons.cordn.CordnBlobCipher +import com.vitorpamplona.amethyst.commons.cordn.CordnCoordinatorDiscovery +import com.vitorpamplona.amethyst.commons.cordn.CordnCoordinatorLinkFactory +import com.vitorpamplona.amethyst.commons.cordn.CordnCoordinatorRegistry +import com.vitorpamplona.amethyst.commons.cordn.CordnGroupManager +import com.vitorpamplona.amethyst.commons.cordn.CordnHandoffState +import com.vitorpamplona.amethyst.commons.cordn.CordnLinks +import com.vitorpamplona.amethyst.commons.cordn.CordnMigration +import com.vitorpamplona.amethyst.commons.cordn.CordnMigrationSnapshot +import com.vitorpamplona.amethyst.commons.cordn.CordnMigrationStores +import com.vitorpamplona.amethyst.commons.cordn.CordnRoomState +import com.vitorpamplona.amethyst.commons.cordn.CordnSession +import com.vitorpamplona.amethyst.commons.cordn.CordnStorageLayout +import com.vitorpamplona.amethyst.commons.cordn.CordnSyncLoop +import com.vitorpamplona.amethyst.commons.cordn.CordnSyncSource +import com.vitorpamplona.amethyst.commons.cordn.FileBackedCordnScopeFactory +import com.vitorpamplona.amethyst.commons.cordn.FileCordnCoordinatorStore +import com.vitorpamplona.amethyst.commons.cordn.FileCordnGroupStore +import com.vitorpamplona.amethyst.commons.cordn.FileCordnHandoffStore +import com.vitorpamplona.amethyst.commons.cordn.FileCordnKeyPackageStore +import com.vitorpamplona.amethyst.commons.cordn.KeyStoreCordnBlobCipher +import com.vitorpamplona.amethyst.commons.cordn.OpenedWelcome +import com.vitorpamplona.amethyst.commons.model.cordnGroups.CordnGroupList +import com.vitorpamplona.quartz.contextvm.core.CvmKinds +import com.vitorpamplona.quartz.cordn.appMultiDevice.CordnHandoffCode +import com.vitorpamplona.quartz.cordn.spec00Coordinator.CoordinatorServerInfo +import com.vitorpamplona.quartz.cordn.spec00Coordinator.JoinRequest +import com.vitorpamplona.quartz.cordn.spec01GroupMetadata.CordnGroupMetadata +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnDeliveredMessage +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAll +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import java.io.File +import kotlin.coroutines.cancellation.CancellationException +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +/** + * One account's cordn feature, assembled. + * + * This is the wiring Stage A left open on purpose: every piece below it is + * shared KMP code that knows nothing about Amethyst, and this class supplies + * the two things only the running app has — a relay pool and the account's + * signer. + * + * ## The ephemeral signer is created here, once, and never persisted + * + * `spec/00.md` §8 splits the identity a coordinator sees: the account key + * signs what must be attributable (publishing a KeyPackage, posting to a + * group), and a throwaway key signs everything else, so the coordinator cannot + * link a session's reads to an account. Which key signs which call is fixed by + * `CoordinatorMethod` and not a choice made here; what IS decided here is that + * the throwaway key lives as long as this runtime and no longer. Persisting it + * would quietly undo the split — a "session" key reused across launches is + * just a second account key with worse ergonomics. + */ +class CordnRuntime( + private val accountSigner: NostrSigner, + private val client: INostrClient, + private val filesDir: File, + private val scope: CoroutineScope, + private val cipher: CordnBlobCipher = KeyStoreCordnBlobCipher(), + /** + * How a coordinator connection is opened. The default is the real one. + * + * A seam for the same reason [cipher] is one: the production path needs a + * relay, a transport and a live coordinator, so without it nothing in this + * class — the KeyPackage pool rule, purge, restore — could be tested at + * all. The ephemeral signer stays inside the default, because §8's + * identity split is not a thing a caller should be able to widen. + */ + private val links: CordnCoordinatorLinkFactory = CordnLinks.over(accountSigner, client), +) { + /** See the class KDoc: per-runtime, never written to disk. */ + private val registry = + CordnCoordinatorRegistry( + accountPubKey = accountSigner.pubKey, + scopes = FileBackedCordnScopeFactory(filesDir, cipher, links), + ) + + private val coordinatorStore = + FileCordnCoordinatorStore( + CordnStorageLayout.accountDirectoryFor(filesDir, accountSigner.pubKey), + cipher, + ) + + /** + * Whether this device has handed its groups to another one. + * + * Read before the first sync loop starts (see [restore]); checked on every + * path that would advance an epoch, because two devices committing from one + * leaf fork the ratchet tree irrecoverably. + */ + val handoff = + CordnHandoffState( + FileCordnHandoffStore(CordnStorageLayout.accountDirectoryFor(filesDir, accountSigner.pubKey)), + ) + + /** Every cordn room this account is in, for the inbox and the screens. */ + val groups = CordnGroupList(accountSigner.pubKey) + + /** + * A running sync loop, the manager it was built over, and its log watcher. + * + * The manager is the part that matters. [CordnCoordinatorRegistry] hands + * back a **new** session, with a new manager over a freshly opened + * transport, whenever a coordinator's relays are corrected -- the transport + * is bound to them, so it cannot be carried over. [CordnSyncLoop] captures + * its source for the life of the loop, so keeping the old loop across that + * swap left it polling a transport that had just been closed: the + * coordinator retried forever, its rooms never updated again, and the only + * way out was restarting the app. + */ + private class SyncLoopHandle( + val loop: CordnSyncLoop, + val source: CordnSyncSource, + val watcher: Job, + /** + * The one-shot profile fetch. Held so it can be cancelled with the rest: + * it is launched into the runtime scope, and an unheld launch outlives a + * logout or a forget for as long as its relays take to answer. + */ + val prefetch: Job, + ) + + private val loops = mutableMapOf() + private val lock = Mutex() + + val coordinators = registry.coordinators + + /** + * The session for [config], opening and starting it if it is new. + * + * Every epoch-advancing path goes through here — creating a group, joining + * one, accepting a Welcome, sending — so this is where a handed-off device + * is stopped. Reads of state already in memory are left alone: showing a + * user the conversations they had is harmless, and refusing it would make a + * failed migration look like data loss. + */ + suspend fun session(config: CoordinatorConfig): CordnSession { + handoff.requireNotHandedOff() + val session = registry.session(config) + lock.withLock { + // Rebuilt, not only created: an existing loop whose source is not + // this session's manager is one the registry has already orphaned by + // reopening the transport under it. Stopped before the replacement + // starts, because two loops on one coordinator would file every + // delivery twice. + val current = loops[config.pubKey] + if (current == null || current.source !== session.manager) { + current?.let { + it.watcher.cancel() + it.loop.stop() + } + + val loop = + CordnSyncLoop( + source = session.manager, + onDelivery = { file(config.pubKey, it) }, + ) + loop.start(scope) + + // A coordinator is a Nostr identity, and CEP-23/CEP-17 say it + // may publish a kind 0 and a kind 10002 like anybody else. Its + // own relays are where those live, so they are fetched here, + // from the relays this account already talks to for cordn. + // + // All three are ordinary public events about this pubkey, and now + // all three are typed, so the global cache connector files them and + // the cache does the keeping: newest-wins per (kind, pubkey) for the + // replaceable ones, and verification before anything trusts them. + // Nothing is parsed or stored by hand here any more. The fetch + // remains only to aim the request at the coordinator's OWN relays, + // which is where its announcement lives and is the one thing the + // generic data sources cannot know until its relay list is cached. + // + // Not a nicety -- it is what keeps the *outbox discovery* for + // this pubkey off this account's home relays. Rendering a + // coordinator with UserPicture/observeUserNameByHex puts it in + // LocalCache as a User, and UserOutboxFinderSubAssembler then + // asks who it is: given a cached relay list it has an outbox for + // the pubkey and issues no filter at all + // (`if (noOutboxList.isEmpty()) return null`), while without one + // pickRelaysToLoadUsers falls through to this account's index and + // home relays -- telling them the account is interested in a + // pubkey that CEP-6 announcements publicly identify as a + // coordinator. Relay hints alone would not close it either: the + // search broadens anyway below three hints, and a coordinator + // usually lists one or two. + // + // It is not a complete seal, and the KDoc above must not be read + // as one: this only silences the assembler that gates on a + // missing relay list. UserReportsSubAssembler and + // UserCardsSubAssembler ask this account's own relays about the + // pubkey either way, and a coordinator that publishes no kind + // 10002 falls back to the broad path regardless. What this buys + // is the largest of those queries, not silence. + // + // The global CacheClientConnector files whatever comes back, so + // there is nothing to consume here, and failure is silent on + // purpose: a coordinator with no profile is ordinary, and the + // screens already fall back to the key. + val prefetch = + scope.launch { + runCatching { + client.fetchAll( + filters = + config.relays.associateWith { + listOf( + Filter( + kinds = + listOf( + MetadataEvent.KIND, + AdvertisedRelayListEvent.KIND, + CvmKinds.SERVER_ANNOUNCEMENT, + ), + authors = listOf(config.pubKey), + ), + ) + }, + ) + } + } + // A coordinator that stops answering is otherwise invisible: + // the rooms are there, they are simply never updated again, and + // the whole sync path logged nothing at all. Only the failing + // state is worth a line, and only when it changes. + val watcher = + scope.launch { + loop.state + .filterIsInstance() + .distinctUntilChanged() + .collect { + Log.w(TAG, "coordinator ${config.pubKey.take(8)}\u2026 not syncing (attempt ${it.attempt}, retry in ${it.inMs}ms): ${it.reason}") + } + } + + loops[config.pubKey] = SyncLoopHandle(loop, session.manager, watcher, prefetch) + } + } + // Whatever the store already held, so a relaunch shows its rooms + // before the first message of the session arrives. + session.manager.gids.value + .forEach { + refresh(session, it) + // Read positions come back with the rooms, not when a room is + // opened: an inbox that shows every room as unread until it is + // visited is worse than one with no unread state at all. + val saved = session.manager.roomState(it) + val room = groups.get(config.pubKey, it) + room?.restoreState(saved.draft, saved.lastReadCursor) + // The last message, for the inbox line. One small key per group + // rather than the whole log: the conversation itself loads when + // a room is opened. Without it every cordn room read "No + // messages yet" after a relaunch, however much had been said. + session.manager.storedMessageSummary(it)?.let { summary -> room?.restorePreview(summary.newest) } + } + remember() + maintainKeyPackages(session) + return session + } + + /** + * Keeps the KeyPackage pool healthy on a coordinator this account already + * uses — and does nothing at all on one it does not. + * + * The condition is the whole design. Publishing a KeyPackage is an + * attributable act under the account key (§8.4): it tells the coordinator + * this account exists and is invitable, which is not something to do on + * someone's behalf at login. But once a KeyPackage IS published there, the + * coordinator already knows, and letting the pool drain silently has a + * cost with no matching benefit — `kp_take` consumes a single-use package, + * so the pool falls as people invite us, and an empty pool means the next + * invitation fails for a reason the inviter sees and the invitee never + * does. + * + * So: never the first package, always the ones after it. Publishing the + * first one stays an explicit act on the key-package screen or a join + * request. + * + * Runs detached and swallows failures: this is upkeep, and a coordinator + * that will not take a KeyPackage must not stop its groups from syncing. + */ + private fun maintainKeyPackages(session: CordnSession) { + scope.launch { + try { + if (!session.keyPackages.hasPublished()) return@launch + session.keyPackages.topUp() + session.keyPackages.ensureLastResort() + } catch (e: Exception) { + Log.w(TAG, "could not top up key packages on ${session.coordinatorPubKey.take(8)}\u2026: ${e.message}", e) + } + } + } + + /** + * Creates a group on [config] and returns the `gid` it was given. + * + * The `gid` is a random UUID, which is what cordn's own client uses. It is + * the caller's to choose (`spec/00.md` §4) and the coordinator never + * interprets it — the one hard rule is that it must not be derived from the + * MLS `group_id`, which is secret. + * + * Nothing is sent anywhere. Creating a group is a local MLS operation; the + * coordinator learns the group exists when the first Commit or message is + * posted to it. That is why this succeeds against a coordinator that is + * down, and why a group with no other members has told the coordinator + * nothing at all. + */ + @OptIn(ExperimentalUuidApi::class) + suspend fun createGroup( + config: CoordinatorConfig, + metadata: CordnGroupMetadata, + gid: String = Uuid.random().toString(), + ): String { + val session = session(config) + session.manager.createGroup(gid, metadata) + refresh(session, gid) + return gid + } + + /** + * Which of [roster] this coordinator could deliver a Welcome to. + * + * The question the create-group screen is really asking. An invitation is a + * Welcome left on the coordinator addressed to a KeyPackage the invitee + * published *there* -- so a coordinator that holds no KeyPackage for + * somebody cannot be told to invite them at all, and picking it means + * leaving them out. One `kp_list` answers for the whole roster at once, + * which is why this takes a set rather than a pubkey. + * + * Only answers for a coordinator this account already has a session with. + * Opening one is what commits to a coordinator, and a screen that is still + * only *looking* must not do that on the user's behalf -- so a discovery + * offer reports [CordnCoverage.answered] false rather than being probed. + */ + suspend fun coverage( + coordinatorPubKey: HexKey, + roster: Set, + ): CordnCoverage { + val identities = + identitiesWithKeyPackages(coordinatorPubKey) + ?: return CordnCoverage(coordinatorPubKey, emptySet(), roster, answered = false) + val reachable = roster.filterTo(mutableSetOf()) { it in identities } + return CordnCoverage(coordinatorPubKey, reachable, roster - reachable, answered = true) + } + + /** + * Creates the group, then invites [invitees] into it one at a time. + * + * Not one operation, and the result says so. `createGroup` and each + * `invite` are separate calls to the coordinator, so the group can exist + * with only some of the roster invited -- and nothing that happens to an + * invitation can undo the group. The caller gets a row per invitee instead + * of one thrown exception, because "three of five went out" is the state + * the user has to be shown, not an error to report. + * + * One commit for the whole roster, via [CordnGroupManager.inviteAll]. A + * loop of single invites was not just N epochs and N round trips: an Add is + * applied locally before it is posted, so one failed post left the group + * forked and every later invite in the loop building on the fork. + */ + suspend fun createGroupAndInvite( + config: CoordinatorConfig, + metadata: CordnGroupMetadata, + invitees: List, + ): CordnGroupCreation { + val gid = createGroup(config, metadata) + val session = requireSession(config.pubKey) + + val outcomes = + try { + val batch = session.manager.inviteAll(gid, invitees) + // Order follows the roster, not the batch: this is what the + // screen lists, and a list that reshuffles by outcome is harder + // to read than one that matches what was asked for. + invitees.map { target -> + val undelivered = batch.undelivered[target] + CordnInviteOutcome( + pubKey = target, + failure = batch.refused[target] ?: undelivered, + // Added, but with no Welcome to join by. Kept apart from + // a refusal because the two need opposite answers: this + // one is already a member and needs the Welcome resent, + // where a refusal never reached the group at all. + joinedWithoutWelcome = undelivered != null, + ) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + // The commit itself failed, so nobody was added. One failure + // for everyone rather than a made-up per-person story. + Log.w(TAG, "could not invite into $gid: ${e.message}", e) + invitees.map { CordnInviteOutcome(it, failure = e) } + } + + refresh(session, gid) + return CordnGroupCreation(gid, outcomes) + } + + fun sessionOrNull(coordinatorPubKey: HexKey): CordnSession? = registry.sessionOrNull(coordinatorPubKey) + + /** + * Renames a coordinator, for this device only. + * + * The user's own word for it, which is the only name here that means + * anything: a coordinator cannot prove one, and both the names it can + * publish (a kind 0 under CEP-23, the CEP-6 announcement's surface) are its + * own claim. Until now a label could only be given while adding a + * coordinator by hand, so one added from discovery -- or restored on a new + * device -- could never be named at all. + * + * Goes through the registry rather than [session]: the loop is already + * running and must not be rebuilt, and renaming is local bookkeeping that + * advances no epoch, so it is allowed on a device that has handed its + * groups away. A label-only change no longer reopens the transport, so this + * costs one write to disk. + */ + suspend fun relabel( + coordinatorPubKey: HexKey, + label: String?, + ) { + val existing = registry.sessionOrNull(coordinatorPubKey) ?: return + registry.session(existing.config.copy(label = label?.trim()?.ifEmpty { null })) + remember() + } + + /** + * Every invitation waiting for this account, opened but not answered. + * + * One round trip per open coordinator, made when someone asks to see their + * invitations and at no other time. There is no background poll on purpose: + * every call to a coordinator is metadata (§8), so a badge that stayed + * up to date would mean telling each coordinator how often this account + * opens the app. + * + * A coordinator that fails to answer is reported rather than logged and + * dropped. "We could not ask" and "there is nothing for you" look the same + * on screen and mean opposite things. + */ + suspend fun invitations(): CordnInvitations { + val pending = mutableListOf() + val skipped = mutableListOf() + val unreachable = mutableListOf() + + registry.coordinators.value.forEach { config -> + val session = registry.sessionOrNull(config.pubKey) ?: return@forEach + try { + val inbox = session.manager.pendingWelcomes(session.keyPackages::bundleFor) + pending += inbox.pending.map { CordnInvitation(config, it) } + skipped += inbox.skipped.map { CordnSkippedInvitation(config, it.keyPackageRef, it.reason) } + } catch (e: Exception) { + Log.w(TAG, "could not read invitations from ${config.pubKey.take(8)}\u2026: ${e.message}", e) + unreachable += CordnCoordinatorFailure(config, e.message ?: "the coordinator did not answer") + } + } + return CordnInvitations(pending, skipped, unreachable) + } + + /** Joins the group [invitation] opens, and shows it in the inbox. */ + suspend fun accept(invitation: CordnInvitation): String { + val session = + registry.sessionOrNull(invitation.coordinator.pubKey) + ?: throw IllegalStateException("no session for ${invitation.coordinator.pubKey}") + val gid = session.manager.accept(invitation.welcome) + refresh(session, gid) + return gid + } + + /** Retires [invitation] without joining. There is no undo; see `decline`. */ + suspend fun decline(invitation: CordnInvitation) { + registry.sessionOrNull(invitation.coordinator.pubKey)?.manager?.decline(invitation.welcome) + } + + /** + * Asks to be added to [gid] on [config]. + * + * Publishes a fresh KeyPackage first, because there is no way to be added + * without one: `spec/00.md` §4.2 gives cordn no KeyPackage event kind, so + * the coordinator is the only place an inviter can find it. This is the + * one moment where publishing is unambiguously what the user asked for — + * they are asking strangers to add them — which is why it happens here + * rather than silently at login (§8.4). + */ + suspend fun requestToJoin( + config: CoordinatorConfig, + gid: String, + ): Long { + val session = session(config) + val published = session.keyPackages.publishNew() + return session.manager.requestToJoin(gid, published.keyPackageRef) + } + + /** + * Everyone asking to join [gid], or an empty list if we hold no session. + * + * Fetched when asked for, never polled — the same §8 reasoning as + * [invitations]. **Any member can answer these**, not only an admin: + * cordn's `admin_pubkeys` is presentation metadata and nothing enforces + * it, so there is no admin check to make here and a UI that implied one + * would be inventing a boundary the protocol does not have. + */ + suspend fun joinRequests( + coordinatorPubKey: HexKey, + gid: String, + ): List = + registry + .sessionOrNull(coordinatorPubKey) + ?.manager + ?.pendingJoinRequests() + ?.filter { it.gid == gid } + .orEmpty() + + /** + * The three admin commits, each followed by the refresh that makes them + * visible. + * + * The manager does the MLS and the posting; only [refresh] pushes the new + * member list, epoch and metadata into the [CordnGroupChatroom] the screens + * render. A caller that reached the manager directly -- which the group info + * screen did -- got a commit that worked and a roster that went on showing + * the group as it was before, until a message happened to arrive or the app + * was relaunched. Every other mutation here already pairs the two; these are + * the ones that were missing it. + */ + suspend fun invite( + coordinatorPubKey: HexKey, + gid: String, + targetPubKey: HexKey, + ) { + val session = requireSession(coordinatorPubKey) + session.manager.invite(gid, targetPubKey) + refresh(session, gid) + } + + suspend fun removeMember( + coordinatorPubKey: HexKey, + gid: String, + targetPubKey: HexKey, + ) { + val session = requireSession(coordinatorPubKey) + session.manager.removeMember(gid, targetPubKey) + refresh(session, gid) + } + + suspend fun updateGroupMetadata( + coordinatorPubKey: HexKey, + gid: String, + metadata: CordnGroupMetadata, + ) { + val session = requireSession(coordinatorPubKey) + session.manager.updateGroupMetadata(gid, metadata) + refresh(session, gid) + } + + private fun requireSession(coordinatorPubKey: HexKey): CordnSession = + registry.sessionOrNull(coordinatorPubKey) + ?: throw IllegalStateException("no session for $coordinatorPubKey") + + /** Adds the account behind [request] to its group. */ + suspend fun acceptJoinRequest( + coordinatorPubKey: HexKey, + request: JoinRequest, + ) { + val session = + registry.sessionOrNull(coordinatorPubKey) + ?: throw IllegalStateException("no session for $coordinatorPubKey") + session.manager.acceptJoinRequest(request) + refresh(session, request.gid) + } + + /** Retires [request] without adding anyone. */ + suspend fun declineJoinRequest( + coordinatorPubKey: HexKey, + request: JoinRequest, + ) { + registry.sessionOrNull(coordinatorPubKey)?.manager?.declineJoinRequest(request) + } + + /** + * Reopens the coordinators this account used last time, and starts syncing. + * + * Call at login. Without it a cordn group is unreachable after a relaunch: + * its MLS state is still on disk, but a `gid` with no coordinator is not a + * group anyone can open, and nothing else on the device knows which + * coordinator serves it. + */ + suspend fun restore() { + handoff.restore() + // A handed-off device does not open sessions at all. Its stores stay on + // disk so the handoff can be undone, but a live sync loop would fetch, + // decrypt and self-echo alongside the device that took over. + if (handoff.handedOff.value) return + start(coordinatorStore.load()) + } + + /** Opens every configured coordinator and starts syncing. */ + suspend fun start(configs: List) { + configs.forEach { + try { + session(it) + } catch (e: Exception) { + // One unreachable coordinator must not stop the others: they + // are independent authorities and a user with two of them has + // two unrelated sets of groups. + Log.w(TAG, "could not open coordinator ${it.pubKey.take(8)}…: ${e.message}", e) + } + } + } + + /** Stops every loop and closes every transport. Call at logout. */ + suspend fun stop() { + lock.withLock { + loops.values.forEach { + it.watcher.cancel() + it.prefetch.cancel() + it.loop.stop() + } + loops.clear() + } + registry.close() + groups.clear() + } + + /** Drops one coordinator, leaving its stored groups on disk. */ + suspend fun forget(coordinatorPubKey: HexKey) { + lock.withLock { + loops.remove(coordinatorPubKey)?.let { + it.watcher.cancel() + it.prefetch.cancel() + it.loop.stop() + } + } + registry.forget(coordinatorPubKey) + remember(forgotten = coordinatorPubKey) + } + + /** + * Forgets [coordinatorPubKey] **and destroys everything stored for it**. + * + * Separate from [forget] because they are different decisions and only one + * of them is reversible. Forgetting closes the session and leaves the MLS + * state on disk, so re-adding the coordinator brings the groups back. + * Purging deletes the ratchet trees, the cursors and the KeyPackage + * private halves — after which those groups cannot be rejoined, only + * re-entered by a fresh invitation, because MLS state cannot be rebuilt + * from anywhere else. + * + * The coordinator is not told. It keeps whatever it already had; purging + * is about this device, not about undoing the exposure, and a UI that + * implied otherwise would be selling a deletion nobody can perform. + */ + suspend fun purge(coordinatorPubKey: HexKey) { + forget(coordinatorPubKey) + groups.forgetCoordinator(coordinatorPubKey) + withContext(Dispatchers.IO) { + CordnStorageLayout.directoryFor(filesDir, accountSigner.pubKey, coordinatorPubKey).deleteRecursively() + } + } + + /** + * What this account has published on [coordinatorPubKey], and which of + * them this device can still open. + * + * Both halves matter and neither implies the other. The coordinator's + * listing is the truth about what an inviter can take; the local store is + * the truth about whether the resulting Welcome can be opened. A package + * listed there with no private half here belongs to another device of this + * account — or to an install that is gone, in which case anyone using it + * sends a Welcome nobody will ever read. + */ + suspend fun keyPackages(coordinatorPubKey: HexKey): List { + val session = registry.sessionOrNull(coordinatorPubKey) ?: return emptyList() + val held = session.keyPackages.published.value + return session.keyPackages.listPublished().map { + CordnKeyPackageRow( + keyPackageRef = it.keyPackageRef, + lastResort = it.lastResort, + at = it.at, + openableHere = it.keyPackageRef in held, + ) + } + } + + /** + * Which identities [coordinatorPubKey] can deliver a Welcome to, or null. + * + * For annotating people you are *about* to invite. cordn can only add a + * member by spending a KeyPackage they published to this coordinator, and + * `kp_list` is the only non-destructive way to learn who did -- `kp_take` + * by identity consumes one of their single-use packages + * (`spec/00.md` §"Coordinator retrieval behavior"), so it can never be + * used as a probe. One call answers for everybody, which is why this hands + * back the whole set instead of taking a pubkey. + * + * Null is **not** an empty set. It means the question went unanswered, and + * a caller that renders it as "cannot be added" turns a network failure + * into a claim about a person. Distinguish them. + */ + suspend fun identitiesWithKeyPackages(coordinatorPubKey: HexKey): Set? { + val session = registry.sessionOrNull(coordinatorPubKey) ?: return null + return session.keyPackages.identitiesWithKeyPackages() + } + + /** Publishes one KeyPackage. An attributable act under the account key (§8.4). */ + suspend fun publishKeyPackage( + coordinatorPubKey: HexKey, + lastResort: Boolean = false, + ) { + val session = + registry.sessionOrNull(coordinatorPubKey) + ?: throw IllegalStateException("no session for $coordinatorPubKey") + session.keyPackages.publishNew(lastResort) + } + + /** Withdraws [refs], coordinator first. See `CordnKeyPackages.withdraw`. */ + suspend fun withdrawKeyPackages( + coordinatorPubKey: HexKey, + refs: List, + ): List = + registry + .sessionOrNull(coordinatorPubKey) + ?.keyPackages + ?.withdraw(refs) + .orEmpty() + + /** + * Collects everything a replacement device would need. + * + * Reads from the stores rather than from memory, so a coordinator whose + * session failed to open is still exported — a backup that silently + * omitted the groups the app could not reach today would be worst + * precisely when it is needed. + */ + suspend fun exportArchive(passphrase: String): ByteArray { + val configs = coordinatorStore.load() + val groups = mutableListOf() + val keyPackages = mutableListOf() + + withContext(Dispatchers.IO) { + configs.forEach { config -> + val dir = CordnStorageLayout.directoryFor(filesDir, accountSigner.pubKey, config.pubKey) + val groupStore = FileCordnGroupStore(dir, cipher) + val keyPackageStore = FileCordnKeyPackageStore(dir, cipher) + + groupStore.listGroups().forEach { gid -> + val state = groupStore.loadGroup(gid) ?: return@forEach + groups += + CordnBackup.Archive.Group( + coordinatorPubKey = config.pubKey, + gid = gid, + state = state, + cursor = groupStore.loadCursor(gid), + joinedViaRequest = groupStore.loadJoinedViaRequest(gid), + messages = groupStore.loadMessages(gid), + ) + } + + keyPackageStore.list().forEach { ref -> + val bundle = keyPackageStore.load(ref) ?: return@forEach + keyPackages += CordnBackup.Archive.KeyPackage(config.pubKey, ref, bundle) + } + } + } + + return CordnBackup.seal( + CordnBackup.Archive(accountSigner.pubKey, configs, groups, keyPackages), + passphrase, + ) + } + + /** + * Replaces this device's cordn state with [sealed]'s. + * + * **Replaces, and is not a merge.** An MLS state import is a cloneable + * identity: two devices holding one group's state and both committing fork + * the ratchet tree, and MLS does not recover — every message after the + * fork silently fails to decrypt for somebody. Merging would produce that + * on purpose. Restoring is for a device that has taken over from another, + * which is what §5.3 means by keeping multi-device a non-goal. + * + * An archive from a different account is refused outright: restoring one + * account's groups under another's key gives a device MLS state whose + * credentials name somebody else, and every Commit it made would be + * rejected by the rest of the group. + */ + suspend fun importArchive( + sealed: ByteArray, + passphrase: String, + ) { + val archive = CordnBackup.open(sealed, passphrase) + require(archive.accountPubKey == accountSigner.pubKey) { + "this backup belongs to a different account" + } + + stop() + + withContext(Dispatchers.IO) { + // The old state goes first. Leaving it would merge two devices' + // histories for any gid present in both, which is the one outcome + // this must never produce. + File(filesDir, "cordn/${accountSigner.pubKey}").deleteRecursively() + + archive.groups.forEach { group -> + val store = FileCordnGroupStore(CordnStorageLayout.directoryFor(filesDir, accountSigner.pubKey, group.coordinatorPubKey), cipher) + store.saveGroup(group.gid, group.state) + group.cursor?.let { store.saveCursor(group.gid, it) } + if (group.joinedViaRequest) store.saveJoinedViaRequest(group.gid) + // Before the cursor is trusted again. The cursor says the + // stream has been read to here, so nothing will re-deliver + // these -- if they are not written back now they are gone. + group.messages.forEach { store.appendMessage(group.gid, it) } + } + + archive.keyPackages.forEach { keyPackage -> + FileCordnKeyPackageStore(CordnStorageLayout.directoryFor(filesDir, accountSigner.pubKey, keyPackage.coordinatorPubKey), cipher) + .save(keyPackage.keyPackageRef, keyPackage.bundle) + } + } + + coordinatorStore.save(archive.coordinators) + start(archive.coordinators) + } + + /** + * Snapshots every group for a handoff, read off disk. + * + * Reads from the stores rather than memory for the same reason + * [exportArchive] does: a coordinator whose session failed to open today is + * still migrated, because a handoff that silently omitted the groups the + * app could not reach would be wrong precisely when it matters. + */ + suspend fun migrationSnapshot(): CordnMigrationSnapshot = + withContext(Dispatchers.IO) { + CordnMigrationStores.read(filesDir, accountSigner.pubKey, cipher, coordinatorStore.load()) + } + + /** + * Coordinators announcing themselves on [relays], newest first. + * + * Touches no coordinator: it reads the CEP-6 announcements they already + * published, so nothing discovered here learns this account exists. That + * is the whole reason discovery can be offered before the user has + * committed to anything — see [CordnCoordinatorDiscovery]. + */ + suspend fun discover(relays: Set): CordnCoordinatorDiscovery.Result = CordnCoordinatorDiscovery(client).discover(relays) + + /** + * Publishes a handoff and stands this device down. + * + * The order is deliberate. Everything is stored and advertised first, and + * only a migration that got that far marks the device — a failed publish + * that had already locked would leave the account on a phone that refuses + * to send from the only copy of its state. + */ + suspend fun handOff( + migration: CordnMigration, + relays: Set, + ): CordnHandoffCode { + val code = migration.publish(migrationSnapshot(), relays) + stop() + handoff.markHandedOff() + return code + } + + /** Takes this device back after a handoff that did not complete. */ + suspend fun cancelHandOff() { + handoff.resume() + start(coordinatorStore.load()) + } + + /** + * Adopts a snapshot another device published, replacing what is here. + * + * **Replaces, and is not a merge** — the same rule [importArchive] states, + * and for the same reason: two devices holding one group's state and both + * committing fork the ratchet tree, and MLS does not recover. + */ + suspend fun adoptMigration(snapshot: CordnMigrationSnapshot) { + require(snapshot.accountPubKey == accountSigner.pubKey) { + "this migration belongs to a different account" + } + + stop() + + val configs = + withContext(Dispatchers.IO) { + CordnMigrationStores.write(filesDir, accountSigner.pubKey, cipher, snapshot) + } + + // A device that just adopted state is emphatically not handed off, even + // if it had handed off before: it now holds the newest copy. + handoff.resume() + coordinatorStore.save(configs) + start(configs) + } + + /** What [coordinatorPubKey] says about itself, or null if it is not open. */ + suspend fun serverInfo(coordinatorPubKey: HexKey): CoordinatorServerInfo? = registry.sessionOrNull(coordinatorPubKey)?.serverInfo() + + /** Live health for [coordinatorPubKey], as its calls have observed it. */ + fun health(coordinatorPubKey: HexKey): StateFlow? = registry.sessionOrNull(coordinatorPubKey)?.health?.state + + /** + * Writes down which coordinators are open, so the next launch finds them. + * + * Failing to persist must not fail the session it follows: the coordinator + * is already open and working, and losing the record costs a re-entry next + * launch rather than the group. + */ + private suspend fun remember(forgotten: HexKey? = null) { + try { + // Merged with what is already on disk, never replacing it. + // `registry.coordinators` is the coordinators with an OPEN + // session, so a coordinator whose relay is unreachable this launch + // is simply absent from it — and saving that set verbatim would + // erase it, turning "your relay was down" into "your groups are + // gone", silently and permanently. Keyed by pubkey because that is + // the coordinator's identity (§8.5): a reopened one with different + // relays is a corrected address, so the live entry wins. + // + // [forgotten] is the one case the merge cannot infer. A removed + // coordinator and one that simply did not open this launch look + // identical from here — both are missing from `open` — so without + // being told, the merge protects the removed one too and writes it + // straight back. Remove was therefore a no-op that survived until + // the next launch and then undid itself. + val open = registry.coordinators.value + val openKeys = open.mapTo(mutableSetOf()) { it.pubKey } + val kept = + coordinatorStore + .load() + .filterNot { it.pubKey in openKeys || it.pubKey == forgotten } + coordinatorStore.save(kept + open) + } catch (e: Exception) { + Log.w(TAG, "could not persist the coordinator list: ${e.message}", e) + } + } + + private fun file( + coordinatorPubKey: HexKey, + delivery: CordnGroupManager.Delivery, + ) { + when (delivery) { + is CordnGroupManager.Delivery.Message -> + groups.add( + coordinatorPubKey, + delivery.gid, + CordnDeliveredMessage(delivery.received.envelope, delivery.cursor), + ) + + // The rest still get a room. A group whose history we cannot + // read, or that only moved an epoch, is a group we are in — + // showing nothing would read as a bug rather than as the + // forward secrecy it actually is. + is CordnGroupManager.Delivery.Undecryptable, + is CordnGroupManager.Delivery.EpochAdvanced, + is CordnGroupManager.Delivery.Echo, + -> groups.getOrCreate(coordinatorPubKey, delivery.gid) + } + + // After every delivery, not only after an EpochAdvanced: a Commit that + // renames the group or changes its membership arrives as an ordinary + // ingestion, and the room's name and member list are read off MLS + // state rather than stored, so they are only correct if re-read. + sessionOrNull(coordinatorPubKey)?.let { refresh(it, delivery.gid) } + } + + /** Makes sure [gid] has a room, and points it at the current MLS state. */ + private fun refresh( + session: CordnSession, + gid: String, + ) { + val room = groups.getOrCreate(session.coordinatorPubKey, gid) + session.manager.group(gid)?.let { room.refreshFrom(it) } + } + + /** + * Restores a room's draft and read position from disk, once. + * + * Called when a screen opens the room rather than at sync, because it is + * the only moment it matters and because every room's state would + * otherwise be read at login for rooms nobody opens. + */ + suspend fun restoreRoomState( + coordinatorPubKey: HexKey, + gid: String, + ) { + val session = registry.sessionOrNull(coordinatorPubKey) ?: return + val room = groups.get(coordinatorPubKey, gid) ?: return + val saved = session.manager.roomState(gid) + room.restoreState(saved.draft, saved.lastReadCursor) + // The conversation itself. Through addAll, so the annotation fold, the + // ordering and `newest` are all rebuilt by exactly the code that + // handles live delivery — a room restored down a second path would be + // a second set of rules to keep in step. + room.addAll(session.manager.storedMessages(gid)) + } + + /** Persists [gid]'s draft and read position. */ + suspend fun saveRoomState( + coordinatorPubKey: HexKey, + gid: String, + ) { + val session = registry.sessionOrNull(coordinatorPubKey) ?: return + val room = groups.get(coordinatorPubKey, gid) ?: return + session.manager.saveRoomState(gid, CordnRoomState(room.draft.value, room.lastReadCursor.value)) + } + + companion object { + private const val TAG = "CordnRuntime" + } +} + +/** One invitation, and which coordinator it came through. */ +data class CordnInvitation( + val coordinator: CoordinatorConfig, + val welcome: OpenedWelcome, +) + +/** An invitation that could not be opened, and why — the reason is for a person to read. */ +data class CordnSkippedInvitation( + val coordinator: CoordinatorConfig, + val keyPackageRef: String, + val reason: String, +) + +/** A coordinator that did not answer. Not the same as one with nothing to say. */ +data class CordnCoordinatorFailure( + val coordinator: CoordinatorConfig, + val reason: String, +) + +/** What every open coordinator had waiting. */ +data class CordnInvitations( + val pending: List, + val skipped: List, + val unreachable: List, +) { + val isEmpty: Boolean get() = pending.isEmpty() && skipped.isEmpty() && unreachable.isEmpty() +} + +/** One published KeyPackage, as the key-package screen shows it. */ +data class CordnKeyPackageRow( + val keyPackageRef: String, + val lastResort: Boolean, + val at: Long, + /** Whether this device holds the private half and could open its Welcome. */ + val openableHere: Boolean, +) + +/** + * What one coordinator could do with a roster. + * + * [answered] apart from [unreachable] deliberately: an empty [reachable] on an + * unanswered coordinator means nobody asked it, not that it holds nothing. A + * caller that folds the two together turns a missing session or a failed call + * into a claim about the people in the roster. + */ +data class CordnCoverage( + val coordinatorPubKey: HexKey, + val reachable: Set, + val unreachable: Set, + val answered: Boolean, +) + +/** + * One invitation attempt, in one of three states. + * + * [sent] is done. A failure with [joinedWithoutWelcome] means the commit that + * added them went through and only the Welcome did not, so they are a member + * who cannot join yet -- resending reaches them. A failure without it never + * touched the group, so there is nothing to resend and a share link is the + * only way to them. + */ +data class CordnInviteOutcome( + val pubKey: HexKey, + val failure: Throwable?, + val joinedWithoutWelcome: Boolean = false, +) { + val sent: Boolean get() = failure == null +} + +/** A group that now exists, and how its invitations went. */ +data class CordnGroupCreation( + val gid: String, + val outcomes: List, +) { + val failed: List get() = outcomes.filterNot { it.sent } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/LoadRelayInfo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/LoadRelayInfo.kt index 854e7147ea..86a39afb05 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/LoadRelayInfo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/LoadRelayInfo.kt @@ -25,6 +25,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.State import androidx.compose.runtime.produceState import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.commons.relays.nip11RelayInfo.Nip11CachedRetriever import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation import com.vitorpamplona.quartz.utils.Log diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/RelaySupportsNip.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/RelaySupportsNip.kt index 792daf74fd..6cf7b588c7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/RelaySupportsNip.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/RelaySupportsNip.kt @@ -22,8 +22,8 @@ package com.vitorpamplona.amethyst.model.nip11RelayInfo import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel +import com.vitorpamplona.amethyst.commons.relays.nip11RelayInfo.isRelaySignedRelayGroup import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation /** * Whether [relay]'s cached NIP-11 document advertises support for [nip] (as a decimal string, e.g. @@ -43,38 +43,5 @@ fun relayAdvertisesNip( /** NIP-29 (relay-based groups): the relay must run it for its groups to be real. */ fun relayAdvertisesNip29(relay: NormalizedRelayUrl): Boolean = relayAdvertisesNip(relay, "29") -/** - * Whether [relayInfo] affirmatively signals that its relay does NOT run NIP-29 groups: the doc - * resolved with an explicit `supported_nips` list that lacks "29" and no `self` key (the field - * NIP-29 relays publish so clients can verify their relay-signed group metadata — see - * [isRelaySignedRelayGroup]). A doc with a null `supported_nips` proves nothing (still loading, - * or the fetch failed), so it never triggers the warning. - */ -fun looksLikeNonNip29Relay(relayInfo: Nip11RelayInformation): Boolean = relayInfo.supported_nips?.none { it == "29" } == true && relayInfo.self == null - -/** - * Whether [channel]'s relay-signed metadata is genuinely from its host relay, per NIP-29: - * "these are addressable events signed by the relay keypair directly … as stated by the NIP-11 - * `self` pubkey", and "relays shouldn't accept these events if they're signed by anyone else". - * - * So the authoritative check is `39000.author == relay.self`. When the relay publishes a `self` - * key we enforce that strictly — this rejects a stray user-published 39000 even on a real NIP-29 - * relay. When the relay does NOT advertise `self` at all (we can't verify cryptographically), we - * fall back to the weaker "advertises NIP-29" signal so a compliant relay that merely omits `self` - * still works. A relay with neither fails. Reads only the cached NIP-11 doc ([relayInfo]); callers - * driving a live surface should warm it first and re-evaluate as it resolves. - */ -fun isRelaySignedRelayGroup( - channel: RelayGroupChannel, - relayInfo: Nip11RelayInformation, -): Boolean { - val self = relayInfo.self - return if (self != null) { - channel.event?.pubKey == self - } else { - relayInfo.supported_nips?.any { it == "29" } == true - } -} - /** [isRelaySignedRelayGroup] reading the host relay's cached NIP-11 doc (for non-Compose callers). */ fun isRelaySignedRelayGroup(channel: RelayGroupChannel): Boolean = isRelaySignedRelayGroup(channel, Amethyst.instance.nip11Cache.getFromCache(channel.groupId.relayUrl)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip17Dms/DmRelayListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip17Dms/DmRelayListState.kt index 360fd7309b..1f97c26b2f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip17Dms/DmRelayListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip17Dms/DmRelayListState.kt @@ -26,7 +26,7 @@ import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.nip17Dm.settings.DmRelayListEvent import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.DelicateCoroutinesApi @@ -48,14 +48,14 @@ class DmRelayListState( // Creates a long-term reference for this note so that the GC doesn't collect the note it self val dmListNote = cache.getOrCreateAddressableNote(getDMRelayListAddress()) - fun getDMRelayListAddress() = ChatMessageRelayListEvent.createAddress(signer.pubKey) + fun getDMRelayListAddress() = DmRelayListEvent.createAddress(signer.pubKey) fun getDMRelayListFlow(): StateFlow = dmListNote.flow().metadata.stateFlow - fun getDMRelayList(): ChatMessageRelayListEvent? = dmListNote.event as? ChatMessageRelayListEvent + fun getDMRelayList(): DmRelayListEvent? = dmListNote.event as? DmRelayListEvent fun normalizeDMRelayListWithBackup(note: Note): Set { - val event = note.event as? ChatMessageRelayListEvent ?: settings.backupDMRelayList + val event = note.event as? DmRelayListEvent ?: settings.backupDMRelayList return event?.relays()?.toSet() ?: emptySet() } @@ -70,16 +70,16 @@ class DmRelayListState( emptySet(), ) - suspend fun saveRelayList(dmRelays: List): ChatMessageRelayListEvent { + suspend fun saveRelayList(dmRelays: List): DmRelayListEvent { val relayListForDMs = getDMRelayList() return if (relayListForDMs != null && relayListForDMs.tags.isNotEmpty()) { - ChatMessageRelayListEvent.updateRelayList( + DmRelayListEvent.updateRelayList( earlierVersion = relayListForDMs, relays = dmRelays, signer = signer, ) } else { - ChatMessageRelayListEvent.create( + DmRelayListEvent.create( relays = dmRelays, signer = signer, ) @@ -99,7 +99,7 @@ class DmRelayListState( Log.d("AccountRegisterObservers", "NIP-17 Relay List Collector Start") getDMRelayListFlow().collect { Log.d("AccountRegisterObservers") { "Updating DM Relay List for ${signer.pubKey}" } - (it.note.event as? ChatMessageRelayListEvent)?.let { + (it.note.event as? DmRelayListEvent)?.let { settings.updateDMRelayList(it) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip30CustomEmojis/OwnedEmojiPacksState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip30CustomEmojis/OwnedEmojiPacksState.kt index 7021497479..b9af0de585 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip30CustomEmojis/OwnedEmojiPacksState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip30CustomEmojis/OwnedEmojiPacksState.kt @@ -33,7 +33,7 @@ import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions import com.vitorpamplona.quartz.nip01Core.signers.update -import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag import com.vitorpamplona.quartz.nip30CustomEmoji.emoji import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent @@ -129,12 +129,12 @@ class OwnedEmojiPacksState( .onStart { emit(listFeedFlow.value.getPack(dTag)) } .flowOn(Dispatchers.IO) - fun DeletionEvent.hasAnyDeletedOwnedEmojiPacks() = deleteAddressesWithKind(EmojiPackEvent.KIND) || deletesAnyEventIn(ownedEmojiPackEventIds.value) + fun DeletionRequestEvent.hasAnyDeletedOwnedEmojiPacks() = deleteAddressesWithKind(EmojiPackEvent.KIND) || deletesAnyEventIn(ownedEmojiPackEventIds.value) fun hasItemInNoteList(notes: Set): Boolean = notes.anyNotNullEvent { event -> if (event.pubKey == signer.pubKey) { - event is EmojiPackEvent || (event is DeletionEvent && event.hasAnyDeletedOwnedEmojiPacks()) + event is EmojiPackEvent || (event is DeletionRequestEvent && event.hasAnyDeletedOwnedEmojiPacks()) } else { false } @@ -269,7 +269,7 @@ class OwnedEmojiPacksState( account: Account, ) { val packEvent = getOwnedEmojiPackEvent(dTag) ?: return - val deletionEventTemplate = DeletionEvent.build(listOf(packEvent)) + val deletionEventTemplate = DeletionRequestEvent.build(listOf(packEvent)) val deletionEvent = signer.sign(deletionEventTemplate) account.sendMyPublicAndPrivateOutbox(deletionEvent) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcSignerState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcSignerState.kt index 2252b83c4f..1c576e1a4f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcSignerState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcSignerState.kt @@ -36,10 +36,10 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import com.vitorpamplona.quartz.nip47WalletConnect.cache.NostrWalletConnectRequestCache import com.vitorpamplona.quartz.nip47WalletConnect.cache.NostrWalletConnectResponseCache -import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent -import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcInfoEvent import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcNotificationEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcRequestEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcResponseEvent import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcTransaction import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceMethod import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaymentReceivedNotification @@ -199,12 +199,12 @@ class NwcSignerState( override fun isNIP47Author(pubKey: HexKey?): Boolean = nip47Signer.value.pubKey == pubKey - override suspend fun decryptRequest(event: LnZapPaymentRequestEvent): Request? { + override suspend fun decryptRequest(event: NwcRequestEvent): Request? { if (!hasWalletConnectSetup()) return null return zapPaymentRequestDecryptionCache.value.decryptRequest(event) } - override suspend fun decryptResponse(event: LnZapPaymentResponseEvent): Response? { + override suspend fun decryptResponse(event: NwcResponseEvent): Response? { if (!hasWalletConnectSetup()) return null return zapPaymentResponseDecryptionCache.value.decryptResponse(event) } @@ -252,7 +252,7 @@ class NwcSignerState( request: Request, onTimeout: () -> Unit = {}, onResponse: (Response?) -> Unit, - ): Pair = sendNwcRequestToWallet(defaultWalletUri.value, request, onTimeout, onResponse) + ): Pair = sendNwcRequestToWallet(defaultWalletUri.value, request, onTimeout, onResponse) /** * Sends a generic NIP-47 request to a specific wallet. @@ -266,14 +266,14 @@ class NwcSignerState( request: Request, onTimeout: () -> Unit = {}, onResponse: (Response?) -> Unit, - ): Pair { + ): Pair { val walletService = walletUri ?: throw IllegalArgumentException("No NIP47 setup") val walletSigner = buildSigner(walletService) ?: signer val info = walletInfo(walletService) request.dropMetadataIfUnsupported(info) - val event = LnZapPaymentRequestEvent.createRequest(request, walletService.pubKeyHex, walletSigner, useNip44 = prefersNip44(info)) + val event = NwcRequestEvent.createRequest(request, walletService.pubKeyHex, walletSigner, useNip44 = prefersNip44(info)) val filter = NWCPaymentQueryState( @@ -314,7 +314,7 @@ class NwcSignerState( onTimeout: () -> Unit = {}, metadata: Map? = null, onResponse: (Response?) -> Unit, - ): Pair { + ): Pair { val walletService = defaultWalletUri.value ?: throw IllegalArgumentException("No NIP47 setup") val info = walletInfo(walletService) @@ -322,7 +322,7 @@ class NwcSignerState( request.dropMetadataIfUnsupported(info) val event = - LnZapPaymentRequestEvent.createRequest( + NwcRequestEvent.createRequest( request, walletService.pubKeyHex, nip47Signer.value, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/labeledBookmarkLists/LabeledBookmarkListsState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/bookmarkSets/BookmarkSetsState.kt similarity index 73% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/labeledBookmarkLists/LabeledBookmarkListsState.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/bookmarkSets/BookmarkSetsState.kt index 71626d7d4e..305f9c26e0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/labeledBookmarkLists/LabeledBookmarkListsState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/bookmarkSets/BookmarkSetsState.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.model.nip51Lists.labeledBookmarkLists +package com.vitorpamplona.amethyst.model.nip51Lists.bookmarkSets import com.vitorpamplona.amethyst.commons.model.AddressableNote import com.vitorpamplona.amethyst.commons.model.Note @@ -27,20 +27,20 @@ import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.model.cache.filter import com.vitorpamplona.amethyst.commons.model.eventIdSet import com.vitorpamplona.amethyst.commons.model.events -import com.vitorpamplona.amethyst.commons.model.nip51Lists.labeledBookmarkLists.LabeledBookmarkList +import com.vitorpamplona.amethyst.commons.model.nip51Lists.bookmarkSets.BookmarkSet import com.vitorpamplona.amethyst.commons.model.updateFlow import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.update -import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.BookmarkIdTag import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark -import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.LabeledBookmarkListEvent -import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.description -import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.image -import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.title +import com.vitorpamplona.quartz.nip51Lists.bookmarkSet.BookmarkSetEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkSet.description +import com.vitorpamplona.quartz.nip51Lists.bookmarkSet.image +import com.vitorpamplona.quartz.nip51Lists.bookmarkSet.title import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -55,41 +55,41 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.flow.update -class LabeledBookmarkListsState( +class BookmarkSetsState( val signer: NostrSigner, val cache: LocalCache, val scope: CoroutineScope, ) { val user = cache.getOrCreateUser(signer.pubKey) - fun existingLabeledBookmarkNotes() = cache.addressables.filter(LabeledBookmarkListEvent.KIND, user.pubkeyHex) + fun existingBookmarkSetNotes() = cache.addressables.filter(BookmarkSetEvent.KIND, user.pubkeyHex) - val labeledBookmarkListVersions = MutableStateFlow(0) + val bookmarkSetVersions = MutableStateFlow(0) - val labeledBookmarkListNotes = - labeledBookmarkListVersions - .map { existingLabeledBookmarkNotes() } - .onStart { emit(existingLabeledBookmarkNotes()) } + val bookmarkSetNotes = + bookmarkSetVersions + .map { existingBookmarkSetNotes() } + .onStart { emit(existingBookmarkSetNotes()) } .flowOn(Dispatchers.IO) .stateIn(scope, SharingStarted.Eagerly, emptyList()) - val labeledBookmarkListEventIds = - labeledBookmarkListNotes + val bookmarkSetEventIds = + bookmarkSetNotes .map { it.eventIdSet() } - .onStart { emit(labeledBookmarkListNotes.value.eventIdSet()) } + .onStart { emit(bookmarkSetNotes.value.eventIdSet()) } .flowOn(Dispatchers.IO) .stateIn(scope, SharingStarted.Eagerly, emptySet()) @OptIn(ExperimentalCoroutinesApi::class) - val latestBookmarkLists: StateFlow> = - labeledBookmarkListNotes - .transformLatest { emitAll(it.updateFlow()) } - .onStart { emit(labeledBookmarkListNotes.value.events()) } + val latestBookmarkLists: StateFlow> = + bookmarkSetNotes + .transformLatest { emitAll(it.updateFlow()) } + .onStart { emit(bookmarkSetNotes.value.events()) } .flowOn(Dispatchers.IO) .stateIn(scope, SharingStarted.Eagerly, emptyList()) - suspend fun LabeledBookmarkListEvent.toLabeledBookmarkList() = - LabeledBookmarkList( + suspend fun BookmarkSetEvent.toBookmarkSet() = + BookmarkSet( identifier = dTag(), title = titleOrName() ?: dTag(), description = description(), @@ -98,28 +98,28 @@ class LabeledBookmarkListsState( publicBookmarks = publicBookmarks().toSet(), ) - suspend fun List.toLabeledBookmarkListsFeed() = map { it.toLabeledBookmarkList() }.sortedBy { it.title } + suspend fun List.toBookmarkSetsFeed() = map { it.toBookmarkSet() }.sortedBy { it.title } val listFeedFlow = latestBookmarkLists - .map { it.toLabeledBookmarkListsFeed() } - .onStart { emit(latestBookmarkLists.value.toLabeledBookmarkListsFeed()) } + .map { it.toBookmarkSetsFeed() } + .onStart { emit(latestBookmarkLists.value.toBookmarkSetsFeed()) } .flowOn(Dispatchers.IO) .stateIn(scope, SharingStarted.Eagerly, emptyList()) - fun List.getList(bookmarkListId: String) = + fun List.getList(bookmarkListId: String) = this.firstOrNull { it.identifier == bookmarkListId } fun getBookmarkList(dTag: String) = listFeedFlow.value.getList(bookmarkListId = dTag) - fun DeletionEvent.hasAnyDeletedBookmarkLists() = deleteAddressesWithKind(LabeledBookmarkListEvent.KIND) || deletesAnyEventIn(labeledBookmarkListEventIds.value) + fun DeletionRequestEvent.hasAnyDeletedBookmarkLists() = deleteAddressesWithKind(BookmarkSetEvent.KIND) || deletesAnyEventIn(bookmarkSetEventIds.value) fun hasItemInNoteList(notes: Set): Boolean = notes.anyNotNullEvent { event -> if (event.pubKey == signer.pubKey) { - event is LabeledBookmarkListEvent || (event is DeletionEvent && event.hasAnyDeletedBookmarkLists()) + event is BookmarkSetEvent || (event is DeletionRequestEvent && event.hasAnyDeletedBookmarkLists()) } else { false } @@ -138,14 +138,14 @@ class LabeledBookmarkListsState( } fun forceRefresh() { - labeledBookmarkListVersions.update { it + 1 } + bookmarkSetVersions.update { it + 1 } } - fun getLabeledBookmarkListNote(bookmarkIdentifier: String): AddressableNote? = existingLabeledBookmarkNotes().find { it.dTag() == bookmarkIdentifier } + fun getBookmarkSetNote(bookmarkIdentifier: String): AddressableNote? = existingBookmarkSetNotes().find { it.dTag() == bookmarkIdentifier } - fun getLabeledBookmarkListEvent(bookmarkIdentifier: String): LabeledBookmarkListEvent = getLabeledBookmarkListNote(bookmarkIdentifier)?.event as LabeledBookmarkListEvent + fun getBookmarkSetEvent(bookmarkIdentifier: String): BookmarkSetEvent = getBookmarkSetNote(bookmarkIdentifier)?.event as BookmarkSetEvent - fun getLabeledBookmarkListFlow(bookmarkIdentifier: String) = + fun getBookmarkSetFlow(bookmarkIdentifier: String) = listFeedFlow .map { it.getList(bookmarkIdentifier) } .onStart { @@ -154,7 +154,7 @@ class LabeledBookmarkListsState( ) }.flowOn(Dispatchers.IO) - suspend fun addLabeledBookmarkList( + suspend fun addBookmarkSet( listName: String, listDescription: String? = null, listImage: String? = null, @@ -163,7 +163,7 @@ class LabeledBookmarkListsState( account: Account, ) { val newList = - LabeledBookmarkListEvent.create( + BookmarkSetEvent.create( title = listName, description = listDescription, image = listImage, @@ -178,10 +178,10 @@ class LabeledBookmarkListsState( listName: String?, listDescription: String?, listImage: String?, - bookmarkList: LabeledBookmarkList, + bookmarkList: BookmarkSet, account: Account, ) { - val listEvent = getLabeledBookmarkListEvent(bookmarkList.identifier) + val listEvent = getBookmarkSetEvent(bookmarkList.identifier) val template = listEvent.update { @@ -197,12 +197,12 @@ class LabeledBookmarkListsState( suspend fun renameBookmarkList( newName: String, - bookmarkList: LabeledBookmarkList, + bookmarkList: BookmarkSet, account: Account, ) { - val listEvent = getLabeledBookmarkListEvent(bookmarkList.identifier) + val listEvent = getBookmarkSetEvent(bookmarkList.identifier) val renamedList = - LabeledBookmarkListEvent.modifyName( + BookmarkSetEvent.modifyName( earlierVersion = listEvent, newTitle = newName, signer = account.signer, @@ -212,12 +212,12 @@ class LabeledBookmarkListsState( suspend fun modifyListDescription( newDescription: String?, - bookmarkList: LabeledBookmarkList, + bookmarkList: BookmarkSet, account: Account, ) { - val listEvent = getLabeledBookmarkListEvent(bookmarkList.identifier) + val listEvent = getBookmarkSetEvent(bookmarkList.identifier) val modifiedList = - LabeledBookmarkListEvent.modifyDescription( + BookmarkSetEvent.modifyDescription( earlierVersion = listEvent, newDescription = newDescription, signer = account.signer, @@ -226,13 +226,13 @@ class LabeledBookmarkListsState( } suspend fun cloneBookmarkList( - currentBookmarkList: LabeledBookmarkList, + currentBookmarkList: BookmarkSet, customCloneName: String?, customCloneDescription: String?, account: Account, ) { val clonedList = - LabeledBookmarkListEvent.create( + BookmarkSetEvent.create( title = customCloneName ?: currentBookmarkList.title, description = customCloneDescription ?: currentBookmarkList.description, publicBookmarks = currentBookmarkList.publicBookmarks.toList(), @@ -246,8 +246,8 @@ class LabeledBookmarkListsState( bookmarkListIdentifier: String, account: Account, ) { - val listEvent = getLabeledBookmarkListEvent(bookmarkListIdentifier) - val deletionEventTemplate = DeletionEvent.build(listOf(listEvent)) + val listEvent = getBookmarkSetEvent(bookmarkListIdentifier) + val deletionEventTemplate = DeletionRequestEvent.build(listOf(listEvent)) val deletionEvent = account.signer.sign(deletionEventTemplate) account.sendMyPublicAndPrivateOutbox(deletionEvent) } @@ -258,9 +258,9 @@ class LabeledBookmarkListsState( isBookmarkPrivate: Boolean, account: Account, ) { - val currentBookmarkList = getLabeledBookmarkListEvent(bookmarkListIdentifier) + val currentBookmarkList = getBookmarkSetEvent(bookmarkListIdentifier) val updatedList = - LabeledBookmarkListEvent.addBookmark( + BookmarkSetEvent.addBookmark( earlierVersion = currentBookmarkList, bookmarkIdTag = bookmark, isPrivate = isBookmarkPrivate, @@ -275,9 +275,9 @@ class LabeledBookmarkListsState( isBookmarkCurrentlyPrivate: Boolean, account: Account, ) { - val bookmarkList = getLabeledBookmarkListEvent(bookmarkListIdentifier) + val bookmarkList = getBookmarkSetEvent(bookmarkListIdentifier) val updatedList = - LabeledBookmarkListEvent.moveBookmark( + BookmarkSetEvent.moveBookmark( earlierVersion = bookmarkList, bookmarkIdTag = bookmark, isCurrentlyPrivate = isBookmarkCurrentlyPrivate, @@ -292,9 +292,9 @@ class LabeledBookmarkListsState( isBookmarkPrivate: Boolean, account: Account, ) { - val currentBookmarkList = getLabeledBookmarkListEvent(bookmarkListIdentifier) + val currentBookmarkList = getBookmarkSetEvent(bookmarkListIdentifier) val updatedList = - LabeledBookmarkListEvent.removeBookmark( + BookmarkSetEvent.removeBookmark( earlierVersion = currentBookmarkList, bookmarkIdTag = bookmark, isPrivate = isBookmarkPrivate, @@ -311,7 +311,7 @@ class LabeledBookmarkListsState( ) { if (deletedEventIds.isEmpty() && deletedAddresses.isEmpty()) return - val currentList = getLabeledBookmarkListNote(bookmarkListIdentifier)?.event as? LabeledBookmarkListEvent ?: return + val currentList = getBookmarkSetNote(bookmarkListIdentifier)?.event as? BookmarkSetEvent ?: return val newPublicTags = currentList.tags @@ -328,7 +328,7 @@ class LabeledBookmarkListsState( val updatedList = if (oldPrivateTags == null) { if (newPublicTags.size == currentList.tags.size) return - LabeledBookmarkListEvent.resign( + BookmarkSetEvent.resign( content = currentList.content, tags = newPublicTags, signer = account.signer, @@ -344,7 +344,7 @@ class LabeledBookmarkListsState( } }.toTypedArray() if (newPublicTags.size == currentList.tags.size && newPrivateTags.size == oldPrivateTags.size) return - LabeledBookmarkListEvent.resign( + BookmarkSetEvent.resign( tags = newPublicTags, privateTags = newPrivateTags, signer = account.signer, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/relayFeeds/RelayFeedListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteRelays/FavoriteRelayListState.kt similarity index 58% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/relayFeeds/RelayFeedListState.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteRelays/FavoriteRelayListState.kt index 7c803a398d..2f0fa88e44 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/relayFeeds/RelayFeedListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteRelays/FavoriteRelayListState.kt @@ -18,16 +18,16 @@ * 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.nip51Lists.relayFeeds +package com.vitorpamplona.amethyst.model.nip51Lists.favoriteRelays import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.NoteState import com.vitorpamplona.amethyst.commons.model.cache.LocalCache -import com.vitorpamplona.amethyst.commons.model.nip51Lists.relayFeeds.RelayFeedsListDecryptionCache +import com.vitorpamplona.amethyst.commons.model.nip51Lists.favoriteRelays.FavoriteRelayListDecryptionCache import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.FavoriteRelayListEvent import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.DelicateCoroutinesApi @@ -40,32 +40,32 @@ import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -class RelayFeedListState( +class FavoriteRelayListState( val signer: NostrSigner, val cache: LocalCache, - val decryptionCache: RelayFeedsListDecryptionCache, + val decryptionCache: FavoriteRelayListDecryptionCache, val scope: CoroutineScope, val settings: AccountSettings, ) { // Creates a long-term reference for this note so that the GC doesn't collect the note it self - val relayFeedListNote = cache.getOrCreateAddressableNote(getRelayFeedsListAddress()) + val favoriteRelayListNote = cache.getOrCreateAddressableNote(getFavoriteRelayListAddress()) - fun getRelayFeedsListAddress() = RelayFeedsListEvent.createAddress(signer.pubKey) + fun getFavoriteRelayListAddress() = FavoriteRelayListEvent.createAddress(signer.pubKey) - fun getRelayFeedsListFlow(): StateFlow = relayFeedListNote.flow().metadata.stateFlow + fun getFavoriteRelayListFlow(): StateFlow = favoriteRelayListNote.flow().metadata.stateFlow - fun getRelayFeedsList(): RelayFeedsListEvent? = relayFeedListNote.event as? RelayFeedsListEvent + fun getFavoriteRelayList(): FavoriteRelayListEvent? = favoriteRelayListNote.event as? FavoriteRelayListEvent - fun relayFeedsListEvent(note: Note) = note.event as? RelayFeedsListEvent ?: settings.backupRelayFeedsList + fun favoriteRelayListEvent(note: Note) = note.event as? FavoriteRelayListEvent ?: settings.backupFavoriteRelayList - suspend fun normalizeRelayFeedsListWithBackup(note: Note): Set = relayFeedsListEvent(note)?.let { decryptionCache.relays(it) }?.ifEmpty { null } ?: emptySet() + suspend fun normalizeFavoriteRelayListWithBackup(note: Note): Set = favoriteRelayListEvent(note)?.let { decryptionCache.relays(it) }?.ifEmpty { null } ?: emptySet() - suspend fun normalizeRelayFeedsListWithBackupNoDefaults(note: Note): Set = relayFeedsListEvent(note)?.let { decryptionCache.relays(it) } ?: emptySet() + suspend fun normalizeFavoriteRelayListWithBackupNoDefaults(note: Note): Set = favoriteRelayListEvent(note)?.let { decryptionCache.relays(it) } ?: emptySet() val flow = - getRelayFeedsListFlow() - .map { normalizeRelayFeedsListWithBackup(it.note) } - .onStart { emit(normalizeRelayFeedsListWithBackup(relayFeedListNote)) } + getFavoriteRelayListFlow() + .map { normalizeFavoriteRelayListWithBackup(it.note) } + .onStart { emit(normalizeFavoriteRelayListWithBackup(favoriteRelayListNote)) } .flowOn(Dispatchers.IO) .stateIn( scope, @@ -74,9 +74,9 @@ class RelayFeedListState( ) val flowNoDefaults = - getRelayFeedsListFlow() - .map { normalizeRelayFeedsListWithBackupNoDefaults(it.note) } - .onStart { emit(normalizeRelayFeedsListWithBackupNoDefaults(relayFeedListNote)) } + getFavoriteRelayListFlow() + .map { normalizeFavoriteRelayListWithBackupNoDefaults(it.note) } + .onStart { emit(normalizeFavoriteRelayListWithBackupNoDefaults(favoriteRelayListNote)) } .flowOn(Dispatchers.IO) .stateIn( scope, @@ -84,30 +84,30 @@ class RelayFeedListState( emptySet(), ) - suspend fun addRelay(relay: NormalizedRelayUrl): RelayFeedsListEvent { - val current = normalizeRelayFeedsListWithBackupNoDefaults(relayFeedListNote).toMutableList() + suspend fun addRelay(relay: NormalizedRelayUrl): FavoriteRelayListEvent { + val current = normalizeFavoriteRelayListWithBackupNoDefaults(favoriteRelayListNote).toMutableList() if (relay !in current) current.add(relay) return saveRelayList(current) } - suspend fun removeRelay(relay: NormalizedRelayUrl): RelayFeedsListEvent? { - val current = normalizeRelayFeedsListWithBackupNoDefaults(relayFeedListNote).toMutableList() + suspend fun removeRelay(relay: NormalizedRelayUrl): FavoriteRelayListEvent? { + val current = normalizeFavoriteRelayListWithBackupNoDefaults(favoriteRelayListNote).toMutableList() if (relay !in current) return null current.remove(relay) return saveRelayList(current) } - suspend fun saveRelayList(relayFeeds: List): RelayFeedsListEvent { - val relayFeedsList = getRelayFeedsList() + suspend fun saveRelayList(relayFeeds: List): FavoriteRelayListEvent { + val favoriteRelayList = getFavoriteRelayList() - return if (relayFeedsList != null && relayFeedsList.tags.isNotEmpty()) { - RelayFeedsListEvent.updateRelayList( - earlierVersion = relayFeedsList, + return if (favoriteRelayList != null && favoriteRelayList.tags.isNotEmpty()) { + FavoriteRelayListEvent.updateRelayList( + earlierVersion = favoriteRelayList, relays = relayFeeds, signer = signer, ) } else { - RelayFeedsListEvent.create( + FavoriteRelayListEvent.create( relays = relayFeeds, signer = signer, ) @@ -115,7 +115,7 @@ class RelayFeedListState( } init { - settings.backupRelayFeedsList?.let { + settings.backupFavoriteRelayList?.let { Log.d("AccountRegisterObservers") { "Loading saved relay feeds list ${it.toJson()}" } @OptIn(DelicateCoroutinesApi::class) scope.launch(Dispatchers.IO) { LocalCache.justConsumeMyOwnEvent(it) } @@ -123,10 +123,10 @@ class RelayFeedListState( scope.launch(Dispatchers.IO) { Log.d("AccountRegisterObservers", "Relay feeds list Collector Start") - getRelayFeedsListFlow().collect { + getFavoriteRelayListFlow().collect { Log.d("AccountRegisterObservers") { "Updating Relay feeds list for ${signer.pubKey}" } - (it.note.event as? RelayFeedsListEvent)?.let { - settings.updateRelayFeedList(it) + (it.note.event as? FavoriteRelayListEvent)?.let { + settings.updateFavoriteRelayList(it) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/PeopleListsState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/followSets/FollowSetsState.kt similarity index 84% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/PeopleListsState.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/followSets/FollowSetsState.kt index 013b2c0a07..4b002aec38 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/PeopleListsState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/followSets/FollowSetsState.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.model.nip51Lists.peopleList +package com.vitorpamplona.amethyst.model.nip51Lists.followSets import com.vitorpamplona.amethyst.commons.model.AddressableNote import com.vitorpamplona.amethyst.commons.model.Note @@ -28,19 +28,19 @@ import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.model.cache.filter import com.vitorpamplona.amethyst.commons.model.eventIdSet import com.vitorpamplona.amethyst.commons.model.events -import com.vitorpamplona.amethyst.commons.model.nip51Lists.peopleList.PeopleList -import com.vitorpamplona.amethyst.commons.model.nip51Lists.peopleList.PeopleListDecryptionCache +import com.vitorpamplona.amethyst.commons.model.nip51Lists.followSets.FollowSetDecryptionCache +import com.vitorpamplona.amethyst.commons.model.nip51Lists.followSets.PeopleList import com.vitorpamplona.amethyst.commons.model.updateFlow import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.update -import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent +import com.vitorpamplona.quartz.nip51Lists.followSet.FollowSetEvent +import com.vitorpamplona.quartz.nip51Lists.followSet.description +import com.vitorpamplona.quartz.nip51Lists.followSet.image +import com.vitorpamplona.quartz.nip51Lists.followSet.title import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag -import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent -import com.vitorpamplona.quartz.nip51Lists.peopleList.description -import com.vitorpamplona.quartz.nip51Lists.peopleList.image -import com.vitorpamplona.quartz.nip51Lists.peopleList.title import com.vitorpamplona.quartz.utils.flattenToSet import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -62,10 +62,10 @@ import java.util.UUID * * This class must receive updates from Large Cache as it receives new events. */ -class PeopleListsState( +class FollowSetsState( val signer: NostrSigner, val cache: LocalCache, - val decryptionCache: PeopleListDecryptionCache, + val decryptionCache: FollowSetDecryptionCache, val scope: CoroutineScope, ) { val user = cache.getOrCreateUser(signer.pubKey) @@ -78,8 +78,8 @@ class PeopleListsState( // can subscribe and fetch them from relays. fun existingPeopleListNotes() = cache.addressables - .filter(PeopleListEvent.KIND, user.pubkeyHex) - .filter { it.dTag() != PeopleListEvent.BLOCK_LIST_D_TAG || it.event != null } + .filter(FollowSetEvent.KIND, user.pubkeyHex) + .filter { it.dTag() != FollowSetEvent.BLOCK_LIST_D_TAG || it.event != null } .filter { it.event != null || !cache.hasBeenDeleted(it.address) } val peopleListVersions = MutableStateFlow(0) @@ -99,21 +99,21 @@ class PeopleListsState( .stateIn(scope, SharingStarted.Eagerly, emptySet()) @OptIn(ExperimentalCoroutinesApi::class) - val latestLists: StateFlow> = + val latestLists: StateFlow> = peopleListNotes - .transformLatest { emitAll(it.updateFlow()) } + .transformLatest { emitAll(it.updateFlow()) } .onStart { emit(peopleListNotes.value.events()) } .flowOn(Dispatchers.IO) .stateIn(scope, SharingStarted.Eagerly, emptyList()) - suspend fun PeopleListEvent.userIdSet() = decryptionCache.userIdSet(this) + suspend fun FollowSetEvent.userIdSet() = decryptionCache.userIdSet(this) - suspend fun List.mapToUserIdSet() = this.map { it.userIdSet() }.flattenToSet() + suspend fun List.mapToUserIdSet() = this.map { it.userIdSet() }.flattenToSet() - suspend fun List.mapGoodUsersToIdSet() = + suspend fun List.mapGoodUsersToIdSet() = this .mapNotNull { - if (it.dTag() != PeopleListEvent.BLOCK_LIST_D_TAG) { + if (it.dTag() != FollowSetEvent.BLOCK_LIST_D_TAG) { it.userIdSet() } else { null @@ -127,7 +127,7 @@ class PeopleListsState( .flowOn(Dispatchers.IO) .stateIn(scope, SharingStarted.Eagerly, emptySet()) - suspend fun PeopleListEvent.toUI() = + suspend fun FollowSetEvent.toUI() = PeopleList( identifierTag = this.dTag(), title = this.titleOrName() ?: this.dTag(), @@ -137,7 +137,7 @@ class PeopleListsState( publicMembers = cache.load(this.publicUsersIdSet()), ) - suspend fun List.toUI() = this.map { it.toUI() }.sortedBy { it.title } + suspend fun List.toUI() = this.map { it.toUI() }.sortedBy { it.title } val uiListFlow = latestLists @@ -158,12 +158,12 @@ class PeopleListsState( .map { it.select(dTag) } .onStart { emit(selectList(dTag)) } - fun DeletionEvent.hasDeletedAnyPeopleList() = deleteAddressesWithKind(PeopleListEvent.KIND) || deletesAnyEventIn(peopleListsEventIds.value) + fun DeletionRequestEvent.hasDeletedAnyPeopleList() = deleteAddressesWithKind(FollowSetEvent.KIND) || deletesAnyEventIn(peopleListsEventIds.value) fun hasItemInNoteList(notes: Set): Boolean = notes.anyNotNullEvent { event -> if (event.pubKey == signer.pubKey) { - event is PeopleListEvent || (event is DeletionEvent && event.hasDeletedAnyPeopleList()) + event is FollowSetEvent || (event is DeletionRequestEvent && event.hasDeletedAnyPeopleList()) } else { false } @@ -191,7 +191,7 @@ class PeopleListsState( fun getPeopleListNote(noteIdentifier: String): AddressableNote? = existingPeopleListNotes().find { it.dTag() == noteIdentifier } - fun getPeopleList(noteIdentifier: String): PeopleListEvent = getPeopleListNote(noteIdentifier)?.event as PeopleListEvent + fun getPeopleList(noteIdentifier: String): FollowSetEvent = getPeopleListNote(noteIdentifier)?.event as FollowSetEvent fun User.toUserTag() = UserTag(this.pubkeyHex, this.bestRelayHint()) @@ -207,7 +207,7 @@ class PeopleListsState( ): String { val dTag = UUID.randomUUID().toString() val newListTemplate = - PeopleListEvent.build( + FollowSetEvent.build( dTag = dTag, title = listName, publicMembers = if (!isPrivate && member != null) listOf(member.toUserTag()) else emptyList(), @@ -252,7 +252,7 @@ class PeopleListsState( account: Account, ) { val newList = - PeopleListEvent.createListWithDescription( + FollowSetEvent.createListWithDescription( dTag = UUID.randomUUID().toString(), title = customCloneName ?: currentPeopleList.title, description = customCloneDescription ?: currentPeopleList.description, @@ -268,7 +268,7 @@ class PeopleListsState( account: Account, ) { val followListEvent = getPeopleList(identifierTag) - val deletionEvent = account.signer.sign(DeletionEvent.build(listOf(followListEvent))) + val deletionEvent = account.signer.sign(DeletionRequestEvent.build(listOf(followListEvent))) account.sendMyPublicAndPrivateOutbox(deletionEvent) // Any screen whose persisted feed filter still points at this list would keep // re-creating an empty shell for its address (and render the dTag/UUID in the @@ -284,7 +284,7 @@ class PeopleListsState( ) { val followListEvent = getPeopleList(identifierTag) val newList = - PeopleListEvent.addUser( + FollowSetEvent.addUser( earlierVersion = followListEvent, pubKeyHex = user.pubkeyHex, relayHint = user.bestRelayHint(), @@ -302,7 +302,7 @@ class PeopleListsState( ) { val followListEvent = getPeopleList(identifierTag) val newList = - PeopleListEvent.addUserFirst( + FollowSetEvent.addUserFirst( earlierVersion = followListEvent, pubKeyHex = user.pubkeyHex, relayHint = user.bestRelayHint(), @@ -320,7 +320,7 @@ class PeopleListsState( ) { val followListEvent = getPeopleList(identifierTag) val newList = - PeopleListEvent.removeUser( + FollowSetEvent.removeUser( earlierVersion = followListEvent, pubKeyHex = user.pubkeyHex, isUserPrivate = isPrivate, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/FollowListsState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/followSets/StarterPacksState.kt similarity index 84% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/FollowListsState.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/followSets/StarterPacksState.kt index 0c10d3d49b..881b97a43d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/FollowListsState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/followSets/StarterPacksState.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.model.nip51Lists.peopleList +package com.vitorpamplona.amethyst.model.nip51Lists.followSets import com.vitorpamplona.amethyst.commons.model.AddressableNote import com.vitorpamplona.amethyst.commons.model.Note @@ -28,22 +28,22 @@ import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.model.cache.filter import com.vitorpamplona.amethyst.commons.model.eventIdSet import com.vitorpamplona.amethyst.commons.model.events -import com.vitorpamplona.amethyst.commons.model.nip51Lists.peopleList.PeopleList +import com.vitorpamplona.amethyst.commons.model.nip51Lists.followSets.PeopleList import com.vitorpamplona.amethyst.commons.model.updateFlow import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.update import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag -import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent -import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent -import com.vitorpamplona.quartz.nip51Lists.followList.description -import com.vitorpamplona.quartz.nip51Lists.followList.image -import com.vitorpamplona.quartz.nip51Lists.followList.person -import com.vitorpamplona.quartz.nip51Lists.followList.personFirst -import com.vitorpamplona.quartz.nip51Lists.followList.removePerson -import com.vitorpamplona.quartz.nip51Lists.followList.title +import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag +import com.vitorpamplona.quartz.nip51Lists.starterPack.StarterPackEvent +import com.vitorpamplona.quartz.nip51Lists.starterPack.description +import com.vitorpamplona.quartz.nip51Lists.starterPack.image +import com.vitorpamplona.quartz.nip51Lists.starterPack.person +import com.vitorpamplona.quartz.nip51Lists.starterPack.personFirst +import com.vitorpamplona.quartz.nip51Lists.starterPack.removePerson +import com.vitorpamplona.quartz.nip51Lists.starterPack.title import com.vitorpamplona.quartz.utils.flattenToSet import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -65,7 +65,7 @@ import java.util.UUID * * This class must receive updates from Large Cache as it receives new events. */ -class FollowListsState( +class StarterPacksState( val signer: NostrSigner, val cache: LocalCache, val scope: CoroutineScope, @@ -79,34 +79,34 @@ class FollowListsState( // them from relays. fun existingPeopleListNotes() = cache.addressables - .filter(FollowListEvent.KIND, user.pubkeyHex) + .filter(StarterPackEvent.KIND, user.pubkeyHex) .filter { it.event != null || !cache.hasBeenDeleted(it.address) } val followListVersions = MutableStateFlow(0) - val followListNotes = + val starterPackNotes = followListVersions .map { existingPeopleListNotes() } .onStart { emit(existingPeopleListNotes()) } .flowOn(Dispatchers.IO) .stateIn(scope, SharingStarted.Eagerly, emptyList()) - val followListsEventIds = - followListNotes + val starterPacksEventIds = + starterPackNotes .map { it.eventIdSet() } - .onStart { emit(followListNotes.value.eventIdSet()) } + .onStart { emit(starterPackNotes.value.eventIdSet()) } .flowOn(Dispatchers.IO) .stateIn(scope, SharingStarted.Eagerly, emptySet()) @OptIn(ExperimentalCoroutinesApi::class) - val latestLists: StateFlow> = - followListNotes - .transformLatest { emitAll(it.updateFlow()) } - .onStart { emit(followListNotes.value.events()) } + val latestLists: StateFlow> = + starterPackNotes + .transformLatest { emitAll(it.updateFlow()) } + .onStart { emit(starterPackNotes.value.events()) } .flowOn(Dispatchers.IO) .stateIn(scope, SharingStarted.Eagerly, emptyList()) - fun List.mapToUserIdSet() = this.map { it.followIdSet() }.flattenToSet() + fun List.mapToUserIdSet() = this.map { it.followIdSet() }.flattenToSet() val allPeopleListProfiles: StateFlow> = latestLists @@ -115,7 +115,7 @@ class FollowListsState( .flowOn(Dispatchers.IO) .stateIn(scope, SharingStarted.Eagerly, emptySet()) - fun FollowListEvent.toUI() = + fun StarterPackEvent.toUI() = PeopleList( identifierTag = this.dTag(), title = this.title() ?: this.dTag(), @@ -125,7 +125,7 @@ class FollowListsState( publicMembers = cache.load(this.followIdSet()), ) - fun List.toUI() = this.map { it.toUI() } + fun List.toUI() = this.map { it.toUI() } val uiListFlow = latestLists @@ -148,12 +148,12 @@ class FollowListsState( fun isUserInFollowSets(user: User): Boolean = allPeopleListProfiles.value.contains(user.pubkeyHex) - fun DeletionEvent.hasDeletedAnyFollowList() = deleteAddressesWithKind(FollowListEvent.KIND) || deletesAnyEventIn(followListsEventIds.value) + fun DeletionRequestEvent.hasDeletedAnyStarterPack() = deleteAddressesWithKind(StarterPackEvent.KIND) || deletesAnyEventIn(starterPacksEventIds.value) fun hasItemInNoteList(notes: Set): Boolean = notes.anyNotNullEvent { event -> if (event.pubKey == signer.pubKey) { - event is FollowListEvent || (event is DeletionEvent && event.hasDeletedAnyFollowList()) + event is StarterPackEvent || (event is DeletionRequestEvent && event.hasDeletedAnyStarterPack()) } else { false } @@ -181,7 +181,7 @@ class FollowListsState( fun getPeopleListNote(noteIdentifier: String): AddressableNote? = existingPeopleListNotes().find { it.dTag() == noteIdentifier } - fun getPeopleList(noteIdentifier: String): FollowListEvent = getPeopleListNote(noteIdentifier)?.event as FollowListEvent + fun getPeopleList(noteIdentifier: String): StarterPackEvent = getPeopleListNote(noteIdentifier)?.event as StarterPackEvent fun User.toUserTag() = UserTag(this.pubkeyHex, this.bestRelayHint()) @@ -198,7 +198,7 @@ class FollowListsState( val dTag = UUID.randomUUID().toString() val newListTemplate = - FollowListEvent.build( + StarterPackEvent.build( name = name, people = if (!isPrivate && member != null) listOf(member.toUserTag()) else emptyList(), dTag = dTag, @@ -262,7 +262,7 @@ class FollowListsState( account: Account, ) { val followListEvent = getPeopleList(identifierTag) - val deletionEvent = account.signer.sign(DeletionEvent.build(listOf(followListEvent))) + val deletionEvent = account.signer.sign(DeletionRequestEvent.build(listOf(followListEvent))) account.sendMyPublicAndPrivateOutbox(deletionEvent) // Any screen whose persisted feed filter still points at this follow pack would // keep re-creating an empty shell for its address (and render the dTag/UUID in diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/hashtagLists/HashtagListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/interestLists/InterestListState.kt similarity index 68% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/hashtagLists/HashtagListState.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/interestLists/InterestListState.kt index 92869bfbb5..25dcf8712b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/hashtagLists/HashtagListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/interestLists/InterestListState.kt @@ -18,15 +18,15 @@ * 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.nip51Lists.hashtagLists +package com.vitorpamplona.amethyst.model.nip51Lists.interestLists import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.NoteState import com.vitorpamplona.amethyst.commons.model.cache.LocalCache -import com.vitorpamplona.amethyst.commons.model.nip51Lists.hashtagLists.HashtagListDecryptionCache +import com.vitorpamplona.amethyst.commons.model.nip51Lists.interestLists.InterestListDecryptionCache import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent +import com.vitorpamplona.quartz.nip51Lists.interestList.InterestListEvent import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.DelicateCoroutinesApi @@ -40,30 +40,30 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.launch -class HashtagListState( +class InterestListState( val signer: NostrSigner, val cache: LocalCache, - val decryptionCache: HashtagListDecryptionCache, + val decryptionCache: InterestListDecryptionCache, val scope: CoroutineScope, val settings: AccountSettings, ) { // Creates a long-term reference for this note so that the GC doesn't collect the note it self - val hashtagListNote = cache.getOrCreateAddressableNote(getHashtagListAddress()) + val hashtagListNote = cache.getOrCreateAddressableNote(getInterestListAddress()) - fun getHashtagListAddress() = HashtagListEvent.createAddress(signer.pubKey) + fun getInterestListAddress() = InterestListEvent.createAddress(signer.pubKey) - fun getHashtagListFlow(): StateFlow = hashtagListNote.flow().metadata.stateFlow + fun getInterestListFlow(): StateFlow = hashtagListNote.flow().metadata.stateFlow - fun getHashtagList(): HashtagListEvent? = hashtagListNote.event as? HashtagListEvent + fun getInterestList(): InterestListEvent? = hashtagListNote.event as? InterestListEvent suspend fun hashtagListWithBackup(note: Note): Set { - val event = note.event as? HashtagListEvent ?: settings.backupHashtagList + val event = note.event as? InterestListEvent ?: settings.backupInterestList return event?.let { decryptionCache.hashtags(it).mapTo(mutableSetOf()) { it.lowercase() } } ?: emptySet() } @OptIn(ExperimentalCoroutinesApi::class) val flow: StateFlow> = - getHashtagListFlow() + getInterestListFlow() .transformLatest { noteState -> emit(hashtagListWithBackup(noteState.note)) }.onStart { @@ -75,38 +75,38 @@ class HashtagListState( emptySet(), ) - suspend fun follow(hashtags: List): HashtagListEvent { - val hashtagList = getHashtagList() + suspend fun follow(hashtags: List): InterestListEvent { + val hashtagList = getInterestList() return if (hashtagList == null) { - HashtagListEvent.create(hashtags, true, signer) + InterestListEvent.create(hashtags, true, signer) } else { - HashtagListEvent.add(hashtagList, hashtags, true, signer) + InterestListEvent.add(hashtagList, hashtags, true, signer) } } - suspend fun follow(hashtag: String): HashtagListEvent { - val hashtagList = getHashtagList() + suspend fun follow(hashtag: String): InterestListEvent { + val hashtagList = getInterestList() return if (hashtagList == null) { - HashtagListEvent.create(hashtag.lowercase(), true, signer) + InterestListEvent.create(hashtag.lowercase(), true, signer) } else { - HashtagListEvent.add(hashtagList, hashtag.lowercase(), true, signer) + InterestListEvent.add(hashtagList, hashtag.lowercase(), true, signer) } } - suspend fun unfollow(hashtag: String): HashtagListEvent? { - val hashtagList = getHashtagList() + suspend fun unfollow(hashtag: String): InterestListEvent? { + val hashtagList = getInterestList() return if (hashtagList != null) { - HashtagListEvent.remove(hashtagList, hashtag, signer) + InterestListEvent.remove(hashtagList, hashtag, signer) } else { null } } init { - settings.backupHashtagList?.let { event -> + settings.backupInterestList?.let { event -> Log.d("AccountRegisterObservers") { "Loading saved Hashtag list ${event.toJson()}" } @OptIn(DelicateCoroutinesApi::class) scope.launch(Dispatchers.IO) { @@ -116,10 +116,10 @@ class HashtagListState( scope.launch(Dispatchers.IO) { Log.d("AccountRegisterObservers", "Hashtag List Collector Start") - getHashtagListFlow().collect { + getInterestListFlow().collect { Log.d("AccountRegisterObservers") { "Hashtag List for ${signer.pubKey}" } - (it.note.event as? HashtagListEvent)?.let { - settings.updateHashtagListTo(it) + (it.note.event as? InterestListEvent)?.let { + settings.updateInterestListTo(it) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/interestSets/InterestSetsState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/interestSets/InterestSetsState.kt index 487aa8748b..f70f9a13cb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/interestSets/InterestSetsState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/interestSets/InterestSetsState.kt @@ -31,7 +31,7 @@ import com.vitorpamplona.amethyst.commons.model.nip51Lists.interestSets.Interest import com.vitorpamplona.amethyst.commons.model.updateFlow import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent import com.vitorpamplona.quartz.nip51Lists.interestSet.InterestSetEvent import com.vitorpamplona.quartz.nip51Lists.tags.TitleTag import kotlinx.coroutines.CoroutineScope @@ -106,12 +106,12 @@ class InterestSetsState( fun getInterestSet(dTag: String) = listFeedFlow.value.getSet(dTag) - fun DeletionEvent.hasAnyDeletedInterestSets() = deleteAddressesWithKind(InterestSetEvent.KIND) || deletesAnyEventIn(interestSetEventIds.value) + fun DeletionRequestEvent.hasAnyDeletedInterestSets() = deleteAddressesWithKind(InterestSetEvent.KIND) || deletesAnyEventIn(interestSetEventIds.value) fun hasItemInNoteList(notes: Set): Boolean = notes.anyNotNullEvent { event -> if (event.pubKey == signer.pubKey) { - event is InterestSetEvent || (event is DeletionEvent && event.hasAnyDeletedInterestSets()) + event is InterestSetEvent || (event is DeletionRequestEvent && event.hasAnyDeletedInterestSets()) } else { false } @@ -178,7 +178,7 @@ class InterestSetsState( account: Account, ) { val event = getInterestSetEvent(identifier) - val template = DeletionEvent.build(listOf(event)) + val template = DeletionRequestEvent.build(listOf(event)) val deletionEvent = account.signer.sign(template) account.sendMyPublicAndPrivateOutbox(deletionEvent) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuPreferences.kt index 02ce5266ac..e856c3fe07 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuPreferences.kt @@ -20,110 +20,68 @@ */ package com.vitorpamplona.amethyst.model.nip60Cashu -import android.annotation.SuppressLint import android.content.Context -import android.content.SharedPreferences -import androidx.core.content.edit +import androidx.datastore.preferences.core.longPreferencesKey import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.cashu.CashuKeysetCounterStore +import com.vitorpamplona.amethyst.commons.cashu.DataStoreCashuCounterStore +import com.vitorpamplona.amethyst.commons.model.preferences.CopyOnceMigration +import com.vitorpamplona.quartz.utils.cache.LargeCache /** - * Per-account Cashu state that needs durable, synchronous persistence — - * separate from [com.vitorpamplona.amethyst.model.AccountSettings] which - * batches writes through a 1-second debounced StateFlow. + * Android's per-account NUT-13 counter store: the shared + * [DataStoreCashuCounterStore] over a file in the app's data directory. * - * # Why a separate store + * Two older layers feed into it, and neither may move a counter backwards: * - * The NUT-13 keyset counter is the critical bit. Every mint / swap / - * melt reserves counter slots, derives deterministic blinded outputs at - * those slots, sends them to the mint, and the mint signs them. The - * mint persists which (keyset, blind_message) pairs it has ever signed; - * a second request to sign the same blind_message returns HTTP 400 - * "outputs already signed". So once the wallet hands a counter to the - * mint, the local counter advance MUST survive a crash — otherwise the - * next reservation pulls the same slot and the mint rejects it. + * - `cashu_prefs_` SharedPreferences, copied in full on first read by + * [CopyOnceMigration]. The copy happens inside the same atomic DataStore + * write that records it happened, so a crash cannot leave the marker set + * with the counters missing. It is a copy, not a move: the old file stays + * intact, so a rolled-back build still finds its counters. + * - `AccountSettings.cashuKeysetCounters`, an older in-settings map, still + * applied per keyset through `seedIfMissing` on every read. * - * The default settings save path debounces writes by 1000 ms, which is - * exactly the race window between "we asked the mint to sign" and "the - * mint replied". A crash inside that window (OOM, signer dialog dismiss, - * unexpected process death) loses the counter advance and makes the - * wallet unusable. This store writes via `commit = true` so each - * reservation is durable before the function returns. - * - * # Layout - * - * One SharedPreferences file per account, named - * `cashu_prefs_.xml`. Keys are flat: - * - `counter_` → Long, the next free NUT-13 counter - * - * Plain (non-encrypted) prefs because keyset counters aren't secret — - * they're not the seed, they don't carry value, and a leak would only - * tell an attacker how many proofs the wallet has minted at each - * keyset (a privacy signal at most). - * - * # Migration - * - * Older builds stored counters inside `AccountSettings.cashuKeysetCounters`. - * On first read of a given keyset, callers should pre-seed the store - * from the legacy map (one-time copy) so an upgrade doesn't reset the - * counter to zero. See `AccountSettings.migrateCashuCountersTo` for - * the helper. + * Losing a counter here means restarting a keyset at zero and reusing + * indices, which costs real ecash — so nothing on this path is best-effort. */ -class CashuPreferences( - private val prefs: SharedPreferences, -) : CashuKeysetCounterStore { - /** Inspect the next free counter for [keysetId] without advancing it. */ - @Synchronized - override fun peek(keysetId: String): Long = prefs.getLong(counterKey(keysetId), 0L) +object CashuPreferences { + private const val LEGACY_FILE_PREFIX = "cashu_prefs_" + + /** The store file name for [npub], as AppPreferenceStores takes it. */ + const val FILE_PREFIX = "cashu_" + + fun fileName(npub: String) = FILE_PREFIX + npub + + private val stores = LargeCache() /** - * Atomically reserve [count] consecutive NUT-13 counters for - * [keysetId] and return the first reserved index. The write is - * forced to disk with `commit = true` BEFORE returning — see the - * class header for why this isn't optional. + * Per-account instance, cached: DataStore refuses two live instances over + * one file, and a second instance would defeat the single-writer + * serialisation that `reserve` depends on. */ - @Synchronized - @SuppressLint("ApplySharedPref") - override fun reserve( - keysetId: String, - count: Int, - ): Long { - require(count > 0) { "Counter reservation must be positive" } - val current = peek(keysetId) - val next = current + count.toLong() - prefs.edit(commit = true) { putLong(counterKey(keysetId), next) } - return current - } + fun forAccount(npub: String): CashuKeysetCounterStore = + stores.getOrCreate(npub) { + DataStoreCashuCounterStore(Amethyst.instance.appStores.getDataStore(fileName(npub))) + } /** - * Seed [keysetId]'s counter from a legacy value found in - * [AccountSettings.cashuKeysetCounters]. No-op when the store - * already has a value at or above [legacyValue] — never moves the - * counter backwards. Called once at wallet load to carry forward - * pre-migration state. + * The copy out of `cashu_prefs_`, wired to the file by AppModules + * rather than attached here. + * + * DataStore runs a file's migrations when that file is first opened, and + * the holder is what opens it, so the migration has to be registered with + * the holder or it would never run. */ - @Synchronized - @SuppressLint("ApplySharedPref") - override fun seedIfMissing( - keysetId: String, - legacyValue: Long, - ) { - if (legacyValue <= 0L) return - val current = peek(keysetId) - if (current >= legacyValue) return - prefs.edit(commit = true) { putLong(counterKey(keysetId), legacyValue) } - } - - companion object { - private const val FILE_PREFIX = "cashu_prefs_" - - private fun counterKey(keysetId: String) = "counter_$keysetId" - - /** Per-account instance. [npub] keys the on-disk file so each account is isolated. */ - fun forAccount(npub: String): CashuPreferences { - val context = Amethyst.instance.appContext - val prefs = context.getSharedPreferences("$FILE_PREFIX$npub", Context.MODE_PRIVATE) - return CashuPreferences(prefs) + fun legacyMigration( + context: Context, + npub: String, + ) = CopyOnceMigration("migrated.cashuCounters") { out -> + val legacy = context.getSharedPreferences("$LEGACY_FILE_PREFIX$npub", Context.MODE_PRIVATE) + legacy.all.forEach { (key, value) -> + if (key.startsWith(DataStoreCashuCounterStore.COUNTER_PREFIX) && value is Long) { + out[longPreferencesKey(key)] = value + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt index 98a005ebdc..1a97ee66c1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt @@ -41,7 +41,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllPages import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal -import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent import com.vitorpamplona.quartz.nip60Cashu.history.CashuSpendingHistoryEvent import com.vitorpamplona.quartz.nip60Cashu.mintApi.DeterministicSecretFactory import com.vitorpamplona.quartz.nip60Cashu.mintApi.MeltQuoteBolt11ResponseDto @@ -486,7 +486,7 @@ class CashuWalletState( // Process our own NIP-09 deletions inline rather than // relying on LocalCache's `deletedEventBundles`. That - // path only fires when `consume(DeletionEvent)` finds + // path only fires when `consume(DeletionRequestEvent)` finds // the target Note still resident in `notes` — a // LargeSoftCache backed by WeakReferences, which can // be cleared on any GC cycle. When the weak ref is @@ -503,7 +503,7 @@ class CashuWalletState( val ourDeleteIds = all .asSequence() - .filterIsInstance() + .filterIsInstance() .filter { it.pubKey == pubKey } .flatMap { it.deleteEventIds().asSequence() } .toSet() @@ -1447,7 +1447,7 @@ class CashuWalletState( } val redundantIds = redundant.map { it.event.id }.toSet() runCatching { - val template = DeletionEvent.build(redundant.map { it.event }) + val template = DeletionRequestEvent.build(redundant.map { it.event }) val signed = signer.sign(template) publishEvent(signed) }.onFailure { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/AccountPreferenceStores.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/AccountPreferenceStores.kt deleted file mode 100644 index c7fdad1616..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/AccountPreferenceStores.kt +++ /dev/null @@ -1,83 +0,0 @@ -/* - * 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.preferences - -import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.PreferenceDataStoreFactory -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.core.stringPreferencesKey -import com.vitorpamplona.quartz.utils.cache.LargeCache -import java.io.File - -class AccountPreferenceStores( - val rootFilesDir: () -> File, -) { - companion object { - val defaultHomeFollowList = stringPreferencesKey("defaultHomeFollowList") - val defaultStoriesFollowList = stringPreferencesKey("defaultStoriesFollowList") - val defaultNotificationFollowList = stringPreferencesKey("defaultNotificationFollowList") - val defaultDiscoveryFollowList = stringPreferencesKey("defaultDiscoveryFollowList") - - val localRelayServers = stringPreferencesKey("localRelayServers") - val defaultFileServer = stringPreferencesKey("defaultFileServer") - - val latestUserMetadata = stringPreferencesKey("latestUserMetadata") - val latestContactList = stringPreferencesKey("latestContactList") - val latestDMRelayList = stringPreferencesKey("latestDMRelayList") - val latestNIP65RelayList = stringPreferencesKey("latestNIP65RelayList") - val latestSearchRelayList = stringPreferencesKey("latestSearchRelayList") - val latestBlockedRelayList = stringPreferencesKey("latestBlockedRelayList") - val latestTrustedRelayList = stringPreferencesKey("latestTrustedRelayList") - val latestMuteList = stringPreferencesKey("latestMuteList") - val latestPrivateHomeRelayList = stringPreferencesKey("latestPrivateHomeRelayList") - val latestAppSpecificData = stringPreferencesKey("latestAppSpecificData") - val latestChannelList = stringPreferencesKey("latestChannelList") - val latestCommunityList = stringPreferencesKey("latestCommunityList") - val latestHashtagList = stringPreferencesKey("latestHashtagList") - val latestGeohashList = stringPreferencesKey("latestGeohashList") - val latestEphemeralChatList = stringPreferencesKey("latestEphemeralChatList") - - val hideDeleteRequestDialog = stringPreferencesKey("hideDeleteRequestDialog") - val hideBlockAlertDialog = stringPreferencesKey("hideBlockAlertDialog") - val hideNip17WarningDialog = stringPreferencesKey("hideNip17WarningDialog") - - val torSettings = stringPreferencesKey("tor_settings") - - val hasDonatedInVersion = stringPreferencesKey("hasDonatedInVersion") - } - - private val storeCache = LargeCache>() - - fun file(npub: String) = File(rootFilesDir(), "datastore/$npub.preferences") - - private fun getDataStore(npub: String): DataStore = - storeCache.getOrCreate(npub) { - PreferenceDataStoreFactory.create( - produceFile = { file(npub) }, - ) - } - - fun removeAccount(npub: String): Boolean { - val deleted = file(npub).delete() - storeCache.remove(npub) - return deleted - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/AccountSecretsEncryptedStores.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/AccountSecretsEncryptedStores.kt deleted file mode 100644 index b9318a4713..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/AccountSecretsEncryptedStores.kt +++ /dev/null @@ -1,78 +0,0 @@ -/* - * 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.preferences - -import androidx.datastore.preferences.core.PreferenceDataStoreFactory -import androidx.datastore.preferences.core.stringPreferencesKey -import com.vitorpamplona.amethyst.commons.model.preferences.UpdatablePropertyFlow -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect -import com.vitorpamplona.quartz.utils.cache.LargeCache -import kotlinx.coroutines.CoroutineScope -import java.io.File - -class AccountSecretsEncryptedStores( - val rootFilesDir: () -> File, - val scope: CoroutineScope, -) { - companion object Companion { - val encryption = KeyStoreEncryption() - val key = stringPreferencesKey("privKey") - val nwc = stringPreferencesKey("nwc") - } - - private val storeCache = LargeCache() - - fun file(npub: String) = File(rootFilesDir(), "datastore/$npub.secrets") - - private fun getDataStore(npub: String): EncryptedDataStore = - storeCache.getOrCreate(npub) { - EncryptedDataStore( - PreferenceDataStoreFactory.create( - produceFile = { file(npub) }, - ), - encryption, - scope = scope, - ) - } - - suspend fun getPrivateKey(npub: String): String? = getDataStore(npub).get(key) - - suspend fun savePrivateKey( - npub: String, - value: HexKey, - ) { - getDataStore(npub).save(key, value) - } - - suspend fun nwc(npub: String): UpdatablePropertyFlow = - getDataStore(npub).getProperty( - key = nwc, - parser = Nip47WalletConnect.Nip47URI::parser, - serializer = Nip47WalletConnect.Nip47URI::serializer, - ) - - fun removeAccount(npub: String): Boolean { - val deleted = file(npub).delete() - storeCache.remove(npub) - return deleted - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/EncryptedDataStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/EncryptedDataStore.kt deleted file mode 100644 index 49091d62c7..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/EncryptedDataStore.kt +++ /dev/null @@ -1,108 +0,0 @@ -/* - * 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.preferences - -import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.core.edit -import androidx.datastore.preferences.core.emptyPreferences -import com.vitorpamplona.amethyst.commons.model.preferences.UpdatablePropertyFlow -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.flow.map -import java.io.IOException -import kotlin.io.encoding.Base64 - -class EncryptedDataStore( - private val store: DataStore, - private val encryption: KeyStoreEncryption = KeyStoreEncryption(), - private val scope: CoroutineScope, -) { - private fun decode(str: String): ByteArray = Base64.decode(str) - - private fun encode(bytes: ByteArray): String = Base64.encode(bytes) - - private fun encrypt(value: String): String = encode(encryption.encrypt(value.toByteArray())) - - private fun decrypt(value: String): String = encryption.decrypt(decode(value)).contentToString() - - suspend fun remove(key: Preferences.Key) { - store.edit { prefs -> - prefs.remove(key) - } - } - - suspend fun save( - key: Preferences.Key, - value: String, - ) { - store.edit { prefs -> - prefs[key] = encrypt(value) - } - } - - suspend fun get(key: Preferences.Key): String? = - store.data - .catch { e -> - if (e is IOException) emit(emptyPreferences()) else throw e - }.firstOrNull() - ?.get(key) - ?.let { decrypt(it) } - - fun getProperty( - key: Preferences.Key, - parser: (String) -> T, - serializer: (T) -> String, - ): UpdatablePropertyFlow = - UpdatablePropertyFlow( - flow = - store.data - .catch { e -> - if (e is IOException) emit(emptyPreferences()) else throw e - }.map { prefs -> - val value = prefs[key] - if (value != null) { - val decrypted = decrypt(value) - if (decrypted.isNotBlank()) { - parser(decrypted) - } else { - null - } - } else { - null - } - }, - update = { newValue -> - if (newValue != null) { - val serialized = serializer(newValue) - if (serialized.isNotBlank()) { - save(key, serialized) - } else { - remove(key) - } - } else { - remove(key) - } - }, - scope = scope, - ) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt deleted file mode 100644 index 78239e6911..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt +++ /dev/null @@ -1,322 +0,0 @@ -/* - * 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.preferences - -import android.app.UiModeManager -import android.content.Context -import android.os.Build -import androidx.appcompat.app.AppCompatDelegate -import androidx.compose.runtime.Stable -import androidx.core.content.getSystemService -import androidx.core.os.LocaleListCompat -import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.core.booleanPreferencesKey -import androidx.datastore.preferences.core.edit -import androidx.datastore.preferences.core.stringPreferencesKey -import androidx.datastore.preferences.preferencesDataStore -import com.vitorpamplona.amethyst.LocalPreferences -import com.vitorpamplona.amethyst.commons.model.AccentColorType -import com.vitorpamplona.amethyst.commons.model.BooleanType -import com.vitorpamplona.amethyst.commons.model.ConnectivityType -import com.vitorpamplona.amethyst.commons.model.FeatureSetType -import com.vitorpamplona.amethyst.commons.model.FontFamilyType -import com.vitorpamplona.amethyst.commons.model.FontSizeType -import com.vitorpamplona.amethyst.commons.model.ProfileGalleryType -import com.vitorpamplona.amethyst.commons.model.ThemeType -import com.vitorpamplona.amethyst.commons.model.UiSettings -import com.vitorpamplona.amethyst.commons.model.UiSettingsFlow -import com.vitorpamplona.quartz.utils.Log -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.withContext -import kotlin.coroutines.cancellation.CancellationException - -val Context.sharedPreferencesDataStore: DataStore by preferencesDataStore(name = "shared_settings") - -@Stable -class UiSharedPreferences( - prefs: UiSettings, - val context: Context, - val scope: CoroutineScope, -) { - // UI Preferences. Makes sure to wait for it to avoid blinking themes and language preferences - val value = UiSettingsFlow.build(prefs) - - val languageUpdate = - value.preferredLanguage - .onEach { language -> applyLanguage(language) } - .flowOn(Dispatchers.IO) - .stateIn( - scope, - SharingStarted.Eagerly, - value.toSettings(), - ) - - val nightModeUpdate = - value.theme - .onEach { theme -> applyNightMode(theme) } - .flowOn(Dispatchers.IO) - .stateIn( - scope, - SharingStarted.Eagerly, - prefs.theme, - ) - - /** - * Mirrors the in-app theme choice into the system's *per-application* night mode, so the - * launch splash agrees with a theme that is pinned against the phone's own light/dark setting. - * - * The system composites the splash from the manifest theme before the process starts, resolving - * it against this app's configuration -- so day/night resource qualifiers alone can only ever - * follow the phone. [UiModeManager.setApplicationNightMode] commits a *persisted per-package - * configuration override* (UiModeManagerService hands it to - * ActivityTaskManagerInternal.PackageConfigurationUpdater), which the system then applies when - * it launches the app. That is what carries a pinned LIGHT/DARK choice into the splash, from - * the next cold start onwards -- the current launch is already painted. - * - * This is deliberately [UiModeManager.setApplicationNightMode] and not - * [UiModeManager.setNightMode]: the latter changes the night mode for every app on the device - * and is gated behind MODIFY_DAY_NIGHT_MODE, which this app does not hold -- that call was a - * silent no-op and was removed. The per-application setter is the documented app-local - * alternative and is not permission-checked; UiModeManagerService only validates the argument. - * - * MODE_NIGHT_AUTO is how [ThemeType.SYSTEM] is expressed: the service maps everything other - * than YES/NO onto `Configuration.UI_MODE_NIGHT_UNDEFINED`, which clears the override and lets - * the app fall back to the device configuration. - * - * Not deduplicated, deliberately. There is no public getter for the per-application override, - * so the only way to skip a repeat call would be to shadow it in our own store -- a cache of - * state we do not own, which goes stale silently and takes the splash with it. Re-sending the - * value on every launch is self-healing instead, and the platform already no-ops the expensive - * half: PackageConfigPersister.updateFromImpl returns early without writing when the mode is - * unchanged, and ActivityRecord.applyAppSpecificConfig gates the activity reconfiguration on - * having actually changed. What remains is one Binder round trip per launch, off the main - * thread. (This is why the deduplication in applyLanguage below does not generalise here: it - * compares against getApplicationLocales(), the authoritative value, not a private copy.) - * - * MainActivity declares `uiMode` in its `configChanges`, so any change that does result is - * delivered to `onConfigurationChanged` rather than recreating the activity. - */ - private suspend fun applyNightMode(theme: ThemeType) { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return - - val mode = - when (theme) { - ThemeType.DARK -> UiModeManager.MODE_NIGHT_YES - ThemeType.LIGHT -> UiModeManager.MODE_NIGHT_NO - ThemeType.SYSTEM -> UiModeManager.MODE_NIGHT_AUTO - } - - try { - context.getSystemService()?.setApplicationNightMode(mode) - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - Log.w("UiSharedPreferences", "Could not apply the per-application night mode", e) - } - } - - /** - * Pushes the preferred language into AppCompat, skipping the call when the app already - * runs in that locale. - * - * On API 33+, [AppCompatDelegate.setApplicationLocales] does not deduplicate: every call - * is a blocking Binder round trip into the system's LocaleManagerService, which commits a - * SharedPreferences file (and, on Samsung ROMs, appends to a log file) before returning. - * That was measured at ~220ms on a Galaxy device, charged to the calling thread. Since - * this flow starts eagerly, the app paid it on the main thread on every launch, even when - * the locale had not changed since the previous run -- and StrictMode reported it as a - * DiskReadViolation via the Binder call. - * - * [AppCompatDelegate.getApplicationLocales] is `@AnyThread` and only reads state, so the - * comparison runs off the main thread. Actual changes still hop to the main thread: - * below API 33 AppCompat applies them in process by reconfiguring (and possibly - * recreating) the active activities. - */ - private suspend fun applyLanguage(language: String?) { - val newLocales = LocaleListCompat.forLanguageTags(language) - if (newLocales == AppCompatDelegate.getApplicationLocales()) return - - withContext(Dispatchers.Main) { - AppCompatDelegate.setApplicationLocales(newLocales) - } - } - - @OptIn(FlowPreview::class) - val saving = - value.propertyWatchFlow - .debounce(1000) - .distinctUntilChanged() - .onEach { - save(it, context) - }.flowOn(Dispatchers.IO) - .stateIn( - scope, - SharingStarted.Eagerly, - value.toSettings(), - ) - - companion object { - // loads faster when individualized - val UI_THEME = stringPreferencesKey("ui.theme") - val UI_LANGUAGE = stringPreferencesKey("ui.language") - val UI_SHOW_IMAGES = stringPreferencesKey("ui.show_images") - val UI_START_PLAYBACK = stringPreferencesKey("ui.start_playback") - val UI_PLAY_VIDEOS = stringPreferencesKey("ui.play_videos") - val UI_SHOW_URL_PREVIEW = stringPreferencesKey("ui.show_url_preview") - val UI_HIDE_NAVIGATION_BARS = stringPreferencesKey("ui.hide_navigation_bars") - val UI_SHOW_PROFILE_PICTURES = stringPreferencesKey("ui.show_profile_pictures") - val UI_DONT_SHOW_PUSH_NOTIFICATION_SELECTOR = booleanPreferencesKey("ui.dont_show_push_notification_selector") - val UI_DONT_ASK_FOR_NOTIFICATION_PERMISSIONS = booleanPreferencesKey("ui.dont_ask_for_notification_permissions") - val UI_FEATURE_SET = stringPreferencesKey("ui.feature_set") - val UI_GALLERY_SET = stringPreferencesKey("ui.gallery_set") - val UI_PROPOSE_AI_IMPROVEMENTS = stringPreferencesKey("ui.propose_ai_improvements") - val UI_USE_TRACKED_BROADCASTS = stringPreferencesKey("ui.use_tracked_broadcasts") - val UI_AUTOMATICALLY_CREATE_DRAFTS = stringPreferencesKey("ui.automatically_create_drafts") - val UI_SHOW_HOME_NEW_THREADS_TAB = booleanPreferencesKey("ui.show_home_new_threads_tab") - val UI_SHOW_HOME_CONVERSATIONS_TAB = booleanPreferencesKey("ui.show_home_conversations_tab") - val UI_SHOW_HOME_EVERYTHING_TAB = booleanPreferencesKey("ui.show_home_everything_tab") - val UI_SHOW_PROFILE_BADGES = booleanPreferencesKey("ui.show_profile_badges") - val UI_SHOW_PROFILE_APP_RECOMMENDATIONS = booleanPreferencesKey("ui.show_profile_app_recommendations") - val UI_SHOW_PROFILE_ZAP_RECEIVED_FEED = booleanPreferencesKey("ui.show_profile_zap_received_feed") - val UI_SHOW_PROFILE_FOLLOWERS_FEED = booleanPreferencesKey("ui.show_profile_followers_feed") - val UI_DONT_SHOW_ONCHAIN_PUBLIC_WARNING = booleanPreferencesKey("ui.dont_show_onchain_public_warning") - val UI_SUGGEST_WORKOUTS_FROM_HEALTH_CONNECT = stringPreferencesKey("ui.suggest_workouts_from_health_connect") - val UI_ACCENT_COLOR = stringPreferencesKey("ui.accent_color") - val UI_FONT_FAMILY = stringPreferencesKey("ui.font_family") - val UI_FONT_SIZE = stringPreferencesKey("ui.font_size") - val UI_COMPOSE_SIGNATURE = stringPreferencesKey("ui.compose_signature") - val UI_SHOW_ONCHAIN_WALLET = booleanPreferencesKey("ui.show_onchain_wallet") - val UI_SHOW_PAYTO_ZAP_CHIP = booleanPreferencesKey("ui.show_payto_zap_chip") - - suspend fun uiPreferences(context: Context): UiSettings? = - try { - // Get the preference flow and take the first value. - val preferences = context.sharedPreferencesDataStore.data.first() - - val featureSet = preferences[UI_FEATURE_SET]?.let { FeatureSetType.valueOf(it) } ?: FeatureSetType.SIMPLIFIED - - UiSettings( - theme = preferences[UI_THEME]?.let { ThemeType.valueOf(it) } ?: ThemeType.SYSTEM, - preferredLanguage = preferences[UI_LANGUAGE]?.ifBlank { null }, - automaticallyShowImages = preferences[UI_SHOW_IMAGES]?.let { ConnectivityType.valueOf(it) } ?: ConnectivityType.ALWAYS, - automaticallyStartPlayback = preferences[UI_START_PLAYBACK]?.let { ConnectivityType.valueOf(it) } ?: ConnectivityType.ALWAYS, - automaticallyPlayVideos = preferences[UI_PLAY_VIDEOS]?.let { BooleanType.valueOf(it) } ?: BooleanType.ALWAYS, - automaticallyShowUrlPreview = preferences[UI_SHOW_URL_PREVIEW]?.let { ConnectivityType.valueOf(it) } ?: ConnectivityType.ALWAYS, - automaticallyHideNavigationBars = preferences[UI_HIDE_NAVIGATION_BARS]?.let { BooleanType.valueOf(it) } ?: BooleanType.ALWAYS, - automaticallyShowProfilePictures = preferences[UI_SHOW_PROFILE_PICTURES]?.let { ConnectivityType.valueOf(it) } ?: ConnectivityType.ALWAYS, - dontShowPushNotificationSelector = preferences[UI_DONT_SHOW_PUSH_NOTIFICATION_SELECTOR] ?: false, - dontAskForNotificationPermissions = preferences[UI_DONT_ASK_FOR_NOTIFICATION_PERMISSIONS] ?: false, - featureSet = featureSet, - gallerySet = preferences[UI_GALLERY_SET]?.let { ProfileGalleryType.valueOf(it) } ?: ProfileGalleryType.CLASSIC, - automaticallyProposeAiImprovements = preferences[UI_PROPOSE_AI_IMPROVEMENTS]?.let { BooleanType.valueOf(it) } ?: BooleanType.ALWAYS, - useTrackedBroadcasts = - preferences[UI_USE_TRACKED_BROADCASTS]?.let { BooleanType.valueOf(it) } - ?: if (featureSet == FeatureSetType.COMPLETE) BooleanType.ALWAYS else BooleanType.NEVER, - automaticallyCreateDrafts = preferences[UI_AUTOMATICALLY_CREATE_DRAFTS]?.let { BooleanType.valueOf(it) } ?: BooleanType.ALWAYS, - showHomeNewThreadsTab = preferences[UI_SHOW_HOME_NEW_THREADS_TAB] ?: true, - showHomeConversationsTab = preferences[UI_SHOW_HOME_CONVERSATIONS_TAB] ?: true, - showHomeEverythingTab = preferences[UI_SHOW_HOME_EVERYTHING_TAB] ?: false, - showProfileBadges = preferences[UI_SHOW_PROFILE_BADGES] ?: true, - showProfileAppRecommendations = preferences[UI_SHOW_PROFILE_APP_RECOMMENDATIONS] ?: true, - showProfileZapReceivedFeed = preferences[UI_SHOW_PROFILE_ZAP_RECEIVED_FEED] ?: true, - showProfileFollowersFeed = preferences[UI_SHOW_PROFILE_FOLLOWERS_FEED] ?: true, - dontShowOnchainPublicWarning = preferences[UI_DONT_SHOW_ONCHAIN_PUBLIC_WARNING] ?: false, - suggestWorkoutsFromHealthConnect = - preferences[UI_SUGGEST_WORKOUTS_FROM_HEALTH_CONNECT]?.let { BooleanType.valueOf(it) } ?: BooleanType.ALWAYS, - accentColor = preferences[UI_ACCENT_COLOR]?.let { AccentColorType.valueOf(it) } ?: AccentColorType.PURPLE, - fontFamily = preferences[UI_FONT_FAMILY]?.let { FontFamilyType.valueOf(it) } ?: FontFamilyType.SYSTEM, - fontSize = preferences[UI_FONT_SIZE]?.let { FontSizeType.valueOf(it) } ?: FontSizeType.NORMAL, - composeSignature = preferences[UI_COMPOSE_SIGNATURE] ?: "", - showOnchainWallet = preferences[UI_SHOW_ONCHAIN_WALLET] ?: true, - showPayToZapChip = preferences[UI_SHOW_PAYTO_ZAP_CHIP] ?: true, - ) - } catch (e: Exception) { - if (e is CancellationException) throw e - // Log any errors that occur while reading the DataStore. - Log.e("SharedPreferences") { "Error reading DataStore preferences: ${e.message}" } - - try { - val oldVersion = LocalPreferences.loadSharedSettings() - if (oldVersion != null) { - save(oldVersion, context) - } - oldVersion - } catch (e: Exception) { - if (e is CancellationException) throw e - null - } - } - - suspend fun save( - sharedSettings: UiSettings, - context: Context, - ) { - try { - context.sharedPreferencesDataStore.edit { preferences -> - preferences[UI_THEME] = sharedSettings.theme.name - preferences[UI_LANGUAGE] = sharedSettings.preferredLanguage ?: "" - preferences[UI_SHOW_IMAGES] = sharedSettings.automaticallyShowImages.name - preferences[UI_START_PLAYBACK] = sharedSettings.automaticallyStartPlayback.name - preferences[UI_PLAY_VIDEOS] = sharedSettings.automaticallyPlayVideos.name - preferences[UI_SHOW_URL_PREVIEW] = sharedSettings.automaticallyShowUrlPreview.name - preferences[UI_HIDE_NAVIGATION_BARS] = sharedSettings.automaticallyHideNavigationBars.name - preferences[UI_SHOW_PROFILE_PICTURES] = sharedSettings.automaticallyShowProfilePictures.name - preferences[UI_DONT_SHOW_PUSH_NOTIFICATION_SELECTOR] = sharedSettings.dontShowPushNotificationSelector - preferences[UI_DONT_ASK_FOR_NOTIFICATION_PERMISSIONS] = sharedSettings.dontAskForNotificationPermissions - preferences[UI_FEATURE_SET] = sharedSettings.featureSet.name - preferences[UI_GALLERY_SET] = sharedSettings.gallerySet.name - preferences[UI_PROPOSE_AI_IMPROVEMENTS] = sharedSettings.automaticallyProposeAiImprovements.name - preferences[UI_USE_TRACKED_BROADCASTS] = sharedSettings.useTrackedBroadcasts.name - preferences[UI_AUTOMATICALLY_CREATE_DRAFTS] = sharedSettings.automaticallyCreateDrafts.name - preferences[UI_SHOW_HOME_NEW_THREADS_TAB] = sharedSettings.showHomeNewThreadsTab - preferences[UI_SHOW_HOME_CONVERSATIONS_TAB] = sharedSettings.showHomeConversationsTab - preferences[UI_SHOW_HOME_EVERYTHING_TAB] = sharedSettings.showHomeEverythingTab - preferences[UI_SHOW_PROFILE_BADGES] = sharedSettings.showProfileBadges - preferences[UI_SHOW_PROFILE_APP_RECOMMENDATIONS] = sharedSettings.showProfileAppRecommendations - preferences[UI_SHOW_PROFILE_ZAP_RECEIVED_FEED] = sharedSettings.showProfileZapReceivedFeed - preferences[UI_SHOW_PROFILE_FOLLOWERS_FEED] = sharedSettings.showProfileFollowersFeed - preferences[UI_DONT_SHOW_ONCHAIN_PUBLIC_WARNING] = sharedSettings.dontShowOnchainPublicWarning - preferences[UI_SUGGEST_WORKOUTS_FROM_HEALTH_CONNECT] = sharedSettings.suggestWorkoutsFromHealthConnect.name - preferences[UI_ACCENT_COLOR] = sharedSettings.accentColor.name - preferences[UI_FONT_FAMILY] = sharedSettings.fontFamily.name - preferences[UI_FONT_SIZE] = sharedSettings.fontSize.name - preferences[UI_COMPOSE_SIGNATURE] = sharedSettings.composeSignature - preferences[UI_SHOW_ONCHAIN_WALLET] = sharedSettings.showOnchainWallet - preferences[UI_SHOW_PAYTO_ZAP_CHIP] = sharedSettings.showPayToZapChip - } - } catch (e: Exception) { - if (e is CancellationException) throw e - // Log any errors that occur while reading the DataStore. - Log.e("SharedPreferences") { "Error saving DataStore preferences: ${e.message}" } - } - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UiSharedPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UiSharedPreferences.kt new file mode 100644 index 0000000000..273445a3fb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UiSharedPreferences.kt @@ -0,0 +1,186 @@ +/* + * 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.preferences + +import android.app.UiModeManager +import android.content.Context +import android.os.Build +import androidx.appcompat.app.AppCompatDelegate +import androidx.compose.runtime.Stable +import androidx.core.content.getSystemService +import androidx.core.os.LocaleListCompat +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import com.vitorpamplona.amethyst.commons.model.ThemeType +import com.vitorpamplona.amethyst.commons.model.UiSettings +import com.vitorpamplona.amethyst.commons.model.UiSettingsFlow +import com.vitorpamplona.amethyst.commons.model.preferences.UiSettingsStore +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.withContext +import kotlin.coroutines.cancellation.CancellationException + +/** + * The Android half of the UI settings: the flows the app observes, and the two + * platform side effects that a theme or language change has to perform. + * + * Persistence is [UiSettingsStore] in `commons`, which every front end shares. + * What stays here is the part that has no desktop equivalent — the per-app night + * mode override that the launch splash reads, and AppCompat's locale list. + */ +@Stable +class UiSharedPreferences( + prefs: UiSettings, + dataStore: DataStore, + val context: Context, + val scope: CoroutineScope, +) { + private val store = UiSettingsStore(dataStore) + + // UI Preferences. Makes sure to wait for it to avoid blinking themes and language preferences + val value = UiSettingsFlow.build(prefs) + + val languageUpdate = + value.preferredLanguage + .onEach { language -> applyLanguage(language) } + .flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.Eagerly, + value.toSettings(), + ) + + val nightModeUpdate = + value.theme + .onEach { theme -> applyNightMode(theme) } + .flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.Eagerly, + prefs.theme, + ) + + /** + * Mirrors the in-app theme choice into the system's *per-application* night mode, so the + * launch splash agrees with a theme that is pinned against the phone's own light/dark setting. + * + * The system composites the splash from the manifest theme before the process starts, resolving + * it against this app's configuration -- so day/night resource qualifiers alone can only ever + * follow the phone. [UiModeManager.setApplicationNightMode] commits a *persisted per-package + * configuration override* (UiModeManagerService hands it to + * ActivityTaskManagerInternal.PackageConfigurationUpdater), which the system then applies when + * it launches the app. That is what carries a pinned LIGHT/DARK choice into the splash, from + * the next cold start onwards -- the current launch is already painted. + * + * This is deliberately [UiModeManager.setApplicationNightMode] and not + * [UiModeManager.setNightMode]: the latter changes the night mode for every app on the device + * and is gated behind MODIFY_DAY_NIGHT_MODE, which this app does not hold -- that call was a + * silent no-op and was removed. The per-application setter is the documented app-local + * alternative and is not permission-checked; UiModeManagerService only validates the argument. + * + * MODE_NIGHT_AUTO is how [ThemeType.SYSTEM] is expressed: the service maps everything other + * than YES/NO onto `Configuration.UI_MODE_NIGHT_UNDEFINED`, which clears the override and lets + * the app fall back to the device configuration. + * + * Not deduplicated, deliberately. There is no public getter for the per-application override, + * so the only way to skip a repeat call would be to shadow it in our own store -- a cache of + * state we do not own, which goes stale silently and takes the splash with it. Re-sending the + * value on every launch is self-healing instead, and the platform already no-ops the expensive + * half: PackageConfigPersister.updateFromImpl returns early without writing when the mode is + * unchanged, and ActivityRecord.applyAppSpecificConfig gates the activity reconfiguration on + * having actually changed. What remains is one Binder round trip per launch, off the main + * thread. (This is why the deduplication in applyLanguage below does not generalise here: it + * compares against getApplicationLocales(), the authoritative value, not a private copy.) + * + * MainActivity declares `uiMode` in its `configChanges`, so any change that does result is + * delivered to `onConfigurationChanged` rather than recreating the activity. + */ + private suspend fun applyNightMode(theme: ThemeType) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return + + val mode = + when (theme) { + ThemeType.DARK -> UiModeManager.MODE_NIGHT_YES + ThemeType.LIGHT -> UiModeManager.MODE_NIGHT_NO + ThemeType.SYSTEM -> UiModeManager.MODE_NIGHT_AUTO + } + + try { + context.getSystemService()?.setApplicationNightMode(mode) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.w("UiSharedPreferences", "Could not apply the per-application night mode", e) + } + } + + /** + * Pushes the preferred language into AppCompat, skipping the call when the app already + * runs in that locale. + * + * On API 33+, [AppCompatDelegate.setApplicationLocales] does not deduplicate: every call + * is a blocking Binder round trip into the system's LocaleManagerService, which commits a + * SharedPreferences file (and, on Samsung ROMs, appends to a log file) before returning. + * That was measured at ~220ms on a Galaxy device, charged to the calling thread. Since + * this flow starts eagerly, the app paid it on the main thread on every launch, even when + * the locale had not changed since the previous run -- and StrictMode reported it as a + * DiskReadViolation via the Binder call. + * + * [AppCompatDelegate.getApplicationLocales] is `@AnyThread` and only reads state, so the + * comparison runs off the main thread. Actual changes still hop to the main thread: + * below API 33 AppCompat applies them in process by reconfiguring (and possibly + * recreating) the active activities. + */ + private suspend fun applyLanguage(language: String?) { + val newLocales = LocaleListCompat.forLanguageTags(language) + if (newLocales == AppCompatDelegate.getApplicationLocales()) return + + withContext(Dispatchers.Main) { + AppCompatDelegate.setApplicationLocales(newLocales) + } + } + + @OptIn(FlowPreview::class) + val saving = + value.propertyWatchFlow + .debounce(1000) + .distinctUntilChanged() + .onEach { + store.save(it) + }.flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.Eagerly, + value.toSettings(), + ) + + companion object { + suspend fun uiPreferences(dataStore: DataStore): UiSettings? = UiSettingsStore(dataStore).load() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/RoleBasedHttpClientBuilder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/RoleBasedHttpClientBuilder.kt index e2cdd4c805..278443ed5e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/RoleBasedHttpClientBuilder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/RoleBasedHttpClientBuilder.kt @@ -23,10 +23,10 @@ package com.vitorpamplona.amethyst.model.privacyOptions import com.vitorpamplona.amethyst.commons.service.http.DualHttpClientManager import com.vitorpamplona.amethyst.commons.service.http.IRoleBasedHttpClientBuilder import com.vitorpamplona.amethyst.commons.service.http.ProxiedSocketFactory +import com.vitorpamplona.amethyst.commons.tor.TorSettingsFlow import com.vitorpamplona.amethyst.commons.tor.TorType import com.vitorpamplona.amethyst.service.resourceusage.HttpUsageMeter import com.vitorpamplona.amethyst.service.resourceusage.UsageKeys -import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import okhttp3.OkHttpClient import java.net.InetSocketAddress diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/serverList/MergedFollowListsState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/serverList/MergedFollowListsState.kt index 85a875363d..29ad3470c2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/serverList/MergedFollowListsState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/serverList/MergedFollowListsState.kt @@ -22,10 +22,10 @@ package com.vitorpamplona.amethyst.model.serverList import androidx.compose.runtime.Immutable import com.vitorpamplona.amethyst.model.nip02FollowLists.Kind3FollowListState +import com.vitorpamplona.amethyst.model.nip51Lists.followSets.FollowSetsState +import com.vitorpamplona.amethyst.model.nip51Lists.followSets.StarterPacksState import com.vitorpamplona.amethyst.model.nip51Lists.geohashLists.GeohashListState -import com.vitorpamplona.amethyst.model.nip51Lists.hashtagLists.HashtagListState -import com.vitorpamplona.amethyst.model.nip51Lists.peopleList.FollowListsState -import com.vitorpamplona.amethyst.model.nip51Lists.peopleList.PeopleListsState +import com.vitorpamplona.amethyst.model.nip51Lists.interestLists.InterestListState import com.vitorpamplona.amethyst.model.nip72Communities.CommunityListState import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip72ModCommunities.follow.tags.CommunityTag @@ -44,9 +44,9 @@ import kotlinx.coroutines.flow.stateIn @Suppress("UNCHECKED_CAST") class MergedFollowListsState( val kind3List: Kind3FollowListState, - val peopleList: PeopleListsState, - val followList: FollowListsState, - val hashtagList: HashtagListState, + val followSets: FollowSetsState, + val starterPacks: StarterPacksState, + val interestList: InterestListState, val geohashList: GeohashListState, val communityList: CommunityListState, val scope: CoroutineScope, @@ -85,9 +85,9 @@ class MergedFollowListsState( combine( listOf( kind3List.flow, - peopleList.allGoodPeopleListProfiles, - followList.allPeopleListProfiles, - hashtagList.flow, + followSets.allGoodPeopleListProfiles, + starterPacks.allPeopleListProfiles, + interestList.flow, geohashList.flow, communityList.flow, ), @@ -104,9 +104,9 @@ class MergedFollowListsState( emit( mergeLists( kind3List.flow.value, - peopleList.allGoodPeopleListProfiles.value, - followList.allPeopleListProfiles.value, - hashtagList.flow.value, + followSets.allGoodPeopleListProfiles.value, + starterPacks.allPeopleListProfiles.value, + interestList.flow.value, geohashList.flow.value, communityList.flow.value, ), @@ -118,9 +118,9 @@ class MergedFollowListsState( SharingStarted.Eagerly, mergeLists( kind3List.flow.value, - peopleList.allGoodPeopleListProfiles.value, - followList.allPeopleListProfiles.value, - hashtagList.flow.value, + followSets.allGoodPeopleListProfiles.value, + starterPacks.allPeopleListProfiles.value, + interestList.flow.value, geohashList.flow.value, communityList.flow.value, ), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/AccountsTorStateConnector.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/AccountsTorStateConnector.kt index 8f6dc5c9c0..8476c746b8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/AccountsTorStateConnector.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/AccountsTorStateConnector.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.model.torState +import com.vitorpamplona.amethyst.commons.tor.TorRelayState import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNappletPermissionStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNappletPermissionStore.kt index 073d458f83..7550c41334 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNappletPermissionStore.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNappletPermissionStore.kt @@ -20,19 +20,15 @@ */ package com.vitorpamplona.amethyst.napplet -import android.content.Context import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey -import androidx.datastore.preferences.preferencesDataStore import com.vitorpamplona.amethyst.commons.napplet.NappletCapability import com.vitorpamplona.amethyst.commons.napplet.permissions.GrantState import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionStore import kotlinx.coroutines.flow.first -private val Context.nappletPermissionsDataStore by preferencesDataStore(name = "napplet_permissions") - /** * Persists the standing napplet grants ([GrantState.ALLOW_ALWAYS] / [GrantState.DENY]) in a * dedicated DataStore. Keyed by `"\u0000"` so a coordinate's grants can @@ -43,9 +39,6 @@ class DataStoreNappletPermissionStore( private val dataStore: DataStore, private val accountPubKey: () -> String, ) : NappletPermissionStore { - 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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNappletStorage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNappletStorage.kt index 14d3499371..4c3141b6e4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNappletStorage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNappletStorage.kt @@ -20,17 +20,13 @@ */ package com.vitorpamplona.amethyst.napplet -import android.content.Context import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey -import androidx.datastore.preferences.preferencesDataStore import com.vitorpamplona.amethyst.commons.napplet.NappletStorage import kotlinx.coroutines.flow.first -private val Context.nappletStorageDataStore by preferencesDataStore(name = "napplet_storage") - /** * 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 @@ -44,9 +40,6 @@ class DataStoreNappletStorage( private val dataStore: DataStore, private val accountPubKey: () -> String, ) : NappletStorage { - constructor(context: Context, accountPubKey: () -> String) : - this(context.applicationContext.nappletStorageDataStore, accountPubKey) - override suspend fun get( coordinate: String, key: String, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt index fdf5493f62..30007d4ec8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt @@ -43,9 +43,6 @@ import com.vitorpamplona.amethyst.commons.napplet.NappletIdentityWatch import com.vitorpamplona.amethyst.commons.napplet.NappletRequestRouter import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletResponse -import com.vitorpamplona.amethyst.favorites.BrowserHistoryRegistry -import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry -import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry import com.vitorpamplona.amethyst.favorites.WebShortcuts import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.napplet.gateways.AccountNappletGateways @@ -92,7 +89,7 @@ class NappletBrokerService : Service() { private val signerLedger by lazy { NostrSignerPermissionLedger(Amethyst.instance.signerPermissionStore) } // 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 storage by lazy { DataStoreNappletStorage(Amethyst.instance.appStores.getDataStore("napplet_storage"), Amethyst.instance.nappletAccountScope) } private val incoming by lazy { Messenger(Handler(Looper.getMainLooper(), ::handleMessage)) } @@ -205,8 +202,9 @@ class NappletBrokerService : Service() { if (msg.what == NappletIpc.MSG_RECORD_HISTORY) { val data = msg.data ?: return true val url = data.getString(NappletIpc.KEY_HISTORY_URL)?.takeIf { it.isNotBlank() } ?: return true - BrowserHistoryRegistry.init(applicationContext) - BrowserHistoryRegistry.record(url, data.getString(NappletIpc.KEY_HISTORY_TITLE).orEmpty()) + val history = Amethyst.instance.browserHistory + history.init() + history.record(url, data.getString(NappletIpc.KEY_HISTORY_TITLE).orEmpty()) return true } @@ -215,8 +213,9 @@ class NappletBrokerService : Service() { val data = msg.data ?: return true val host = data.getString(NappletIpc.KEY_ICON_HOST)?.takeIf { it.isNotBlank() } ?: return true val bytes = data.getByteArray(NappletIpc.KEY_ICON_BYTES) ?: return true - BrowserIconRegistry.init(applicationContext) - BrowserIconRegistry.record(host, bytes) + val icons = Amethyst.instance.browserIcons + icons.init() + icons.record(host, bytes) return true } @@ -225,18 +224,20 @@ class NappletBrokerService : Service() { val data = msg.data ?: return true val url = data.getString(NappletIpc.KEY_FAVORITE_URL)?.takeIf { it.isNotBlank() } ?: return true val label = data.getString(NappletIpc.KEY_FAVORITE_LABEL).orEmpty().ifBlank { url } - FavoriteAppsRegistry.init(applicationContext) + val favorites = Amethyst.instance.favoriteApps + favorites.init() val id = "url:$url" + // The star sends the state it wants (it flips what it shows); an older client sends none: toggle. val target = if (data.containsKey(NappletIpc.KEY_FAVORITE_IS_FAVORITE)) { data.getBoolean(NappletIpc.KEY_FAVORITE_IS_FAVORITE) } else { - !FavoriteAppsRegistry.isFavorite(id) + !favorites.isFavorite(id) } if (target) { - FavoriteAppsRegistry.add(FavoriteApp.WebApp(url, label, System.currentTimeMillis())) + favorites.add(FavoriteApp.WebApp(url, label, System.currentTimeMillis())) } else { - FavoriteAppsRegistry.remove(id) + favorites.remove(id) } msg.replyTo?.let { replyWebFavoriteState(it, url) } return true @@ -246,7 +247,7 @@ class NappletBrokerService : Service() { if (msg.what == NappletIpc.MSG_QUERY_WEB_FAVORITE) { val replyTo = msg.replyTo ?: return true val url = msg.data?.getString(NappletIpc.KEY_FAVORITE_URL)?.takeIf { it.isNotBlank() } ?: return true - FavoriteAppsRegistry.init(applicationContext) + Amethyst.instance.favoriteApps.init() replyWebFavoriteState(replyTo, url) return true } @@ -287,7 +288,7 @@ class NappletBrokerService : Service() { if (msg.what == NappletIpc.MSG_ADD_TO_HOME_SCREEN) { val data = msg.data ?: return true val url = data.getString(NappletIpc.KEY_FAVORITE_URL)?.takeIf { it.startsWith("https://") || it.startsWith("http://") } ?: return true - BrowserIconRegistry.init(applicationContext) + Amethyst.instance.browserIcons.init() WebShortcuts.requestPin(applicationContext, url, data.getString(NappletIpc.KEY_FAVORITE_LABEL).orEmpty()) return true } @@ -529,7 +530,7 @@ class NappletBrokerService : Service() { data = Bundle().apply { putString(NappletIpc.KEY_FAVORITE_URL, url) - putBoolean(NappletIpc.KEY_FAVORITE_IS_FAVORITE, FavoriteAppsRegistry.isFavorite("url:$url")) + putBoolean(NappletIpc.KEY_FAVORITE_IS_FAVORITE, Amethyst.instance.favoriteApps.isFavorite("url:$url")) } } try { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentSummary.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentSummary.kt index 500b133ef6..8b0065aea7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentSummary.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentSummary.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.napplet import android.content.Context +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.browser.OmniboxInput import com.vitorpamplona.amethyst.commons.napplet.NappletCapability import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity @@ -62,12 +63,11 @@ import com.vitorpamplona.amethyst.commons.resources.napplet_consent_upload import com.vitorpamplona.amethyst.commons.resources.napplet_fallback_title import com.vitorpamplona.amethyst.commons.ui.loadPluralStringRes import com.vitorpamplona.amethyst.commons.ui.loadStringRes -import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry import com.vitorpamplona.amethyst.model.Account 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.nip09Deletions.DeletionRequestEvent import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import org.jetbrains.compose.resources.PluralStringResource @@ -95,7 +95,7 @@ class NappletConsentSummary( val (title, iconUrl) = if (identity.authorPubKey == "browser") { val host = OmniboxInput.hostOf(identity.identifier) ?: identity.identifier - host to BrowserIconRegistry.iconModelFor(host) + host to Amethyst.instance.browserIcons.iconModelFor(host) } else { resolveNappletMeta(identity.authorPubKey, identity.identifier, untitled) } @@ -199,7 +199,7 @@ class NappletConsentSummary( oneRemoved = Res.string.napplet_consent_diff_unmute_one, ) // Deletions have no prior version to compare against — the tags are the whole request. - DeletionEvent.KIND -> + DeletionRequestEvent.KIND -> pluralFor(Res.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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletNetworkRegistry.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletNetworkRegistry.kt index 3dc4e46da8..1e95e1fb02 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletNetworkRegistry.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletNetworkRegistry.kt @@ -21,9 +21,11 @@ package com.vitorpamplona.amethyst.napplet import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey -import androidx.datastore.preferences.preferencesDataStore +import com.vitorpamplona.amethyst.Amethyst import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -32,7 +34,15 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import java.util.concurrent.ConcurrentHashMap -private val Context.nappletNetworkDataStore by preferencesDataStore(name = "napplet_network") +/** + * The per-napplet routing file, on the app-wide holder rather than a `Context` delegate. + * Same path the delegate resolved to, so nothing migrates. + * + * Main process only: [Amethyst.instance] is deliberately unset in the + * `:napplet` sandbox. + */ +private val nappletNetworkDataStore: DataStore + get() = Amethyst.instance.appStores.getDataStore("napplet_network") /** * Per-nSite network-routing preference: whether a site's traffic goes through **Tor** (the default) @@ -69,7 +79,7 @@ object NappletNetworkRegistry { appContext = ctx hydration = scope.launch { - ctx.nappletNetworkDataStore.data.first().asMap().forEach { (key, value) -> + nappletNetworkDataStore.data.first().asMap().forEach { (key, value) -> // putIfAbsent: never clobber a choice made in this session before hydration finished. modes.putIfAbsent(key.name, value != OPEN_WEB) } @@ -95,9 +105,9 @@ object NappletNetworkRegistry { useTor: Boolean, ) { modes[coordinate] = useTor - val ctx = appContext ?: return + appContext ?: return scope.launch { - ctx.nappletNetworkDataStore.edit { it[stringPreferencesKey(coordinate)] = if (useTor) TOR else OPEN_WEB } + nappletNetworkDataStore.edit { it[stringPreferencesKey(coordinate)] = if (useTor) TOR else OPEN_WEB } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NostrSignerOpLabels.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NostrSignerOpLabels.kt index ce636d427a..9ff3361771 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NostrSignerOpLabels.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NostrSignerOpLabels.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.napplet import android.content.Context +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.browser.OmniboxInput import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp import com.vitorpamplona.amethyst.commons.model.cache.LocalCache @@ -39,7 +40,6 @@ import com.vitorpamplona.amethyst.commons.resources.nip46_signer_allow_always_fo import com.vitorpamplona.amethyst.commons.ui.loadStringRes import com.vitorpamplona.amethyst.connectedApps.consent.SignerConnectInfo import com.vitorpamplona.amethyst.connectedApps.consent.SignerConsentInfo -import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.kindNameFor import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -83,7 +83,7 @@ suspend fun buildSignerConsentInfo( val (title, iconUrl) = if (identity.authorPubKey == "browser") { val host = OmniboxInput.hostOf(identity.identifier) ?: identity.identifier - host to BrowserIconRegistry.iconModelFor(host) + host to Amethyst.instance.browserIcons.iconModelFor(host) } else { resolveNappletMeta(identity.authorPubKey, identity.identifier, untitled) } @@ -183,7 +183,7 @@ suspend fun buildConnectInfo( val (title, iconUrl) = if (identity.authorPubKey == "browser") { val host = OmniboxInput.hostOf(identity.identifier) ?: identity.identifier - host to BrowserIconRegistry.iconModelFor(host) + host to Amethyst.instance.browserIcons.iconModelFor(host) } else { resolveNappletMeta(identity.authorPubKey, identity.identifier, untitled) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/WebAppNetworkRegistry.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/WebAppNetworkRegistry.kt index 2038417d1a..a274fb036c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/WebAppNetworkRegistry.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/WebAppNetworkRegistry.kt @@ -22,9 +22,11 @@ package com.vitorpamplona.amethyst.napplet import android.content.Context import androidx.core.net.toUri +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey -import androidx.datastore.preferences.preferencesDataStore +import com.vitorpamplona.amethyst.Amethyst import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -33,7 +35,16 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import java.util.concurrent.ConcurrentHashMap -private val Context.webUrlNetworkDataStore by preferencesDataStore(name = "weburl_network") +/** + * The per-site routing file, on the app-wide holder rather than a `Context` + * delegate. Same path the delegate resolved to, so nothing migrates. + * + * Main process only: [Amethyst.instance] is deliberately unset in the + * `:napplet` sandbox, and this registry is only touched from the browser + * chrome that runs in the main process. + */ +private val webUrlNetworkDataStore: DataStore + get() = Amethyst.instance.appStores.getDataStore("weburl_network") /** * Per-web-client network-routing preference: whether a favorited URL / browsed site routes through @@ -67,7 +78,7 @@ object WebAppNetworkRegistry { appContext = ctx hydration = scope.launch { - ctx.webUrlNetworkDataStore.data.first().asMap().forEach { (key, value) -> + webUrlNetworkDataStore.data.first().asMap().forEach { (key, value) -> // putIfAbsent: never clobber a choice made in this session before hydration finished. modes.putIfAbsent(key.name, value != OPEN_WEB) } @@ -98,9 +109,9 @@ object WebAppNetworkRegistry { ) { val host = hostKeyOf(url) modes[host] = useTor - val ctx = appContext ?: return + appContext ?: return scope.launch { - ctx.webUrlNetworkDataStore.edit { it[stringPreferencesKey(host)] = if (useTor) TOR else OPEN_WEB } + webUrlNetworkDataStore.edit { it[stringPreferencesKey(host)] = if (useTor) TOR else OPEN_WEB } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/WebSitePermissionRegistry.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/WebSitePermissionRegistry.kt index 655c527a1d..efd41df623 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/WebSitePermissionRegistry.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/WebSitePermissionRegistry.kt @@ -21,9 +21,11 @@ package com.vitorpamplona.amethyst.napplet import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey -import androidx.datastore.preferences.preferencesDataStore +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.browser.BrowserSitePermission import com.vitorpamplona.amethyst.commons.browser.BrowserSitePermission.Decision import kotlinx.coroutines.CoroutineScope @@ -36,7 +38,15 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -private val Context.webSitePermissionDataStore by preferencesDataStore(name = "web_site_permissions") +/** + * The per-site permission file, on the app-wide holder rather than a `Context` delegate (the same + * `filesDir/datastore/web_site_permissions.preferences_pb` path the delegate used). + * + * Main process only: [Amethyst.instance] is deliberately unset in the `:napplet` sandbox, which reaches + * these answers through the broker. + */ +private val webSitePermissionDataStore: DataStore + get() = Amethyst.instance.appStores.getDataStore("web_site_permissions") /** * The user's answers to web sites' camera / microphone / location requests, per **origin** @@ -66,7 +76,7 @@ object WebSitePermissionRegistry { appContext = ctx scope.launch { val loaded = mutableMapOf>() - ctx.webSitePermissionDataStore.data.first().asMap().forEach { (key, value) -> + webSitePermissionDataStore.data.first().asMap().forEach { (key, value) -> val origin = key.name.substringBeforeLast('|') val permission = BrowserSitePermission.fromKey(key.name.substringAfterLast('|')) ?: return@forEach val decision = runCatching { Decision.valueOf(value.toString()) }.getOrNull() ?: return@forEach @@ -94,9 +104,9 @@ object WebSitePermissionRegistry { if (decision == Decision.ASK) forOrigin.remove(permission) else forOrigin[permission] = decision if (forOrigin.isEmpty()) current - origin else current + (origin to forOrigin) } - val ctx = appContext ?: return + if (appContext == null) return scope.launch { - ctx.webSitePermissionDataStore.edit { prefs -> + webSitePermissionDataStore.edit { prefs -> val key = stringPreferencesKey("$origin|${permission.key}") if (decision == Decision.ASK) prefs.remove(key) else prefs[key] = decision.name } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountIdentityReader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountIdentityReader.kt index 369a7b95c9..8bc8f18df3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountIdentityReader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountIdentityReader.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.napplet.gateways import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag -import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent import kotlinx.serialization.json.add @@ -137,7 +137,7 @@ class AccountIdentityReader( val receipts = cache .filter(Filter(kinds = listOf(9735), tags = mapOf("p" to listOf(pubkey)))) - .mapNotNull { it.event as? LnZapEvent } + .mapNotNull { it.event as? ZapReceiptEvent } return buildJsonArray { receipts.forEach { zap -> addJsonObject { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/V4VPaymentHandler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/V4VPaymentHandler.kt index 0c07fa506f..cb7cc0ef22 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/V4VPaymentHandler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/V4VPaymentHandler.kt @@ -40,7 +40,7 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendMethod import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response import com.vitorpamplona.quartz.nip47WalletConnect.rpc.TlvRecord -import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent import com.vitorpamplona.quartz.nip57Zaps.validate.LnurlForm import com.vitorpamplona.quartz.podcasts.PodcastBoostagram import com.vitorpamplona.quartz.podcasts.PodcastValue @@ -91,7 +91,7 @@ class V4VPaymentHandler( onProgress: (percent: Float) -> Unit, onPayInvoicesViaIntent: (invoices: List) -> Unit, asZap: Boolean = false, - zapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PUBLIC, + zapType: ZapReceiptEvent.ZapType = ZapReceiptEvent.ZapType.PUBLIC, ) = withContext(Dispatchers.IO) { val shares = value.computeShares(totalMilliSats) if (shares.isEmpty()) { @@ -190,7 +190,7 @@ class V4VPaymentHandler( shares: List, message: String, asZap: Boolean, - zapType: LnZapEvent.ZapType, + zapType: ZapReceiptEvent.ZapType, zappedNote: Note?, okHttpClient: (String) -> OkHttpClient, context: Context, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt index 86e3a61284..6c8a9e667c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt @@ -48,8 +48,8 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorCode import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcTransactionMetadata import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapRequestEvent import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetup import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetupLnAddress import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplitSetup @@ -79,7 +79,7 @@ class ZapPaymentHandler( // The signed kind 9734 this invoice was fetched with, and the message on it. // Carried so the NWC payment can name the payee (NWC-06 `metadata`); null for // a NONZAP split, which has no zap request to send. - val zapRequest: LnZapRequestEvent? = null, + val zapRequest: ZapRequestEvent? = null, val message: String = "", ) @@ -122,7 +122,7 @@ class ZapPaymentHandler( onError: (String, String, User?) -> Unit, onProgress: (percent: Float) -> Unit, onPayViaIntent: (ImmutableList) -> Unit, - zapType: LnZapEvent.ZapType, + zapType: ZapReceiptEvent.ZapType, ) = withContext(Dispatchers.IO) { val noteEvent = note.event val zapSplitSetup = noteEvent?.zapSplitSetup() @@ -297,7 +297,7 @@ class ZapPaymentHandler( note: Note, pollOption: Int?, message: String, - zapType: LnZapEvent.ZapType, + zapType: ZapReceiptEvent.ZapType, totalAmountMilliSats: Long, totalWeight: Double, okHttpClient: (String) -> OkHttpClient, @@ -360,14 +360,14 @@ class ZapPaymentHandler( class ZapRequestReady( val inputSetup: MyZapSplitSetup, - val zapRequest: LnZapRequestEvent?, + val zapRequest: ZapRequestEvent?, ) suspend fun signAllZapRequests( note: Note, pollOption: Int?, message: String, - zapType: LnZapEvent.ZapType, + zapType: ZapReceiptEvent.ZapType, zapsToSend: List, totalAmountMilliSats: Long, // Shared across the lightning + BOLT12 lanes so a mixed split stays proportional. @@ -389,7 +389,7 @@ class ZapPaymentHandler( val splitLnurl = LnurlForm.toUrl(next.lnAddress)?.let(LnurlForm::urlToBech32) val zapRequest = - if (zapType != LnZapEvent.ZapType.NONZAP && noteEvent != null) { + if (zapType != ZapReceiptEvent.ZapType.NONZAP && noteEvent != null) { account.zaps.createZapRequestFor( event = noteEvent, pollOption = pollOption, @@ -535,7 +535,7 @@ class ZapPaymentHandler( totalAmountMilliSats: Long, totalWeight: Double, message: String, - zapType: LnZapEvent.ZapType, + zapType: ZapReceiptEvent.ZapType, okHttpClient: (String) -> OkHttpClient, onError: (String, String, User?) -> Unit, onProgress: (percent: Float) -> Unit, @@ -672,7 +672,7 @@ class ZapPaymentHandler( private suspend fun assembleInvoice( lud16: String, splitSetup: MyZapSplitSetup, - nostrZapRequest: LnZapRequestEvent?, + nostrZapRequest: ZapRequestEvent?, zapValue: Long, message: String, okHttpClient: (String) -> OkHttpClient, @@ -683,7 +683,7 @@ class ZapPaymentHandler( // Only the request the provider actually accepted may be claimed as bound to // this invoice; see lnAddressInvoice's onZapRequestSent. - var sentZapRequest: LnZapRequestEvent? = null + var sentZapRequest: ZapRequestEvent? = null val invoice = LightningAddressResolver().lnAddressInvoice( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderPrefs.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderPrefs.kt deleted file mode 100644 index 737cffdadb..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderPrefs.kt +++ /dev/null @@ -1,66 +0,0 @@ -/* - * 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.calendar - -import android.content.Context -import android.content.SharedPreferences -import androidx.core.content.edit - -/** - * Device-wide preferences for the calendar reminder worker. - * - * Stored at device scope (rather than per-account) because the worker that consults them runs - * globally — multiplexing per-account preferences would require account-context plumbing into - * WorkManager that the rest of the app doesn't have. A user who flips between two accounts on - * the same device shares the same lead-time and enabled-state. Per-account preferences could be - * a follow-up if anyone asks. - */ -class CalendarReminderPrefs( - context: Context, -) { - private val prefs: SharedPreferences = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) - - fun isEnabled(): Boolean = prefs.getBoolean(KEY_ENABLED, DEFAULT_ENABLED) - - fun setEnabled(enabled: Boolean) { - prefs.edit { putBoolean(KEY_ENABLED, enabled) } - } - - fun leadMinutes(): Int = prefs.getInt(KEY_LEAD_MINUTES, DEFAULT_LEAD_MINUTES) - - fun setLeadMinutes(minutes: Int) { - prefs.edit { putInt(KEY_LEAD_MINUTES, minutes) } - } - - companion object { - const val DEFAULT_LEAD_MINUTES = 15 - const val DEFAULT_ENABLED = true - - // Choices presented in the settings UI. Anchored to the worker cadence — lead times - // smaller than the cadence (15 min) can't be honoured reliably; 60 is the largest the - // UX shape supports without an extra "hours" picker. - val LEAD_TIME_CHOICES = listOf(5, 15, 30, 60) - - private const val PREF_NAME = "amethyst_calendar_reminder_prefs" - private const val KEY_ENABLED = "enabled" - private const val KEY_LEAD_MINUTES = "lead_minutes" - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderStore.kt deleted file mode 100644 index 81c3ad882f..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderStore.kt +++ /dev/null @@ -1,84 +0,0 @@ -/* - * 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.calendar - -import android.content.Context -import android.content.SharedPreferences -import androidx.core.content.edit - -/** - * Persistent "I've already notified for this event" set. Backed by [SharedPreferences] because - * the worker that consults it runs in the app process and the dataset is tiny (≤ a few hundred - * IDs at most). Without persistence, every worker run after a restart would re-notify for the - * same upcoming event until it started, since LocalCache has no memory of past reminders. - * - * Keys are event ids (the 32-byte hex from a 31922/31923 appointment). Values aren't used; only - * presence in the set matters. Entries are pruned by [forgetBefore] when the worker has just - * fired so the store doesn't grow unbounded over time. - */ -class CalendarReminderStore( - context: Context, -) { - private val prefs: SharedPreferences = - context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) - - /** - * Returns true when we've previously notified for this exact event-start pairing. If the - * author updates the appointment to a new start time, the stored value won't match and - * we'll fire a fresh reminder for the new time — that's the desired behaviour: a moved - * meeting shouldn't be silently skipped. - */ - fun wasNotified( - eventId: String, - eventStartSeconds: Long, - ): Boolean = prefs.getLong(keyFor(eventId), Long.MIN_VALUE) == eventStartSeconds - - fun markNotified( - eventId: String, - eventStartSeconds: Long, - ) { - prefs.edit { putLong(keyFor(eventId), eventStartSeconds) } - } - - /** - * Drops any entry whose recorded event-start time is older than [cutoffSeconds]. Called - * after each worker run so the store stays bounded — events that have long since ended - * can't fire a second reminder, so their entries are dead weight. - */ - fun forgetBefore(cutoffSeconds: Long) { - val editor = prefs.edit() - var changed = false - prefs.all.forEach { (key, value) -> - if (value is Long && value < cutoffSeconds) { - editor.remove(key) - changed = true - } - } - if (changed) editor.apply() - } - - companion object { - private const val PREF_NAME = "amethyst_calendar_reminders" - private const val KEY_PREFIX = "notified:" - - private fun keyFor(eventId: String) = KEY_PREFIX + eventId - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderStores.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderStores.kt new file mode 100644 index 0000000000..e430a79bc0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderStores.kt @@ -0,0 +1,82 @@ +/* + * 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.calendar + +import android.content.Context +import androidx.datastore.core.DataMigration +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.intPreferencesKey +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.commons.model.preferences.CalendarReminderLogStore +import com.vitorpamplona.amethyst.commons.model.preferences.CalendarReminderSettings +import com.vitorpamplona.amethyst.commons.model.preferences.CalendarReminderSettingsStore +import com.vitorpamplona.amethyst.commons.model.preferences.CopyOnceMigration + +/** + * Android wiring for the two calendar-reminder stores. + * + * The store classes live in commons; only the file location and the one-off + * lift out of the legacy SharedPreferences are Android's business. + * + * Both files sit on `AppPreferenceStores` rather than a `Context` delegate. + * The names below are the delegate's names, and the holder reproduces the path + * it resolved to, so nothing migrates. The migrations move with them: DataStore + * runs a file's migrations once, when that file is first opened, so they have + * to be attached to the file by the holder rather than by whoever opens it. + */ +private const val LEGACY_SETTINGS_FILE = "amethyst_calendar_reminder_prefs" +private const val LEGACY_LOG_FILE = "amethyst_calendar_reminders" + +/** Store (and file) names, as [Amethyst.appStores] keys them. */ +const val CALENDAR_REMINDER_SETTINGS_STORE = "calendar_reminder_settings" +const val CALENDAR_REMINDER_LOG_STORE = "calendar_reminder_log" + +/** The one-off copy of the reminder settings out of the legacy prefs file. */ +fun calendarReminderSettingsMigrations(context: Context): List> = + listOf( + CopyOnceMigration("migrated.calendarReminderSettings") { out -> + val legacy = context.getSharedPreferences(LEGACY_SETTINGS_FILE, Context.MODE_PRIVATE) + if (legacy.contains("enabled")) { + out[booleanPreferencesKey("enabled")] = legacy.getBoolean("enabled", CalendarReminderSettings.DEFAULT_ENABLED) + } + if (legacy.contains("lead_minutes")) { + out[intPreferencesKey("lead_minutes")] = legacy.getInt("lead_minutes", CalendarReminderSettings.DEFAULT_LEAD_MINUTES) + } + }, + ) + +/** The one-off copy of the fired-reminder log out of the legacy prefs file. */ +fun calendarReminderLogMigrations(context: Context): List> = + listOf( + CopyOnceMigration("migrated.calendarReminderLog") { out -> + val legacy = context.getSharedPreferences(LEGACY_LOG_FILE, Context.MODE_PRIVATE) + // Values are the event-start times the reminders fired for; anything + // else in the file is not ours and is left behind. + legacy.all.forEach { (key, value) -> + if (value is Long) out[CalendarReminderLogStore.keyFor(key.removePrefix("notified:"))] = value + } + }, + ) + +fun calendarReminderSettings() = CalendarReminderSettingsStore(Amethyst.instance.appStores.getDataStore(CALENDAR_REMINDER_SETTINGS_STORE)) + +fun calendarReminderLog() = CalendarReminderLogStore(Amethyst.instance.appStores.getDataStore(CALENDAR_REMINDER_LOG_STORE)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderWorker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderWorker.kt index 0ec27f7e71..b3f34ae3ce 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderWorker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderWorker.kt @@ -48,7 +48,7 @@ import java.util.concurrent.TimeUnit * to as ACCEPTED. * * The work is bounded — scans LocalCache (which is bounded by the relay subscription) and - * consults [CalendarReminderStore] to skip events that have already been notified for. Run as + * consults [CalendarReminderLogStore] to skip events that have already been notified for. Run as * a 15-minute periodic worker: that's the WorkManager minimum and matches the resolution of * the reminder UI ("starts in ~15 min" is the smallest interval users perceive as "soon"). * @@ -65,8 +65,8 @@ class CalendarReminderWorker( ) : CoroutineWorker(appContext, params) { override suspend fun doWork(): Result { runCatching { Amethyst.instance.resourceUsage.add(UsageKeys.workerRuns("calendarReminder"), 1) } - val prefs = CalendarReminderPrefs(applicationContext) - if (!prefs.isEnabled()) { + val settings = calendarReminderSettings().load() + if (!settings.enabled) { Log.d(TAG) { "Reminders disabled; ending periodic chain." } // The settings toggle re-schedules on enable; no reason to keep // waking the process while the feature is off. @@ -74,8 +74,8 @@ class CalendarReminderWorker( return Result.success() } val now = TimeUtils.now() - val windowEnd = now + prefs.leadMinutes() * 60L - val store = CalendarReminderStore(applicationContext) + val windowEnd = now + settings.leadMinutes * 60L + val store = calendarReminderLog() // Walk every kind-31925 RSVP authored by an account on this device. We don't have a // multi-account "all logged-in pubkeys" view here, so we accept any RSVP that's @@ -83,7 +83,7 @@ class CalendarReminderWorker( // silently break notifications for account switching during the lead window. val acceptedRsvps = acceptedRsvpsInCache() - Log.d(TAG) { "Worker scanning ${acceptedRsvps.size} accepted RSVPs (now=$now, lead=${prefs.leadMinutes()}m)" } + Log.d(TAG) { "Worker scanning ${acceptedRsvps.size} accepted RSVPs (now=$now, lead=${settings.leadMinutes}m)" } acceptedRsvps.forEach { rsvp -> val targetAddress = rsvp.calendarEventAddress() ?: return@forEach diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LightningAddressResolver.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LightningAddressResolver.kt index 83efb8028e..9056561530 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LightningAddressResolver.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LightningAddressResolver.kt @@ -38,7 +38,7 @@ import com.vitorpamplona.amethyst.commons.ui.loadStringRes import com.vitorpamplona.amethyst.service.HttpStatusMessages import com.vitorpamplona.quartz.lightning.LnInvoiceUtil import com.vitorpamplona.quartz.lightning.Lud06 -import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapRequestEvent import com.vitorpamplona.quartz.nip57Zaps.validate.LnurlEndpointCache import com.vitorpamplona.quartz.nip57Zaps.validate.LnurlEndpointInfo import com.vitorpamplona.quartz.utils.Log @@ -128,7 +128,7 @@ class LightningAddressResolver { lnCallback: String, milliSats: Long, message: String, - nostrRequest: LnZapRequestEvent? = null, + nostrRequest: ZapRequestEvent? = null, okHttpClient: (String) -> OkHttpClient, context: Context, ): String { @@ -219,11 +219,11 @@ class LightningAddressResolver { lnAddress: String, milliSats: Long, message: String, - nostrRequest: LnZapRequestEvent? = null, + nostrRequest: ZapRequestEvent? = null, okHttpClient: (String) -> OkHttpClient, onProgress: (percent: Float) -> Unit, context: Context, - onZapRequestSent: (LnZapRequestEvent?) -> Unit = {}, + onZapRequestSent: (ZapRequestEvent?) -> Unit = {}, ): String { val mapper = ObjectMapper() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt index 30b71e240a..b596797b6a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt @@ -64,7 +64,7 @@ import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser -import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip04Dm.messages.EncryptedDmEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent @@ -72,7 +72,7 @@ import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes import com.vitorpamplona.quartz.nip22Comments.CommentEvent -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent @@ -84,17 +84,17 @@ import com.vitorpamplona.quartz.nip34Git.status.GitStatusAppliedEvent import com.vitorpamplona.quartz.nip34Git.status.GitStatusClosedEvent import com.vitorpamplona.quartz.nip34Git.status.GitStatusDraftEvent import com.vitorpamplona.quartz.nip34Git.status.GitStatusOpenEvent -import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip54Wiki.WikiArticleEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent import com.vitorpamplona.quartz.nip64Chess.challenge.accept.LiveChessGameAcceptEvent import com.vitorpamplona.quartz.nip64Chess.move.LiveChessMoveEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent -import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent +import com.vitorpamplona.quartz.nip71Video.AddressableNormalVideoEvent +import com.vitorpamplona.quartz.nip71Video.AddressableShortVideoEvent import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent import com.vitorpamplona.quartz.nip71Video.VideoShortEvent -import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallOfferEvent @@ -234,7 +234,7 @@ class EventNotificationConsumer( // public chat the user has silenced (matches the in-app feed, which mutes all four). // Without the second check, muting a channel still let a like on your own message // there notify you — the row's glyph promises silence, so it has to mean it. - if (event is ReactionEvent || event is LnZapEvent || event is RepostEvent || event is GenericRepostEvent) { + if (event is ReactionEvent || event is ZapReceiptEvent || event is RepostEvent || event is GenericRepostEvent) { val target = LocalCache.getNoteIfExists(event)?.replyTo?.lastOrNull() if (target != null && ( @@ -247,7 +247,7 @@ class EventNotificationConsumer( } when (event) { - is PrivateDmEvent -> DirectMessageNotification.notify(applicationContext, account, event) + is EncryptedDmEvent -> DirectMessageNotification.notify(applicationContext, account, event) is ChatMessageEvent -> DirectMessageNotification.notify(applicationContext, account, event) is ChatMessageEncryptedFileHeaderEvent -> DirectMessageNotification.notify(applicationContext, account, event) @@ -256,7 +256,7 @@ class EventNotificationConsumer( is StreamMessageV2Event -> BuzzDmNotification.notify(applicationContext, account, event) is ChatEvent -> BuzzDmNotification.notify(applicationContext, account, event) - is LnZapEvent -> ZapNotification.notify(applicationContext, account, event) + is ZapReceiptEvent -> ZapNotification.notify(applicationContext, account, event) is NutzapEvent -> ZapNotification.notify(applicationContext, account, event) is OnchainZapEvent -> ZapNotification.notify(applicationContext, account, event) @@ -274,15 +274,15 @@ class EventNotificationConsumer( is PictureEvent, is VideoNormalEvent, is VideoShortEvent, - is VideoHorizontalEvent, - is VideoVerticalEvent, + is AddressableNormalVideoEvent, + is AddressableShortVideoEvent, -> MediaNotification.notify(applicationContext, account, event) is PollEvent -> MentionNotification.notify(applicationContext, account, event, titleRes = Res.string.app_notification_poll_channel_message) is HighlightEvent, - is LongTextNoteEvent, - is WikiNoteEvent, + is LongFormContentEvent, + is WikiArticleEvent, -> ArticleNotification.notify(applicationContext, account, event) is GitIssueEvent -> CodeNotification.notify(applicationContext, account, event) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationContent.kt index 238b20c0bc..68077c7d66 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationContent.kt @@ -27,11 +27,11 @@ import com.vitorpamplona.amethyst.commons.model.nip71Video.selectVideoTrack import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip04Dm.messages.EncryptedDmEvent import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile import com.vitorpamplona.quartz.nip19Bech32.entities.NPub -import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapRequestEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent import com.vitorpamplona.quartz.nip71Video.VideoEvent @@ -177,7 +177,7 @@ object NotificationContent { } suspend fun decryptZapContentAuthor( - event: LnZapRequestEvent, + event: ZapRequestEvent, signer: NostrSigner, ): Event? = if (event.isPrivateZap() && event.zappedAuthor().contains(event.pubKey)) { @@ -191,8 +191,8 @@ object NotificationContent { signer: NostrSigner, ): String? = when (val event = note.event) { - is PrivateDmEvent -> event.decryptContent(signer) - is LnZapRequestEvent -> decryptZapContentAuthor(event, signer)?.content + is EncryptedDmEvent -> event.decryptContent(signer) + is ZapRequestEvent -> decryptZapContentAuthor(event, signer)?.content else -> event?.content } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationDispatcher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationDispatcher.kt index f75661b6f3..b7f3b0908b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationDispatcher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationDispatcher.kt @@ -32,7 +32,7 @@ import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser -import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip04Dm.messages.EncryptedDmEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent @@ -40,7 +40,7 @@ import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes import com.vitorpamplona.quartz.nip22Comments.CommentEvent -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent @@ -52,17 +52,17 @@ import com.vitorpamplona.quartz.nip34Git.status.GitStatusAppliedEvent import com.vitorpamplona.quartz.nip34Git.status.GitStatusClosedEvent import com.vitorpamplona.quartz.nip34Git.status.GitStatusDraftEvent import com.vitorpamplona.quartz.nip34Git.status.GitStatusOpenEvent -import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip54Wiki.WikiArticleEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent import com.vitorpamplona.quartz.nip64Chess.challenge.accept.LiveChessGameAcceptEvent import com.vitorpamplona.quartz.nip64Chess.move.LiveChessMoveEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent -import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent +import com.vitorpamplona.quartz.nip71Video.AddressableNormalVideoEvent +import com.vitorpamplona.quartz.nip71Video.AddressableShortVideoEvent import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent import com.vitorpamplona.quartz.nip71Video.VideoShortEvent -import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallOfferEvent @@ -101,7 +101,7 @@ class NotificationDispatcher( private const val TAG = "NotificationDispatcher" // The dispatcher observes the *final* notification payload kinds. - // GiftWrap/EphemeralGiftWrap (1059/21059) and SealedRumor (13) are NOT + // GiftWrap/EphemeralGiftWrap (1059/21059) and Seal (13) are NOT // listed here — by the time we care, Account.newNotesPreProcessor has // already unwrapped them and inserted the inner payload into LocalCache, // which fires the observer a second time on the inner event. @@ -118,8 +118,8 @@ class NotificationDispatcher( internal val NOTIFICATION_KINDS: Set = setOf( // Direct-arrival - PrivateDmEvent.KIND, - LnZapEvent.KIND, + EncryptedDmEvent.KIND, + ZapReceiptEvent.KIND, NutzapEvent.KIND, OnchainZapEvent.KIND, Bolt12ZapEvent.KIND, @@ -133,8 +133,8 @@ class NotificationDispatcher( PictureEvent.KIND, VideoNormalEvent.KIND, VideoShortEvent.KIND, - VideoHorizontalEvent.KIND, - VideoVerticalEvent.KIND, + AddressableNormalVideoEvent.KIND, + AddressableShortVideoEvent.KIND, ChannelMessageEvent.KIND, PollEvent.KIND, GitPatchEvent.KIND, @@ -150,8 +150,8 @@ class NotificationDispatcher( GitStatusClosedEvent.KIND, GitStatusDraftEvent.KIND, HighlightEvent.KIND, - LongTextNoteEvent.KIND, - WikiNoteEvent.KIND, + LongFormContentEvent.KIND, + WikiArticleEvent.KIND, LiveChessGameAcceptEvent.KIND, LiveChessMoveEvent.KIND, WakeUpEvent.KIND, @@ -235,8 +235,8 @@ class NotificationDispatcher( if (event is WakeUpEvent) return@predicate true // getNoteIfExists(event) — not (event.id) — so - // AddressableEvent kinds (LongTextNote, WikiNote, - // LiveChess*, VideoHorizontal/Vertical) resolve to + // AddressableEvent kinds (LongFormContent, WikiArticle, + // LiveChess*, AddressableNormalVideo/AddressableShortVideo) resolve to // their address-keyed replaceable note. The id-keyed // version note has its replyTo moved away during // insertion (LocalCache.consumeBaseReplaceable), so diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/PushWrapDecryptor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/PushWrapDecryptor.kt index 17964dbecd..356051c867 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/PushWrapDecryptor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/PushWrapDecryptor.kt @@ -36,7 +36,7 @@ import kotlinx.coroutines.CancellationException * * Once decrypted, the inner event is fed into [LocalCache]. From there the * normal `Account.newNotesPreProcessor` → `GiftWrapEventHandler` → - * `SealedRumorEventHandler` chain unwraps any remaining layers, and + * `SealEventHandler` chain unwraps any remaining layers, and * [NotificationDispatcher] picks up the final payload and notifies. */ object PushWrapDecryptor { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/DirectMessageNotification.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/DirectMessageNotification.kt index 23a67fc369..6935da0691 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/DirectMessageNotification.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/DirectMessageNotification.kt @@ -32,7 +32,7 @@ import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.Conver import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.ReplyAction import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.postConversation import com.vitorpamplona.amethyst.service.notifications.notificationManager -import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip04Dm.messages.EncryptedDmEvent import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent @@ -64,7 +64,7 @@ object DirectMessageNotification { suspend fun notify( context: Context, account: Account, - event: PrivateDmEvent, + event: EncryptedDmEvent, ) { if (account.signer.pubKey != event.verifiedRecipientPubKey()) return notifyRoom(context, account, event.id, event.createdAt, event.chatroomKey(account.signer.pubKey), decrypt = true) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/ZapNotification.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/ZapNotification.kt index f60e54aad7..6f13908502 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/ZapNotification.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/renderers/ZapNotification.kt @@ -39,8 +39,8 @@ import com.vitorpamplona.amethyst.service.notifications.NotificationRoutes import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.postStandard import com.vitorpamplona.amethyst.service.notifications.notificationManager import com.vitorpamplona.amethyst.ui.note.showAmount -import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapRequestEvent import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent import org.jetbrains.compose.resources.StringResource @@ -59,7 +59,7 @@ object ZapNotification { suspend fun notify( context: Context, account: Account, - event: LnZapEvent, + event: ZapReceiptEvent, ) { LocalCache.getNoteIfExists(event.id) ?: return val zapRequestNote = event.zapRequest?.id?.let { LocalCache.checkGetOrCreateNote(it) } ?: return @@ -67,7 +67,7 @@ object ZapNotification { if (!account.isAcceptable(zappedNote)) return if ((event.amount ?: BigDecimal.ZERO) < MIN_ZAP_AMOUNT) return - val zapRequestEvent = zapRequestNote.event as? LnZapRequestEvent ?: return + val zapRequestEvent = zapRequestNote.event as? ZapRequestEvent ?: return // Resolve the (possibly private) zapper once — never re-decrypt per tick. val decrypted = NotificationContent.decryptZapContentAuthor(zapRequestEvent, account.signer) ?: return val sender = LocalCache.getOrCreateUser(decrypted.pubKey) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowJobRestorer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowJobRestorer.kt index e28ec90c8a..2dfdcfb482 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowJobRestorer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowJobRestorer.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.service.pow import com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPost import com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPostStore import com.vitorpamplona.amethyst.commons.service.pow.PersistedPoWJob +import com.vitorpamplona.amethyst.commons.service.pow.PoWJobStore import com.vitorpamplona.amethyst.commons.service.pow.PoWPublishQueue import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.quartz.nip01Core.core.Event @@ -33,14 +34,14 @@ import com.vitorpamplona.quartz.utils.Log import java.util.UUID /** - * Re-enqueues the mining jobs checkpointed by [PowJobStore] when an account + * Re-enqueues the mining jobs checkpointed by [PoWJobStore] when an account * logs in, replacing the lost in-memory continuation with the headless replay * described by each record. Restore is idempotent: the queue dedupes by job * id, so a login flow that emits twice cannot double-mine. */ class PowJobRestorer( private val queue: PoWPublishQueue, - private val store: PowJobStore, + private val store: PoWJobStore, private val scheduledPostStore: ScheduledPostStore, ) { suspend fun restore(account: Account) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowMiningForegroundService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowMiningForegroundService.kt index 08d040d01c..2686ae587d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowMiningForegroundService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowMiningForegroundService.kt @@ -45,7 +45,7 @@ import kotlinx.coroutines.launch * * Uses the Android 14+ `shortService` type — no special permission, but a * hard ~3 minute budget. On `onTimeout` the service exits cleanly; every - * persistable job is already checkpointed by [PowJobStore], so anything still + * persistable job is already checkpointed by [com.vitorpamplona.amethyst.commons.service.pow.PoWJobStore], so anything still * unmined resumes on the next app launch. Started on every enqueue (the app * is necessarily in the foreground then), stops itself when the queue drains. * diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt index 087e2cdeee..a4403f5659 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metadata -import com.vitorpamplona.amethyst.commons.relayClient.assemblers.filterContactCardsByAuthorInTheRelay +import com.vitorpamplona.amethyst.commons.relayClient.assemblers.filterUserAssertionsByAuthorInTheRelay import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.nip78AppSpecific.AppSpecificState.Companion.APP_SPECIFIC_DATA_D_TAG @@ -32,18 +32,18 @@ import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent -import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.nip17Dm.settings.DmRelayListEvent import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayListEvent -import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent +import com.vitorpamplona.quartz.nip38UserStatus.UserStatusEvent import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent -import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent +import com.vitorpamplona.quartz.nip51Lists.interestList.InterestListEvent import com.vitorpamplona.quartz.nip51Lists.interestSet.InterestSetEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BroadcastRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.FavoriteRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent -import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.SimpleGroupListEvent import com.vitorpamplona.quartz.nip53LiveActivities.nestsServers.NestsServersEvent @@ -60,9 +60,9 @@ val AccountInfoAndListsFromKeyKinds = listOf( MetadataEvent.KIND, ContactListEvent.KIND, - StatusEvent.KIND, + UserStatusEvent.KIND, AdvertisedRelayListEvent.KIND, - ChatMessageRelayListEvent.KIND, + DmRelayListEvent.KIND, KeyPackageRelayListEvent.KIND, SearchRelayListEvent.KIND, FileServersEvent.KIND, @@ -78,12 +78,12 @@ val AccountInfoAndListsFromKeyKinds2 = BroadcastRelayListEvent.KIND, IndexerRelayListEvent.KIND, ProxyRelayListEvent.KIND, - HashtagListEvent.KIND, + InterestListEvent.KIND, GeohashListEvent.KIND, TrustProviderListEvent.KIND, PaymentTargetsEvent.KIND, Bolt12OfferListEvent.KIND, - RelayFeedsListEvent.KIND, + FavoriteRelayListEvent.KIND, InterestSetEvent.KIND, // NIP-51 "simple groups" list (kind 10009): the user's joined NIP-29 groups + servers. // Loaded up-front so "My Groups" and group memberships resolve immediately at login, @@ -141,7 +141,7 @@ fun filterAccountInfoAndListsFromKey( ), // The account's own kind:30382 contact cards (nicknames, NIP-44 encrypted). // Addressable — one card per target user — hence its own larger-limit filter. - filterContactCardsByAuthorInTheRelay( + filterUserAssertionsByAuthorInTheRelay( relay = relay, authors = pubkeys, since = since, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/NWCFinderFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/NWCFinderFilterAssemblerSubscription.kt index 065304ec16..6f7d9e6064 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/NWCFinderFilterAssemblerSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/NWCFinderFilterAssemblerSubscription.kt @@ -28,7 +28,7 @@ import com.vitorpamplona.amethyst.commons.relayClient.nip47WalletConnect.NWCPaym import com.vitorpamplona.amethyst.commons.relayClient.nip47WalletConnect.NWCPaymentQueryState import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcRequestEvent @SuppressLint("StateFlowValueCalledInComposition") @Composable @@ -50,7 +50,7 @@ fun NWCFinderFilterAssemblerSubscription( val states = remember(note) { val zapPaymentRequestNote = note - (zapPaymentRequestNote.event as? LnZapPaymentRequestEvent)?.let { noteEvent -> + (zapPaymentRequestNote.event as? NwcRequestEvent)?.let { noteEvent -> noteEvent.walletServicePubKey()?.let { zapPaymentRequestNote.relays.map { NWCPaymentQueryState( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt index 691179b7e7..ba253f5585 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt @@ -39,7 +39,7 @@ import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip51Lists.PinListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent -import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent +import com.vitorpamplona.quartz.nip51Lists.interestList.InterestListEvent import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -64,11 +64,11 @@ fun observeUserName( // Subscribe in the relay for changes in the metadata of this user. UserFinderFilterAssemblerSubscription(user, accountViewModel) - val contactCards = accountViewModel.account.contactCards - val flow = remember(user) { contactCards.displayNameFlow(user) } + val userAssertions = accountViewModel.account.userAssertions + val flow = remember(user) { userAssertions.displayNameFlow(user) } // Subscribe in the LocalCache for changes that arrive in the device - return flow.collectAsStateWithLifecycle(remember(user) { contactCards.cachedDisplayName(user) }) + return flow.collectAsStateWithLifecycle(remember(user) { userAssertions.cachedDisplayName(user) }) } /** @@ -83,10 +83,10 @@ fun observeUserNickname( user: User, accountViewModel: AccountViewModel, ): State { - val contactCards = accountViewModel.account.contactCards - val flow = remember(user) { contactCards.nicknameFlow(user) } + val userAssertions = accountViewModel.account.userAssertions + val flow = remember(user) { userAssertions.nicknameFlow(user) } - return flow.collectAsStateWithLifecycle(remember(user) { contactCards.cachedNickname(user) }) + return flow.collectAsStateWithLifecycle(remember(user) { userAssertions.cachedNickname(user) }) } @Composable @@ -132,9 +132,9 @@ fun observeUserTagFollowCount( .metadata.stateFlow .sample(1000) .mapLatest { noteState -> - (noteState.note.event as? HashtagListEvent)?.let { accountViewModel.account.hashtagListDecryptionCache.hashtags(it) }?.size ?: 0 + (noteState.note.event as? InterestListEvent)?.let { accountViewModel.account.interestListDecryptionCache.hashtags(it) }?.size ?: 0 }.onStart { - emit((accountViewModel.hashtagFollows(user).event as? HashtagListEvent)?.let { accountViewModel.account.hashtagListDecryptionCache.hashtags(it) }?.size ?: 0) + emit((accountViewModel.hashtagFollows(user).event as? InterestListEvent)?.let { accountViewModel.account.interestListDecryptionCache.hashtags(it) }?.size ?: 0) }.distinctUntilChanged() .flowOn(Dispatchers.IO) } @@ -160,9 +160,9 @@ fun observeUserTagFollows( .metadata.stateFlow .sample(200) .mapLatest { noteState -> - (noteState.note.event as? HashtagListEvent)?.let { accountViewModel.account.hashtagListDecryptionCache.hashtags(it) }?.sorted() ?: emptyList() + (noteState.note.event as? InterestListEvent)?.let { accountViewModel.account.interestListDecryptionCache.hashtags(it) }?.sorted() ?: emptyList() }.onStart { - emit((accountViewModel.hashtagFollows(user).event as? HashtagListEvent)?.let { accountViewModel.account.hashtagListDecryptionCache.hashtags(it) }?.sorted() ?: emptyList()) + emit((accountViewModel.hashtagFollows(user).event as? InterestListEvent)?.let { accountViewModel.account.interestListDecryptionCache.hashtags(it) }?.sorted() ?: emptyList()) }.distinctUntilChanged() .flowOn(Dispatchers.IO) } @@ -304,16 +304,16 @@ fun observeUserIsFollowingHashtag( // Subscribe in the LocalCache for changes that arrive in the device val flow = remember(accountViewModel) { - accountViewModel.account.hashtagList.flow + accountViewModel.account.interestList.flow .mapLatest { hashtags -> hashtag in hashtags }.onStart { - emit(hashtag in accountViewModel.account.hashtagList.flow.value) + emit(hashtag in accountViewModel.account.interestList.flow.value) }.distinctUntilChanged() .flowOn(Dispatchers.IO) } - return flow.collectAsStateWithLifecycle(hashtag in accountViewModel.account.hashtagList.flow.value) + return flow.collectAsStateWithLifecycle(hashtag in accountViewModel.account.interestList.flow.value) } @SuppressLint("StateFlowValueCalledInComposition") @@ -373,16 +373,16 @@ fun observeUserIsFollowingRelay( ): State { val flow = remember(accountViewModel) { - accountViewModel.account.relayFeedsList.flowNoDefaults + accountViewModel.account.favoriteRelayList.flowNoDefaults .mapLatest { relays -> relayUrl in relays }.onStart { - emit(relayUrl in accountViewModel.account.relayFeedsList.flowNoDefaults.value) + emit(relayUrl in accountViewModel.account.favoriteRelayList.flowNoDefaults.value) }.distinctUntilChanged() .flowOn(Dispatchers.IO) } - return flow.collectAsStateWithLifecycle(relayUrl in accountViewModel.account.relayFeedsList.flowNoDefaults.value) + return flow.collectAsStateWithLifecycle(relayUrl in accountViewModel.account.favoriteRelayList.flowNoDefaults.value) } @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) @@ -439,7 +439,7 @@ fun observeUserIsFollowingChannel( @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) @Composable -fun observeUserContactCardsScore( +fun observeUserAssertionsScore( user: User, accountViewModel: AccountViewModel, subscribe: Boolean = true, @@ -459,7 +459,7 @@ fun observeUserContactCardsScore( @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) @Composable -fun observeUserContactCardsFollowerCount( +fun observeUserAssertionsFollowerCount( user: User, accountViewModel: AccountViewModel, ): State { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/MeteringNostrSigner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/MeteringNostrSigner.kt index 93bb31f4a5..8bfe2fe129 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/MeteringNostrSigner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/MeteringNostrSigner.kt @@ -26,8 +26,8 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip46RemoteSigner.signer.NostrSignerRemote import com.vitorpamplona.quartz.nip55AndroidSigner.client.NostrSignerExternal -import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import com.vitorpamplona.quartz.nip57Zaps.PrivateZapEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapRequestEvent import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.NostrSignerWithClientTag /** @@ -89,7 +89,7 @@ class MeteringNostrSigner( fromPublicKey: HexKey, ): String = metered(UsageKeys.DECRYPT_COUNT, UsageKeys.DECRYPT_US) { inner.nip44Decrypt(ciphertext, fromPublicKey) } - override suspend fun decryptZapEvent(event: LnZapRequestEvent): LnZapPrivateEvent = metered(UsageKeys.DECRYPT_COUNT, UsageKeys.DECRYPT_US) { inner.decryptZapEvent(event) } + override suspend fun decryptZapEvent(event: ZapRequestEvent): PrivateZapEvent = metered(UsageKeys.DECRYPT_COUNT, UsageKeys.DECRYPT_US) { inner.decryptZapEvent(event) } override suspend fun deriveKey(nonce: HexKey): HexKey = inner.deriveKey(nonce) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt index 8150139391..5728bab2e0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt @@ -432,7 +432,7 @@ class UploadOrchestrator { * Deletes a temporary file created during the upload pipeline if its URI * differs from the original (meaning it's an intermediate temp file, not the user's content). */ - private fun deleteTempUri( + internal fun deleteTempUri( tempUri: Uri, originalUri: Uri, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsVideoEventBuilder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsVideoEventBuilder.kt index 6a326f99c9..fe15bfee85 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsVideoEventBuilder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsVideoEventBuilder.kt @@ -26,9 +26,9 @@ import com.davotoula.lightcompressor.hls.HlsUploaded import com.vitorpamplona.amethyst.service.uploads.MediaUploadResult import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning -import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent +import com.vitorpamplona.quartz.nip71Video.AddressableNormalVideoEvent +import com.vitorpamplona.quartz.nip71Video.AddressableShortVideoEvent import com.vitorpamplona.quartz.nip71Video.VideoMeta -import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent import com.vitorpamplona.quartz.nip71Video.duration import com.vitorpamplona.quartz.nip71Video.title import com.vitorpamplona.quartz.nip71Video.videoIMetas @@ -61,11 +61,11 @@ data class HlsVideoPublishInput( sealed class HlsVideoEventTemplate { data class Horizontal( - val template: EventTemplate, + val template: EventTemplate, ) : HlsVideoEventTemplate() data class Vertical( - val template: EventTemplate, + val template: EventTemplate, ) : HlsVideoEventTemplate() } @@ -80,7 +80,7 @@ data class HlsBuiltTemplate( ) /** - * Assembles a NIP-71 VideoHorizontalEvent / VideoVerticalEvent template from an HLS upload + * Assembles a NIP-71 AddressableNormalVideoEvent / AddressableShortVideoEvent template from an HLS upload * result. Orientation is decided from the first rendition's width/height: portrait * (height > width) selects kind 34236, otherwise 34235. * @@ -142,7 +142,7 @@ object HlsVideoEventBuilder { val template = if (isVertical) { HlsVideoEventTemplate.Vertical( - VideoVerticalEvent.build(input.description, dTag, createdAt) { + AddressableShortVideoEvent.build(input.description, dTag, createdAt) { videoIMetas(videoMetas) title(input.title) input.durationSeconds?.let { duration(it) } @@ -151,7 +151,7 @@ object HlsVideoEventBuilder { ) } else { HlsVideoEventTemplate.Horizontal( - VideoHorizontalEvent.build(input.description, dTag, createdAt) { + AddressableNormalVideoEvent.build(input.description, dTag, createdAt) { videoIMetas(videoMetas) title(input.title) input.durationSeconds?.let { duration(it) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt index 5db9dcbbd4..f7f574dbd6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt @@ -78,6 +78,7 @@ import com.vitorpamplona.amethyst.commons.resources.lightning_create_and_add_inv import com.vitorpamplona.amethyst.commons.resources.lightning_invoice import com.vitorpamplona.amethyst.commons.resources.what_s_on_your_mind import com.vitorpamplona.amethyst.commons.richtext.RichTextParser +import com.vitorpamplona.amethyst.commons.ui.components.OutlinedThinPaddingTextField import com.vitorpamplona.amethyst.commons.ui.insets.imePaddingSafe import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.PostingTopBar @@ -98,7 +99,6 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.ui.components.BechLink import com.vitorpamplona.amethyst.ui.components.LoadUrlPreview -import com.vitorpamplona.amethyst.ui.components.OutlinedThinPaddingTextField import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.note.creators.invoice.InvoiceRequest import com.vitorpamplona.amethyst.ui.note.creators.uploads.ImageVideoDescription diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessageRecorder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessageRecorder.kt index 5dff75f140..892b84128b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessageRecorder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessageRecorder.kt @@ -102,6 +102,13 @@ class VoiceMessageRecorder { amplitudeSamplingJob = recorderScope?.launch { while (isActive) { + // Delay FIRST. `maxAmplitude` reports the peak since the + // previous call, and the call that happens immediately + // after start() covers a window in which the encoder has + // captured nothing — it answers 0 and puts a bar of silence + // at the head of every recording that is not in the audio. + delay(SAMPLE_INTERVAL_MS) + val recorderRef = recorder ?: break try { val amplitude = recorderRef.maxAmplitude.toFloat() @@ -113,7 +120,6 @@ class VoiceMessageRecorder { Log.w("VoiceMessageRecorder", "MediaRecorder in invalid state during amplitude sampling", e) break } - delay(1000) } } } @@ -131,7 +137,7 @@ class VoiceMessageRecorder { val amplitudesCopy = synchronized(amplitudes) { amplitudes.toList() - } + }.downsampled(MAX_WAVEFORM_POINTS) val duration = (currentTime - startTime).toInt() // Clean up recorder and scope @@ -197,4 +203,43 @@ class VoiceMessageRecorder { amplitudes.clear() } } + + companion object { + /** + * How often the peak is read while recording. + * + * Was one second, which is not a waveform: a five-second note produced + * five numbers, stretched across fifty-odd bars, and the result could + * not track speech at any resolution a person would recognise. Ten a + * second is fine to sample — `maxAmplitude` is a cheap read — and the + * count is bounded on the way out rather than here. + */ + const val SAMPLE_INTERVAL_MS = 100L + + /** + * The most amplitudes a recording reports. + * + * Sampling finely and reducing at the end keeps the shape faithful + * without letting a long recording turn into a long list: these travel + * inside events and `imeta` tags, so the size has to depend on the + * detail wanted rather than on how long somebody spoke. + */ + const val MAX_WAVEFORM_POINTS = 100 + } +} + +/** + * Averages [this] down to at most [max] buckets, keeping the ends in place. + * + * Averaging rather than dropping samples: taking every Nth would let a single + * loud frame stand for a whole bucket and make the bars flicker with the + * sampling phase rather than with the sound. + */ +internal fun List.downsampled(max: Int): List { + if (max <= 0 || size <= max) return this + return List(max) { bucket -> + val from = bucket * size / max + val to = ((bucket + 1) * size / max).coerceAtLeast(from + 1) + subList(from, to.coerceAtMost(size)).average().toFloat() + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/AmethystRichText.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/AmethystRichText.kt index 2f8261e466..176ba467b5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/AmethystRichText.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/AmethystRichText.kt @@ -52,6 +52,12 @@ import com.vitorpamplona.amethyst.commons.richtext.RichTextViewerState import com.vitorpamplona.amethyst.commons.richtext.SecretEmoji import com.vitorpamplona.amethyst.commons.richtext.Segment import com.vitorpamplona.amethyst.commons.richtext.WithdrawSegment +import com.vitorpamplona.amethyst.commons.ui.components.ClickableBuzzInviteLink +import com.vitorpamplona.amethyst.commons.ui.components.ClickableConcordInviteLink +import com.vitorpamplona.amethyst.commons.ui.components.ClickableEmail +import com.vitorpamplona.amethyst.commons.ui.components.ClickablePhone +import com.vitorpamplona.amethyst.commons.ui.components.ClickableRelayGroupLink +import com.vitorpamplona.amethyst.commons.ui.components.ClickableRelayUrl import com.vitorpamplona.amethyst.commons.ui.components.NowhereLinkCard import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.richtext.LocalRichTextInteractions diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableEmail.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableEmail.kt deleted file mode 100644 index 6e25f4be6b..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableEmail.kt +++ /dev/null @@ -1,60 +0,0 @@ -/* - * 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.components - -import android.content.ActivityNotFoundException -import android.content.Context -import android.content.Intent -import androidx.compose.runtime.Composable -import androidx.compose.ui.platform.LocalContext -import com.vitorpamplona.amethyst.commons.ui.components.ClickableTextPrimary -import kotlinx.coroutines.CancellationException - -@Composable -fun ClickableEmail(email: String) { - val stripped = email.replaceFirst("mailto:", "") - val context = LocalContext.current - - ClickableTextPrimary( - text = stripped, - onClick = { runCatching { context.sendMail(stripped) } }, - ) -} - -fun Context.sendMail( - to: String, - subject: String? = null, -) { - try { - val intent = Intent(Intent.ACTION_SEND) - intent.type = "vnd.android.cursor.item/email" // or "message/rfc822" - intent.putExtra(Intent.EXTRA_EMAIL, arrayOf(to)) - if (subject != null) { - intent.putExtra(Intent.EXTRA_SUBJECT, subject) - } - startActivity(intent) - } catch (e: ActivityNotFoundException) { - // TODO: Handle case where no email app is available - } catch (t: Throwable) { - if (t is CancellationException) throw t - // TODO: Handle potential other type of exceptions - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ConcordInviteCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ConcordInviteCard.kt index 8935486076..714fc0af02 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ConcordInviteCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ConcordInviteCard.kt @@ -49,6 +49,7 @@ import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.concord_home_title import com.vitorpamplona.amethyst.commons.resources.concord_invite_card_join import com.vitorpamplona.amethyst.commons.resources.concord_invite_card_subtitle +import com.vitorpamplona.amethyst.commons.ui.components.ClickableConcordInviteLink import com.vitorpamplona.amethyst.commons.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.screen.LocalDisplaySettings diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RelayGroupCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RelayGroupCard.kt index cda380757e..e2a636f8c7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RelayGroupCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RelayGroupCard.kt @@ -53,6 +53,7 @@ import com.vitorpamplona.amethyst.commons.resources.relay_group_badge_invite_onl import com.vitorpamplona.amethyst.commons.resources.relay_group_badge_private import com.vitorpamplona.amethyst.commons.resources.relay_group_member_count import com.vitorpamplona.amethyst.commons.resources.relay_group_open +import com.vitorpamplona.amethyst.commons.ui.components.ClickableRelayGroupLink import com.vitorpamplona.amethyst.commons.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.pluralStringRes diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt index ede5174be2..bfa2899272 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt @@ -102,6 +102,12 @@ import com.vitorpamplona.amethyst.commons.richtext.Segment import com.vitorpamplona.amethyst.commons.richtext.VideoSegment import com.vitorpamplona.amethyst.commons.richtext.WithdrawSegment import com.vitorpamplona.amethyst.commons.ui.components.AnimatedBorderTextCornerRadius +import com.vitorpamplona.amethyst.commons.ui.components.ClickableBuzzInviteLink +import com.vitorpamplona.amethyst.commons.ui.components.ClickableConcordInviteLink +import com.vitorpamplona.amethyst.commons.ui.components.ClickableEmail +import com.vitorpamplona.amethyst.commons.ui.components.ClickablePhone +import com.vitorpamplona.amethyst.commons.ui.components.ClickableRelayGroupLink +import com.vitorpamplona.amethyst.commons.ui.components.ClickableRelayUrl import com.vitorpamplona.amethyst.commons.ui.components.ClickableTextPrimary import com.vitorpamplona.amethyst.commons.ui.components.CrossfadeIfEnabled import com.vitorpamplona.amethyst.commons.ui.components.NowhereLinkCard diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/RenderContentAsMarkdown.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/RenderContentAsMarkdown.kt index 650224ae95..2b0d73753f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/RenderContentAsMarkdown.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/RenderContentAsMarkdown.kt @@ -54,7 +54,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.uriToRoute import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent import com.vitorpamplona.quartz.nip92IMeta.imetasByUrl import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking @@ -425,7 +425,7 @@ fun RenderContentAsMarkdownNotePreview() { runBlocking { withContext(Dispatchers.IO) { val blogPost = - LongTextNoteEvent( + LongFormContentEvent( id = "e69fa72a221d0b7362f8e63fd6ab84cc6b5e7b505fe49cefe6823a47a6a4b583", pubKey = "88cc134b1a65f54ef48acc1df3665063d3ea45f04eab8af4646e561c5ae99079", createdAt = 1718785725, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/FilterByListParams.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/FilterByListParams.kt index 9c36e806a9..9715816619 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/FilterByListParams.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/FilterByListParams.kt @@ -32,8 +32,8 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasMoreHashtagsThan import com.vitorpamplona.quartz.nip10Notes.threadRootIdOrSelf +import com.vitorpamplona.quartz.nip51Lists.followSet.FollowSetEvent import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent -import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import com.vitorpamplona.quartz.utils.TimeUtils class FilterByListParams( @@ -105,7 +105,7 @@ class FilterByListParams( fun showHiddenKey( selectedListName: String, userHex: String, - ) = selectedListName == PeopleListEvent.blockListFor(userHex) || selectedListName == MuteListEvent.blockListFor(userHex) + ) = selectedListName == FollowSetEvent.blockListFor(userHex) || selectedListName == MuteListEvent.blockListFor(userHex) fun create( followLists: IFeedTopNavFilter?, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedContentStateView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedContentStateView.kt index ce29dc0daf..85dc9c6610 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedContentStateView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedContentStateView.kt @@ -35,6 +35,8 @@ import com.vitorpamplona.amethyst.commons.ui.feeds.FeedEmpty import com.vitorpamplona.amethyst.commons.ui.feeds.FeedError import com.vitorpamplona.amethyst.commons.ui.feeds.LoadingFeed import com.vitorpamplona.amethyst.commons.ui.feeds.RefresheableBox +import com.vitorpamplona.amethyst.commons.ui.feeds.StickToTopOnPrepend +import com.vitorpamplona.amethyst.commons.ui.feeds.WatchScrollToTop import com.vitorpamplona.amethyst.commons.ui.feeds.rememberForeverLazyGridState import com.vitorpamplona.amethyst.commons.ui.feeds.rememberForeverLazyListState import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/PrefetchFeedMedia.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/PrefetchFeedMedia.kt index 1fbdc53637..c13a8124a8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/PrefetchFeedMedia.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/PrefetchFeedMedia.kt @@ -298,12 +298,12 @@ private class WarmTargets { /** * Adds NIP-94 file-header media: blobs declared as top-level `url` tags (not - * imeta), as used by FileHeaderEvent and the gallery/file kinds. Only image + * imeta), as used by FileMetadataEvent and the gallery/file kinds. Only image * files are prefetched — detected by the URL extension or the event's top-level * `m` mime type (a hashed URL may carry no extension). The decode listener * records their dims like any other image. */ - fun addFileHeaderMedia(event: Event) { + fun addFileMetadataMedia(event: Event) { val urls = event.tags.mapNotNull(UrlTag::parse) if (urls.isEmpty()) return val imageMime = event.tags.firstNotNullOfOrNull(MimeTypeTag::parse)?.startsWith("image/") == true @@ -333,9 +333,9 @@ private fun Note.collectWarmTargets(): WarmTargets? { } // NIP-94 file-header convention: the blob is a top-level `url` tag with sibling - // `m`/`dim`, not imeta (FileHeaderEvent and the gallery/file kinds). The generic + // `m`/`dim`, not imeta (FileMetadataEvent and the gallery/file kinds). The generic // imetas() above misses these entirely. - targets.addFileHeaderMedia(ev) + targets.addFileMetadataMedia(ev) // NIP-73 external-content scope. Only kind 1111 carries one, so the type check // keeps every other kind out of the tag walk. Reuses scope() — the same accessor diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/WatchScrollToTop.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/WatchScrollToTop.kt index b9580ea8ba..d0d7daa8c5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/WatchScrollToTop.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/WatchScrollToTop.kt @@ -21,52 +21,11 @@ package com.vitorpamplona.amethyst.ui.feeds import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.grid.LazyGridState -import androidx.compose.foundation.pager.PagerState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.runtime.snapshotFlow import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.commons.feeds.FeedContentState -import com.vitorpamplona.amethyst.commons.feeds.FeedState import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.CardFeedContentState -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.map - -@Composable -fun WatchScrollToTop( - feedContentState: FeedContentState, - listState: LazyListState, -) { - val scrollToTop by feedContentState.scrollToTop.collectAsStateWithLifecycle() - - LaunchedEffect(scrollToTop) { - if (scrollToTop > 0 && feedContentState.scrollToTopPending) { - listState.scrollToItem(index = 0) - feedContentState.sentToTop() - } - } -} - -@Composable -fun WatchScrollToTop( - feedContentState: FeedContentState, - listState: LazyGridState, -) { - val scrollToTop by feedContentState.scrollToTop.collectAsStateWithLifecycle() - - LaunchedEffect(scrollToTop) { - if (scrollToTop > 0 && feedContentState.scrollToTopPending) { - listState.scrollToItem(index = 0) - feedContentState.sentToTop() - } - } -} @Composable fun WatchScrollToTop( @@ -82,166 +41,3 @@ fun WatchScrollToTop( } } } - -@Composable -fun WatchScrollToTop( - videoFeedContentState: FeedContentState, - pagerState: PagerState, -) { - val scrollToTop by videoFeedContentState.scrollToTop.collectAsStateWithLifecycle() - - LaunchedEffect(scrollToTop) { - if (scrollToTop > 0 && videoFeedContentState.scrollToTopPending) { - pagerState.scrollToPage(page = 0) - videoFeedContentState.sentToTop() - } - } -} - -/** - * Keeps the user pinned to index 0 when new items prepend to a feed, but - * only if they were already at the very top right before the update. - * - * Why this is non-trivial: every feed uses stable `key = item.idHex` in - * its lazy list, which makes Compose preserve the visual anchor across - * data changes. When N items prepend, the user's previously-visible - * top item is still on screen but its index is now N — so - * `firstVisibleItemIndex` shifts from 0 to N without any user gesture. - * A naive `if (firstVisibleItemIndex <= 1) scrollToItem(0)` check inside - * `LaunchedEffect(items.firstOrNull())` therefore fails as soon as more - * than one item arrives in the same batch. - * - * The trick: track "was at top" continuously via [snapshotFlow], but - * only flip it from true → false when [LazyListState.isScrollInProgress] - * is true (i.e. the user is actively scrolling). Compose's keyed-item - * shift after a data update does not set that flag — only real touch - * gestures and `animate*` calls do — so data-driven index shifts can - * never poison the cached value. When [firstItemKey] changes (head of - * the list moved), if the cached value is still true, snap back to 0 - * with an instant (non-animated) scroll so the prepend appears as - * in-place growth rather than a visible jump-then-scroll. - * - * Most callers should not invoke this directly: [SaveableFeedContentState], - * [SaveableGridFeedContentState], and the analogous wrappers in - * `ui/screen/FeedView.kt` already apply auto-stick to every feed they - * own. Invoke the explicit overload only when the listState is - * constructed outside one of those wrappers, or when the key that - * should trigger the snap is not the default `items.list[0].idHex` - * (e.g. notifications, chats, or feeds keyed on something other than a - * Note's hex id). - */ -@Composable -fun StickToTopOnPrepend( - listState: LazyListState, - firstItemKey: Any?, -) { - stickToTopOnPrepend( - stateKey = listState, - firstItemKey = firstItemKey, - initialAtTop = { - listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0 - }, - sampler = { - snapshotFlow { - listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0 - } - }, - isScrollInProgress = { listState.isScrollInProgress }, - firstVisibleItemIndex = { listState.firstVisibleItemIndex }, - scrollToTop = { listState.scrollToItem(0) }, - ) -} - -@Composable -fun StickToTopOnPrepend( - gridState: LazyGridState, - firstItemKey: Any?, -) { - stickToTopOnPrepend( - stateKey = gridState, - firstItemKey = firstItemKey, - initialAtTop = { - gridState.firstVisibleItemIndex == 0 && gridState.firstVisibleItemScrollOffset == 0 - }, - sampler = { - snapshotFlow { - gridState.firstVisibleItemIndex == 0 && gridState.firstVisibleItemScrollOffset == 0 - } - }, - isScrollInProgress = { gridState.isScrollInProgress }, - firstVisibleItemIndex = { gridState.firstVisibleItemIndex }, - scrollToTop = { gridState.scrollToItem(0) }, - ) -} - -/** - * Auto-stick wired straight to a [FeedContentState]: derives the head - * key from `feedContent → Loaded.feed → list.firstOrNull()?.idHex` so - * callers don't have to collect the inner feed flow themselves. Used - * by the Saveable* wrappers; suitable for any Note-keyed feed. - */ -@Composable -fun StickToTopOnPrepend( - feedContentState: FeedContentState, - listState: LazyListState, -) { - StickToTopOnPrepend(listState, rememberFirstItemIdHex(feedContentState)) -} - -@Composable -fun StickToTopOnPrepend( - feedContentState: FeedContentState, - gridState: LazyGridState, -) { - StickToTopOnPrepend(gridState, rememberFirstItemIdHex(feedContentState)) -} - -@OptIn(ExperimentalCoroutinesApi::class) -@Composable -private fun rememberFirstItemIdHex(feedContentState: FeedContentState): String? { - val flow = - remember(feedContentState) { - feedContentState.feedContent.flatMapLatest { state -> - when (state) { - is FeedState.Loaded -> state.feed.map { it.list.firstOrNull()?.idHex } - else -> flowOf(null) - } - } - } - val key by flow.collectAsStateWithLifecycle(initialValue = null) - return key -} - -@Composable -private fun stickToTopOnPrepend( - stateKey: Any, - firstItemKey: Any?, - initialAtTop: () -> Boolean, - sampler: () -> Flow, - isScrollInProgress: () -> Boolean, - firstVisibleItemIndex: () -> Int, - scrollToTop: suspend () -> Unit, -) { - // Plain holder instead of mutableStateOf — we only read this inside - // effects, never in composition, so we don't need snapshot tracking. - // Seed from the actual restored scroll position: when the user returns - // to a feed via rememberForeverLazyListState, the saved offset is - // already in place, and a hardcoded `true` would mis-snap them to 0. - val wasAtTop = remember(stateKey) { booleanArrayOf(initialAtTop()) } - - LaunchedEffect(stateKey) { - sampler().collect { atTop -> - if (atTop) { - wasAtTop[0] = true - } else if (isScrollInProgress()) { - wasAtTop[0] = false - } - } - } - - LaunchedEffect(firstItemKey) { - if (firstItemKey != null && wasAtTop[0] && firstVisibleItemIndex() > 0) { - scrollToTop() - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ChatHeaderLayout.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ChatHeaderLayoutPreview.kt similarity index 77% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ChatHeaderLayout.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ChatHeaderLayoutPreview.kt index 5abf8308cf..1f49f5ac52 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ChatHeaderLayout.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ChatHeaderLayoutPreview.kt @@ -20,16 +20,10 @@ */ package com.vitorpamplona.amethyst.ui.layouts -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image -import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.ListItem import androidx.compose.material3.Text @@ -38,20 +32,19 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.profile_banner +import com.vitorpamplona.amethyst.commons.ui.components.NewItemsBubble +import com.vitorpamplona.amethyst.commons.ui.layouts.ChatHeaderLayout +import com.vitorpamplona.amethyst.commons.ui.layouts.listItem.SlimListItem import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness -import com.vitorpamplona.amethyst.commons.ui.theme.DoubleHorzSpacer import com.vitorpamplona.amethyst.commons.ui.theme.Height4dpModifier import com.vitorpamplona.amethyst.commons.ui.theme.Size55Modifier import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn -import com.vitorpamplona.amethyst.ui.layouts.listItem.SlimListItem import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo import com.vitorpamplona.amethyst.ui.painterRes -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.NewItemsBubble import com.vitorpamplona.quartz.utils.TimeUtils private const val PREVIEW_AUTHOR = "This is my author" @@ -136,43 +129,3 @@ fun ChannelNamePreview() { } } } - -@OptIn(ExperimentalFoundationApi::class) -@Composable -fun ChatHeaderLayout( - channelPicture: @Composable () -> Unit, - firstRow: @Composable RowScope.() -> Unit, - secondRow: @Composable RowScope.() -> Unit, - onClick: () -> Unit, - onLongClick: (() -> Unit)? = null, -) { - Row( - modifier = - Modifier - .combinedClickable(onClick = onClick, onLongClick = onLongClick) - .padding(10.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Box(Size55Modifier) { channelPicture() } - - Spacer(modifier = DoubleHorzSpacer) - - Column( - modifier = Modifier.fillMaxWidth(), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - firstRow() - } - - Spacer(modifier = Height4dpModifier) - - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - secondRow() - } - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/LeftPictureLayout.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/LeftPictureLayoutPreview.kt similarity index 72% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/LeftPictureLayout.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/LeftPictureLayoutPreview.kt index 167bbc4a51..2f26c2e73e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/LeftPictureLayout.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/LeftPictureLayoutPreview.kt @@ -21,18 +21,11 @@ package com.vitorpamplona.amethyst.ui.layouts import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale @@ -43,10 +36,10 @@ import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.profile_banner +import com.vitorpamplona.amethyst.commons.ui.layouts.LeftPictureLayout import com.vitorpamplona.amethyst.commons.ui.note.LikeIcon import com.vitorpamplona.amethyst.commons.ui.note.ZappedIcon import com.vitorpamplona.amethyst.commons.ui.stringRes -import com.vitorpamplona.amethyst.commons.ui.theme.DoubleHorzSpacer import com.vitorpamplona.amethyst.commons.ui.theme.QuoteBorder import com.vitorpamplona.amethyst.commons.ui.theme.Size16Modifier import com.vitorpamplona.amethyst.commons.ui.theme.Size20Modifier @@ -104,47 +97,3 @@ fun LeftPictureLayoutPreviewCard() { onBottomRow = { Text("This is my Moderator List") }, ) } - -@Composable -fun LeftPictureLayout( - onImage: @Composable () -> Unit, - onTitleRow: @Composable RowScope.() -> Unit, - onDescription: @Composable () -> Unit, - onBottomRow: @Composable RowScope.() -> Unit, - imageFraction: Float = 0.25f, -) { - Row(Modifier.aspectRatio(ratio = 1 / imageFraction)) { - Column( - modifier = Modifier.fillMaxWidth(imageFraction).aspectRatio(ratio = 1f), - ) { - onImage() - } - - Spacer(modifier = DoubleHorzSpacer) - - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.SpaceBetween, - ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - onTitleRow() - } - - Row( - modifier = Modifier.fillMaxWidth().weight(1f), - ) { - onDescription() - } - - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - onBottomRow() - } - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/listItem/SlimListItemPreview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/listItem/SlimListItemPreview.kt new file mode 100644 index 0000000000..f4529fc097 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/listItem/SlimListItemPreview.kt @@ -0,0 +1,132 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.layouts.listItem + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.ListItem +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment.Companion.CenterVertically +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.tooling.preview.Preview +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.profile_banner +import com.vitorpamplona.amethyst.commons.ui.components.NewItemsBubble +import com.vitorpamplona.amethyst.commons.ui.layouts.ChatHeaderLayout +import com.vitorpamplona.amethyst.commons.ui.layouts.listItem.SlimListItem +import com.vitorpamplona.amethyst.commons.ui.stringRes +import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.commons.ui.theme.Height4dpModifier +import com.vitorpamplona.amethyst.commons.ui.theme.Size55Modifier +import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn +import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo +import com.vitorpamplona.amethyst.ui.painterRes +import com.vitorpamplona.quartz.utils.TimeUtils + +private const val PREVIEW_AUTHOR = "This is my author" +private const val PREVIEW_MESSAGE = "This is a message from this person" + +@Composable +@Preview +fun ChannelNamePreview() { + ThemeComparisonColumn { + Column { + ChatHeaderLayout( + channelPicture = { + Image( + painter = painterRes(R.drawable.github, 1), + contentDescription = stringRes(id = Res.string.profile_banner), + contentScale = ContentScale.FillWidth, + ) + }, + firstRow = { + Text(PREVIEW_AUTHOR, Modifier.weight(1f)) + TimeAgo(TimeUtils.now()) + }, + secondRow = { + Text(PREVIEW_MESSAGE, Modifier.weight(1f)) + NewItemsBubble() + }, + onClick = {}, + ) + + HorizontalDivider(thickness = DividerThickness) + + SlimListItem( + headlineContent = { + Row(verticalAlignment = CenterVertically) { + Text(PREVIEW_AUTHOR, Modifier.weight(1f)) + TimeAgo(TimeUtils.now()) + } + }, + supportingContent = { + Row(verticalAlignment = CenterVertically) { + Text(PREVIEW_MESSAGE, Modifier.weight(1f)) + Spacer(modifier = Height4dpModifier) + NewItemsBubble() + } + }, + leadingContent = { + Image( + painter = painterRes(R.drawable.github, 2), + contentDescription = stringRes(id = Res.string.profile_banner), + contentScale = ContentScale.FillWidth, + modifier = Size55Modifier, + ) + }, + ) + + HorizontalDivider(thickness = DividerThickness) + + ListItem( + headlineContent = { + Row(verticalAlignment = CenterVertically) { + Text(PREVIEW_AUTHOR, Modifier.weight(1f)) + TimeAgo(TimeUtils.now()) + } + }, + supportingContent = { + Row(verticalAlignment = CenterVertically) { + Text(PREVIEW_MESSAGE, Modifier.weight(1f)) + Spacer(modifier = Height4dpModifier) + NewItemsBubble() + } + }, + leadingContent = { + Image( + painter = painterRes(R.drawable.github, 2), + contentDescription = stringRes(id = Res.string.profile_banner), + contentScale = ContentScale.FillWidth, + modifier = Size55Modifier, + ) + }, + ) + + HorizontalDivider(thickness = DividerThickness) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index c99e788323..cb32a317ff 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -128,6 +128,12 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.create.NewCalendarCollectionScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.create.NewCalendarEventScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.detail.CalendarEventDetailScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.cordnGroup.CordnCreateGroupScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.cordnGroup.CordnCreateMembersScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.cordnGroup.CordnGroupChatScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.cordnGroup.CordnGroupInfoScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.cordnGroup.CordnGroupListScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.cordnGroup.CordnInvitationsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.geohashChat.GeohashChatScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.geohashChat.GeohashChatsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.geohashChat.GeohashTeleportScreen @@ -297,6 +303,12 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SpammingUsersScree import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.UpdateZapAmountScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.UserSettingsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.VideoPlayerSettingsScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.cordn.CordnBackupScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.cordn.CordnCoordinatorsScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.cordn.CordnHubScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.cordn.CordnKeyPackagesScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.cordn.CordnLinkScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.cordn.CordnMigrateScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.nip46.Nip46ConnectedAppsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.nip46.Nip46SignerScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts.ShortsScreen @@ -662,6 +674,12 @@ fun BuildNavigation( com.vitorpamplona.amethyst.ui.actions.nestsServers .NestsServersScreen(accountViewModel, nav) } + composableFromEnd { CordnLinkScreen(accountViewModel, nav) } + composableFromEnd { CordnCoordinatorsScreen(accountViewModel, nav) } + composableFromEnd { CordnKeyPackagesScreen(accountViewModel, nav) } + composableFromEnd { CordnBackupScreen(accountViewModel, nav) } + composableFromEnd { CordnHubScreen(accountViewModel, nav) } + composableFromEnd { CordnMigrateScreen(accountViewModel, nav) } composableFromEnd { FavoriteAlgoFeedsListScreen(accountViewModel, nav) } composableFromEnd { PaymentTargetsScreen(accountViewModel, nav) } composableFromEnd { Bolt12OffersScreen(accountViewModel, nav) } @@ -707,6 +725,17 @@ fun BuildNavigation( } composableFromEndArgs { MarmotGroupInfoScreen(it.nostrGroupId, accountViewModel, nav) } + composableFromEndArgs { + CordnGroupChatScreen(it.coordinatorPubKey, it.gid, accountViewModel, nav) + } + composableFromEndArgs { + CordnGroupInfoScreen(it.coordinatorPubKey, it.gid, accountViewModel, nav) + } + composableFromEnd { CordnGroupListScreen(accountViewModel, nav) } + composableFromBottom { CordnCreateGroupScreen(accountViewModel, nav) } + composableFromBottom { CordnCreateMembersScreen(accountViewModel, nav) } + composableFromEnd { CordnInvitationsScreen(accountViewModel, nav) } + composableFromBottom { CreateGroupScreen(accountViewModel, nav) } composableFromBottomArgs { EditGroupInfoScreen(it.nostrGroupId, accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt index 42c7f91d64..c91ee5a633 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt @@ -43,6 +43,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.browser.OmniboxInput import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon @@ -59,8 +60,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.Size10Modifier import com.vitorpamplona.amethyst.commons.ui.theme.Size25Modifier import com.vitorpamplona.amethyst.commons.ui.theme.Size27Modifier import com.vitorpamplona.amethyst.commons.ui.theme.onSurface65 -import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry -import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry import com.vitorpamplona.amethyst.favorites.rememberNappletIconModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -112,7 +111,8 @@ fun AppBottomBar( // Favorite entries in the unified list resolve to a live favorite for their icon/label and to an // embedded-tab route. Both kinds embed in-process (WebApp → browser surface, NostrApp → napplet // surface), so such a tab swaps in place rather than launching an activity from the bottom row. - val favorites by FavoriteAppsRegistry.favorites.collectAsStateWithLifecycle() + val favorites by Amethyst.instance.favoriteApps.favorites + .collectAsStateWithLifecycle() val isKeyboardState by keyboardAsState() if (isKeyboardState == KeyboardState.Closed) { @@ -131,9 +131,10 @@ internal fun rememberFavoriteIconModel(fav: FavoriteApp): Any? = when (fav) { is FavoriteApp.WebApp -> { // Captured favicons, keyed so the icon appears once the site's capture lands. - val iconKeys by BrowserIconRegistry.keys.collectAsStateWithLifecycle() + val iconKeys by Amethyst.instance.browserIcons.keys + .collectAsStateWithLifecycle() remember(fav, iconKeys) { - OmniboxInput.hostOf(fav.url)?.let(BrowserIconRegistry::iconModelFor) + OmniboxInput.hostOf(fav.url)?.let(Amethyst.instance.browserIcons::iconModelFor) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppNavigationRail.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppNavigationRail.kt index b51d1059b5..30dddb074a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppNavigationRail.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppNavigationRail.kt @@ -36,9 +36,9 @@ import androidx.navigation.NavDestination import androidx.navigation.NavDestination.Companion.hasRoute import androidx.navigation.NavHostController import androidx.navigation.compose.currentBackStackEntryAsState +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.model.navigation.BottomBarEntry import com.vitorpamplona.amethyst.commons.model.navigation.Route -import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry import com.vitorpamplona.amethyst.ui.navigation.navs.Nav import com.vitorpamplona.amethyst.ui.navigation.routes.getRouteWithArguments import com.vitorpamplona.amethyst.ui.navigation.topbars.LoggedInUserPictureDrawer @@ -58,7 +58,8 @@ fun AppNavigationRail( ) { val items by accountViewModel.account.settings.syncedSettings.navigation.bottomBarItems .collectAsStateWithLifecycle() - val favorites by FavoriteAppsRegistry.favorites.collectAsStateWithLifecycle() + val favorites by Amethyst.instance.favoriteApps.favorites + .collectAsStateWithLifecycle() val favoritesById = remember(favorites) { favorites.associateBy { it.id } } val reselectCoordinator = LocalTabReselectCoordinator.current diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt index 0e2bb228fe..b649c9da45 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt @@ -38,6 +38,7 @@ import com.vitorpamplona.amethyst.commons.resources.bottom_bar_category_you import com.vitorpamplona.amethyst.commons.resources.browser import com.vitorpamplona.amethyst.commons.resources.communities import com.vitorpamplona.amethyst.commons.resources.concord_home_title +import com.vitorpamplona.amethyst.commons.resources.cordn_groups_title import com.vitorpamplona.amethyst.commons.resources.discover_marketplace import com.vitorpamplona.amethyst.commons.resources.discover_reads import com.vitorpamplona.amethyst.commons.resources.drafts @@ -391,6 +392,16 @@ val NavBarCatalog: Map = icon = MaterialSymbols.Lock, resolveRoute = { Route.MarmotGroupList }, ), + NavBarItem.CORDN_GROUPS to + NavBarItemDef( + id = NavBarItem.CORDN_GROUPS, + labelRes = Res.string.cordn_groups_title, + // Dns, the symbol every other cordn surface uses: a coordinator + // is a server, and that is the one thing that distinguishes + // these from the Marmot rooms directly above. + icon = MaterialSymbols.Dns, + resolveRoute = { Route.CordnGroupList }, + ), NavBarItem.GEOHASH_CHATS to NavBarItemDef( id = NavBarItem.GEOHASH_CHATS, @@ -527,6 +538,7 @@ val BottomBarCategories: List = NavBarItem.RELAY_GROUPS, NavBarItem.CONCORD, NavBarItem.MARMOT_GROUPS, + NavBarItem.CORDN_GROUPS, NavBarItem.GEOHASH_CHATS, ), ), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt index a500753e1b..e37627eee3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt @@ -95,6 +95,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.model.navigation.DrawerSectionId import com.vitorpamplona.amethyst.commons.model.navigation.NavBarItem import com.vitorpamplona.amethyst.commons.model.navigation.Route import com.vitorpamplona.amethyst.commons.model.navigation.routeFor @@ -146,7 +147,7 @@ import com.vitorpamplona.amethyst.commons.ui.theme.profileContentHeaderModifier import com.vitorpamplona.amethyst.isDebug import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserContactCardsFollowerCount +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserAssertionsFollowerCount import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserStatuses import com.vitorpamplona.amethyst.ui.layouts.PermanentDrawerWidth @@ -587,7 +588,7 @@ fun DisplayFollowerCount( baseAccountUser: Account, accountViewModel: AccountViewModel, ) { - val followerCount by observeUserContactCardsFollowerCount(baseAccountUser.userProfile(), accountViewModel) + val followerCount by observeUserAssertionsFollowerCount(baseAccountUser.userProfile(), accountViewModel) Text( text = followerCount, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerSections.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerSections.kt index 378e698010..0e0b1353f8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerSections.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerSections.kt @@ -25,8 +25,8 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.navigation.DrawerItemVisibility +import com.vitorpamplona.amethyst.commons.model.navigation.DrawerSectionId import com.vitorpamplona.amethyst.commons.model.navigation.NavBarItem -import com.vitorpamplona.amethyst.commons.model.navigation.navBarItemsFromNames import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.drawer_section_create import com.vitorpamplona.amethyst.commons.resources.drawer_section_feeds @@ -69,38 +69,6 @@ data class DrawerSection( * copied — a `DrawerSections.map { it.copy(...) }` would silently defeat an `===` check, with no * compile error and nothing to fail a test. */ -enum class DrawerSectionId { - YOU, - NAVIGATE, - FEEDS, - - /** Composer entry points. Carries no catalog destinations, so nothing in it is configurable. */ - CREATE, - - /** Also renders the relay-status row, which isn't a catalog destination (it shows a live counter). */ - SYSTEM, -} - -private val DrawerSectionIdsByName = DrawerSectionId.entries.associateBy { it.name } - -/** - * Parses the persisted names of the headings the user has collapsed, silently dropping any this - * build doesn't know. Mirrors [com.vitorpamplona.amethyst.commons.model.navigation.navBarItemsFromNames]: - * names rather than ordinals, so reordering this enum renames nothing by accident, and a value left - * by a build with one more section costs that heading rather than the whole read. - * - * The stored set holds the **collapsed** headings rather than the expanded ones, for the same reason - * [DrawerItemVisibility] stores the hidden rows: a heading nobody has ever collapsed simply isn't in - * the set, so a section added in a later release opens expanded for everyone with no migration. - */ -fun drawerSectionIdsFromNames(names: Collection): Set = names.mapNotNullTo(mutableSetOf()) { DrawerSectionIdsByName[it] } - -/** - * The inverse of [drawerSectionIdsFromNames]. Unlike the NavBarItem codec this returns an unsorted - * Set rather than a sorted List: the destination is a DataStore string set, whose equality is - * already order-independent, so there is no serialized form to keep deterministic. - */ -fun Set.toNames(): Set = mapTo(mutableSetOf()) { it.name } private val DrawerNavigateItems: List = listOf( @@ -153,6 +121,7 @@ private val DrawerFeedsItems: List = NavBarItem.RELAY_GROUPS, NavBarItem.CONCORD, NavBarItem.MARMOT_GROUPS, + NavBarItem.CORDN_GROUPS, NavBarItem.GEOHASH_CHATS, NavBarItem.CALENDARS, NavBarItem.CALENDAR_COLLECTIONS, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt index 13f0e95cf2..198ff3adac 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt @@ -44,7 +44,7 @@ import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable import com.vitorpamplona.quartz.nip22Comments.CommentEvent -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent import com.vitorpamplona.quartz.nip28PublicChat.base.IsInPublicChatChannel import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent @@ -57,12 +57,12 @@ import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestEvent import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestUpdateEvent import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent -import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import com.vitorpamplona.quartz.nip51Lists.starterPack.StarterPackEvent import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent import com.vitorpamplona.quartz.nip59Giftwrap.HasInnerEvent -import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent +import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent @@ -247,7 +247,7 @@ fun routeForInner( } } - is FollowListEvent -> { + is StarterPackEvent -> { Route.FollowPack(noteEvent.address()) } @@ -294,7 +294,7 @@ fun routeForInner( Route.GeocacheHunt(noteEvent.kind, noteEvent.pubKey, noteEvent.dTag()) } - is GiftWrapEvent, is SealedRumorEvent -> { + is GiftWrapEvent, is SealEvent -> { val wrap = noteEvent as HasInnerEvent wrap.innerEventId?.let { routeFor(LocalCache.getOrCreateNote(it), loggedIn) @@ -456,7 +456,7 @@ fun routeReplyTo( } is ChatroomKeyable -> { - // Covers PrivateDmEvent (NIP-04) and BaseDMGroupEvent (NIP-17) — both + // Covers EncryptedDmEvent (NIP-04) and BaseDMGroupEvent (NIP-17) — both // implement ChatroomKeyable with identical routing semantics here. routeToMessage( room = noteEvent.chatroomKey(account.userProfile().pubkeyHex), @@ -477,7 +477,7 @@ fun routeReplyTo( } } - is LnZapEvent -> { + is ZapReceiptEvent -> { // A public reply can't tag a private zapper without exposing them. // When we hold the decrypted sender (we are the zap recipient), reply // in their DM room instead of the public comment composer. @@ -532,7 +532,7 @@ suspend fun routeEditDraftTo( Route.NewShortNote(draft = note.idHex) } - is LongTextNoteEvent -> { + is LongFormContentEvent -> { Route.NewLongFormPost(draft = note.idHex) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt index 12c29afce3..a227d40fcb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt @@ -106,7 +106,7 @@ import com.vitorpamplona.amethyst.commons.ui.theme.bitcoinColor import com.vitorpamplona.amethyst.commons.ui.theme.overPictureBackground import com.vitorpamplona.amethyst.commons.ui.theme.profile35dpModifier import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderFilterAssemblerSubscription -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserContactCardsScore +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserAssertionsScore import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserPicture import com.vitorpamplona.amethyst.ui.components.CoreSecretMessage import com.vitorpamplona.amethyst.ui.components.ExpandableRichTextViewer @@ -841,7 +841,7 @@ fun ObserveAndRenderBoxedUserCards( accountViewModel: AccountViewModel, subscribe: Boolean = true, ) { - val score by observeUserContactCardsScore(user, accountViewModel, subscribe) + val score by observeUserAssertionsScore(user, accountViewModel, subscribe) score?.let { Box(modifier = Size35Modifier, contentAlignment = Alignment.BottomCenter) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt index ee0acbe5e7..208b2c4ba1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt @@ -84,7 +84,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip01Core.tags.events.firstTaggedEvent import com.vitorpamplona.quartz.nip01Core.tags.people.firstTaggedUserId import com.vitorpamplona.quartz.nip30CustomEmoji.taggedEmojis -import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent +import com.vitorpamplona.quartz.nip38UserStatus.UserStatusEvent import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableMap import kotlinx.collections.immutable.persistentMapOf @@ -219,14 +219,14 @@ fun DisplayStatus( nav: INav, ) { val noteState by observeNote(addressableNote, accountViewModel) - val noteEvent = noteState.note.event as? StatusEvent ?: return + val noteEvent = noteState.note.event as? UserStatusEvent ?: return DisplayStatus(noteEvent, accountViewModel, nav) } @Composable fun DisplayStatus( - event: StatusEvent, + event: UserStatusEvent, accountViewModel: AccountViewModel, nav: INav, ) { @@ -266,7 +266,7 @@ fun DisplayStatusInner( nav: INav, ) { when (type) { - StatusEvent.MUSIC -> { + UserStatusEvent.MUSIC -> { Icon( imageVector = CustomHashTagIcons.Tunestr, null, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 08ae330cde..29134ae6da 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -140,18 +140,18 @@ import com.vitorpamplona.amethyst.ui.note.types.DisplayBlockedRelayList import com.vitorpamplona.amethyst.ui.note.types.DisplayBroadcastRelayList import com.vitorpamplona.amethyst.ui.note.types.DisplayContactList import com.vitorpamplona.amethyst.ui.note.types.DisplayDMRelayList -import com.vitorpamplona.amethyst.ui.note.types.DisplayFollowList +import com.vitorpamplona.amethyst.ui.note.types.DisplayFavoriteRelayList import com.vitorpamplona.amethyst.ui.note.types.DisplayIndexerRelayList import com.vitorpamplona.amethyst.ui.note.types.DisplayNIP65RelayList import com.vitorpamplona.amethyst.ui.note.types.DisplayPeopleList import com.vitorpamplona.amethyst.ui.note.types.DisplayProxyRelayList -import com.vitorpamplona.amethyst.ui.note.types.DisplayRelayFeedsList import com.vitorpamplona.amethyst.ui.note.types.DisplayRelaySet import com.vitorpamplona.amethyst.ui.note.types.DisplaySearchRelayList +import com.vitorpamplona.amethyst.ui.note.types.DisplayStarterPack import com.vitorpamplona.amethyst.ui.note.types.DisplayTrustedRelayList import com.vitorpamplona.amethyst.ui.note.types.EditState import com.vitorpamplona.amethyst.ui.note.types.EmptyState -import com.vitorpamplona.amethyst.ui.note.types.FileHeaderDisplay +import com.vitorpamplona.amethyst.ui.note.types.FileMetadataDisplay import com.vitorpamplona.amethyst.ui.note.types.FileStorageHeaderDisplay import com.vitorpamplona.amethyst.ui.note.types.PictureDisplay import com.vitorpamplona.amethyst.ui.note.types.RenderAppDefinition @@ -180,6 +180,8 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderCitation import com.vitorpamplona.amethyst.ui.note.types.RenderClassifieds import com.vitorpamplona.amethyst.ui.note.types.RenderCommunity import com.vitorpamplona.amethyst.ui.note.types.RenderCyberspaceBag +import com.vitorpamplona.amethyst.ui.note.types.RenderDvmContentDiscoveryResponse +import com.vitorpamplona.amethyst.ui.note.types.RenderDvmStatus import com.vitorpamplona.amethyst.ui.note.types.RenderEmojiPack import com.vitorpamplona.amethyst.ui.note.types.RenderEntityRating import com.vitorpamplona.amethyst.ui.note.types.RenderExternalReaction @@ -200,14 +202,11 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderLiveActivityChatMessage import com.vitorpamplona.amethyst.ui.note.types.RenderLiveActivityEvent import com.vitorpamplona.amethyst.ui.note.types.RenderLiveChessChallenge import com.vitorpamplona.amethyst.ui.note.types.RenderLiveChessGameEnd -import com.vitorpamplona.amethyst.ui.note.types.RenderLnZap import com.vitorpamplona.amethyst.ui.note.types.RenderLongFormContent import com.vitorpamplona.amethyst.ui.note.types.RenderMeetingRoomEvent import com.vitorpamplona.amethyst.ui.note.types.RenderMeetingSpaceEvent import com.vitorpamplona.amethyst.ui.note.types.RenderMusicPlaylist import com.vitorpamplona.amethyst.ui.note.types.RenderMusicTrack -import com.vitorpamplona.amethyst.ui.note.types.RenderNIP90ContentDiscoveryResponse -import com.vitorpamplona.amethyst.ui.note.types.RenderNIP90Status import com.vitorpamplona.amethyst.ui.note.types.RenderNamedNappletEvent import com.vitorpamplona.amethyst.ui.note.types.RenderNamedSiteEvent import com.vitorpamplona.amethyst.ui.note.types.RenderNipContent @@ -257,21 +256,22 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderWikiMergeAcceptance import com.vitorpamplona.amethyst.ui.note.types.RenderWikiMergeRequest import com.vitorpamplona.amethyst.ui.note.types.RenderWikiRedirect import com.vitorpamplona.amethyst.ui.note.types.RenderZapPoll +import com.vitorpamplona.amethyst.ui.note.types.RenderZapReceipt import com.vitorpamplona.amethyst.ui.note.types.ReplyRenderType import com.vitorpamplona.amethyst.ui.note.types.VideoDisplay import com.vitorpamplona.amethyst.ui.note.types.lists.RenderAppCurationSet import com.vitorpamplona.amethyst.ui.note.types.lists.RenderArticleCurationSet import com.vitorpamplona.amethyst.ui.note.types.lists.RenderBookmarkList +import com.vitorpamplona.amethyst.ui.note.types.lists.RenderBookmarkSet import com.vitorpamplona.amethyst.ui.note.types.lists.RenderFavoriteAlgoFeedsList import com.vitorpamplona.amethyst.ui.note.types.lists.RenderGeohashList import com.vitorpamplona.amethyst.ui.note.types.lists.RenderGitAuthorList import com.vitorpamplona.amethyst.ui.note.types.lists.RenderGitRepositoryList import com.vitorpamplona.amethyst.ui.note.types.lists.RenderGoodWikiAuthorList import com.vitorpamplona.amethyst.ui.note.types.lists.RenderGoodWikiRelayList -import com.vitorpamplona.amethyst.ui.note.types.lists.RenderHashtagList +import com.vitorpamplona.amethyst.ui.note.types.lists.RenderInterestList import com.vitorpamplona.amethyst.ui.note.types.lists.RenderInterestSet import com.vitorpamplona.amethyst.ui.note.types.lists.RenderKindMuteSet -import com.vitorpamplona.amethyst.ui.note.types.lists.RenderLabeledBookmarkList import com.vitorpamplona.amethyst.ui.note.types.lists.RenderMediaFollowList import com.vitorpamplona.amethyst.ui.note.types.lists.RenderMediaStarterPack import com.vitorpamplona.amethyst.ui.note.types.lists.RenderMuteList @@ -331,16 +331,16 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.tags.geohash.geoHashOrScope import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent -import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip04Dm.messages.EncryptedDmEvent import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip13Pow.strongPoWOrNull import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent -import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.nip17Dm.settings.DmRelayListEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent import com.vitorpamplona.quartz.nip25Reactions.ExternalReactionEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent @@ -367,47 +367,47 @@ import com.vitorpamplona.quartz.nip51Lists.appCurationSet.AppCurationSetEvent import com.vitorpamplona.quartz.nip51Lists.articleCurationSet.ArticleCurationSetEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkSet.BookmarkSetEvent import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListEvent -import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import com.vitorpamplona.quartz.nip51Lists.followSet.FollowSetEvent import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent import com.vitorpamplona.quartz.nip51Lists.gitAuthorList.GitAuthorListEvent import com.vitorpamplona.quartz.nip51Lists.gitRepositoryList.GitRepositoryListEvent import com.vitorpamplona.quartz.nip51Lists.goodWikiAuthorList.GoodWikiAuthorListEvent import com.vitorpamplona.quartz.nip51Lists.goodWikiRelayList.GoodWikiRelayListEvent -import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent +import com.vitorpamplona.quartz.nip51Lists.interestList.InterestListEvent import com.vitorpamplona.quartz.nip51Lists.interestSet.InterestSetEvent import com.vitorpamplona.quartz.nip51Lists.kindMuteSet.KindMuteSetEvent -import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.LabeledBookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.mediaFollowList.MediaFollowListEvent import com.vitorpamplona.quartz.nip51Lists.mediaStarterPack.MediaStarterPackEvent import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent -import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import com.vitorpamplona.quartz.nip51Lists.pictureCurationSet.PictureCurationSetEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BroadcastRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.FavoriteRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent -import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relaySets.RelaySetEvent import com.vitorpamplona.quartz.nip51Lists.releaseArtifactSet.ReleaseArtifactSetEvent import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.SimpleGroupListEvent +import com.vitorpamplona.quartz.nip51Lists.starterPack.StarterPackEvent import com.vitorpamplona.quartz.nip51Lists.videoCurationSet.VideoCurationSetEvent import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent -import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent +import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarCollectionEvent import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent +import com.vitorpamplona.quartz.nip54Wiki.WikiArticleEvent import com.vitorpamplona.quartz.nip54Wiki.WikiMergeAcceptanceEvent import com.vitorpamplona.quartz.nip54Wiki.WikiMergeRequestEvent -import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip54Wiki.WikiRedirectEvent import com.vitorpamplona.quartz.nip56Reports.ReportEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent import com.vitorpamplona.quartz.nip57Zaps.splits.hasZapSplitSetupBesidesAuthor import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent @@ -427,7 +427,7 @@ import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprov import com.vitorpamplona.quartz.nip72ModCommunities.communityAddress import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.nip72ModCommunities.isACommunityPost -import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent +import com.vitorpamplona.quartz.nip75ZapGoals.ZapGoalEvent import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent @@ -437,9 +437,9 @@ import com.vitorpamplona.quartz.nip87Ecash.recommendation.MintRecommendationEven import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.AppRecommendationEvent -import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent -import com.vitorpamplona.quartz.nip90Dvms.status.NIP90StatusEvent -import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent +import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.DvmContentDiscoveryResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.status.DvmStatusEvent +import com.vitorpamplona.quartz.nip94FileMetadata.FileMetadataEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent @@ -1145,7 +1145,7 @@ private fun RenderNoteRow( RenderReport(baseNote, quotesLeft, backgroundColor, accountViewModel, nav) } - is LongTextNoteEvent -> { + is LongFormContentEvent -> { RenderLongFormContent(baseNote, accountViewModel, nav) } @@ -1171,7 +1171,7 @@ private fun RenderNoteRow( RenderCodeSnippetEvent(baseNote) } - is WikiNoteEvent -> { + is WikiArticleEvent -> { RenderWikiContent(baseNote, accountViewModel, nav) } @@ -1191,8 +1191,8 @@ private fun RenderNoteRow( BadgeDisplay(baseNote = baseNote, accountViewModel = accountViewModel, nav = nav) } - is LnZapEvent -> { - RenderLnZap(baseNote, quotesLeft, backgroundColor, accountViewModel, nav) + is ZapReceiptEvent -> { + RenderZapReceipt(baseNote, quotesLeft, backgroundColor, accountViewModel, nav) } is NutzapEvent -> { @@ -1215,12 +1215,12 @@ private fun RenderNoteRow( RenderFhirResource(baseNote, accountViewModel, nav) } - is PeopleListEvent -> { + is FollowSetEvent -> { DisplayPeopleList(baseNote, backgroundColor, accountViewModel, nav) } - is FollowListEvent -> { - DisplayFollowList(baseNote, true, accountViewModel, nav) + is StarterPackEvent -> { + DisplayStarterPack(baseNote, true, accountViewModel, nav) } is ContactListEvent -> { @@ -1231,7 +1231,7 @@ private fun RenderNoteRow( DisplayRelaySet(baseNote, backgroundColor, accountViewModel, nav) } - is ChatMessageRelayListEvent -> { + is DmRelayListEvent -> { DisplayDMRelayList(baseNote, backgroundColor, accountViewModel, nav) } @@ -1251,8 +1251,8 @@ private fun RenderNoteRow( DisplayTrustedRelayList(baseNote, backgroundColor, accountViewModel, nav) } - is RelayFeedsListEvent -> { - DisplayRelayFeedsList(baseNote, backgroundColor, accountViewModel, nav) + is FavoriteRelayListEvent -> { + DisplayFavoriteRelayList(baseNote, backgroundColor, accountViewModel, nav) } is IndexerRelayListEvent -> { @@ -1384,7 +1384,7 @@ private fun RenderNoteRow( ) } - is PrivateDmEvent -> { + is EncryptedDmEvent -> { RenderPrivateMessage( baseNote, makeItShort, @@ -1494,7 +1494,7 @@ private fun RenderNoteRow( RenderCalendarDateSlotEvent(baseNote, accountViewModel, nav) } - is CalendarEvent -> { + is CalendarCollectionEvent -> { RenderCalendarCollectionEvent(baseNote, accountViewModel, nav) } @@ -1502,7 +1502,7 @@ private fun RenderNoteRow( RenderCalendarRSVPEvent(baseNote, accountViewModel, nav) } - is GoalEvent -> { + is ZapGoalEvent -> { RenderGoal(baseNote, accountViewModel, nav) } @@ -1550,8 +1550,8 @@ private fun RenderNoteRow( ) } - is NIP90ContentDiscoveryResponseEvent -> { - RenderNIP90ContentDiscoveryResponse( + is DvmContentDiscoveryResponseEvent -> { + RenderDvmContentDiscoveryResponse( baseNote, makeItShort, canPreview, @@ -1562,8 +1562,8 @@ private fun RenderNoteRow( ) } - is NIP90StatusEvent -> { - RenderNIP90Status( + is DvmStatusEvent -> { + RenderDvmStatus( baseNote, accountViewModel, nav, @@ -1640,8 +1640,8 @@ private fun RenderNoteRow( ) } - is FileHeaderEvent -> { - FileHeaderDisplay(baseNote, true, ContentScale.FillWidth, accountViewModel) + is FileMetadataEvent -> { + FileMetadataDisplay(baseNote, true, ContentScale.FillWidth, accountViewModel) } // Covers every NIP-71 video kind (21, 22, 34235, 34236) via the shared interface, @@ -1879,8 +1879,8 @@ private fun RenderNoteRow( RenderOldBookmarkList(baseNote, quotesLeft, backgroundColor, accountViewModel, nav) } - is LabeledBookmarkListEvent -> { - RenderLabeledBookmarkList(baseNote, quotesLeft, backgroundColor, accountViewModel, nav) + is BookmarkSetEvent -> { + RenderBookmarkSet(baseNote, quotesLeft, backgroundColor, accountViewModel, nav) } is ArticleCurationSetEvent -> { @@ -1923,8 +1923,8 @@ private fun RenderNoteRow( RenderMediaStarterPack(baseNote, backgroundColor, accountViewModel, nav) } - is HashtagListEvent -> { - RenderHashtagList(baseNote, backgroundColor, accountViewModel, nav) + is InterestListEvent -> { + RenderInterestList(baseNote, backgroundColor, accountViewModel, nav) } is InterestSetEvent -> { @@ -2172,7 +2172,7 @@ fun FirstUserInfoRow( // Zap receipts are signed by the recipient's lightning provider; show the // sender from the embedded zap request instead of the service key. val zapSender = - if (baseNote.event is LnZapEvent) { + if (baseNote.event is ZapReceiptEvent) { observeZapSender(baseNote, accountViewModel).value } else { null @@ -2411,7 +2411,7 @@ fun RenderAuthorImages( } else { NoteAuthorPicture(baseNote, Size55dp, accountViewModel = accountViewModel, nav = nav) } - } else if (noteEvent is LnZapEvent) { + } else if (noteEvent is ZapReceiptEvent) { // Zap receipts are signed by the recipient's lightning provider; show the // sender from the embedded zap request instead of the service key, matching // how the thread's master note resolves the author. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt index 5cb7a5d1cd..27c3f60fd7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt @@ -116,8 +116,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.report.ReportNoteDialog import com.vitorpamplona.quartz.experimental.bounties.bountyBaseReward import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent -import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent -import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent +import com.vitorpamplona.quartz.nip51Lists.followSet.FollowSetEvent +import com.vitorpamplona.quartz.nip51Lists.starterPack.StarterPackEvent import kotlinx.coroutines.launch import org.jetbrains.compose.resources.StringResource @@ -149,8 +149,8 @@ val externalLinkForNote = { note: Note -> if (note is AddressableNote) { when { note.event?.bountyBaseReward() != null -> "https://nostrbounties.com/b/${note.toNAddr()}" - note.event is PeopleListEvent -> "https://listr.lol/a/${note.toNAddr()}" - note.event is FollowListEvent -> "https://following.space/d/${note.address.dTag}?p=${note.address.pubKeyHex}" + note.event is FollowSetEvent -> "https://listr.lol/a/${note.toNAddr()}" + note.event is StarterPackEvent -> "https://following.space/d/${note.address.dTag}?p=${note.address.pubKeyHex}" else -> njumpLink(note.toNAddr()) } } else { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateReactionTypeDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateReactionTypeDialog.kt index 7fc4cc5be5..415645df86 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateReactionTypeDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateReactionTypeDialog.kt @@ -95,7 +95,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag -import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEvent +import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiListEvent import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -358,10 +358,10 @@ private fun EmojiSelector( onClick: ((EmojiUrlTag) -> Unit)? = null, ) { LoadAddressableNote( - accountViewModel.account.emoji.getEmojiPackSelectionAddress(), + accountViewModel.account.emoji.getEmojiListAddress(), ) { emptyNote -> emptyNote?.let { usersEmojiList -> - val collections by observeNoteEventAndMapNotNull(usersEmojiList, accountViewModel) { event: EmojiPackSelectionEvent -> + val collections by observeNoteEventAndMapNotNull(usersEmojiList, accountViewModel) { event: EmojiListEvent -> event.emojiPacks().toImmutableList() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt index 51351dac5b..af65ce3dbb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt @@ -121,7 +121,7 @@ import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.keyBackup.getFragmentActivity import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel -import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.launch @@ -153,22 +153,22 @@ fun UpdateZapAmountContent( val zapTypes = listOf( Triple( - LnZapEvent.ZapType.PUBLIC, + ZapReceiptEvent.ZapType.PUBLIC, stringRes(id = Res.string.zap_type_public), stringRes(id = Res.string.zap_type_public_explainer), ), Triple( - LnZapEvent.ZapType.PRIVATE, + ZapReceiptEvent.ZapType.PRIVATE, stringRes(id = Res.string.zap_type_private), stringRes(id = Res.string.zap_type_private_explainer), ), Triple( - LnZapEvent.ZapType.ANONYMOUS, + ZapReceiptEvent.ZapType.ANONYMOUS, stringRes(id = Res.string.zap_type_anonymous), stringRes(id = Res.string.zap_type_anonymous_explainer), ), Triple( - LnZapEvent.ZapType.NONZAP, + ZapReceiptEvent.ZapType.NONZAP, stringRes(id = Res.string.zap_type_nonzap), stringRes(id = Res.string.zap_type_nonzap_explainer), ), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountViewModel.kt index b1b44291e6..168e937299 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountViewModel.kt @@ -32,7 +32,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip19Bech32.decodePrivateKeyAsHexOrNull import com.vitorpamplona.quartz.nip19Bech32.decodePublicKey import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect -import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent import kotlinx.coroutines.CancellationException @Stable @@ -44,7 +44,7 @@ class UpdateZapAmountViewModel : ViewModel() { var walletConnectRelay by mutableStateOf(TextFieldValue("")) var walletConnectPubkey by mutableStateOf(TextFieldValue("")) var walletConnectSecret by mutableStateOf(TextFieldValue("")) - var selectedZapType by mutableStateOf(LnZapEvent.ZapType.PRIVATE) + var selectedZapType by mutableStateOf(ZapReceiptEvent.ZapType.PRIVATE) // A local UI preference rather than synced account state, but it is edited on // this screen, so it follows this screen's Save/Cancel contract instead of diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserCompose.kt index 46771e5f29..f002b59bef 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserCompose.kt @@ -43,6 +43,7 @@ import com.vitorpamplona.amethyst.commons.resources.follow import com.vitorpamplona.amethyst.commons.resources.login_with_a_private_key_to_be_able_to_follow import com.vitorpamplona.amethyst.commons.resources.login_with_a_private_key_to_be_able_to_unfollow import com.vitorpamplona.amethyst.commons.resources.read_only_user +import com.vitorpamplona.amethyst.commons.ui.layouts.listItem.SlimListItem import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.note.ShowUserButton import com.vitorpamplona.amethyst.commons.ui.theme.Size55dp @@ -51,7 +52,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.observeAccountIsHiddenUser import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserAboutMe import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserIsFollowing -import com.vitorpamplona.amethyst.ui.layouts.listItem.SlimListItem import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt index 753d91f79f..a5af97da5f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt @@ -52,7 +52,7 @@ import com.vitorpamplona.amethyst.commons.ui.note.ScoreTag import com.vitorpamplona.amethyst.commons.ui.screen.LocalDisplaySettings import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserContactCardsScore +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserAssertionsScore import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -565,7 +565,7 @@ fun ObserveAndRenderUserCards( modifier: Modifier = Modifier, accountViewModel: AccountViewModel, ) { - val score by observeUserContactCardsScore(user, accountViewModel) + val score by observeUserAssertionsScore(user, accountViewModel) score?.let { ScoreTag(it, size, modifier) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt index 75a78c8df2..ac414ad2b2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt @@ -117,7 +117,7 @@ import com.vitorpamplona.amethyst.service.ZapPaymentHandler import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.OnchainZapSendDialog import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.CancellationException @@ -163,35 +163,35 @@ fun ZapCustomDialog( val zapTypes = listOf( Triple( - LnZapEvent.ZapType.PUBLIC, + ZapReceiptEvent.ZapType.PUBLIC, stringRes(id = Res.string.zap_type_public), stringRes(id = Res.string.zap_type_public_explainer), ), Triple( - LnZapEvent.ZapType.PRIVATE, + ZapReceiptEvent.ZapType.PRIVATE, stringRes(id = Res.string.zap_type_private), stringRes(id = Res.string.zap_type_private_explainer), ), Triple( - LnZapEvent.ZapType.ANONYMOUS, + ZapReceiptEvent.ZapType.ANONYMOUS, stringRes(id = Res.string.zap_type_anonymous), stringRes(id = Res.string.zap_type_anonymous_explainer), ), Triple( - LnZapEvent.ZapType.NONZAP, + ZapReceiptEvent.ZapType.NONZAP, stringRes(id = Res.string.zap_type_nonzap), stringRes(id = Res.string.zap_type_nonzap_explainer), ), ).filter { - !isPrivateTarget || it.first == LnZapEvent.ZapType.PRIVATE || it.first == LnZapEvent.ZapType.NONZAP + !isPrivateTarget || it.first == ZapReceiptEvent.ZapType.PRIVATE || it.first == ZapReceiptEvent.ZapType.NONZAP } var selectedZapType by remember(accountViewModel, baseNote) { val default = accountViewModel.defaultZapType() mutableStateOf( - if (isPrivateTarget && default != LnZapEvent.ZapType.NONZAP) { - LnZapEvent.ZapType.PRIVATE + if (isPrivateTarget && default != ZapReceiptEvent.ZapType.NONZAP) { + ZapReceiptEvent.ZapType.PRIVATE } else { default }, @@ -327,15 +327,15 @@ fun ZapCustomDialog( OutlinedTextField( label = { when (selectedZapType) { - LnZapEvent.ZapType.PUBLIC, LnZapEvent.ZapType.ANONYMOUS -> { + ZapReceiptEvent.ZapType.PUBLIC, ZapReceiptEvent.ZapType.ANONYMOUS -> { Text(text = stringRes(id = Res.string.custom_zaps_add_a_message)) } - LnZapEvent.ZapType.PRIVATE -> { + ZapReceiptEvent.ZapType.PRIVATE -> { Text(text = stringRes(id = Res.string.custom_zaps_add_a_message_private)) } - LnZapEvent.ZapType.NONZAP -> { + ZapReceiptEvent.ZapType.NONZAP -> { Text(text = stringRes(id = Res.string.custom_zaps_add_a_message_nonzap)) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapPollNote.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapPollNote.kt index 445efa5a78..60899cf0e4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapPollNote.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapPollNote.kt @@ -113,7 +113,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockVitorAccountViewModel import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -145,7 +145,7 @@ fun ZapZapPollNotePreview() { ) val zapVote = - LnZapEvent( + ZapReceiptEvent( id = "2a17fdcd0e387d1623c7313d7aa2848e18dde8a942cfe8a2d6b686ea5f68f01a", pubKey = "79f00d3f5a19ec806189fcab03c1be4ff81d18ee4f653c88fac41fe03570f432", createdAt = 1729186293, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapPollNoteViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapPollNoteViewModel.kt index 1a331eb8ac..d8970b6b23 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapPollNoteViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapPollNoteViewModel.kt @@ -30,8 +30,8 @@ import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapRequestEvent import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -233,9 +233,9 @@ class PollNoteViewModel : ViewModel() { user: User, ): Boolean = pollNote!!.zaps.any { - val zapEvent = it.value?.event as? LnZapEvent + val zapEvent = it.value?.event as? ZapReceiptEvent val privateZapAuthor = - (it.key.event as? LnZapRequestEvent)?.let { + (it.key.event as? ZapRequestEvent)?.let { account.privateZapsDecryptionCache.cachedPrivateZap(it) } zapEvent?.zappedPollOption() == option && @@ -244,7 +244,7 @@ class PollNoteViewModel : ViewModel() { private fun zappedPollOptionAmount(option: Int): BigDecimal = pollNote?.zaps?.values?.sumOf { - val event = it?.event as? LnZapEvent + val event = it?.event as? ZapReceiptEvent val zapAmount = event?.amount ?: BigDecimal.ZERO val isValidAmount = isValidInputVoteAmount(event?.amount) @@ -258,7 +258,7 @@ class PollNoteViewModel : ViewModel() { private fun totalZapped(): BigDecimal = pollNote?.zaps?.values?.sumOf { - val zapEvent = (it?.event as? LnZapEvent) + val zapEvent = (it?.event as? ZapReceiptEvent) val zapAmount = zapEvent?.amount ?: BigDecimal.ZERO val isValidAmount = isValidInputVoteAmount(zapEvent?.amount) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/emojiSuggestions/WatchAndLoadMyEmojiList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/emojiSuggestions/WatchAndLoadMyEmojiList.kt index 1687f0a6ea..cabe2aafbe 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/emojiSuggestions/WatchAndLoadMyEmojiList.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/emojiSuggestions/WatchAndLoadMyEmojiList.kt @@ -27,16 +27,16 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNo import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip01Core.tags.aTag.taggedAddresses -import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEvent +import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiListEvent import kotlinx.collections.immutable.toImmutableList @Composable fun WatchAndLoadMyEmojiList(accountViewModel: AccountViewModel) { LoadAddressableNote( - EmojiPackSelectionEvent.createAddress(accountViewModel.userProfile().pubkeyHex), + EmojiListEvent.createAddress(accountViewModel.userProfile().pubkeyHex), ) { emptyNote -> emptyNote?.let { usersEmojiList -> - val collections by observeNoteEventAndMapNotNull(usersEmojiList, accountViewModel) { event: EmojiPackSelectionEvent -> + val collections by observeNoteEventAndMapNotNull(usersEmojiList, accountViewModel) { event: EmojiListEvent -> event.taggedAddresses().toImmutableList() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/AudienceSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/AudienceSheet.kt index bb78a2b1b1..86c454cf7e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/AudienceSheet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/AudienceSheet.kt @@ -67,7 +67,7 @@ import com.vitorpamplona.amethyst.commons.model.composer.AudienceList import com.vitorpamplona.amethyst.commons.model.composer.AudienceListKind import com.vitorpamplona.amethyst.commons.model.composer.AudienceMember import com.vitorpamplona.amethyst.commons.model.composer.AudienceSelection -import com.vitorpamplona.amethyst.commons.model.nip51Lists.peopleList.PeopleList +import com.vitorpamplona.amethyst.commons.model.nip51Lists.followSets.PeopleList import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.audience_add_anyway import com.vitorpamplona.amethyst.commons.resources.audience_add_people @@ -88,6 +88,7 @@ import com.vitorpamplona.amethyst.commons.resources.discover_follows import com.vitorpamplona.amethyst.commons.resources.follow_sets import com.vitorpamplona.amethyst.commons.resources.num_selected import com.vitorpamplona.amethyst.commons.resources.select_all +import com.vitorpamplona.amethyst.commons.ui.components.OutlinedThinPaddingTextField import com.vitorpamplona.amethyst.commons.ui.pluralStringRes import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness @@ -95,7 +96,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.Size24dp import com.vitorpamplona.amethyst.commons.ui.theme.grayText import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText import com.vitorpamplona.amethyst.commons.ui.theme.warningColor -import com.vitorpamplona.amethyst.ui.components.OutlinedThinPaddingTextField import com.vitorpamplona.amethyst.ui.note.BaseUserPicture import com.vitorpamplona.amethyst.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList @@ -545,9 +545,9 @@ private fun MemberBadge( */ @Composable fun rememberAudienceLists(accountViewModel: AccountViewModel): List { - val peopleLists by accountViewModel.account.peopleLists.uiListFlow + val peopleLists by accountViewModel.account.followSets.uiListFlow .collectAsStateWithLifecycle() - val followPacks by accountViewModel.account.followLists.uiListFlow + val followPacks by accountViewModel.account.starterPacks.uiListFlow .collectAsStateWithLifecycle() return remember(peopleLists, followPacks) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/ShowUserSuggestionList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/ShowUserSuggestionList.kt index b13c5e1386..ee8e180270 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/ShowUserSuggestionList.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/ShowUserSuggestionList.kt @@ -53,6 +53,7 @@ import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.Nip05State import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.user_suggestion_in_this_chat +import com.vitorpamplona.amethyst.commons.ui.layouts.listItem.SlimListItem import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness import com.vitorpamplona.amethyst.commons.ui.theme.Font14SP @@ -60,7 +61,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.NIP05IconSize import com.vitorpamplona.amethyst.commons.ui.theme.Size55dp import com.vitorpamplona.amethyst.commons.ui.theme.nip05 import com.vitorpamplona.amethyst.service.relayClient.searchCommand.UserSearchDataSourceSubscription -import com.vitorpamplona.amethyst.ui.layouts.listItem.SlimListItem import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture import com.vitorpamplona.amethyst.ui.note.ObserveAndRenderNIP05VerifiedSymbol import com.vitorpamplona.amethyst.ui.note.UsernameDisplay diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt index 94534ff21a..764d734442 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt @@ -254,8 +254,18 @@ class UserSuggestionState( state: TextFieldState, word: String, item: User, + /** + * Always insert the resolved `nostr:` URI, never the `@npub1…` short form. + * + * The short form is only half a mention: it relies on a send-time tagger to + * rewrite it and emit the `p` tag. Surfaces that have one (every nostr-event + * composer) keep it. cordn does not — its content goes into a sealed envelope + * verbatim, and its reader parses `nostr:` URIs out of that content — so an + * `@npub1…` there would ship as literal text and mention nobody. + */ + forceNostrUri: Boolean = false, ) { - val wordToInsert = mentionInsertion(word, item) + val wordToInsert = mentionInsertion(word, item, forceNostrUri) state.edit { val lastWordStart = selection.end - word.length replace(lastWordStart, selection.end, wordToInsert) @@ -280,7 +290,10 @@ class UserSuggestionState( private fun mentionInsertion( word: String, item: User, + forceNostrUri: Boolean = false, ): String { + if (forceNostrUri) return "nostr:${item.toNProfile()} " + val typed = userSearchTermOrNull(word) val wasNip05Mention = typed != null && diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapsplits/ForwardZapTo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapsplits/ForwardZapTo.kt index 6554cdcabe..ad2b1c3ea5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapsplits/ForwardZapTo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapsplits/ForwardZapTo.kt @@ -45,6 +45,7 @@ import com.vitorpamplona.amethyst.commons.resources.zap_split_explainer import com.vitorpamplona.amethyst.commons.resources.zap_split_search_and_add_user import com.vitorpamplona.amethyst.commons.resources.zap_split_search_and_add_user_placeholder import com.vitorpamplona.amethyst.commons.resources.zap_split_title +import com.vitorpamplona.amethyst.commons.ui.components.OutlinedThinPaddingTextField import com.vitorpamplona.amethyst.commons.ui.note.creators.zapsplits.IZapField import com.vitorpamplona.amethyst.commons.ui.note.creators.zapsplits.ZapSplitIcon import com.vitorpamplona.amethyst.commons.ui.stringRes @@ -55,7 +56,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.Size55dp import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation -import com.vitorpamplona.amethyst.ui.components.OutlinedThinPaddingTextField import com.vitorpamplona.amethyst.ui.note.BaseUserPicture import com.vitorpamplona.amethyst.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/AddInboxRelayForDMCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/AddInboxRelayForDMCard.kt index 15205698c9..856bddd12c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/AddInboxRelayForDMCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/AddInboxRelayForDMCard.kt @@ -55,7 +55,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.dm.AddDMRelayListDialog import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.nip17Dm.settings.DmRelayListEvent @Preview @Composable @@ -88,7 +88,7 @@ fun ObserveRelayListForDMsAndDisplayIfNotFound( @Composable fun ObserveRelayListForDMs( accountViewModel: AccountViewModel, - inner: @Composable (relayListEvent: ChatMessageRelayListEvent?) -> Unit, + inner: @Composable (relayListEvent: DmRelayListEvent?) -> Unit, ) { ObserveRelayListForDMs( pubkey = accountViewModel.account.userProfile().pubkeyHex, @@ -102,13 +102,13 @@ fun ObserveRelayListForDMs( fun ObserveRelayListForDMs( pubkey: HexKey, accountViewModel: AccountViewModel, - inner: @Composable (relayListEvent: ChatMessageRelayListEvent?) -> Unit, + inner: @Composable (relayListEvent: DmRelayListEvent?) -> Unit, ) { LoadAddressableNote( - ChatMessageRelayListEvent.createAddress(pubkey), + DmRelayListEvent.createAddress(pubkey), ) { relayList -> if (relayList != null) { - val relayListEvent by observeNoteEvent(relayList, accountViewModel) + val relayListEvent by observeNoteEvent(relayList, accountViewModel) inner(relayListEvent) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt index dabc6e7890..fd09217ed1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt @@ -281,7 +281,7 @@ fun observeBookmarksFollowsAndAccount( accountViewModel.account.bookmarkState.bookmarks, accountViewModel.account.pinState.pinnedEventIdSet, accountViewModel.showSensitiveContent(), - accountViewModel.account.emoji.getEmojiPackSelectionFlow(), + accountViewModel.account.emoji.getEmojiListFlow(), ) { follows, bookmarks, pinnedIds, showSensitiveContent, emojiSelectionState -> val isEmojiPackInMyList = if (noteIdForEmoji != null) { @@ -312,7 +312,7 @@ fun observeBookmarksFollowsAndAccount( isEmojiPackInMyList = noteIdForEmoji?.let { accountViewModel.account.emoji - .getEmojiPackSelection() + .getEmojiList() ?.isTaggedAddressableNote(it) == true } ?: false, ), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteActionSections.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteActionSections.kt index cbb23ef5a4..958acad531 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteActionSections.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteActionSections.kt @@ -80,7 +80,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent import kotlinx.coroutines.Dispatchers @@ -222,7 +222,7 @@ fun noteActionSections( // nostr source pre-tagged (`a` for an addressable article, else `e`) plus the author, // and the passage left for the user to type or paste. Prose kinds only, and never a // private rumor (a public highlight would e-tag the unsigned rumor onto relays). - if (!isPrivateRumor && (note.event is TextNoteEvent || note.event is LongTextNoteEvent)) { + if (!isPrivateRumor && (note.event is TextNoteEvent || note.event is LongFormContentEvent)) { add( NoteAction(MaterialSymbols.FormatQuote, stringRes(Res.string.highlight_action)) { val author = note.author?.pubkeyHex @@ -256,7 +256,7 @@ fun noteActionSections( onClick = handlers.onEditPost, ), ) - } else if (note.event is LongTextNoteEvent && state.isLoggedUser) { + } else if (note.event is LongFormContentEvent && state.isLoggedUser) { add( NoteAction(MaterialSymbols.Edit, stringRes(Res.string.edit_article)) { nav.nav { Route.NewLongFormPost(version = note.idHex) } @@ -343,10 +343,10 @@ fun noteActionSections( } else -> { - val noteBookmarkType = if (note.event is LongTextNoteEvent) stringRes(Res.string.article) else stringRes(Res.string.post) + val noteBookmarkType = if (note.event is LongFormContentEvent) stringRes(Res.string.article) else stringRes(Res.string.post) add( NoteAction(MaterialSymbols.BookmarkAdd, stringRes(Res.string.manage_bookmark_label, noteBookmarkType)) { - if (note.event is LongTextNoteEvent) { + if (note.event is LongFormContentEvent) { nav.nav(Route.ArticleBookmarkManagement((note as AddressableNote).address)) } else { nav.nav(Route.PostBookmarkManagement(note.idHex)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt index 8afb6da337..f6550f6e3a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt @@ -109,7 +109,7 @@ import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarningReason import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitive import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent import com.vitorpamplona.quartz.nip40Expiration.expiration -import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplits import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiser import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount @@ -352,7 +352,7 @@ open class CommentPostViewModel : this.externalIdentity = (post.event as? CommentEvent)?.scope() mutedNotifies = emptySet() notifyProvenance = emptyMap() - (post.event as? LnZapEvent)?.let { zap -> + (post.event as? ZapReceiptEvent)?.let { zap -> notifying = listOfNotNull(zapSenderToNotify(zap)) } observeCommunityRules(post) @@ -366,7 +366,7 @@ open class CommentPostViewModel : * by an ephemeral key: skip those — tagging the throwaway key is useless, and * tagging the decrypted sender would publicly expose a private zapper. */ - private fun zapSenderToNotify(zapEvent: LnZapEvent): User? { + private fun zapSenderToNotify(zapEvent: ZapReceiptEvent): User? { val request = zapEvent.zapRequest ?: return null if (request.hasAnonTag()) return null if (request.pubKey == account.signer.pubKey) return null @@ -554,7 +554,7 @@ open class CommentPostViewModel : // Replies to zaps notify the zap sender through a plain p tag (the receipt's // author keys above are the lightning provider). The sender chip always comes // back; a missing p tag in the draft means the user muted their bell. - (replyingTo?.event as? LnZapEvent)?.let { zap -> + (replyingTo?.event as? ZapReceiptEvent)?.let { zap -> zapSenderToNotify(zap)?.let { sender -> notifying = ((notifying ?: emptyList()) + sender).distinct() if (!draftEvent.tags.mapNotNull(PTag::parseKey).contains(sender.pubkeyHex)) { @@ -726,7 +726,7 @@ open class CommentPostViewModel : null } } - } else if (replyingToEvent is LnZapEvent) { + } else if (replyingToEvent is ZapReceiptEvent) { val sender = zapSenderToNotify(replyingToEvent) // The sender's chip stays in the list; a muted bell means // the user doesn't want to ping them, so don't tag them. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/share/SharedNoteCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/share/SharedNoteCard.kt index 0a54abc2f0..f0d9265a41 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/share/SharedNoteCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/share/SharedNoteCard.kt @@ -63,7 +63,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.gallery.GalleryThum import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.gallery.UrlImageView import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent import com.vitorpamplona.quartz.nip36SensitiveContent.ContentWarningTag import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent @@ -278,7 +278,7 @@ private fun secondaryLineFor( return when { event is PictureEvent -> stringRes(Res.string.share_as_qr_kind_picture) event is VideoEvent -> stringRes(Res.string.kind_video) - event is LongTextNoteEvent -> stringRes(Res.string.article) + event is LongFormContentEvent -> stringRes(Res.string.article) hasContentImage -> stringRes(Res.string.share_as_qr_kind_picture) else -> "" } @@ -293,7 +293,7 @@ internal fun secondaryBodyTextFor( // Gated: never surface the note's own title, content or alt text, only the kind label above. if (isGated) return null - if (event is LongTextNoteEvent) { + if (event is LongFormContentEvent) { val title = event.title() if (!title.isNullOrBlank()) return title } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ActivityCardPreviews.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ActivityCardPreviews.kt index d98200b394..d63b6a8880 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ActivityCardPreviews.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ActivityCardPreviews.kt @@ -103,10 +103,10 @@ private object ActivityCardPreviewData { } } - fun lnZapNote(): Note { + fun zapReceiptNote(): Note { val id = "d".repeat(64) // Receipts are signed by the lightning provider; the preview skips the - // embedded request and feeds the decrypted card to RenderLnZapCard directly. + // embedded request and feeds the decrypted card to RenderZapReceiptCard directly. return Note(id).apply { event = null author = null @@ -171,12 +171,12 @@ fun EmojiReactionActivityCardPreview() { @Preview @Composable -fun LnZapActivityCardPreview() { +fun ZapReceiptActivityCardPreview() { val accountViewModel = mockAccountViewModel() ThemeComparisonColumn { - RenderLnZapCard( - note = ActivityCardPreviewData.lnZapNote(), + RenderZapReceiptCard( + note = ActivityCardPreviewData.zapReceiptNote(), card = ZapAmountCommentNotification( user = LocalCache.getOrCreateUser(ActivityCardPreviewData.senderHex), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CalendarCollectionRender.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CalendarCollectionRender.kt index e28e4021e7..22b1fa72c6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CalendarCollectionRender.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CalendarCollectionRender.kt @@ -25,7 +25,7 @@ import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.note.CalendarCollectionCard import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent +import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarCollectionEvent /** * Entry for a NIP-52 calendar collection: decodes the [Note] and renders the shared commons @@ -38,7 +38,7 @@ fun RenderCalendarCollectionEvent( accountViewModel: AccountViewModel, nav: INav, ) { - val event = note.event as? CalendarEvent ?: return + val event = note.event as? CalendarCollectionEvent ?: return CalendarCollectionCard(event) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Emoji.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Emoji.kt index ebdf0422df..d65958b79d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Emoji.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Emoji.kt @@ -181,7 +181,7 @@ private fun EmojiListOptions( emojiPackNote: Note, ) { LoadAddressableNote( - accountViewModel.account.emoji.getEmojiPackSelectionAddress(), + accountViewModel.account.emoji.getEmojiListAddress(), ) { it?.let { usersEmojiList -> val hasAddedThis by observeNoteAndMap(usersEmojiList, accountViewModel) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileHeader.kt index 81a4a54709..29017e1a15 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileHeader.kt @@ -35,16 +35,16 @@ import com.vitorpamplona.amethyst.ui.components.SensitivityWarning import com.vitorpamplona.amethyst.ui.components.ZoomableContentView import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip31Alts.alt -import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent +import com.vitorpamplona.quartz.nip94FileMetadata.FileMetadataEvent @Composable -fun FileHeaderDisplay( +fun FileMetadataDisplay( note: Note, roundedCorner: Boolean, contentScale: ContentScale, accountViewModel: AccountViewModel, ) { - val event = (note.event as? FileHeaderEvent) ?: return + val event = (note.event as? FileMetadataEvent) ?: return val fullUrl = event.url() ?: return val mimeType = remember(note) { event.mimeType() } val content = remember(note) { event.toMediaContent(note, fullUrl, mimeType) } @@ -53,7 +53,7 @@ fun FileHeaderDisplay( // which viewer happens to render it, so an NSFW-tagged archive stays behind the same gate. SensitivityWarning(note = note, accountViewModel = accountViewModel) { if (content == null) { - FileHeaderAttachmentCard(event, fullUrl, mimeType) + FileMetadataAttachmentCard(event, fullUrl, mimeType) } else { ZoomableContentView( content = content, @@ -70,9 +70,9 @@ fun FileHeaderDisplay( * * Kind 1063 is a *generic* file container — its `m` tag can name any type, so unlike a NIP-71 * video event the kind itself asserts nothing about how to render the payload. A null here means - * the file belongs in [FileHeaderAttachmentCard] rather than being pushed into the video player. + * the file belongs in [FileMetadataAttachmentCard] rather than being pushed into the video player. */ -internal fun FileHeaderEvent.toMediaContent( +internal fun FileMetadataEvent.toMediaContent( note: Note, url: String, mimeType: String?, @@ -128,8 +128,8 @@ internal fun FileHeaderEvent.toMediaContent( /** The link card a kind-1063 header falls back to when [toMediaContent] returns null. */ @Composable -internal fun FileHeaderAttachmentCard( - event: FileHeaderEvent, +internal fun FileMetadataAttachmentCard( + event: FileMetadataEvent, url: String, mimeType: String?, ) { @@ -145,4 +145,4 @@ internal fun FileHeaderAttachmentCard( } /** The human-facing name of the file: NIP-94 `content` when present, else the `alt` tag. */ -private fun FileHeaderEvent.fileDescription(): String? = content.ifEmpty { null } ?: alt() +private fun FileMetadataEvent.fileDescription(): String? = content.ifEmpty { null } ?: alt() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Goal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Goal.kt index a272a55618..0c314f5017 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Goal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Goal.kt @@ -60,7 +60,7 @@ import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeader import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeaderBackground import com.vitorpamplona.amethyst.ui.note.showAmount import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent +import com.vitorpamplona.quartz.nip75ZapGoals.ZapGoalEvent import com.vitorpamplona.quartz.utils.TimeUtils import java.math.BigDecimal import kotlin.math.roundToInt @@ -71,14 +71,14 @@ fun RenderGoal( accountViewModel: AccountViewModel, nav: INav, ) { - val noteEvent = note.event as? GoalEvent ?: return + val noteEvent = note.event as? ZapGoalEvent ?: return GoalHeader(noteEvent, note, accountViewModel, nav) } @Composable fun GoalHeader( - noteEvent: GoalEvent, + noteEvent: ZapGoalEvent, note: Note, accountViewModel: AccountViewModel, nav: INav, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LongForm.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LongForm.kt index 7e87980caa..c316416092 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LongForm.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LongForm.kt @@ -65,7 +65,7 @@ import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeader import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeaderBackground import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent import kotlinx.collections.immutable.toImmutableList private const val WORDS_PER_MINUTE = 225 @@ -77,7 +77,7 @@ fun RenderLongFormContent( accountViewModel: AccountViewModel, nav: INav, ) { - val noteEvent = note.event as? LongTextNoteEvent ?: return + val noteEvent = note.event as? LongFormContentEvent ?: return LongFormHeader(noteEvent, note, accountViewModel) } @@ -85,7 +85,7 @@ fun RenderLongFormContent( @OptIn(ExperimentalLayoutApi::class) @Composable fun LongFormHeader( - noteEvent: LongTextNoteEvent, + noteEvent: LongFormContentEvent, note: Note, accountViewModel: AccountViewModel, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/NIP90ContentDiscoveryResponse.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/NIP90ContentDiscoveryResponse.kt index d5c9a604ab..8e2aa280a6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/NIP90ContentDiscoveryResponse.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/NIP90ContentDiscoveryResponse.kt @@ -35,10 +35,10 @@ import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasHashtags -import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.DvmContentDiscoveryResponseEvent @Composable -fun RenderNIP90ContentDiscoveryResponse( +fun RenderDvmContentDiscoveryResponse( note: Note, makeItShort: Boolean, canPreview: Boolean, @@ -47,7 +47,7 @@ fun RenderNIP90ContentDiscoveryResponse( accountViewModel: AccountViewModel, nav: INav, ) { - val noteEvent = note.event as? NIP90ContentDiscoveryResponseEvent ?: return + val noteEvent = note.event as? DvmContentDiscoveryResponseEvent ?: return val callbackUri = remember(note) { note.toNostrUri() } SensitivityWarning( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/NIP90Status.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/NIP90Status.kt index a2afc2ebec..19c27a2c83 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/NIP90Status.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/NIP90Status.kt @@ -25,15 +25,15 @@ import androidx.compose.runtime.Composable import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.quartz.nip90Dvms.status.NIP90StatusEvent +import com.vitorpamplona.quartz.nip90Dvms.status.DvmStatusEvent @Composable -fun RenderNIP90Status( +fun RenderDvmStatus( note: Note, accountViewModel: AccountViewModel, nav: INav, ) { - val noteEvent = note.event as? NIP90StatusEvent ?: return + val noteEvent = note.event as? DvmStatusEvent ?: return Text(text = noteEvent.content) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PeopleList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PeopleList.kt index effd49f5e1..1f5a19840b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PeopleList.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PeopleList.kt @@ -56,7 +56,7 @@ import com.vitorpamplona.amethyst.ui.note.UserCompose import com.vitorpamplona.amethyst.ui.note.getGradient import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUserIds -import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent +import com.vitorpamplona.quartz.nip51Lists.followSet.FollowSetEvent import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -68,7 +68,7 @@ fun DisplayPeopleList( accountViewModel: AccountViewModel, nav: INav, ) { - val noteEvent = baseNote.event as? PeopleListEvent ?: return + val noteEvent = baseNote.event as? FollowSetEvent ?: return var members by remember { mutableStateOf>(persistentListOf()) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PrivateMessage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PrivateMessage.kt index 1a2310f3fc..743ada7075 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PrivateMessage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PrivateMessage.kt @@ -53,7 +53,7 @@ import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.ChatroomHeader import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasHashtags -import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip04Dm.messages.EncryptedDmEvent import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable import com.vitorpamplona.quartz.nip19Bech32.toNpub @@ -67,7 +67,7 @@ fun RenderPrivateMessage( accountViewModel: AccountViewModel, nav: INav, ) { - val noteEvent = note.event as? PrivateDmEvent ?: return + val noteEvent = note.event as? EncryptedDmEvent ?: return val userRoom by remember(note) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ProfileCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ProfileCard.kt index 344fc539a2..c12f7659cb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ProfileCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ProfileCard.kt @@ -76,7 +76,7 @@ import com.vitorpamplona.amethyst.commons.ui.theme.bitcoinColor import com.vitorpamplona.amethyst.commons.ui.theme.innerPostModifier import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.observeAccountIsHiddenUser -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserContactCardsFollowerCount +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserAssertionsFollowerCount import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserIsFollowing import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor @@ -288,7 +288,7 @@ private fun ProfileCardChips( // A self-follow in your own kind:3 is common; "Follows you" on your own card is not a fact. val followsMe by observeUserIsFollowing(author, accountViewModel.account.userProfile(), accountViewModel) val followsYou = followsMe && !accountViewModel.isLoggedUser(author) - val followerCount by observeUserContactCardsFollowerCount(author, accountViewModel) + val followerCount by observeUserAssertionsFollowerCount(author, accountViewModel) // "--" is the placeholder the contact-card observer emits while no trusted // assertion for this user has arrived. Nothing to brag about yet. val followers = followerCount.takeIf { it != "--" } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Publication.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Publication.kt index ae654a1f05..fdccdb6b39 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Publication.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Publication.kt @@ -80,8 +80,8 @@ import com.vitorpamplona.quartz.experimental.publications.PublicationContentEven import com.vitorpamplona.quartz.experimental.publications.PublicationIndexEvent import com.vitorpamplona.quartz.experimental.publications.PublicationSectionRef import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent -import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent +import com.vitorpamplona.quartz.nip54Wiki.WikiArticleEvent import kotlinx.collections.immutable.toImmutableList // A 2:3 portrait cover — a book jacket, which is what NKBIP-01's default `book` type is. The @@ -355,8 +355,8 @@ private fun ObservedSectionRow( // list long-form, wiki and spec events as sections too. is PublicationIndexEvent -> event.titleOrIdentifier() is PublicationContentEvent -> event.titleOrIdentifier() - is LongTextNoteEvent -> event.title() - is WikiNoteEvent -> event.title() + is LongFormContentEvent -> event.title() + is WikiArticleEvent -> event.title() // A bookshelf directory lists whatever it likes, including another directory, so the // row has to name the library kinds too -- otherwise a nested entry falls through to // the section placeholder and reads "Untitled section", which it is not. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PublicationSection.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PublicationSection.kt index 73fea0a6e4..8eeaeba528 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PublicationSection.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PublicationSection.kt @@ -67,7 +67,7 @@ import com.vitorpamplona.quartz.experimental.publications.PublicationIndexEvent import com.vitorpamplona.quartz.experimental.publications.PublicationSectionRef import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent -import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent +import com.vitorpamplona.quartz.nip54Wiki.WikiArticleEvent /** * Renders a kind-30041 NKBIP-01 publication section — a chapter, zettel or episode. @@ -331,12 +331,12 @@ private fun wikilinkResolver(event: PublicationContentEvent): (String) -> String link == null -> null link.eventId != null -> - "nostr:" + NEvent.create(link.eventId!!, link.pubKey, WikiNoteEvent.KIND, link.relay) + "nostr:" + NEvent.create(link.eventId!!, link.pubKey, WikiArticleEvent.KIND, link.relay) link.pubKey != null -> "nostr:" + NAddress.create( - WikiNoteEvent.KIND, + WikiArticleEvent.KIND, link.pubKey!!, PublicationContentEvent.normalizeWikilink(link.target), link.relay, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RelayList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RelayList.kt index e90e933cbb..3a5e7e4ed6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RelayList.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RelayList.kt @@ -70,7 +70,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip01Core.core.firstTagValueFor import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl -import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.nip17Dm.settings.DmRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relaySets.RelaySetEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import kotlinx.collections.immutable.toImmutableList @@ -158,7 +158,7 @@ fun DisplayDMRelayList( accountViewModel: AccountViewModel, nav: INav, ) { - val noteEvent = baseNote.event as? ChatMessageRelayListEvent ?: return + val noteEvent = baseNote.event as? DmRelayListEvent ?: return val relays = remember(noteEvent) { @@ -241,14 +241,14 @@ fun DisplayTrustedRelayList( } @Composable -fun DisplayRelayFeedsList( +fun DisplayFavoriteRelayList( baseNote: Note, backgroundColor: MutableState, accountViewModel: AccountViewModel, nav: INav, ) { - val relays by accountViewModel.account.relayFeedsListDecryptionCache.observeDecryptedRelayList(baseNote).collectAsStateWithLifecycle( - accountViewModel.account.relayFeedsListDecryptionCache.fastStartValueForRelayList(baseNote), + val relays by accountViewModel.account.favoriteRelayListDecryptionCache.observeDecryptedRelayList(baseNote).collectAsStateWithLifecycle( + accountViewModel.account.favoriteRelayListDecryptionCache.fastStartValueForRelayList(baseNote), ) DisplayRelaySet( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SoftwareApp.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SoftwareApp.kt index 2bb1f341e6..2caa900e85 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SoftwareApp.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SoftwareApp.kt @@ -96,12 +96,11 @@ import com.vitorpamplona.amethyst.ui.note.elements.MoreOptionsButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.SoftwareApplicationEvent import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.asset.SoftwareAssetEvent -import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.SoftwareReleaseEvent -import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.asSoftwareRelease import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.isNip82SoftwareRelease import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.tags.AppIdTag import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag +import com.vitorpamplona.quartz.nip51Lists.releaseArtifactSet.ReleaseArtifactSetEvent import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.distinctUntilChanged @@ -209,7 +208,7 @@ fun RenderSoftwareApplication( } /** - * Latest NIP-82 [SoftwareReleaseEvent] version for [app], kept live. + * Latest NIP-82 [ReleaseArtifactSetEvent] version for [app], kept live. * * Releases (kind 30063) are separate events that point back to the app via an * `i` tag rather than an `a` tag, so they are never indexed as replies to the @@ -230,7 +229,7 @@ fun produceLatestReleaseVersion(app: SoftwareApplicationEvent): State { remember(app.pubKey, app.id) { val filter = Filter( - kinds = listOf(SoftwareReleaseEvent.KIND), + kinds = listOf(ReleaseArtifactSetEvent.KIND), authors = listOf(app.pubKey), tags = mapOf(AppIdTag.TAG_NAME to listOf(app.appId())), ) @@ -243,13 +242,13 @@ fun produceLatestReleaseVersion(app: SoftwareApplicationEvent): State { return flow.collectAsStateWithLifecycle(initialValue = null) } -fun findLatestNip82Release(app: SoftwareApplicationEvent): SoftwareReleaseEvent? = latestNip82Release(nip82ReleaseNotesFor(app), app) +fun findLatestNip82Release(app: SoftwareApplicationEvent): ReleaseArtifactSetEvent? = nip82ReleasesFor(app).maxByOrNull { it.createdAt } /** Picks the newest NIP-82 release for [app] out of an already-narrowed [notes] collection. */ private fun latestNip82Release( notes: Collection, app: SoftwareApplicationEvent, -): SoftwareReleaseEvent? { +): ReleaseArtifactSetEvent? { val prefix = "${app.dTag()}@" return notes .mapNotNull { it.asNip82ReleaseFor(prefix) } @@ -257,33 +256,26 @@ private fun latestNip82Release( } /** kind-30063 addressables authored by [app] whose `d` tag is `@`. */ -private fun nip82ReleaseNotesFor(app: SoftwareApplicationEvent): Set { +private fun nip82ReleasesFor(app: SoftwareApplicationEvent): List { val prefix = "${app.dTag()}@" - return LocalCache.addressables.filterIntoSet(SoftwareReleaseEvent.KIND, app.pubKey) { _, addr -> - val ev = addr.event ?: return@filterIntoSet false - ev.isNip82SoftwareRelease() && ev.dTag().startsWith(prefix) - } + return LocalCache.addressables + .filterIntoSet(ReleaseArtifactSetEvent.KIND, app.pubKey) { _, addr -> + val ev = addr.event ?: return@filterIntoSet false + ev.dTag().startsWith(prefix) && ev.isNip82SoftwareRelease() + }.mapNotNull { it.event as? ReleaseArtifactSetEvent } } /** - * Re-reads this note as the NIP-82 release for [prefix] (`@`). kind 30063 - * collides with NIP-51 `ReleaseArtifactSetEvent`, which is what `EventFactory` - * builds, so we re-parse matching tag arrays as the NIP-82 form. + * This note as the NIP-82 release for [prefix] (`@`). kind 30063 is shared + * with NIP-51 release artifact sets, so only events carrying the NIP-82 tags qualify. */ -private fun Note.asNip82ReleaseFor(prefix: String): SoftwareReleaseEvent? = - when (val ev = event) { - null -> null - is SoftwareReleaseEvent -> ev.takeIf { it.dTag().startsWith(prefix) } - else -> if (ev.isNip82SoftwareRelease() && ev.dTag().startsWith(prefix)) ev.asSoftwareRelease() else null - } - -fun findAllNip82Releases(app: SoftwareApplicationEvent): List { - val prefix = "${app.dTag()}@" - return nip82ReleaseNotesFor(app) - .mapNotNull { it.asNip82ReleaseFor(prefix) } - .sortedByDescending { it.createdAt } +private fun Note.asNip82ReleaseFor(prefix: String): ReleaseArtifactSetEvent? { + val ev = event as? ReleaseArtifactSetEvent ?: return null + return ev.takeIf { it.dTag().startsWith(prefix) && it.isNip82SoftwareRelease() } } +fun findAllNip82Releases(app: SoftwareApplicationEvent): List = nip82ReleasesFor(app).sortedByDescending { it.createdAt } + @Composable fun AppIcon( icon: String?, @@ -486,25 +478,17 @@ fun RenderSoftwareRelease( accountViewModel: AccountViewModel, nav: INav, ) { - // kind 30063 is shared between NIP-51 ReleaseArtifactSetEvent and NIP-82 SoftwareReleaseEvent; - // EventFactory always returns the NIP-51 class, so we re-parse the same tag array as the - // NIP-82 form when the tag signature matches. - val rawEvent = note.event ?: return - val event: SoftwareReleaseEvent = - if (rawEvent is SoftwareReleaseEvent) { - rawEvent - } else if (rawEvent.isNip82SoftwareRelease()) { - rawEvent.asSoftwareRelease() - } else { - return - } + // kind 30063 is shared between NIP-51 release artifact sets and NIP-82 software + // releases; only render the NIP-82 form here. + val event = note.event as? ReleaseArtifactSetEvent ?: return + if (!event.isNip82SoftwareRelease()) return RenderSoftwareReleaseBody(event = event, accountViewModel = accountViewModel, nav = nav) } @Composable fun RenderSoftwareReleaseBody( - event: SoftwareReleaseEvent, + event: ReleaseArtifactSetEvent, accountViewModel: AccountViewModel, nav: INav, showAppId: Boolean = true, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FollowList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/StarterPack.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FollowList.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/StarterPack.kt index 2f394b2fe8..88ca1ebd7e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FollowList.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/StarterPack.kt @@ -66,20 +66,20 @@ import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeaderBackground import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets.FollowSetCard import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel -import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import com.vitorpamplona.quartz.nip51Lists.starterPack.StarterPackEvent import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @OptIn(ExperimentalLayoutApi::class) @Composable -fun DisplayFollowList( +fun DisplayStarterPack( baseNote: Note, makeItShort: Boolean, accountViewModel: AccountViewModel, nav: INav, ) { val card = - observeNoteEventAndMap(baseNote, accountViewModel) { event: FollowListEvent? -> + observeNoteEventAndMap(baseNote, accountViewModel) { event: StarterPackEvent? -> if (event == null) { FollowSetCard( name = "", @@ -190,7 +190,7 @@ fun RenderFollowSetThumbPreview() { val accountViewModel = mockAccountViewModel() val followCard = - FollowListEvent( + StarterPackEvent( id = "eca31634fce7c9068b56fa8db9f387da70bdcceb3986a77ca1a9844f3128eb5f", pubKey = "3c39a7b53dec9ac85acf08b267637a9841e6df7b7b0f5e2ac56a8cf107de37da", createdAt = 1761736286, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt index b9bdd996a6..908ff3f919 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt @@ -60,7 +60,7 @@ import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip14Subject.subject import com.vitorpamplona.quartz.nip22Comments.CommentEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent enum class ReplyRenderType { FULL, @@ -129,7 +129,7 @@ fun RenderTextEvent( // Zap receipts are signed by the recipient's lightning provider; // label the reply with the zap sender instead of the service key. val zapSender = - if (parentNote.event is LnZapEvent) { + if (parentNote.event is ZapReceiptEvent) { observeZapSender(parentNote, accountViewModel).value } else { null diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VoiceTrack.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VoiceTrack.kt index cf881e04eb..502b9da0e2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VoiceTrack.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VoiceTrack.kt @@ -148,37 +148,15 @@ fun RenderAudioWithWaveform( val callbackUri = remember(note) { note.toNostrUri() } Column(modifier = MaxWidthPaddingTop5dp, horizontalAlignment = Alignment.CenterHorizontally) { - Row( - Modifier.fillMaxWidth().height(100.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - GetMediaItem( - videoUri = mediaUrl, - title = title, - artworkUri = null, - authorName = note.author?.toBestDisplayName(), - callbackUri = callbackUri, - mimeType = mimeType, - aspectRatio = null, - proxyPort = accountViewModel.httpClientBuilder.proxyPortForVideo(mediaUrl), - keepPlaying = false, - waveformData = waveform, - ) { mediaItem -> - GetVideoController( - mediaItem = mediaItem, - muted = false, - ) { controller -> - PauseControllerWhenInBackground(controller) - RenderVoicePlayer( - mediaItem = mediaItem, - controllerState = controller, - waveform = waveform, - borderModifier = MaterialTheme.colorScheme.imageModifier, - accountViewModel = accountViewModel, - ) - } - } - } + RenderAudioWaveformPlayer( + mediaUrl = mediaUrl, + title = title, + mimeType = mimeType, + waveform = waveform, + authorName = note.author?.toBestDisplayName(), + callbackUri = callbackUri, + accountViewModel = accountViewModel, + ) if (noteEvent.hasHashtags()) { Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { @@ -188,6 +166,86 @@ fun RenderAudioWithWaveform( } } +/** + * The voice player on its own, with no [Note] behind it. + * + * Separated because a chat that carries audio does not necessarily have a Note + * to hand: cordn messages never enter LocalCache, so the only way they can show + * the same player as every other chat is for the player not to demand one. The + * Note-shaped overload above is this plus the hashtag row, which is the only + * part that genuinely needs the event. + * + * [waveform] may be null. Nothing that arrives as an encrypted blob carries one + * today, and the player then shows its transport controls without the bars — + * still the voice UI, which is the point, rather than a video surface with no + * picture in it. + */ +@Composable +fun RenderAudioWaveformPlayer( + mediaUrl: String, + title: String?, + mimeType: String?, + waveform: WaveformData?, + authorName: String?, + callbackUri: String?, + accountViewModel: AccountViewModel, +) { + Row( + Modifier.fillMaxWidth().height(100.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + GetMediaItem( + videoUri = mediaUrl, + title = title, + artworkUri = null, + authorName = authorName, + callbackUri = callbackUri, + mimeType = mimeType, + aspectRatio = null, + proxyPort = accountViewModel.httpClientBuilder.proxyPortForVideo(mediaUrl), + keepPlaying = false, + waveformData = waveform, + ) { mediaItem -> + GetVideoController( + mediaItem = mediaItem, + muted = false, + ) { controller -> + PauseControllerWhenInBackground(controller) + RenderVoicePlayer( + mediaItem = mediaItem, + controllerState = controller, + waveform = waveform, + borderModifier = MaterialTheme.colorScheme.imageModifier, + accountViewModel = accountViewModel, + ) + } + } + } +} + +/** + * The bars drawn when a track carries no amplitudes of its own. + * + * A voice note with no waveform used to render as a play button on an empty + * black bar, which reads as a broken video rather than as audio. Every + * encrypted format can hit this: MIP-04 and encrypted-media-v2 define no + * waveform field at all, and a cordn message only carries one if the sender's + * client wrote the hint. + * + * **It has to vary.** `AudioWaveformReadOnly` normalises the amplitudes onto + * the bar height, so a constant list has no range to normalise and every bar + * collapses to the minimum — a flat 0.35f placeholder drew a dotted line, not + * bars. The shape below is a short pattern tiled across the width: enough + * relief to read as audio, regular enough that nobody mistakes it for a + * measurement of their recording. + * + * What it buys beyond looking right is somewhere for the progress fill to run, + * so position stays legible while it plays. + */ +private val PlaceholderPattern = listOf(0.35f, 0.62f, 0.45f, 0.85f, 0.5f, 0.72f, 0.4f, 0.58f) + +private val PlaceholderWaveform = WaveformData(List(48) { PlaceholderPattern[it % PlaceholderPattern.size] }) + @Composable @OptIn(UnstableApi::class) fun RenderVoicePlayer( @@ -224,7 +282,7 @@ fun RenderVoicePlayer( Row(VoiceHeightModifier, verticalAlignment = Alignment.CenterVertically) { PlayPauseButton(controllerState) - waveform?.let { Waveform(it, controllerState, Modifier) } + Waveform(waveform ?: PlaceholderWaveform, controllerState, Modifier) } RenderTopButtonsForVoice( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Wiki.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Wiki.kt index 22d717f56d..1412044f68 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Wiki.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Wiki.kt @@ -50,7 +50,7 @@ import com.vitorpamplona.amethyst.ui.components.MyAsyncImage import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeader import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeaderBackground import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent +import com.vitorpamplona.quartz.nip54Wiki.WikiArticleEvent @Composable fun RenderWikiContent( @@ -58,14 +58,14 @@ fun RenderWikiContent( accountViewModel: AccountViewModel, nav: INav, ) { - val noteEvent = note.event as? WikiNoteEvent ?: return + val noteEvent = note.event as? WikiArticleEvent ?: return - WikiNoteHeader(noteEvent, note, accountViewModel, nav) + WikiArticleHeader(noteEvent, note, accountViewModel, nav) } @Composable -private fun WikiNoteHeader( - noteEvent: WikiNoteEvent, +private fun WikiArticleHeader( + noteEvent: WikiArticleEvent, note: Note, accountViewModel: AccountViewModel, nav: INav, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/WikiCollaboration.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/WikiCollaboration.kt index 4338782fd5..521e0e02db 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/WikiCollaboration.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/WikiCollaboration.kt @@ -55,9 +55,9 @@ import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip54Wiki.WikiArticleEvent import com.vitorpamplona.quartz.nip54Wiki.WikiMergeAcceptanceEvent import com.vitorpamplona.quartz.nip54Wiki.WikiMergeRequestEvent -import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip54Wiki.WikiRedirectEvent /** @@ -215,7 +215,7 @@ private fun WikiArticleRow( ) { LoadAddressableNote(address) { articleNote -> if (articleNote != null) { - val article by observeNoteEvent(articleNote, accountViewModel) + val article by observeNoteEvent(articleNote, accountViewModel) WikiRow( // The coordinate's own identifier is a readable slug, so it stands in until the @@ -240,7 +240,7 @@ private fun WikiVersionRow( ) { LoadNote(eventId) { versionNote -> if (versionNote != null) { - val article by observeNoteEvent(versionNote, accountViewModel) + val article by observeNoteEvent(versionNote, accountViewModel) WikiRow( title = article?.title() ?: eventId.take(8), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ZapEvent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ZapEvent.kt index 94c76b141e..26815bdee9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ZapEvent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ZapEvent.kt @@ -64,7 +64,7 @@ import com.vitorpamplona.amethyst.ui.note.UserPicture import com.vitorpamplona.amethyst.ui.note.ZapAmountCommentNotification import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel -import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext @@ -97,18 +97,18 @@ fun RenderZappedPost( } @Composable -fun RenderLnZap( +fun RenderZapReceipt( note: Note, quotesLeft: Int, backgroundColor: MutableState, accountViewModel: AccountViewModel, nav: INav, ) { - val zapEvent = note.event as? LnZapEvent ?: return + val zapEvent = note.event as? ZapReceiptEvent ?: return val card by parseAuthorCommentAndAmount(note, accountViewModel) - RenderLnZapCard( + RenderZapReceiptCard( note = note, card = card, recipientKey = zapEvent.zappedAuthor().firstOrNull(), @@ -120,7 +120,7 @@ fun RenderLnZap( } @Composable -fun RenderLnZapCard( +fun RenderZapReceiptCard( note: Note, card: ZapAmountCommentNotification, recipientKey: String?, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/lists/BookmarkLists.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/lists/BookmarkLists.kt index 157bf7f0de..24ccbbe3c7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/lists/BookmarkLists.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/lists/BookmarkLists.kt @@ -38,7 +38,7 @@ import com.vitorpamplona.quartz.nip51Lists.articleCurationSet.ArticleCurationSet import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.BookmarkIdTag -import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.LabeledBookmarkListEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkSet.BookmarkSetEvent import com.vitorpamplona.quartz.nip51Lists.pictureCurationSet.PictureCurationSetEvent /** NIP-51 kind 10003: the user's bookmarks — notes, articles, hashtags and links. */ @@ -101,14 +101,14 @@ fun RenderOldBookmarkList( /** NIP-51 kind 30003: "user-defined bookmarks categories, for when bookmarks must be in labeled separate groups". */ @Composable -fun RenderLabeledBookmarkList( +fun RenderBookmarkSet( baseNote: Note, quotesLeft: Int, backgroundColor: MutableState, accountViewModel: AccountViewModel, nav: INav, ) { - val noteEvent = baseNote.event as? LabeledBookmarkListEvent ?: return + val noteEvent = baseNote.event as? BookmarkSetEvent ?: return val public = remember(noteEvent) { noteEvent.publicBookmarks() } val private by loadPrivateItems(noteEvent, accountViewModel) { noteEvent.privateBookmarks(it) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/lists/TagLists.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/lists/TagLists.kt index 4294e5386d..993591f7cf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/lists/TagLists.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/lists/TagLists.kt @@ -36,21 +36,21 @@ import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip01Core.tags.hashtags.HashtagTag import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent -import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent +import com.vitorpamplona.quartz.nip51Lists.interestList.InterestListEvent import com.vitorpamplona.quartz.nip51Lists.interestSet.InterestSetEvent /** NIP-51 kind 10015: the hashtags this user follows. */ @Composable -fun RenderHashtagList( +fun RenderInterestList( baseNote: Note, backgroundColor: MutableState, accountViewModel: AccountViewModel, nav: INav, ) { - val noteEvent = baseNote.event as? HashtagListEvent ?: return + val noteEvent = baseNote.event as? InterestListEvent ?: return val public = remember(noteEvent) { noteEvent.publicHashtags() } - // HashtagListEvent exposes no private accessor of its own, but it is a PrivateTagArrayEvent + // InterestListEvent exposes no private accessor of its own, but it is a PrivateTagArrayEvent // like the rest, so its private half is read the same way InterestSetEvent reads its own. val private by loadPrivateItems(noteEvent, accountViewModel) { signer -> noteEvent.privateTags(signer)?.mapNotNull(HashtagTag::parse) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt index b8f38aab80..4b6d470a1c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt @@ -303,8 +303,8 @@ class AccountSessionManager( accountSettings.backupKeyPackageRelayList?.let { client.publish(it, toPost) } accountSettings.backupSearchRelayList?.let { client.publish(it, toPost) } accountSettings.backupIndexRelayList?.let { client.publish(it, toPost) } - accountSettings.backupRelayFeedsList?.let { client.publish(it, toPost) } - accountSettings.backupChannelList?.let { client.publish(it, toPost) } + accountSettings.backupFavoriteRelayList?.let { client.publish(it, toPost) } + accountSettings.backupPublicChatList?.let { client.publish(it, toPost) } accountSettings.backupCashuWallet?.let { client.publish(it, toPost) } accountSettings.backupNutzapInfo?.let { client.publish(it, toPost) } } @@ -329,8 +329,8 @@ class AccountSessionManager( backupKeyPackageRelayList = bootstrap.keyPackageRelayList, backupSearchRelayList = bootstrap.searchRelayList, backupIndexRelayList = bootstrap.indexerRelayList, - backupChannelList = bootstrap.channelList, - backupRelayFeedsList = bootstrap.relayFeedsList, + backupPublicChatList = bootstrap.publicChatList, + backupFavoriteRelayList = bootstrap.favoriteRelayList, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt index 421549ffb6..bcb73b461a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt @@ -35,14 +35,14 @@ import com.vitorpamplona.amethyst.commons.ui.feeds.FeedEmpty import com.vitorpamplona.amethyst.commons.ui.feeds.FeedError import com.vitorpamplona.amethyst.commons.ui.feeds.LoadingFeed import com.vitorpamplona.amethyst.commons.ui.feeds.RefresheableBox +import com.vitorpamplona.amethyst.commons.ui.feeds.StickToTopOnPrepend +import com.vitorpamplona.amethyst.commons.ui.feeds.WatchScrollToTop import com.vitorpamplona.amethyst.commons.ui.feeds.rememberForeverLazyGridState import com.vitorpamplona.amethyst.commons.ui.feeds.rememberForeverLazyListState import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.viewmodels.FeedViewModel import com.vitorpamplona.amethyst.ui.feeds.FeedLoaded import com.vitorpamplona.amethyst.ui.feeds.PrefetchLoadedFeedMedia -import com.vitorpamplona.amethyst.ui.feeds.StickToTopOnPrepend -import com.vitorpamplona.amethyst.ui.feeds.WatchScrollToTop import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt index 5a3eaf6059..04e304dcee 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt @@ -43,9 +43,9 @@ import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl -import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import com.vitorpamplona.quartz.nip51Lists.followSet.FollowSetEvent import com.vitorpamplona.quartz.nip51Lists.interestSet.InterestSetEvent -import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent +import com.vitorpamplona.quartz.nip51Lists.starterPack.StarterPackEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import com.vitorpamplona.quartz.utils.Log @@ -162,14 +162,14 @@ class TopNavFilterState( val livePeopleListsFlow: Flow> = combine( - account.peopleLists.peopleListNotes, - account.followLists.followListNotes, + account.followSets.peopleListNotes, + account.starterPacks.starterPackNotes, ::mergePeopleLists, ).onStart { emit( mergePeopleLists( - account.peopleLists.peopleListNotes.value, - account.followLists.followListNotes.value, + account.followSets.peopleListNotes.value, + account.starterPacks.starterPackNotes.value, ), ) } @@ -247,10 +247,10 @@ class TopNavFilterState( @OptIn(ExperimentalCoroutinesApi::class) val liveInterestFlows: Flow> = combine( - account.hashtagList.flow, + account.interestList.flow, account.geohashList.flow, account.communityList.flowNotes, - account.relayFeedsList.flow, + account.favoriteRelayList.flow, combine( account.favoriteAlgoFeedsList.flowNotes, account.interestSets.listFeedFlow, @@ -261,10 +261,10 @@ class TopNavFilterState( }.onStart { emit( mergeInterests( - account.hashtagList.flow.value, + account.interestList.flow.value, account.geohashList.flow.value, account.communityList.flowNotes.value, - account.relayFeedsList.flow.value, + account.favoriteRelayList.flow.value, account.favoriteAlgoFeedsList.flowNotes.value, account.interestSets.listFeedFlow.value, ), @@ -594,9 +594,9 @@ class PeopleListName( NoteBackedName { override fun name(): String { val noteEvent = note.event - return if (noteEvent is PeopleListEvent) { + return if (noteEvent is FollowSetEvent) { noteEvent.titleOrName() ?: note.dTag() - } else if (noteEvent is FollowListEvent) { + } else if (noteEvent is StarterPackEvent) { noteEvent.title() ?: note.dTag() } else { note.dTag() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedView.kt index 880e12de22..4ed610bed1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedView.kt @@ -35,11 +35,11 @@ import com.vitorpamplona.amethyst.commons.ui.feeds.FeedEmpty import com.vitorpamplona.amethyst.commons.ui.feeds.FeedError import com.vitorpamplona.amethyst.commons.ui.feeds.LoadingFeed import com.vitorpamplona.amethyst.commons.ui.feeds.RefresheableBox +import com.vitorpamplona.amethyst.commons.ui.feeds.StickToTopOnPrepend import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness import com.vitorpamplona.amethyst.commons.ui.theme.FeedPadding -import com.vitorpamplona.amethyst.ui.feeds.StickToTopOnPrepend import com.vitorpamplona.amethyst.ui.note.UserCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt index bab98aebe7..a62a265f18 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt @@ -22,13 +22,13 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn import android.content.ComponentCallbacks2 import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.commons.feeds.ChannelFeedContentState import com.vitorpamplona.amethyst.commons.feeds.FeedContentState import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.service.checkNotInMainThread -import com.vitorpamplona.amethyst.ui.feeds.ChannelFeedContentState import com.vitorpamplona.amethyst.ui.screen.TopNavFilterState import com.vitorpamplona.amethyst.ui.screen.loggedIn.articles.dal.ArticlesFeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.dal.BadgesFeedFilter @@ -184,6 +184,44 @@ class AccountFeedContentStates( } } + // Same for cordn, and more so: a cordn message is an MLS envelope that + // never enters LocalCache at all (§3.3 of + // amethyst/plans/2026-09-19-cordn-ui.md), so nothing about a cordn room + // can ever reach the additive path. Without this the inbox never shows + // a cordn room — not when one is created, not when a Welcome is + // accepted, and not after a relaunch that restored it — and since the + // room list is the only way back into a room, a group became + // unreachable the moment its screen was closed. + // + // `revision` and not `all`: `all` re-emits only when the room *set* + // changes, so a message arriving for a room the inbox already lists + // never reached this collector. The rows therefore kept whatever order + // the build that first saw them gave — and since a cordn row sorts on + // its newest message, that meant the inbox ordered cordn rooms by when + // they were joined and never moved them again. `revision` bumps on the + // set *and* on every message filed into a room. sample() keeps the + // restore burst, when every coordinator's groups arrive at once, from + // rebuilding the feed once per room. + // + // No drop(1), unlike the collectors around it. Those drop the replay + // because the feed's first build already saw their state; cordn's + // restore runs in its own launch from Account's constructor and often + // finishes *after* that build, so the current value is exactly the one + // that matters — a StateFlow replays it on subscribe and dropping it + // waits for a change that, for an account whose rooms are all restored + // rather than newly created, never comes. The cost of keeping it is one + // extra rebuild at login. + account.cordnRuntime?.let { runtime -> + scope.launch(Dispatchers.IO) { + @OptIn(FlowPreview::class) + runtime.groups.revision + .sample(500) + .collect { + dmKnown.invalidateData() + } + } + } + // Same for the NIP-29 joined-group list (kind 10009): joining/leaving changes the list but // doesn't flow through newEventBundles, so force a rebuild — otherwise a just-joined group // (whose messages haven't loaded yet) wouldn't appear on the Messages tab until a later event. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 04244170d0..faa2f7b8af 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -96,11 +96,13 @@ import com.vitorpamplona.amethyst.commons.resources.unauthorized_exception import com.vitorpamplona.amethyst.commons.resources.unauthorized_exception_description import com.vitorpamplona.amethyst.commons.resources.user_x_does_not_have_a_lightning_address_setup_to_receive_sats import com.vitorpamplona.amethyst.commons.resources.video_saved_to_the_gallery +import com.vitorpamplona.amethyst.commons.service.OnlineChecker import com.vitorpamplona.amethyst.commons.service.broadcast.BroadcastTracker import com.vitorpamplona.amethyst.commons.service.http.EmptyRoleBasedHttpClientBuilder import com.vitorpamplona.amethyst.commons.service.http.IRoleBasedHttpClientBuilder import com.vitorpamplona.amethyst.commons.service.pow.PoWCategory import com.vitorpamplona.amethyst.commons.state.UiSettingsState +import com.vitorpamplona.amethyst.commons.tor.TorSettingsFlow import com.vitorpamplona.amethyst.commons.tor.TorType import com.vitorpamplona.amethyst.commons.ui.components.UrlPreviewState import com.vitorpamplona.amethyst.commons.ui.components.toasts.ToastManager @@ -115,7 +117,6 @@ import com.vitorpamplona.amethyst.model.LatestKeyPackageOwner import com.vitorpamplona.amethyst.model.UrlCachedPreviewer import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilder import com.vitorpamplona.amethyst.service.ClinkDebitPayer -import com.vitorpamplona.amethyst.service.OnlineChecker import com.vitorpamplona.amethyst.service.V4VPaymentHandler import com.vitorpamplona.amethyst.service.ZapPaymentHandler import com.vitorpamplona.amethyst.service.cashu.melt.MeltProcessor @@ -141,7 +142,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.CombinedZap import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.NOTIFICATION_LAST_READ_KEY import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync.EventSync import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.ReloadMintRequest -import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow import com.vitorpamplona.quartz.experimental.clink.debits.DebitResponse import com.vitorpamplona.quartz.experimental.clink.pointers.NDebit import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryBaseEvent @@ -172,7 +172,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.people.PubKeyReferenceTag import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip03Timestamp.EmptyOtsResolverBuilder -import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip04Dm.messages.EncryptedDmEvent import com.vitorpamplona.quartz.nip05DnsIdentifiers.EmptyNip05Client import com.vitorpamplona.quartz.nip05DnsIdentifiers.INip05Client import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Client @@ -202,16 +202,16 @@ import com.vitorpamplona.quartz.nip51Lists.PinListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark -import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent +import com.vitorpamplona.quartz.nip51Lists.interestList.InterestListEvent import com.vitorpamplona.quartz.nip56Reports.ReportType -import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapRequestEvent import com.vitorpamplona.quartz.nip57Zaps.validate.LnurlForm import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount -import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent +import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip60Cashu.token.CashuToken -import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.DvmContentDiscoveryResponseEvent import com.vitorpamplona.quartz.nip92IMeta.imeta import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag import com.vitorpamplona.quartz.podcasts.PodcastBoostagram @@ -725,7 +725,7 @@ class AccountViewModel( val isHiddenAuthor = note.author?.let { account.isHidden(it) } == true val noteEvent = note.event - val isDecryptedPostHidden = if (noteEvent is PrivateDmEvent) account.isDecryptedContentHidden(noteEvent) else false + val isDecryptedPostHidden = if (noteEvent is EncryptedDmEvent) account.isDecryptedContentHidden(noteEvent) else false return if (isPostHidden || isDecryptedPostHidden) { // Spam + Blocked Users + Hidden Words + Sensitive Content @@ -924,14 +924,14 @@ class AccountViewModel( it.request.event ?.content ?.ifBlank { null }, - showAmountInteger((it.response.event as? LnZapEvent)?.amount), + showAmountInteger((it.response.event as? ZapReceiptEvent)?.amount), it.response, ) }.toMutableMap() val results = mapNotNullAsync( - zaps.filter { (it.request.event as? LnZapRequestEvent)?.isPrivateZap() == true }, + zaps.filter { (it.request.event as? ZapRequestEvent)?.isPrivateZap() == true }, ) { next -> val info = innerDecryptAmountMessage(next.request, next.response) if (info != null) { @@ -950,14 +950,14 @@ class AccountViewModel( fun cachedDecryptAmountMessageInGroup(zapNotes: List): ImmutableList = zapNotes .map { - val request = it.request.event as? LnZapRequestEvent + val request = it.request.event as? ZapRequestEvent if (request?.isPrivateZap() == true) { val cachedPrivateRequest = account.privateZapsDecryptionCache.cachedPrivateZap(request) if (cachedPrivateRequest != null) { ZapAmountCommentNotification( LocalCache.getUserIfExists(cachedPrivateRequest.pubKey) ?: it.request.author, cachedPrivateRequest.content.ifBlank { null }, - showAmountInteger((it.response.event as? LnZapEvent)?.amount), + showAmountInteger((it.response.event as? ZapReceiptEvent)?.amount), it.response, ) } else { @@ -966,7 +966,7 @@ class AccountViewModel( it.request.event ?.content ?.ifBlank { null }, - showAmountInteger((it.response.event as? LnZapEvent)?.amount), + showAmountInteger((it.response.event as? ZapReceiptEvent)?.amount), it.response, ) } @@ -976,7 +976,7 @@ class AccountViewModel( it.request.event ?.content ?.ifBlank { null }, - showAmountInteger((it.response.event as? LnZapEvent)?.amount), + showAmountInteger((it.response.event as? ZapReceiptEvent)?.amount), it.response, ) } @@ -987,14 +987,14 @@ class AccountViewModel( return myList .map { - val request = it.first.event as? LnZapRequestEvent + val request = it.first.event as? ZapRequestEvent if (request?.isPrivateZap() == true) { val cachedPrivateRequest = account.privateZapsDecryptionCache.cachedPrivateZap(request) if (cachedPrivateRequest != null) { ZapAmountCommentNotification( LocalCache.getUserIfExists(cachedPrivateRequest.pubKey) ?: it.first.author, cachedPrivateRequest.content.ifBlank { null }, - showAmountInteger((it.second?.event as? LnZapEvent)?.amount), + showAmountInteger((it.second?.event as? ZapReceiptEvent)?.amount), it.second, ) } else { @@ -1003,7 +1003,7 @@ class AccountViewModel( it.first.event ?.content ?.ifBlank { null }, - showAmountInteger((it.second?.event as? LnZapEvent)?.amount), + showAmountInteger((it.second?.event as? ZapReceiptEvent)?.amount), it.second, ) } @@ -1013,7 +1013,7 @@ class AccountViewModel( it.first.event ?.content ?.ifBlank { null }, - showAmountInteger((it.second?.event as? LnZapEvent)?.amount), + showAmountInteger((it.second?.event as? ZapReceiptEvent)?.amount), it.second, ) } @@ -1036,7 +1036,7 @@ class AccountViewModel( it.first.event ?.content ?.ifBlank { null }, - showAmountInteger((it.second?.event as? LnZapEvent)?.amount), + showAmountInteger((it.second?.event as? ZapReceiptEvent)?.amount), it.second, ) }.toMutableMap() @@ -1073,7 +1073,7 @@ class AccountViewModel( } suspend fun innerDecryptAmountMessage(zapNote: Note): ZapAmountCommentNotification? { - val zapEvent = zapNote.event as? LnZapEvent ?: return null + val zapEvent = zapNote.event as? ZapReceiptEvent ?: return null val zapRequest = zapEvent.zapRequest ?: return null return innerDecryptAmountMessage(zapRequest, zapEvent)?.copy(zapNote = zapNote) @@ -1083,14 +1083,14 @@ class AccountViewModel( zapRequest: Note, zapEvent: Note, ): ZapAmountCommentNotification? { - val zapEvent = zapEvent.event as? LnZapEvent ?: return null - val zapRequest = zapRequest.event as? LnZapRequestEvent ?: return null + val zapEvent = zapEvent.event as? ZapReceiptEvent ?: return null + val zapRequest = zapRequest.event as? ZapRequestEvent ?: return null return innerDecryptAmountMessage(zapRequest, zapEvent) } suspend fun innerDecryptAmountMessage( - zapRequestEvent: LnZapRequestEvent, - zapEvent: LnZapEvent, + zapRequestEvent: ZapRequestEvent, + zapEvent: ZapReceiptEvent, ): ZapAmountCommentNotification? { val amount = showAmountInteger(zapEvent.amount) return if (zapRequestEvent.isPrivateZap()) { @@ -1148,7 +1148,7 @@ class AccountViewModel( onError: (String, String, User?) -> Unit, onProgress: (percent: Float) -> Unit, onPayViaIntent: (ImmutableList) -> Unit, - zapType: LnZapEvent.ZapType? = null, + zapType: ZapReceiptEvent.ZapType? = null, ) = launchSigner { // A podcast note (episode or show) can carry a Podcasting-2.0 value-for-value block. When it // does, "zapping" it means paying that split — lnaddress recipients go out as real zaps (with @@ -1177,8 +1177,8 @@ class AccountViewModel( // comment stay encrypted. NONZAP is kept: paying without a zap // request produces no receipt at all, which is even more private. val effectiveType = - if (note.isPrivateRumor() && requestedType != LnZapEvent.ZapType.NONZAP) { - LnZapEvent.ZapType.PRIVATE + if (note.isPrivateRumor() && requestedType != ZapReceiptEvent.ZapType.NONZAP) { + ZapReceiptEvent.ZapType.PRIVATE } else { requestedType } @@ -1299,7 +1299,7 @@ class AccountViewModel( zappedNote = zappedNote, context = context, asZap = !streaming, - zapType = LnZapEvent.ZapType.PUBLIC, + zapType = ZapReceiptEvent.ZapType.PUBLIC, okHttpClient = httpClientBuilder::okHttpClientForMoney, onError = { title, message -> if (!streaming) toastManager.toast(title, message) @@ -1463,7 +1463,7 @@ class AccountViewModel( fun follows(user: User): Note = LocalCache.getOrCreateAddressableNote(ContactListEvent.createAddress(user.pubkeyHex)) - fun hashtagFollows(user: User): Note = LocalCache.getOrCreateAddressableNote(HashtagListEvent.createAddress(user.pubkeyHex)) + fun hashtagFollows(user: User): Note = LocalCache.getOrCreateAddressableNote(InterestListEvent.createAddress(user.pubkeyHex)) fun bookmarks(user: User): Note = LocalCache.getOrCreateAddressableNote(BookmarkListEvent.createBookmarkAddress(user.pubkeyHex)) @@ -2122,7 +2122,7 @@ class AccountViewModel( fun updateZapAmounts( amountSet: List, - selectedZapType: LnZapEvent.ZapType, + selectedZapType: ZapReceiptEvent.ZapType, nip47Update: Nip47WalletConnect.Nip47URINorm?, ) = launchSigner { account.updateZapAmounts(amountSet, selectedZapType, nip47Update) } @@ -2144,11 +2144,11 @@ class AccountViewModel( fun hide(user: User) = launchSigner { account.hideUser(user.pubkeyHex) } - fun updateContactCardPetName( + fun updateUserAssertionPetName( user: User, petName: String?, summary: String?, - ) = launchSigner { account.updateContactCardPetName(user.pubkeyHex, petName, summary) } + ) = launchSigner { account.updateUserAssertionPetName(user.pubkeyHex, petName, summary) } fun hide(word: String) = launchSigner { account.hideWord(word) } @@ -2848,7 +2848,7 @@ class AccountViewModel( } } - private suspend fun unwrapSeal(event: SealedRumorEvent): Note? { + private suspend fun unwrapSeal(event: SealEvent): Note? { val cacheInnerEventId = event.innerEventId return if (cacheInnerEventId != null) { val existingNoteEvent = LocalCache.getNoteIfExists(cacheInnerEventId)?.event @@ -2882,7 +2882,7 @@ class AccountViewModel( private suspend fun unwrapIfNeeded(event: Event): Note? = when (event) { is GiftWrapEvent -> unwrapGiftWrap(event) - is SealedRumorEvent -> unwrapSeal(event) + is SealEvent -> unwrapSeal(event) else -> LocalCache.getNoteIfExists(event.id) } @@ -2936,7 +2936,7 @@ class AccountViewModel( LocalCache.notes.maxOrNullOf( filter = { _, note -> val noteEvent = note.event - noteEvent is NIP90ContentDiscoveryResponseEvent && + noteEvent is DvmContentDiscoveryResponseEvent && noteEvent.pubKey == pubkeyHex && noteEvent.isTaggedUser(account.signer.pubKey) && noteEvent.createdAt > fifteenMinsAgo @@ -3005,13 +3005,13 @@ class AccountViewModel( onError: (String, String) -> Unit, onProgress: (percent: Float) -> Unit, context: Context, - zapType: LnZapEvent.ZapType? = null, + zapType: ZapReceiptEvent.ZapType? = null, ) { viewModelScope.launch(Dispatchers.IO) { try { val effectiveZapType = zapType ?: defaultZapType() val zapRequest = - if (effectiveZapType != LnZapEvent.ZapType.NONZAP) { + if (effectiveZapType != ZapReceiptEvent.ZapType.NONZAP) { // NIP-57 Appendix F: include amount + lnurl so the receipt can be validated. val splitLnurl = LnurlForm.toUrl(lnAddress)?.let(LnurlForm::urlToBech32) account.zaps.createZapRequestFor( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt index 5275a0cf0e..585669b95c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt @@ -160,6 +160,13 @@ private fun PreloadFor( // to warm up, and adding one would duplicate those. NavBarItem.MARMOT_GROUPS -> Unit + // Same as Marmot, for a different reason: a cordn group's messages never + // arrive over a relay REQ at all. They are coordinator calls made by + // CordnRuntime's sync loop, which is already running for every + // coordinator this account holds -- there is no relay subscription a + // list could warm up. + NavBarItem.CORDN_GROUPS -> Unit + NavBarItem.FOLLOW_PACKS -> FollowPacksFilterAssemblerSubscription(accountViewModel) NavBarItem.LIVE_STREAMS -> LiveStreamsFilterAssemblerSubscription(accountViewModel) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt index ab256b5804..e09c5e6e4a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt @@ -42,10 +42,10 @@ import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent import com.vitorpamplona.quartz.nip57Zaps.PrivateZapCache -import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapRequestEvent +import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.EphemeralGiftWrapEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallAnswerEvent @@ -68,10 +68,10 @@ class EventProcessor( private val draftHandler = DraftEventHandler(account, cache) private val giftWrapHandler = GiftWrapEventHandler(account, cache, this) - private val sealHandler = SealedRumorEventHandler(account, cache, this) + private val sealHandler = SealEventHandler(account, cache, this) - private val zapRequest = LnZapRequestEventHandler(account.privateZapsDecryptionCache) - private val zapEvent = LnZapEventHandler(account.privateZapsDecryptionCache) + private val zapRequest = ZapRequestEventHandler(account.privateZapsDecryptionCache) + private val zapEvent = ZapReceiptEventHandler(account.privateZapsDecryptionCache) private val groupEventHandler = GroupEventHandler(account, cache) @@ -112,9 +112,9 @@ class EventProcessor( is GiftWrapEvent -> giftWrapHandler.add(event, eventNote, publicNote) - is SealedRumorEvent -> sealHandler.add(event, eventNote, publicNote) + is SealEvent -> sealHandler.add(event, eventNote, publicNote) - is LnZapRequestEvent -> zapRequest.add(event, eventNote, publicNote) + is ZapRequestEvent -> zapRequest.add(event, eventNote, publicNote) } } @@ -153,9 +153,9 @@ class EventProcessor( is ChatroomKeyable -> chatHandler.delete(event, note) is DraftWrapEvent -> draftHandler.delete(event, note) is GiftWrapEvent -> giftWrapHandler.delete(event, note) - is SealedRumorEvent -> sealHandler.delete(event, note) - is LnZapRequestEvent -> zapRequest.delete(event, note) - is LnZapEvent -> zapEvent.delete(event, note) + is SealEvent -> sealHandler.delete(event, note) + is ZapRequestEvent -> zapRequest.delete(event, note) + is ZapReceiptEvent -> zapEvent.delete(event, note) } } @@ -389,7 +389,7 @@ class GiftWrapEventHandler( /** * Shared Marmot Welcome handler used by both [GiftWrapEventHandler] * (in case a Welcome arrives directly inside a kind:1059 with no Seal - * layer) and [SealedRumorEventHandler] (the actual production path — + * layer) and [SealEventHandler] (the actual production path — * Welcomes are wrapped GiftWrap → Seal → Welcome per * [com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeGiftWrap]). */ @@ -453,31 +453,31 @@ private suspend fun processMarmotWelcomeFlow( } } -class SealedRumorEventHandler( +class SealEventHandler( private val account: Account, private val cache: LocalCache, private val eventProcessor: EventProcessor, -) : EventHandler { +) : EventHandler { override suspend fun add( - event: SealedRumorEvent, + event: SealEvent, eventNote: Note, publicNote: Note, ) { val rumorId = event.innerEventId if (rumorId == null) { - processNewSealedRumor(event, eventNote, publicNote) + processNewSeal(event, eventNote, publicNote) } else { // Replayed seal: re-link the rumor to its delivering envelope so // broadcast can republish the wrap after a cache rebuild. // publicNote is the outermost event of this unwrap chain — the // kind-1059 wrap normally, the seal itself when it arrived bare. publicNote.event?.let { envelope -> cache.getOrCreateNote(rumorId).recordRumorHost(envelope) } - processExistingSealedRumor(rumorId, publicNote) + processExistingSeal(rumorId, publicNote) } } override suspend fun delete( - event: SealedRumorEvent, + event: SealEvent, eventNote: Note, ) { event.innerEventId?.let { rumorId -> @@ -488,8 +488,8 @@ class SealedRumorEventHandler( } } - private suspend fun processNewSealedRumor( - event: SealedRumorEvent, + private suspend fun processNewSeal( + event: SealEvent, eventNote: Note, publicNote: Note, ) { @@ -524,7 +524,7 @@ class SealedRumorEventHandler( eventNote.flowSet?.metadata?.invalidateData() } - private suspend fun processExistingSealedRumor( + private suspend fun processExistingSeal( rumorId: String, publicNote: Note, ) { @@ -536,11 +536,11 @@ class SealedRumorEventHandler( } } -class LnZapRequestEventHandler( +class ZapRequestEventHandler( val decryptionCache: PrivateZapCache, -) : EventHandler { +) : EventHandler { override suspend fun add( - event: LnZapRequestEvent, + event: ZapRequestEvent, eventNote: Note, publicNote: Note, ) { @@ -550,7 +550,7 @@ class LnZapRequestEventHandler( } override suspend fun delete( - event: LnZapRequestEvent, + event: ZapRequestEvent, eventNote: Note, ) { if (event.isPrivateZap()) { @@ -559,11 +559,11 @@ class LnZapRequestEventHandler( } } -class LnZapEventHandler( +class ZapReceiptEventHandler( val decryptionCache: PrivateZapCache, -) : EventHandler { +) : EventHandler { override suspend fun delete( - event: LnZapEvent, + event: ZapReceiptEvent, eventNote: Note, ) { event.zapRequest?.let { req -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/articles/ArticlesFeedLoaded.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/articles/ArticlesFeedLoaded.kt index d2a3d6e0d5..13ab488bfd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/articles/ArticlesFeedLoaded.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/articles/ArticlesFeedLoaded.kt @@ -37,7 +37,7 @@ import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness import com.vitorpamplona.amethyst.commons.ui.theme.FeedPadding import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.ChannelCardCompose -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent @Composable fun ArticlesFeedLoaded( @@ -61,7 +61,7 @@ fun ArticlesFeedLoaded( ChannelCardCompose( baseNote = item, modifier = Modifier.fillMaxWidth(), - forceEventKind = LongTextNoteEvent.KIND, + forceEventKind = LongFormContentEvent.KIND, accountViewModel = accountViewModel, nav = nav, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/articles/ArticlesTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/articles/ArticlesTopBar.kt index 4163e6e015..922e4f8445 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/articles/ArticlesTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/articles/ArticlesTopBar.kt @@ -36,7 +36,7 @@ import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar import com.vitorpamplona.amethyst.ui.screen.FeedDefinition import com.vitorpamplona.amethyst.ui.screen.TopNavFilterState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent @Composable fun ArticlesTopBar( @@ -49,7 +49,7 @@ fun ArticlesTopBar( // The feed's own kind window, plus whatever the list spinner narrowed it to — a hashtag or a // geohash says itself as a token; a follow set does not, and seeds nothing (see asSearchQuery). val me = accountViewModel.userProfile().pubkeyHex - val seed = remember(list, me) { SearchSeed.merge(SearchSeed.ofKinds(LongTextNoteEvent.KIND), list.asSearchQuery(me)) } + val seed = remember(list, me) { SearchSeed.merge(SearchSeed.ofKinds(LongFormContentEvent.KIND), list.asSearchQuery(me)) } UserDrawerSearchTopBar(accountViewModel, nav, seed) { ArticlesTopNavFilterBar( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/articles/dal/ArticlesFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/articles/dal/ArticlesFeedFilter.kt index 5ec4bd4976..7bea4bb080 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/articles/dal/ArticlesFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/articles/dal/ArticlesFeedFilter.kt @@ -28,7 +28,7 @@ import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.ui.dal.FilterByListParams import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent class ArticlesFeedFilter( val account: Account, @@ -50,9 +50,9 @@ class ArticlesFeedFilter( override fun feed(): List { val params = buildFilterParams(account) val notes = - LocalCache.addressables.filterIntoSet(LongTextNoteEvent.KIND) { _, it -> + LocalCache.addressables.filterIntoSet(LongFormContentEvent.KIND) { _, it -> val noteEvent = it.event - noteEvent is LongTextNoteEvent && params.match(noteEvent, it.relays) + noteEvent is LongFormContentEvent && params.match(noteEvent, it.relays) } return sort(notes) } @@ -70,7 +70,7 @@ class ArticlesFeedFilter( return collection.filterTo(HashSet()) { val noteEvent = it.event - noteEvent is LongTextNoteEvent && params.match(noteEvent, it.relays) + noteEvent is LongFormContentEvent && params.match(noteEvent, it.relays) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictCards.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictCards.kt index bb977a4184..e2daa15e78 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictCards.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictCards.kt @@ -43,17 +43,57 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -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.backups.BackupEventType import com.vitorpamplona.amethyst.commons.model.backups.ReplaceableBackupConflict import com.vitorpamplona.amethyst.commons.model.navigation.Route +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.backup_card_accept +import com.vitorpamplona.amethyst.commons.resources.backup_card_and_added +import com.vitorpamplona.amethyst.commons.resources.backup_card_and_blocked +import com.vitorpamplona.amethyst.commons.resources.backup_card_and_followed +import com.vitorpamplona.amethyst.commons.resources.backup_card_and_joined +import com.vitorpamplona.amethyst.commons.resources.backup_card_and_muted +import com.vitorpamplona.amethyst.commons.resources.backup_card_app_settings +import com.vitorpamplona.amethyst.commons.resources.backup_card_concord_emptied +import com.vitorpamplona.amethyst.commons.resources.backup_card_concord_rewritten +import com.vitorpamplona.amethyst.commons.resources.backup_card_dm_relays_lost +import com.vitorpamplona.amethyst.commons.resources.backup_card_feeds_removed +import com.vitorpamplona.amethyst.commons.resources.backup_card_follows_dropped +import com.vitorpamplona.amethyst.commons.resources.backup_card_hashtags_unfollowed +import com.vitorpamplona.amethyst.commons.resources.backup_card_indexer_relays_lost +import com.vitorpamplona.amethyst.commons.resources.backup_card_key_package_relays_lost +import com.vitorpamplona.amethyst.commons.resources.backup_card_leave_chats +import com.vitorpamplona.amethyst.commons.resources.backup_card_leave_communities +import com.vitorpamplona.amethyst.commons.resources.backup_card_leave_groups +import com.vitorpamplona.amethyst.commons.resources.backup_card_leave_rooms +import com.vitorpamplona.amethyst.commons.resources.backup_card_mutes_visible +import com.vitorpamplona.amethyst.commons.resources.backup_card_nip65_lost +import com.vitorpamplona.amethyst.commons.resources.backup_card_nutzap_key_removed +import com.vitorpamplona.amethyst.commons.resources.backup_card_nutzap_key_replaced +import com.vitorpamplona.amethyst.commons.resources.backup_card_nutzap_mints_removed +import com.vitorpamplona.amethyst.commons.resources.backup_card_offers_removed +import com.vitorpamplona.amethyst.commons.resources.backup_card_payment_targets_removed +import com.vitorpamplona.amethyst.commons.resources.backup_card_places_unfollowed +import com.vitorpamplona.amethyst.commons.resources.backup_card_private_outbox_lost +import com.vitorpamplona.amethyst.commons.resources.backup_card_profile_lost_fields +import com.vitorpamplona.amethyst.commons.resources.backup_card_relay_feeds_lost +import com.vitorpamplona.amethyst.commons.resources.backup_card_search_relays_lost +import com.vitorpamplona.amethyst.commons.resources.backup_card_trust_services_changed +import com.vitorpamplona.amethyst.commons.resources.backup_card_trusted_lost +import com.vitorpamplona.amethyst.commons.resources.backup_card_unblocked +import com.vitorpamplona.amethyst.commons.resources.backup_card_wallet_emptied +import com.vitorpamplona.amethyst.commons.resources.backup_card_wallet_rewritten +import com.vitorpamplona.amethyst.commons.resources.backup_conflict_count_added +import com.vitorpamplona.amethyst.commons.resources.backup_conflict_count_changed +import com.vitorpamplona.amethyst.commons.resources.backup_conflict_count_removed +import com.vitorpamplona.amethyst.commons.resources.backup_conflict_title +import com.vitorpamplona.amethyst.commons.resources.backup_review_changed_by_other_app import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.note.timeAgoNoDot @@ -65,10 +105,10 @@ import com.vitorpamplona.quartz.nip01Core.diff.ContentChange import com.vitorpamplona.quartz.nip01Core.diff.EventDiff import com.vitorpamplona.quartz.nip01Core.metadata.MetadataDiff import com.vitorpamplona.quartz.nip02FollowList.ContactListDiff -import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListDiff +import com.vitorpamplona.quartz.nip28PublicChat.list.PublicChatListDiff import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListDiff import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListDiff -import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListDiff +import com.vitorpamplona.quartz.nip51Lists.interestList.InterestListDiff import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListDiff import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayListDiff import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.SimpleGroupListDiff @@ -79,6 +119,9 @@ import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListDiff import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataDiff import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListDiff import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListDiff +import org.jetbrains.compose.resources.PluralStringResource +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.pluralStringResource /** * Cards at the top of Home's feed, one per open [ReplaceableBackupConflict]: the user @@ -125,34 +168,34 @@ private fun headlineOf( when (diff) { // Counted, not "shrank": a version that drops 3 follows and adds 100 still raises // a conflict, and calling that a shrink contradicts the card's own bar. - is ContactListDiff -> plural(R.plurals.backup_card_follows_dropped, diff.follows.removed.size) - is MuteListDiff -> plural(R.plurals.backup_card_mutes_visible, diff.publicMutes.removed.size) - is MetadataDiff -> plural(R.plurals.backup_card_profile_lost_fields, counts.removed) - is AdvertisedRelayListDiff -> plural(R.plurals.backup_card_nip65_lost, diff.relays.removed.size) + is ContactListDiff -> plural(Res.plurals.backup_card_follows_dropped, diff.follows.removed.size) + is MuteListDiff -> plural(Res.plurals.backup_card_mutes_visible, diff.publicMutes.removed.size) + is MetadataDiff -> plural(Res.plurals.backup_card_profile_lost_fields, counts.removed) + is AdvertisedRelayListDiff -> plural(Res.plurals.backup_card_nip65_lost, diff.relays.removed.size) is RelayListDiff -> relayListHeadline(conflict.eventType, diff.relays.removed.size) - is ChannelListDiff -> plural(R.plurals.backup_card_leave_chats, diff.channels.removed.size) - is CommunityListDiff -> plural(R.plurals.backup_card_leave_communities, diff.communities.removed.size) - is EphemeralChatListDiff -> plural(R.plurals.backup_card_leave_rooms, diff.rooms.removed.size) - is SimpleGroupListDiff -> plural(R.plurals.backup_card_leave_groups, diff.groups.removed.size) - is FavoriteAlgoFeedsListDiff -> plural(R.plurals.backup_card_feeds_removed, diff.feeds.removed.size) - is HashtagListDiff -> plural(R.plurals.backup_card_hashtags_unfollowed, diff.hashtags.removed.size) - is GeohashListDiff -> plural(R.plurals.backup_card_places_unfollowed, diff.geohashes.removed.size) - is TrustProviderListDiff -> plural(R.plurals.backup_card_trust_services_changed, diff.providers.removed.size + diff.providers.changed.size) + is PublicChatListDiff -> plural(Res.plurals.backup_card_leave_chats, diff.channels.removed.size) + is CommunityListDiff -> plural(Res.plurals.backup_card_leave_communities, diff.communities.removed.size) + is EphemeralChatListDiff -> plural(Res.plurals.backup_card_leave_rooms, diff.rooms.removed.size) + is SimpleGroupListDiff -> plural(Res.plurals.backup_card_leave_groups, diff.groups.removed.size) + is FavoriteAlgoFeedsListDiff -> plural(Res.plurals.backup_card_feeds_removed, diff.feeds.removed.size) + is InterestListDiff -> plural(Res.plurals.backup_card_hashtags_unfollowed, diff.hashtags.removed.size) + is GeohashListDiff -> plural(Res.plurals.backup_card_places_unfollowed, diff.geohashes.removed.size) + is TrustProviderListDiff -> plural(Res.plurals.backup_card_trust_services_changed, diff.providers.removed.size + diff.providers.changed.size) is NutzapInfoDiff -> when { - diff.p2pkPubkey?.after == null && diff.p2pkPubkey != null -> stringRes(R.string.backup_card_nutzap_key_removed) - diff.p2pkPubkey != null -> stringRes(R.string.backup_card_nutzap_key_replaced) - else -> plural(R.plurals.backup_card_nutzap_mints_removed, diff.mints.removed.size) + diff.p2pkPubkey?.after == null && diff.p2pkPubkey != null -> stringRes(Res.string.backup_card_nutzap_key_removed) + diff.p2pkPubkey != null -> stringRes(Res.string.backup_card_nutzap_key_replaced) + else -> plural(Res.plurals.backup_card_nutzap_mints_removed, diff.mints.removed.size) } - is PaymentTargetsDiff -> plural(R.plurals.backup_card_payment_targets_removed, diff.targets.removed.size) - is Bolt12OfferListDiff -> plural(R.plurals.backup_card_offers_removed, diff.offers.removed.size) - is CashuWalletDiff -> encryptedHeadline(R.string.backup_card_wallet_emptied, R.string.backup_card_wallet_rewritten, diff.wallet) - is ConcordCommunityListDiff -> encryptedHeadline(R.string.backup_card_concord_emptied, R.string.backup_card_concord_rewritten, diff.communities) - is AppSpecificDataDiff -> stringRes(R.string.backup_card_app_settings) + is PaymentTargetsDiff -> plural(Res.plurals.backup_card_payment_targets_removed, diff.targets.removed.size) + is Bolt12OfferListDiff -> plural(Res.plurals.backup_card_offers_removed, diff.offers.removed.size) + is CashuWalletDiff -> encryptedHeadline(Res.string.backup_card_wallet_emptied, Res.string.backup_card_wallet_rewritten, diff.wallet) + is ConcordCommunityListDiff -> encryptedHeadline(Res.string.backup_card_concord_emptied, Res.string.backup_card_concord_rewritten, diff.communities) + is AppSpecificDataDiff -> stringRes(Res.string.backup_card_app_settings) else -> null } // A headline counting removed items reads wrong when only encrypted items were lost. - if (specific.isNullOrEmpty()) return stringRes(R.string.backup_conflict_title, stringRes(eventTypeName(conflict.eventType))) + if (specific.isNullOrEmpty()) return stringRes(Res.string.backup_conflict_title, stringRes(eventTypeName(conflict.eventType))) // Headlines lead with the loss; what the other app added to the same list is said too, // so a list that dropped 3 and gained 40 doesn't read as if it only shrank. The wording // follows the headline's: following people, muting, joining chats, blocking relays. @@ -160,11 +203,11 @@ private fun headlineOf( if (added <= 0) return specific val tail = when { - diff is ContactListDiff -> R.plurals.backup_card_and_followed - diff is MuteListDiff -> R.plurals.backup_card_and_muted - diff is ChannelListDiff || diff is CommunityListDiff || diff is EphemeralChatListDiff || diff is SimpleGroupListDiff -> R.plurals.backup_card_and_joined - conflict.eventType == BackupEventType.BLOCKED_RELAYS -> R.plurals.backup_card_and_blocked - else -> R.plurals.backup_card_and_added + diff is ContactListDiff -> Res.plurals.backup_card_and_followed + diff is MuteListDiff -> Res.plurals.backup_card_and_muted + diff is PublicChatListDiff || diff is CommunityListDiff || diff is EphemeralChatListDiff || diff is SimpleGroupListDiff -> Res.plurals.backup_card_and_joined + conflict.eventType == BackupEventType.BLOCKED_RELAYS -> Res.plurals.backup_card_and_blocked + else -> Res.plurals.backup_card_and_added } return pluralStringResource(tail, added, specific, added) } @@ -182,12 +225,12 @@ private fun addedToHeadlinedList( is MuteListDiff -> diff.publicMutes.added.size is AdvertisedRelayListDiff -> diff.relays.added.size is RelayListDiff -> if (relayListHasHeadline(type)) diff.relays.added.size else 0 - is ChannelListDiff -> diff.channels.added.size + is PublicChatListDiff -> diff.channels.added.size is CommunityListDiff -> diff.communities.added.size is EphemeralChatListDiff -> diff.rooms.added.size is SimpleGroupListDiff -> diff.groups.added.size is FavoriteAlgoFeedsListDiff -> diff.feeds.added.size - is HashtagListDiff -> diff.hashtags.added.size + is InterestListDiff -> diff.hashtags.added.size is GeohashListDiff -> diff.geohashes.added.size is NutzapInfoDiff -> if (diff.p2pkPubkey == null) diff.mints.added.size else 0 is PaymentTargetsDiff -> diff.targets.added.size @@ -208,7 +251,7 @@ private fun relayListHasHeadline(type: BackupEventType): Boolean = /** The count's plural, or "" when nothing of that kind was removed. */ @Composable private fun plural( - id: Int, + id: PluralStringResource, count: Int, ): String = if (count > 0) pluralStringResource(id, count, count) else "" @@ -218,21 +261,21 @@ private fun relayListHeadline( removed: Int, ): String = when (type) { - BackupEventType.DM_RELAYS -> plural(R.plurals.backup_card_dm_relays_lost, removed) - BackupEventType.KEY_PACKAGE_RELAYS -> plural(R.plurals.backup_card_key_package_relays_lost, removed) - BackupEventType.SEARCH_RELAYS -> plural(R.plurals.backup_card_search_relays_lost, removed) - BackupEventType.INDEXER_RELAYS -> plural(R.plurals.backup_card_indexer_relays_lost, removed) - BackupEventType.RELAY_FEEDS -> plural(R.plurals.backup_card_relay_feeds_lost, removed) - BackupEventType.PRIVATE_OUTBOX_RELAYS -> plural(R.plurals.backup_card_private_outbox_lost, removed) - BackupEventType.TRUSTED_RELAYS -> plural(R.plurals.backup_card_trusted_lost, removed) - BackupEventType.BLOCKED_RELAYS -> plural(R.plurals.backup_card_unblocked, removed) + BackupEventType.DM_RELAYS -> plural(Res.plurals.backup_card_dm_relays_lost, removed) + BackupEventType.KEY_PACKAGE_RELAYS -> plural(Res.plurals.backup_card_key_package_relays_lost, removed) + BackupEventType.SEARCH_RELAYS -> plural(Res.plurals.backup_card_search_relays_lost, removed) + BackupEventType.INDEXER_RELAYS -> plural(Res.plurals.backup_card_indexer_relays_lost, removed) + BackupEventType.RELAY_FEEDS -> plural(Res.plurals.backup_card_relay_feeds_lost, removed) + BackupEventType.PRIVATE_OUTBOX_RELAYS -> plural(Res.plurals.backup_card_private_outbox_lost, removed) + BackupEventType.TRUSTED_RELAYS -> plural(Res.plurals.backup_card_trusted_lost, removed) + BackupEventType.BLOCKED_RELAYS -> plural(Res.plurals.backup_card_unblocked, removed) else -> "" } @Composable private fun encryptedHeadline( - emptied: Int, - rewritten: Int, + emptied: StringResource, + rewritten: StringResource, change: ContentChange, ): String = if (change == ContentChange.CLEARED) stringRes(emptied) else stringRes(rewritten) @@ -262,7 +305,7 @@ private fun LeadConflictCard( Column(Modifier.weight(1f)) { Text(headlineOf(conflict, counts), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold, maxLines = 3, overflow = TextOverflow.Ellipsis) Text( - stringRes(R.string.backup_review_changed_by_other_app, timeAgoNoDot(conflict.cause.createdAt)), + stringRes(Res.string.backup_review_changed_by_other_app, timeAgoNoDot(conflict.cause.createdAt)), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.placeholderText, ) @@ -296,9 +339,9 @@ private fun LeadConflictCard( private fun CountChips(counts: ConflictCounts) { val tones = conflictTones() Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - if (counts.removed > 0) StatusTag(stringRes(R.string.backup_conflict_count_removed, counts.removed.toString()), tones.removed) - if (counts.added > 0) StatusTag(stringRes(R.string.backup_conflict_count_added, counts.added.toString()), tones.added) - if (counts.changed > 0) StatusTag(stringRes(R.string.backup_conflict_count_changed, counts.changed.toString()), tones.changed) + if (counts.removed > 0) StatusTag(stringRes(Res.string.backup_conflict_count_removed, counts.removed.toString()), tones.removed) + if (counts.added > 0) StatusTag(stringRes(Res.string.backup_conflict_count_added, counts.added.toString()), tones.added) + if (counts.changed > 0) StatusTag(stringRes(Res.string.backup_conflict_count_changed, counts.changed.toString()), tones.changed) } } @@ -342,6 +385,6 @@ private fun AcceptButton(onAccept: () -> Unit) { modifier = Modifier.height(36.dp), contentPadding = PaddingValues(horizontal = 14.dp), ) { - Text(stringRes(R.string.backup_card_accept), fontWeight = FontWeight.SemiBold) + Text(stringRes(Res.string.backup_card_accept), fontWeight = FontWeight.SemiBold) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictEventViews.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictEventViews.kt index 419bd3b2a2..1916f09286 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictEventViews.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictEventViews.kt @@ -62,7 +62,6 @@ import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -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.backups.ReplaceableBackupConflict @@ -70,6 +69,67 @@ import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.model.navigation.Route import com.vitorpamplona.amethyst.commons.model.navigation.routeFor import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.backup_entry_hashtag +import com.vitorpamplona.amethyst.commons.resources.backup_entry_mint +import com.vitorpamplona.amethyst.commons.resources.backup_entry_nutzap_key +import com.vitorpamplona.amethyst.commons.resources.backup_entry_person +import com.vitorpamplona.amethyst.commons.resources.backup_entry_profile_field +import com.vitorpamplona.amethyst.commons.resources.backup_entry_thread +import com.vitorpamplona.amethyst.commons.resources.backup_entry_word +import com.vitorpamplona.amethyst.commons.resources.backup_profile_field_about +import com.vitorpamplona.amethyst.commons.resources.backup_profile_field_banner +import com.vitorpamplona.amethyst.commons.resources.backup_profile_field_birthday +import com.vitorpamplona.amethyst.commons.resources.backup_profile_field_bot +import com.vitorpamplona.amethyst.commons.resources.backup_profile_field_clink_offer +import com.vitorpamplona.amethyst.commons.resources.backup_profile_field_display_name +import com.vitorpamplona.amethyst.commons.resources.backup_profile_field_lud06 +import com.vitorpamplona.amethyst.commons.resources.backup_profile_field_lud16 +import com.vitorpamplona.amethyst.commons.resources.backup_profile_field_name +import com.vitorpamplona.amethyst.commons.resources.backup_profile_field_nip05 +import com.vitorpamplona.amethyst.commons.resources.backup_profile_field_picture +import com.vitorpamplona.amethyst.commons.resources.backup_profile_field_pronouns +import com.vitorpamplona.amethyst.commons.resources.backup_profile_field_website +import com.vitorpamplona.amethyst.commons.resources.backup_review_demoted_tag +import com.vitorpamplona.amethyst.commons.resources.backup_review_dropped_count +import com.vitorpamplona.amethyst.commons.resources.backup_review_dropped_tag +import com.vitorpamplona.amethyst.commons.resources.backup_review_gone +import com.vitorpamplona.amethyst.commons.resources.backup_review_inbox +import com.vitorpamplona.amethyst.commons.resources.backup_review_inbox_explainer +import com.vitorpamplona.amethyst.commons.resources.backup_review_joined +import com.vitorpamplona.amethyst.commons.resources.backup_review_kept_count +import com.vitorpamplona.amethyst.commons.resources.backup_review_key_removed +import com.vitorpamplona.amethyst.commons.resources.backup_review_key_replaced +import com.vitorpamplona.amethyst.commons.resources.backup_review_key_warning +import com.vitorpamplona.amethyst.commons.resources.backup_review_left +import com.vitorpamplona.amethyst.commons.resources.backup_review_left_tag +import com.vitorpamplona.amethyst.commons.resources.backup_review_names_and_more +import com.vitorpamplona.amethyst.commons.resources.backup_review_new +import com.vitorpamplona.amethyst.commons.resources.backup_review_new_count +import com.vitorpamplona.amethyst.commons.resources.backup_review_new_tag +import com.vitorpamplona.amethyst.commons.resources.backup_review_newly_muted +import com.vitorpamplona.amethyst.commons.resources.backup_review_not_set +import com.vitorpamplona.amethyst.commons.resources.backup_review_nothing_here +import com.vitorpamplona.amethyst.commons.resources.backup_review_outbox +import com.vitorpamplona.amethyst.commons.resources.backup_review_outbox_explainer +import com.vitorpamplona.amethyst.commons.resources.backup_review_private_added_body +import com.vitorpamplona.amethyst.commons.resources.backup_review_private_added_title +import com.vitorpamplona.amethyst.commons.resources.backup_review_private_changed_body +import com.vitorpamplona.amethyst.commons.resources.backup_review_private_changed_title +import com.vitorpamplona.amethyst.commons.resources.backup_review_private_wiped_body +import com.vitorpamplona.amethyst.commons.resources.backup_review_private_wiped_title +import com.vitorpamplona.amethyst.commons.resources.backup_review_renamed +import com.vitorpamplona.amethyst.commons.resources.backup_review_renamed_tag +import com.vitorpamplona.amethyst.commons.resources.backup_review_saved +import com.vitorpamplona.amethyst.commons.resources.backup_review_search_people +import com.vitorpamplona.amethyst.commons.resources.backup_review_tab_dropped +import com.vitorpamplona.amethyst.commons.resources.backup_review_tab_edited +import com.vitorpamplona.amethyst.commons.resources.backup_review_tab_new +import com.vitorpamplona.amethyst.commons.resources.backup_review_units_tag +import com.vitorpamplona.amethyst.commons.resources.backup_review_unmuted +import com.vitorpamplona.amethyst.commons.resources.backup_review_unmuted_explainer +import com.vitorpamplona.amethyst.commons.resources.backup_review_was_named +import com.vitorpamplona.amethyst.commons.resources.backup_review_words_and_hashtags import com.vitorpamplona.amethyst.commons.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.screen.LocalDisplaySettings @@ -111,6 +171,7 @@ import com.vitorpamplona.quartz.nip61Nutzaps.info.tags.NutzapMintTag import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListDiff import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayType +import org.jetbrains.compose.resources.StringResource private const val GRID_COLUMNS = 4 private const val PEOPLE_PREVIEW = 6 @@ -168,7 +229,7 @@ private fun LazyListScope.followListItems( item(key = "follow-hero", contentType = "hero") { val tones = conflictTones() Column(Pad.padding(top = 8.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { - HeroCounts(stringRes(R.string.backup_review_saved), saved, stringRes(R.string.backup_review_new), new) + HeroCounts(stringRes(Res.string.backup_review_saved), saved, stringRes(Res.string.backup_review_new), new) SplitBar( listOf( BarSegment(saved - dropped, tones.kept), @@ -177,9 +238,9 @@ private fun LazyListScope.followListItems( ), ) Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) { - LegendItem(tones.kept, stringRes(R.string.backup_review_kept_count, (saved - dropped).toString())) - LegendItem(tones.removed, stringRes(R.string.backup_review_dropped_count, dropped.toString())) - LegendItem(tones.added, stringRes(R.string.backup_review_new_count, gained.toString())) + LegendItem(tones.kept, stringRes(Res.string.backup_review_kept_count, (saved - dropped).toString())) + LegendItem(tones.removed, stringRes(Res.string.backup_review_dropped_count, dropped.toString())) + LegendItem(tones.added, stringRes(Res.string.backup_review_new_count, gained.toString())) } } } @@ -188,9 +249,9 @@ private fun LazyListScope.followListItems( ReviewTabs( labels = listOf( - stringRes(R.string.backup_review_tab_dropped, dropped.toString()), - stringRes(R.string.backup_review_tab_new, gained.toString()), - stringRes(R.string.backup_review_tab_edited, edited.toString()), + stringRes(Res.string.backup_review_tab_dropped, dropped.toString()), + stringRes(Res.string.backup_review_tab_new, gained.toString()), + stringRes(Res.string.backup_review_tab_edited, edited.toString()), ), selected = ui.tab, onSelect = { @@ -223,7 +284,7 @@ private fun LazyListScope.followListItems( if (filtered.isEmpty()) { item(key = "follow-empty", contentType = "note") { - DetailText(stringRes(R.string.backup_review_nothing_here), Pad.padding(vertical = 24.dp)) + DetailText(stringRes(Res.string.backup_review_nothing_here), Pad.padding(vertical = 24.dp)) } } } @@ -359,7 +420,7 @@ private fun PeopleSearch( singleLine = true, shape = RoundedCornerShape(14.dp), leadingIcon = { Icon(symbol = MaterialSymbols.Search, contentDescription = null) }, - placeholder = { Text(stringRes(R.string.backup_review_search_people, count.toString())) }, + placeholder = { Text(stringRes(Res.string.backup_review_search_people, count.toString())) }, ) } @@ -388,15 +449,15 @@ private fun LazyListScope.muteListItems( Icon(symbol = MaterialSymbols.Shield, contentDescription = null, tint = tones.removed, modifier = Modifier.size(44.dp)) Column { Text( - stringRes(R.string.backup_review_unmuted, removed.size.toString()), + stringRes(Res.string.backup_review_unmuted, removed.size.toString()), fontSize = 30.sp, lineHeight = 32.sp, fontWeight = FontWeight.Bold, color = tones.removed, ) - Text(stringRes(R.string.backup_review_unmuted_explainer), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.placeholderText) + Text(stringRes(Res.string.backup_review_unmuted_explainer), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.placeholderText) if (added.isNotEmpty()) { - Text(stringRes(R.string.backup_review_newly_muted, added.size.toString()), style = MaterialTheme.typography.bodySmall, color = tones.added) + Text(stringRes(Res.string.backup_review_newly_muted, added.size.toString()), style = MaterialTheme.typography.bodySmall, color = tones.added) } } } @@ -406,17 +467,17 @@ private fun LazyListScope.muteListItems( item(key = "mute-tiles", contentType = "tiles") { val tones = conflictTones() Row(Pad.padding(top = 12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { - StatTile("−${people.size}", stringRes(R.string.backup_entry_person), tones.removed, Modifier.weight(1f)) - StatTile("−${words.size}", stringRes(R.string.backup_entry_word), tones.removed, Modifier.weight(1f)) - StatTile("−${hashtags.size}", stringRes(R.string.backup_entry_hashtag), tones.removed, Modifier.weight(1f)) - StatTile("−${threads.size}", stringRes(R.string.backup_entry_thread), tones.removed, Modifier.weight(1f)) + StatTile("−${people.size}", stringRes(Res.string.backup_entry_person), tones.removed, Modifier.weight(1f)) + StatTile("−${words.size}", stringRes(Res.string.backup_entry_word), tones.removed, Modifier.weight(1f)) + StatTile("−${hashtags.size}", stringRes(Res.string.backup_entry_hashtag), tones.removed, Modifier.weight(1f)) + StatTile("−${threads.size}", stringRes(Res.string.backup_entry_thread), tones.removed, Modifier.weight(1f)) } } if (people.isNotEmpty()) { item(key = "mute-people", contentType = "facepile") { Column(Pad.padding(top = 18.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - SectionCaption(stringRes(R.string.backup_entry_person)) + SectionCaption(stringRes(Res.string.backup_entry_person)) FacePile(people, accountViewModel) { ui.showAllPeople = !ui.showAllPeople } } } @@ -427,7 +488,7 @@ private fun LazyListScope.muteListItems( item(key = "mute-words", contentType = "pills") { val tones = conflictTones() Column(Pad.padding(top = 18.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - SectionCaption(stringRes(R.string.backup_review_words_and_hashtags)) + SectionCaption(stringRes(Res.string.backup_review_words_and_hashtags)) FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { (words.map { "\"$it\"" } + hashtags).forEach { StruckPill(it, tones.removed) } } @@ -436,7 +497,7 @@ private fun LazyListScope.muteListItems( } if (threads.isNotEmpty()) { - item(key = "mute-threads-caption", contentType = "caption") { SectionCaption(stringRes(R.string.backup_entry_thread), Pad.padding(top = 18.dp)) } + item(key = "mute-threads-caption", contentType = "caption") { SectionCaption(stringRes(Res.string.backup_entry_thread), Pad.padding(top = 18.dp)) } items(threads, key = { "mute-thread-$it" }, contentType = { "note" }) { id -> LoadNote(id) { note -> if (note != null) { @@ -463,7 +524,7 @@ private fun FacePile( val names = people.take(2).map { observedName(it, accountViewModel) } val summary = if (people.size > 2) { - stringRes(R.string.backup_review_names_and_more, names.joinToString(", "), (people.size - 2).toString()) + stringRes(Res.string.backup_review_names_and_more, names.joinToString(", "), (people.size - 2).toString()) } else { names.joinToString(", ") } @@ -564,9 +625,9 @@ internal fun PrivateItemsCard( val tones = conflictTones() val (title, body) = when (change) { - ContentChange.CLEARED -> R.string.backup_review_private_wiped_title to R.string.backup_review_private_wiped_body - ContentChange.ADDED -> R.string.backup_review_private_added_title to R.string.backup_review_private_added_body - else -> R.string.backup_review_private_changed_title to R.string.backup_review_private_changed_body + ContentChange.CLEARED -> Res.string.backup_review_private_wiped_title to Res.string.backup_review_private_wiped_body + ContentChange.ADDED -> Res.string.backup_review_private_added_title to Res.string.backup_review_private_added_body + else -> Res.string.backup_review_private_changed_title to Res.string.backup_review_private_changed_body } Row( modifier @@ -592,28 +653,28 @@ internal fun PrivateItemsCard( // --------------------------------------------------------------------------------------- private class ProfileField( - val labelRes: Int, + val labelRes: StringResource, val change: ValueChange, val isImage: Boolean = false, ) private fun profileFields(diff: MetadataDiff): List = listOfNotNull( - diff.picture?.let { ProfileField(R.string.backup_profile_field_picture, it, isImage = true) }, - diff.banner?.let { ProfileField(R.string.backup_profile_field_banner, it, isImage = true) }, - diff.name?.let { ProfileField(R.string.backup_profile_field_name, it) }, - diff.displayName?.let { ProfileField(R.string.backup_profile_field_display_name, it) }, - diff.about?.let { ProfileField(R.string.backup_profile_field_about, it) }, - diff.nip05?.let { ProfileField(R.string.backup_profile_field_nip05, it) }, - diff.website?.let { ProfileField(R.string.backup_profile_field_website, it) }, - diff.lud16?.let { ProfileField(R.string.backup_profile_field_lud16, it) }, - diff.lud06?.let { ProfileField(R.string.backup_profile_field_lud06, it) }, - diff.clinkOffer?.let { ProfileField(R.string.backup_profile_field_clink_offer, it) }, - diff.pronouns?.let { ProfileField(R.string.backup_profile_field_pronouns, it) }, - diff.bot?.let { ProfileField(R.string.backup_profile_field_bot, ValueChange(it.before?.toString(), it.after?.toString())) }, + diff.picture?.let { ProfileField(Res.string.backup_profile_field_picture, it, isImage = true) }, + diff.banner?.let { ProfileField(Res.string.backup_profile_field_banner, it, isImage = true) }, + diff.name?.let { ProfileField(Res.string.backup_profile_field_name, it) }, + diff.displayName?.let { ProfileField(Res.string.backup_profile_field_display_name, it) }, + diff.about?.let { ProfileField(Res.string.backup_profile_field_about, it) }, + diff.nip05?.let { ProfileField(Res.string.backup_profile_field_nip05, it) }, + diff.website?.let { ProfileField(Res.string.backup_profile_field_website, it) }, + diff.lud16?.let { ProfileField(Res.string.backup_profile_field_lud16, it) }, + diff.lud06?.let { ProfileField(Res.string.backup_profile_field_lud06, it) }, + diff.clinkOffer?.let { ProfileField(Res.string.backup_profile_field_clink_offer, it) }, + diff.pronouns?.let { ProfileField(Res.string.backup_profile_field_pronouns, it) }, + diff.bot?.let { ProfileField(Res.string.backup_profile_field_bot, ValueChange(it.before?.toString(), it.after?.toString())) }, diff.birthday?.let { ProfileField( - R.string.backup_profile_field_birthday, + Res.string.backup_profile_field_birthday, ValueChange( it.before?.let { b -> listOfNotNull(b.year, b.month, b.day).joinToString("-") }, it.after?.let { a -> listOfNotNull(a.year, a.month, a.day).joinToString("-") }, @@ -636,8 +697,8 @@ private fun LazyListScope.profileItems( item(key = "profile-cards", contentType = "profile-cards") { val tones = conflictTones() Row(Pad.padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(10.dp)) { - MiniProfileCard(saved, pubKey, stringRes(R.string.backup_review_saved), MaterialTheme.colorScheme.outlineVariant, accountViewModel, Modifier.weight(1f)) - MiniProfileCard(incoming, pubKey, stringRes(R.string.backup_review_new), tones.removed.copy(alpha = 0.5f), accountViewModel, Modifier.weight(1f)) + MiniProfileCard(saved, pubKey, stringRes(Res.string.backup_review_saved), MaterialTheme.colorScheme.outlineVariant, accountViewModel, Modifier.weight(1f)) + MiniProfileCard(incoming, pubKey, stringRes(Res.string.backup_review_new), tones.removed.copy(alpha = 0.5f), accountViewModel, Modifier.weight(1f)) } } @@ -656,7 +717,7 @@ private fun LazyListScope.profileItems( } } // Linked identities and fields this app doesn't model use the generic sections. - genericDiffItems(buildRows(presentation, skipGroups = setOf(R.string.backup_entry_profile_field)), accountViewModel, nav) + genericDiffItems(buildRows(presentation, skipGroups = setOf(Res.string.backup_entry_profile_field)), accountViewModel, nav) } @Composable @@ -735,10 +796,10 @@ private fun FlowRowDiff( FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(6.dp), itemVerticalAlignment = Alignment.CenterVertically) { if (before != null) DiffChip(clip(before), tones.removed, struck = true) when { - after == null -> StatusTag(stringRes(R.string.backup_review_gone), tones.removed) + after == null -> StatusTag(stringRes(Res.string.backup_review_gone), tones.removed) before == null -> { DiffChip(clip(after), tones.added, struck = false) - StatusTag(stringRes(R.string.backup_review_new_tag), tones.added) + StatusTag(stringRes(Res.string.backup_review_new_tag), tones.added) } else -> { Text("→", color = MaterialTheme.colorScheme.placeholderText) @@ -779,7 +840,7 @@ private fun ImageThumb( contentAlignment = Alignment.Center, ) { if (url == null) { - Text(stringRes(R.string.backup_review_not_set), style = MaterialTheme.typography.labelSmall, color = color) + Text(stringRes(Res.string.backup_review_not_set), style = MaterialTheme.typography.labelSmall, color = color) } else { MyAsyncImage( imageUrl = url, @@ -843,14 +904,14 @@ private fun LazyListScope.nip65Items( val outbox = lane(saved, new, writes) val inbox = lane(saved, new, reads) - laneItems("outbox", stringRes = R.string.backup_review_outbox, subRes = R.string.backup_review_outbox_explainer, rows = outbox, nav = nav) - laneItems("inbox", stringRes = R.string.backup_review_inbox, subRes = R.string.backup_review_inbox_explainer, rows = inbox, nav = nav) + laneItems("outbox", stringRes = Res.string.backup_review_outbox, subRes = Res.string.backup_review_outbox_explainer, rows = outbox, nav = nav) + laneItems("inbox", stringRes = Res.string.backup_review_inbox, subRes = Res.string.backup_review_inbox_explainer, rows = inbox, nav = nav) } private fun LazyListScope.laneItems( key: String, - stringRes: Int, - subRes: Int, + stringRes: StringResource, + subRes: StringResource, rows: List, nav: INav, ) { @@ -873,9 +934,9 @@ private fun LaneRelayRow( val (color, tag) = when (row.state) { LaneState.KEPT -> tones.kept to null - LaneState.DROPPED -> tones.removed to R.string.backup_review_dropped_tag - LaneState.ADDED -> tones.added to R.string.backup_review_new_tag - LaneState.DEMOTED -> tones.changed to R.string.backup_review_demoted_tag + LaneState.DROPPED -> tones.removed to Res.string.backup_review_dropped_tag + LaneState.ADDED -> tones.added to Res.string.backup_review_new_tag + LaneState.DEMOTED -> tones.changed to Res.string.backup_review_demoted_tag } Row( Pad @@ -933,12 +994,12 @@ private fun LazyListScope.nutzapItems( ) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { Icon(symbol = MaterialSymbols.Key, contentDescription = null, tint = tones.changed) - Text(stringRes(if (key.after == null) R.string.backup_review_key_removed else R.string.backup_review_key_replaced), style = MaterialTheme.typography.titleSmall) + Text(stringRes(if (key.after == null) Res.string.backup_review_key_removed else Res.string.backup_review_key_replaced), style = MaterialTheme.typography.titleSmall) } Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) { - KeyColumn(key.before, stringRes(R.string.backup_review_saved), tones.kept) + KeyColumn(key.before, stringRes(Res.string.backup_review_saved), tones.kept) Text("→", fontSize = 24.sp, color = MaterialTheme.colorScheme.placeholderText, modifier = Modifier.padding(horizontal = 18.dp)) - KeyColumn(key.after, stringRes(R.string.backup_review_new), tones.changed) + KeyColumn(key.after, stringRes(Res.string.backup_review_new), tones.changed) } if (key.after != null) { Row( @@ -950,7 +1011,7 @@ private fun LazyListScope.nutzapItems( horizontalArrangement = Arrangement.spacedBy(8.dp), ) { Icon(symbol = MaterialSymbols.Warning, contentDescription = null, tint = tones.changed, modifier = Modifier.size(18.dp)) - Text(stringRes(R.string.backup_review_key_warning), style = MaterialTheme.typography.bodySmall, color = tones.changed) + Text(stringRes(Res.string.backup_review_key_warning), style = MaterialTheme.typography.bodySmall, color = tones.changed) } } } @@ -976,10 +1037,10 @@ private fun LazyListScope.nutzapItems( } + diff.mints.added.map { Triple(it.mintUrl, it.units.joinToString(), LaneState.ADDED) } if (mintRows.isNotEmpty()) { - item(key = "nutzap-mints", contentType = "caption") { SectionCaption(stringRes(R.string.backup_entry_mint), Pad.padding(top = 18.dp, bottom = 6.dp)) } + item(key = "nutzap-mints", contentType = "caption") { SectionCaption(stringRes(Res.string.backup_entry_mint), Pad.padding(top = 18.dp, bottom = 6.dp)) } items(mintRows, key = { "mint-" + it.first }, contentType = { "mint" }) { (url, units, state) -> MintRow(url, units, state) } } - genericDiffItems(buildRows(presentation, skipGroups = setOf(R.string.backup_entry_mint, R.string.backup_entry_nutzap_key)), accountViewModel, nav) + genericDiffItems(buildRows(presentation, skipGroups = setOf(Res.string.backup_entry_mint, Res.string.backup_entry_nutzap_key)), accountViewModel, nav) } @Composable @@ -993,7 +1054,7 @@ private fun KeyColumn( KeyFingerprint(hex, color) } else { Box(Modifier.size(96.dp).clip(RoundedCornerShape(18.dp)).border(2.dp, color, RoundedCornerShape(18.dp)), contentAlignment = Alignment.Center) { - Text(stringRes(R.string.backup_review_not_set), style = MaterialTheme.typography.labelSmall, color = color) + Text(stringRes(Res.string.backup_review_not_set), style = MaterialTheme.typography.labelSmall, color = color) } } StatusTag(label, color) @@ -1010,9 +1071,9 @@ private fun MintRow( val (color, tag) = when (state) { LaneState.KEPT -> MaterialTheme.colorScheme.onSurface to null - LaneState.DROPPED -> tones.removed to R.string.backup_review_dropped_tag - LaneState.ADDED -> tones.added to R.string.backup_review_new_tag - LaneState.DEMOTED -> tones.changed to R.string.backup_review_units_tag + LaneState.DROPPED -> tones.removed to Res.string.backup_review_dropped_tag + LaneState.ADDED -> tones.added to Res.string.backup_review_new_tag + LaneState.DEMOTED -> tones.changed to Res.string.backup_review_units_tag } Row( Pad @@ -1083,21 +1144,21 @@ private fun LazyListScope.groupItems( StatTile( diff.groups.removed.size .toString(), - stringRes(R.string.backup_review_left), + stringRes(Res.string.backup_review_left), tones.removed, Modifier.weight(1f), ) StatTile( diff.groups.added.size .toString(), - stringRes(R.string.backup_review_joined), + stringRes(Res.string.backup_review_joined), tones.added, Modifier.weight(1f), ) StatTile( diff.groups.changed.size .toString(), - stringRes(R.string.backup_review_renamed), + stringRes(Res.string.backup_review_renamed), tones.changed, Modifier.weight(1f), ) @@ -1165,7 +1226,7 @@ private fun GroupTileContent( Column { Text(name, style = MaterialTheme.typography.titleSmall, maxLines = 1, overflow = TextOverflow.Ellipsis) Text( - tile.oldName?.let { stringRes(R.string.backup_review_was_named, it) } ?: tile.group.relayUrl + tile.oldName?.let { stringRes(Res.string.backup_review_was_named, it) } ?: tile.group.relayUrl .removePrefix("wss://") .removeSuffix("/"), style = MaterialTheme.typography.labelSmall, @@ -1176,9 +1237,9 @@ private fun GroupTileContent( } } when (tile.state) { - LaneState.DROPPED -> StatusTag(stringRes(R.string.backup_review_left_tag), tones.removed, Modifier.align(Alignment.TopEnd)) - LaneState.ADDED -> StatusTag(stringRes(R.string.backup_review_new_tag), tones.added, Modifier.align(Alignment.TopEnd)) - LaneState.DEMOTED -> StatusTag(stringRes(R.string.backup_review_renamed_tag), tones.changed, Modifier.align(Alignment.TopEnd)) + LaneState.DROPPED -> StatusTag(stringRes(Res.string.backup_review_left_tag), tones.removed, Modifier.align(Alignment.TopEnd)) + LaneState.ADDED -> StatusTag(stringRes(Res.string.backup_review_new_tag), tones.added, Modifier.align(Alignment.TopEnd)) + LaneState.DEMOTED -> StatusTag(stringRes(Res.string.backup_review_renamed_tag), tones.changed, Modifier.align(Alignment.TopEnd)) LaneState.KEPT -> {} } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictListViews.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictListViews.kt index 55473cce27..19d694dd09 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictListViews.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictListViews.kt @@ -48,13 +48,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols @@ -63,6 +61,53 @@ import com.vitorpamplona.amethyst.commons.model.backups.ReplaceableBackupConflic import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.model.navigation.Route import com.vitorpamplona.amethyst.commons.model.navigation.routeFor +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.backup_review_allowed_tag +import com.vitorpamplona.amethyst.commons.resources.backup_review_concord_body +import com.vitorpamplona.amethyst.commons.resources.backup_review_demoted_tag +import com.vitorpamplona.amethyst.commons.resources.backup_review_dropped_tag +import com.vitorpamplona.amethyst.commons.resources.backup_review_encrypted_added +import com.vitorpamplona.amethyst.commons.resources.backup_review_encrypted_changed +import com.vitorpamplona.amethyst.commons.resources.backup_review_encrypted_cleared +import com.vitorpamplona.amethyst.commons.resources.backup_review_feed +import com.vitorpamplona.amethyst.commons.resources.backup_review_hashtags_you_follow +import com.vitorpamplona.amethyst.commons.resources.backup_review_joined +import com.vitorpamplona.amethyst.commons.resources.backup_review_kept +import com.vitorpamplona.amethyst.commons.resources.backup_review_left +import com.vitorpamplona.amethyst.commons.resources.backup_review_left_tag +import com.vitorpamplona.amethyst.commons.resources.backup_review_listed_tag +import com.vitorpamplona.amethyst.commons.resources.backup_review_moved_tag +import com.vitorpamplona.amethyst.commons.resources.backup_review_new +import com.vitorpamplona.amethyst.commons.resources.backup_review_new_count_caption +import com.vitorpamplona.amethyst.commons.resources.backup_review_new_tag +import com.vitorpamplona.amethyst.commons.resources.backup_review_newly_blocked +import com.vitorpamplona.amethyst.commons.resources.backup_review_no_longer_blocked +import com.vitorpamplona.amethyst.commons.resources.backup_review_offer +import com.vitorpamplona.amethyst.commons.resources.backup_review_offers_gone +import com.vitorpamplona.amethyst.commons.resources.backup_review_payment_targets_gone +import com.vitorpamplona.amethyst.commons.resources.backup_review_payments_explainer +import com.vitorpamplona.amethyst.commons.resources.backup_review_places_gone +import com.vitorpamplona.amethyst.commons.resources.backup_review_places_you_follow +import com.vitorpamplona.amethyst.commons.resources.backup_review_receiving_tag +import com.vitorpamplona.amethyst.commons.resources.backup_review_saved +import com.vitorpamplona.amethyst.commons.resources.backup_review_searching_tag +import com.vitorpamplona.amethyst.commons.resources.backup_review_service +import com.vitorpamplona.amethyst.commons.resources.backup_review_service_followers +import com.vitorpamplona.amethyst.commons.resources.backup_review_service_rank +import com.vitorpamplona.amethyst.commons.resources.backup_review_service_topics +import com.vitorpamplona.amethyst.commons.resources.backup_review_services_changed +import com.vitorpamplona.amethyst.commons.resources.backup_review_services_changed_explainer +import com.vitorpamplona.amethyst.commons.resources.backup_review_still_blocked +import com.vitorpamplona.amethyst.commons.resources.backup_review_storing_tag +import com.vitorpamplona.amethyst.commons.resources.backup_review_topics_gone +import com.vitorpamplona.amethyst.commons.resources.backup_review_trust_warning +import com.vitorpamplona.amethyst.commons.resources.backup_review_trusted_tag +import com.vitorpamplona.amethyst.commons.resources.backup_review_unblocked +import com.vitorpamplona.amethyst.commons.resources.backup_review_unblocked_explainer +import com.vitorpamplona.amethyst.commons.resources.backup_review_untrusted_tag +import com.vitorpamplona.amethyst.commons.resources.backup_review_wallet_body +import com.vitorpamplona.amethyst.commons.resources.backup_type_communities +import com.vitorpamplona.amethyst.commons.resources.backup_type_public_chats import com.vitorpamplona.amethyst.commons.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.screen.LocalDisplaySettings @@ -92,9 +137,9 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.diff.ContentChange import com.vitorpamplona.quartz.nip01Core.diff.ListDiff import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent -import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListDiff -import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent +import com.vitorpamplona.quartz.nip17Dm.settings.DmRelayListEvent +import com.vitorpamplona.quartz.nip28PublicChat.list.PublicChatListDiff +import com.vitorpamplona.quartz.nip28PublicChat.list.PublicChatListEvent import com.vitorpamplona.quartz.nip28PublicChat.list.channels import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayListEvent import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent @@ -102,11 +147,11 @@ import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFee import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListEvent import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListDiff import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent -import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListDiff -import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent +import com.vitorpamplona.quartz.nip51Lists.interestList.InterestListDiff +import com.vitorpamplona.quartz.nip51Lists.interestList.InterestListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.FavoriteRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent -import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayListDiff import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent import com.vitorpamplona.quartz.nip60Cashu.wallet.CashuWalletDiff @@ -118,6 +163,9 @@ import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEve import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListDiff import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListEvent +import org.jetbrains.compose.resources.PluralStringResource +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.pluralStringResource /** * Per-event layouts for the account's other lists: each relay list framed by what it is @@ -133,17 +181,17 @@ internal fun LazyListScope.listDiffItems( ): Boolean { when (val diff = conflict.diff) { is RelayListDiff -> relayListItems(diff, conflict, nav) - is ChannelListDiff -> publicChatItems(diff, conflict, accountViewModel, nav) + is PublicChatListDiff -> publicChatItems(diff, conflict, accountViewModel, nav) is CommunityListDiff -> communityItems(diff, conflict, accountViewModel, nav) is EphemeralChatListDiff -> ephemeralRoomItems(diff, conflict, nav) is FavoriteAlgoFeedsListDiff -> favoriteFeedItems(diff, conflict, accountViewModel, nav) - is HashtagListDiff -> topicItems(diff.hashtags, (conflict.saved as? HashtagListEvent)?.publicHashtags().orEmpty(), diff.privateItems, isPlace = false, nav) + is InterestListDiff -> topicItems(diff.hashtags, (conflict.saved as? InterestListEvent)?.publicHashtags().orEmpty(), diff.privateItems, isPlace = false, nav) is GeohashListDiff -> topicItems(diff.geohashes, (conflict.saved as? GeohashListEvent)?.publicGeohashes().orEmpty(), diff.privateItems, isPlace = true, nav) is TrustProviderListDiff -> trustProviderItems(diff, conflict, accountViewModel, nav) is PaymentTargetsDiff -> paymentTargetItems(diff, conflict) is Bolt12OfferListDiff -> offerItems(diff, conflict) - is CashuWalletDiff -> encryptedOnlyItems(MaterialSymbols.AccountBalanceWallet, diff.wallet, R.string.backup_review_wallet_body) - is ConcordCommunityListDiff -> encryptedOnlyItems(MaterialSymbols.Groups, diff.communities, R.string.backup_review_concord_body) + is CashuWalletDiff -> encryptedOnlyItems(MaterialSymbols.AccountBalanceWallet, diff.wallet, Res.string.backup_review_wallet_body) + is ConcordCommunityListDiff -> encryptedOnlyItems(MaterialSymbols.Groups, diff.communities, Res.string.backup_review_concord_body) else -> return false } return true @@ -193,15 +241,15 @@ private fun fateColor(fate: ItemFate): Color { private fun LazyListScope.countTiles( items: List>, - removedCaption: Int, - addedCaption: Int, + removedCaption: StringResource, + addedCaption: StringResource, ) { item(key = "count-tiles", contentType = "tiles") { val tones = conflictTones() Row(Pad.padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { StatTile(items.count { it.second == ItemFate.DROPPED }.toString(), stringRes(removedCaption), tones.removed, Modifier.weight(1f)) StatTile(items.count { it.second == ItemFate.ADDED }.toString(), stringRes(addedCaption), tones.added, Modifier.weight(1f)) - StatTile(items.count { it.second == ItemFate.KEPT || it.second == ItemFate.CHANGED }.toString(), stringRes(R.string.backup_review_kept), tones.kept, Modifier.weight(1f)) + StatTile(items.count { it.second == ItemFate.KEPT || it.second == ItemFate.CHANGED }.toString(), stringRes(Res.string.backup_review_kept), tones.kept, Modifier.weight(1f)) } } } @@ -218,29 +266,29 @@ private fun LazyListScope.privateItemsCard(change: ContentChange) { /** How a relay list's rows are presented: its icon and what a kept or dropped relay is called. */ private class RelayFraming( val icon: MaterialSymbol, - val keptTag: Int, - val droppedTag: Int = R.string.backup_review_dropped_tag, + val keptTag: StringResource, + val droppedTag: StringResource = Res.string.backup_review_dropped_tag, ) private fun framingOf(type: BackupEventType): RelayFraming = when (type) { - BackupEventType.DM_RELAYS -> RelayFraming(MaterialSymbols.Mail, R.string.backup_review_receiving_tag) - BackupEventType.KEY_PACKAGE_RELAYS -> RelayFraming(MaterialSymbols.Key, R.string.backup_review_listed_tag) - BackupEventType.SEARCH_RELAYS -> RelayFraming(MaterialSymbols.Search, R.string.backup_review_searching_tag) - BackupEventType.INDEXER_RELAYS -> RelayFraming(MaterialSymbols.Dns, R.string.backup_review_listed_tag) - BackupEventType.RELAY_FEEDS -> RelayFraming(MaterialSymbols.CellTower, R.string.backup_review_listed_tag) - BackupEventType.PRIVATE_OUTBOX_RELAYS -> RelayFraming(MaterialSymbols.Lock, R.string.backup_review_storing_tag) - BackupEventType.TRUSTED_RELAYS -> RelayFraming(MaterialSymbols.Shield, R.string.backup_review_trusted_tag, R.string.backup_review_untrusted_tag) - else -> RelayFraming(MaterialSymbols.Dns, R.string.backup_review_listed_tag) + BackupEventType.DM_RELAYS -> RelayFraming(MaterialSymbols.Mail, Res.string.backup_review_receiving_tag) + BackupEventType.KEY_PACKAGE_RELAYS -> RelayFraming(MaterialSymbols.Key, Res.string.backup_review_listed_tag) + BackupEventType.SEARCH_RELAYS -> RelayFraming(MaterialSymbols.Search, Res.string.backup_review_searching_tag) + BackupEventType.INDEXER_RELAYS -> RelayFraming(MaterialSymbols.Dns, Res.string.backup_review_listed_tag) + BackupEventType.RELAY_FEEDS -> RelayFraming(MaterialSymbols.CellTower, Res.string.backup_review_listed_tag) + BackupEventType.PRIVATE_OUTBOX_RELAYS -> RelayFraming(MaterialSymbols.Lock, Res.string.backup_review_storing_tag) + BackupEventType.TRUSTED_RELAYS -> RelayFraming(MaterialSymbols.Shield, Res.string.backup_review_trusted_tag, Res.string.backup_review_untrusted_tag) + else -> RelayFraming(MaterialSymbols.Dns, Res.string.backup_review_listed_tag) } private fun savedRelaysOf(event: Event): List = when (event) { - is ChatMessageRelayListEvent -> event.relays() + is DmRelayListEvent -> event.relays() is KeyPackageRelayListEvent -> event.relays() is SearchRelayListEvent -> event.publicRelays() is IndexerRelayListEvent -> event.publicRelays() - is RelayFeedsListEvent -> event.publicRelays() + is FavoriteRelayListEvent -> event.publicRelays() is BlockedRelayListEvent -> event.publicRelays() is TrustedRelayListEvent -> event.publicRelays() is PrivateOutboxRelayListEvent -> event.publicRelays() @@ -277,8 +325,8 @@ private fun RelayFateRow( when (fate) { ItemFate.KEPT -> framing.keptTag ItemFate.DROPPED -> framing.droppedTag - ItemFate.ADDED -> R.string.backup_review_new_tag - ItemFate.CHANGED -> R.string.backup_review_demoted_tag + ItemFate.ADDED -> Res.string.backup_review_new_tag + ItemFate.CHANGED -> Res.string.backup_review_demoted_tag } Row( Pad @@ -320,14 +368,14 @@ private fun LazyListScope.blockedRelayItems( Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp)) { Icon(symbol = MaterialSymbols.LockOpen, contentDescription = null, tint = tones.changed, modifier = Modifier.size(44.dp)) Column { - Text(stringRes(R.string.backup_review_unblocked, unblocked.size.toString()), fontSize = 30.sp, lineHeight = 32.sp, fontWeight = FontWeight.Bold, color = tones.changed) - Text(stringRes(R.string.backup_review_unblocked_explainer), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.placeholderText) + Text(stringRes(Res.string.backup_review_unblocked, unblocked.size.toString()), fontSize = 30.sp, lineHeight = 32.sp, fontWeight = FontWeight.Bold, color = tones.changed) + Text(stringRes(Res.string.backup_review_unblocked_explainer), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.placeholderText) } } } } if (unblocked.isNotEmpty()) { - item(key = "unblocked-caption", contentType = "caption") { SectionCaption(stringRes(R.string.backup_review_no_longer_blocked), Pad.padding(top = 18.dp)) } + item(key = "unblocked-caption", contentType = "caption") { SectionCaption(stringRes(Res.string.backup_review_no_longer_blocked), Pad.padding(top = 18.dp)) } items(unblocked, key = { "unblocked-" + it.url }, contentType = { "relay-row" }) { url -> val tones = conflictTones() Row( @@ -346,12 +394,12 @@ private fun LazyListScope.blockedRelayItems( Icon(symbol = MaterialSymbols.LockOpen, contentDescription = null, tint = tones.changed, modifier = Modifier.size(18.dp)) } Text(url.url.removePrefix("wss://").removeSuffix("/"), modifier = Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) - StatusTag(stringRes(R.string.backup_review_allowed_tag), tones.changed) + StatusTag(stringRes(Res.string.backup_review_allowed_tag), tones.changed) } } } if (newlyBlocked.isNotEmpty()) { - item(key = "newly-blocked-caption", contentType = "caption") { SectionCaption(stringRes(R.string.backup_review_newly_blocked), Pad.padding(top = 18.dp)) } + item(key = "newly-blocked-caption", contentType = "caption") { SectionCaption(stringRes(Res.string.backup_review_newly_blocked), Pad.padding(top = 18.dp)) } items(newlyBlocked, key = { "newly-blocked-" + it.url }, contentType = { "relay-row" }) { url -> RelayFateRow(url, ItemFate.ADDED, framingOf(BackupEventType.BLOCKED_RELAYS), nav) } @@ -359,7 +407,7 @@ private fun LazyListScope.blockedRelayItems( if (stillBlocked.isNotEmpty()) { item(key = "still-blocked", contentType = "chips") { Column(Pad.padding(top = 18.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - SectionCaption(stringRes(R.string.backup_review_still_blocked)) + SectionCaption(stringRes(Res.string.backup_review_still_blocked)) FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { stillBlocked.forEach { url -> Row( @@ -392,7 +440,7 @@ private fun LazyListScope.spaceGrid( key: (T) -> String, content: @Composable (T, ItemFate, Modifier) -> Unit, ) { - countTiles(items, R.string.backup_review_left, R.string.backup_review_joined) + countTiles(items, Res.string.backup_review_left, Res.string.backup_review_joined) items(items.chunked(2), key = { keyPrefix + key(it.first().first) }, contentType = { "space-row" }) { row -> Row(Pad.padding(top = 10.dp).fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) { row.forEach { (item, fate) -> content(item, fate, Modifier.weight(1f)) } @@ -436,9 +484,9 @@ private fun SpaceTile( } val tag = when (fate) { - ItemFate.DROPPED -> R.string.backup_review_left_tag to tones.removed - ItemFate.ADDED -> R.string.backup_review_new_tag to tones.added - ItemFate.CHANGED -> R.string.backup_review_moved_tag to tones.changed + ItemFate.DROPPED -> Res.string.backup_review_left_tag to tones.removed + ItemFate.ADDED -> Res.string.backup_review_new_tag to tones.added + ItemFate.CHANGED -> Res.string.backup_review_moved_tag to tones.changed ItemFate.KEPT -> null } tag?.let { (res, color) -> StatusTag(stringRes(res), color, Modifier.align(Alignment.TopEnd)) } @@ -446,12 +494,12 @@ private fun SpaceTile( } private fun LazyListScope.publicChatItems( - diff: ChannelListDiff, + diff: PublicChatListDiff, conflict: ReplaceableBackupConflict, accountViewModel: AccountViewModel, nav: INav, ) { - val saved = (conflict.saved as? ChannelListEvent)?.tags?.channels().orEmpty() + val saved = (conflict.saved as? PublicChatListEvent)?.tags?.channels().orEmpty() spaceGrid("chat-", fates(saved, diff.channels) { it.eventId }, { it.eventId }) { tag, fate, modifier -> LoadPublicChatChannel(tag.eventId) { channel -> // Subscribes to the channel's metadata on relays and recomposes when it arrives. @@ -459,7 +507,7 @@ private fun LazyListScope.publicChatItems( val current = state?.channel ?: channel SpaceTile( name = current.toBestDisplayName(), - subtitle = stringRes(R.string.backup_type_public_chats), + subtitle = stringRes(Res.string.backup_type_public_chats), robot = tag.eventId, picture = channel.profilePicture(), fate = fate, @@ -525,7 +573,7 @@ private fun AddressableSpaceTile( } SpaceTile( name = name ?: address.dTag.ifBlank { address.toValue().toShortDisplay() }, - subtitle = stringRes(if (address.kind == AppDefinitionEvent.KIND) R.string.backup_review_feed else R.string.backup_type_communities), + subtitle = stringRes(if (address.kind == AppDefinitionEvent.KIND) Res.string.backup_review_feed else Res.string.backup_type_communities), robot = address.toValue(), picture = picture, fate = fate, @@ -543,7 +591,7 @@ private fun LazyListScope.ephemeralRoomItems( ) { val saved = (conflict.saved as? EphemeralChatListEvent)?.publicRooms().orEmpty() val rooms = fates(saved, diff.rooms) { it } - countTiles(rooms, R.string.backup_review_left, R.string.backup_review_joined) + countTiles(rooms, Res.string.backup_review_left, Res.string.backup_review_joined) items(rooms, key = { "room-" + it.first.toKey() }, contentType = { "room-row" }) { (room, fate) -> RoomRow(room, fate, nav) } privateItemsCard(diff.privateItems) } @@ -582,8 +630,8 @@ private fun RoomRow( ) } when (fate) { - ItemFate.DROPPED -> StatusTag(stringRes(R.string.backup_review_left_tag), color) - ItemFate.ADDED -> StatusTag(stringRes(R.string.backup_review_new_tag), color) + ItemFate.DROPPED -> StatusTag(stringRes(Res.string.backup_review_left_tag), color) + ItemFate.ADDED -> StatusTag(stringRes(Res.string.backup_review_new_tag), color) else -> {} } } @@ -602,11 +650,11 @@ private fun LazyListScope.topicItems( nav: INav, ) { val topics = fates(saved, diff) { it.lowercase() } - countTiles(topics, if (isPlace) R.string.backup_review_places_gone else R.string.backup_review_topics_gone, R.string.backup_review_new_count_caption) + countTiles(topics, if (isPlace) Res.string.backup_review_places_gone else Res.string.backup_review_topics_gone, Res.string.backup_review_new_count_caption) item(key = "topic-cloud", contentType = "cloud") { val tones = conflictTones() Column(Pad.padding(top = 18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { - SectionCaption(stringRes(if (isPlace) R.string.backup_review_places_you_follow else R.string.backup_review_hashtags_you_follow)) + SectionCaption(stringRes(if (isPlace) Res.string.backup_review_places_you_follow else Res.string.backup_review_hashtags_you_follow)) FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp), itemVerticalAlignment = Alignment.CenterVertically) { topics.forEach { (topic, fate) -> val color = fateColor(fate) @@ -706,15 +754,15 @@ private fun LazyListScope.trustProviderItems( Icon(symbol = MaterialSymbols.Group, contentDescription = null, tint = tones.changed) } Column { - Text(stringRes(R.string.backup_review_services_changed, affected.toString(), services.size.toString()), fontSize = 24.sp, lineHeight = 28.sp, fontWeight = FontWeight.Bold) - Text(stringRes(R.string.backup_review_services_changed_explainer), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.placeholderText) + Text(stringRes(Res.string.backup_review_services_changed, affected.toString(), services.size.toString()), fontSize = 24.sp, lineHeight = 28.sp, fontWeight = FontWeight.Bold) + Text(stringRes(Res.string.backup_review_services_changed_explainer), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.placeholderText) } } } } item(key = "trust-header", contentType = "table-header") { Row(Pad.padding(top = 16.dp, bottom = 4.dp).padding(horizontal = 12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { - listOf(R.string.backup_review_service, R.string.backup_review_saved, R.string.backup_review_new).forEachIndexed { index, res -> + listOf(Res.string.backup_review_service, Res.string.backup_review_saved, Res.string.backup_review_new).forEachIndexed { index, res -> Text(stringRes(res).uppercase(), Modifier.weight(if (index == 0) 1.1f else 1f), style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.placeholderText) } } @@ -732,7 +780,7 @@ private fun LazyListScope.trustProviderItems( horizontalArrangement = Arrangement.spacedBy(8.dp), ) { Icon(symbol = MaterialSymbols.Warning, contentDescription = null, tint = tones.changed, modifier = Modifier.size(18.dp)) - Text(stringRes(R.string.backup_review_trust_warning), style = MaterialTheme.typography.bodySmall, color = tones.changed) + Text(stringRes(Res.string.backup_review_trust_warning), style = MaterialTheme.typography.bodySmall, color = tones.changed) } } privateItemsCard(diff.privateItems) @@ -771,9 +819,9 @@ private fun ServiceTableRow( @Composable private fun serviceLabel(type: String): String = when (type) { - "rank" -> stringRes(R.string.backup_review_service_rank) - "followers" -> stringRes(R.string.backup_review_service_followers) - "t" -> stringRes(R.string.backup_review_service_topics) + "rank" -> stringRes(Res.string.backup_review_service_rank) + "followers" -> stringRes(Res.string.backup_review_service_followers) + "t" -> stringRes(Res.string.backup_review_service_topics) else -> type.replace('_', ' ').replaceFirstChar { it.uppercase() } } @@ -826,7 +874,7 @@ private fun LazyListScope.paymentTargetItems( ) { val saved = (conflict.saved as? PaymentTargetsEvent)?.paymentTargets().orEmpty() val targets = fates(saved, diff.targets) { it } - moneyHero(targets.count { it.second == ItemFate.DROPPED }, R.plurals.backup_review_payment_targets_gone) + moneyHero(targets.count { it.second == ItemFate.DROPPED }, Res.plurals.backup_review_payment_targets_gone) items(targets, key = { "target-" + it.first.type + ":" + it.first.authority }, contentType = { "money-row" }) { (target, fate) -> PaymentTargetRow(target, fate) } } @@ -836,15 +884,15 @@ private fun LazyListScope.offerItems( ) { val saved = (conflict.saved as? Bolt12OfferListEvent)?.offers().orEmpty() val offers = fates(saved, diff.offers) { it } - moneyHero(offers.count { it.second == ItemFate.DROPPED }, R.plurals.backup_review_offers_gone) + moneyHero(offers.count { it.second == ItemFate.DROPPED }, Res.plurals.backup_review_offers_gone) items(offers, key = { "offer-" + it.first }, contentType = { "money-row" }) { (offer, fate) -> - MoneyRow(stringRes(R.string.backup_review_offer), offer.toShortDisplay(prefixSize = 4), fate) + MoneyRow(stringRes(Res.string.backup_review_offer), offer.toShortDisplay(prefixSize = 4), fate) } } private fun LazyListScope.moneyHero( dropped: Int, - headline: Int, + headline: PluralStringResource, ) { item(key = "money-hero", contentType = "hero") { val tones = conflictTones() @@ -856,7 +904,7 @@ private fun LazyListScope.moneyHero( } Column { Text(pluralStringResource(headline, dropped, dropped), fontSize = 24.sp, lineHeight = 28.sp, fontWeight = FontWeight.Bold, color = color) - Text(stringRes(R.string.backup_review_payments_explainer), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.placeholderText) + Text(stringRes(Res.string.backup_review_payments_explainer), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.placeholderText) } } } @@ -902,8 +950,8 @@ private fun MoneyRow( ) } when (fate) { - ItemFate.DROPPED -> StatusTag(stringRes(R.string.backup_review_dropped_tag), color) - ItemFate.ADDED -> StatusTag(stringRes(R.string.backup_review_new_tag), color) + ItemFate.DROPPED -> StatusTag(stringRes(Res.string.backup_review_dropped_tag), color) + ItemFate.ADDED -> StatusTag(stringRes(Res.string.backup_review_new_tag), color) else -> {} } } @@ -916,15 +964,15 @@ private fun MoneyRow( private fun LazyListScope.encryptedOnlyItems( icon: MaterialSymbol, change: ContentChange, - body: Int, + body: StringResource, ) { item(key = "encrypted-hero", contentType = "hero") { val tones = conflictTones() val (color, headline) = when (change) { - ContentChange.CLEARED -> tones.removed to R.string.backup_review_encrypted_cleared - ContentChange.ADDED -> tones.added to R.string.backup_review_encrypted_added - else -> tones.changed to R.string.backup_review_encrypted_changed + ContentChange.CLEARED -> tones.removed to Res.string.backup_review_encrypted_cleared + ContentChange.ADDED -> tones.added to Res.string.backup_review_encrypted_added + else -> tones.changed to Res.string.backup_review_encrypted_changed } Column(Pad.padding(top = 8.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) { TintedPanel(color.copy(alpha = 0.12f)) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictPresentation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictPresentation.kt index 58195a5d31..ab29c78d75 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictPresentation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictPresentation.kt @@ -23,9 +23,98 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.backups import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.remember -import androidx.compose.ui.platform.LocalResources -import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.model.backups.BackupEventType +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.backup_conflict_relay_read_only +import com.vitorpamplona.amethyst.commons.resources.backup_conflict_relay_read_write +import com.vitorpamplona.amethyst.commons.resources.backup_conflict_relay_write_only +import com.vitorpamplona.amethyst.commons.resources.backup_entry_algo_feed +import com.vitorpamplona.amethyst.commons.resources.backup_entry_chat_room +import com.vitorpamplona.amethyst.commons.resources.backup_entry_community +import com.vitorpamplona.amethyst.commons.resources.backup_entry_hashtag +import com.vitorpamplona.amethyst.commons.resources.backup_entry_identity_claim +import com.vitorpamplona.amethyst.commons.resources.backup_entry_location +import com.vitorpamplona.amethyst.commons.resources.backup_entry_mint +import com.vitorpamplona.amethyst.commons.resources.backup_entry_nutzap_key +import com.vitorpamplona.amethyst.commons.resources.backup_entry_offer +import com.vitorpamplona.amethyst.commons.resources.backup_entry_other +import com.vitorpamplona.amethyst.commons.resources.backup_entry_payment_target +import com.vitorpamplona.amethyst.commons.resources.backup_entry_person +import com.vitorpamplona.amethyst.commons.resources.backup_entry_profile_field +import com.vitorpamplona.amethyst.commons.resources.backup_entry_public_chat +import com.vitorpamplona.amethyst.commons.resources.backup_entry_relay +import com.vitorpamplona.amethyst.commons.resources.backup_entry_relay_group +import com.vitorpamplona.amethyst.commons.resources.backup_entry_thread +import com.vitorpamplona.amethyst.commons.resources.backup_entry_trust_provider +import com.vitorpamplona.amethyst.commons.resources.backup_entry_word +import com.vitorpamplona.amethyst.commons.resources.backup_profile_field_about +import com.vitorpamplona.amethyst.commons.resources.backup_profile_field_banner +import com.vitorpamplona.amethyst.commons.resources.backup_profile_field_birthday +import com.vitorpamplona.amethyst.commons.resources.backup_profile_field_bot +import com.vitorpamplona.amethyst.commons.resources.backup_profile_field_clink_offer +import com.vitorpamplona.amethyst.commons.resources.backup_profile_field_display_name +import com.vitorpamplona.amethyst.commons.resources.backup_profile_field_lud06 +import com.vitorpamplona.amethyst.commons.resources.backup_profile_field_lud16 +import com.vitorpamplona.amethyst.commons.resources.backup_profile_field_name +import com.vitorpamplona.amethyst.commons.resources.backup_profile_field_nip05 +import com.vitorpamplona.amethyst.commons.resources.backup_profile_field_picture +import com.vitorpamplona.amethyst.commons.resources.backup_profile_field_pronouns +import com.vitorpamplona.amethyst.commons.resources.backup_profile_field_website +import com.vitorpamplona.amethyst.commons.resources.backup_type_app_settings +import com.vitorpamplona.amethyst.commons.resources.backup_type_app_settings_explainer +import com.vitorpamplona.amethyst.commons.resources.backup_type_blocked_relays +import com.vitorpamplona.amethyst.commons.resources.backup_type_blocked_relays_explainer +import com.vitorpamplona.amethyst.commons.resources.backup_type_bolt12_offers +import com.vitorpamplona.amethyst.commons.resources.backup_type_bolt12_offers_explainer +import com.vitorpamplona.amethyst.commons.resources.backup_type_cashu_wallet +import com.vitorpamplona.amethyst.commons.resources.backup_type_cashu_wallet_explainer +import com.vitorpamplona.amethyst.commons.resources.backup_type_communities +import com.vitorpamplona.amethyst.commons.resources.backup_type_communities_explainer +import com.vitorpamplona.amethyst.commons.resources.backup_type_concord_communities +import com.vitorpamplona.amethyst.commons.resources.backup_type_concord_communities_explainer +import com.vitorpamplona.amethyst.commons.resources.backup_type_dm_relays +import com.vitorpamplona.amethyst.commons.resources.backup_type_dm_relays_explainer +import com.vitorpamplona.amethyst.commons.resources.backup_type_ephemeral_chats +import com.vitorpamplona.amethyst.commons.resources.backup_type_ephemeral_chats_explainer +import com.vitorpamplona.amethyst.commons.resources.backup_type_favorite_algo_feeds +import com.vitorpamplona.amethyst.commons.resources.backup_type_favorite_algo_feeds_explainer +import com.vitorpamplona.amethyst.commons.resources.backup_type_follow_list +import com.vitorpamplona.amethyst.commons.resources.backup_type_follow_list_explainer +import com.vitorpamplona.amethyst.commons.resources.backup_type_geohashes +import com.vitorpamplona.amethyst.commons.resources.backup_type_geohashes_explainer +import com.vitorpamplona.amethyst.commons.resources.backup_type_hashtags +import com.vitorpamplona.amethyst.commons.resources.backup_type_hashtags_explainer +import com.vitorpamplona.amethyst.commons.resources.backup_type_indexer_relays +import com.vitorpamplona.amethyst.commons.resources.backup_type_indexer_relays_explainer +import com.vitorpamplona.amethyst.commons.resources.backup_type_key_package_relays +import com.vitorpamplona.amethyst.commons.resources.backup_type_key_package_relays_explainer +import com.vitorpamplona.amethyst.commons.resources.backup_type_mute_list +import com.vitorpamplona.amethyst.commons.resources.backup_type_mute_list_explainer +import com.vitorpamplona.amethyst.commons.resources.backup_type_nutzap_info +import com.vitorpamplona.amethyst.commons.resources.backup_type_nutzap_info_explainer +import com.vitorpamplona.amethyst.commons.resources.backup_type_other +import com.vitorpamplona.amethyst.commons.resources.backup_type_other_explainer +import com.vitorpamplona.amethyst.commons.resources.backup_type_outbox_inbox_relays +import com.vitorpamplona.amethyst.commons.resources.backup_type_outbox_inbox_relays_explainer +import com.vitorpamplona.amethyst.commons.resources.backup_type_payment_targets +import com.vitorpamplona.amethyst.commons.resources.backup_type_payment_targets_explainer +import com.vitorpamplona.amethyst.commons.resources.backup_type_private_outbox_relays +import com.vitorpamplona.amethyst.commons.resources.backup_type_private_outbox_relays_explainer +import com.vitorpamplona.amethyst.commons.resources.backup_type_profile +import com.vitorpamplona.amethyst.commons.resources.backup_type_profile_explainer +import com.vitorpamplona.amethyst.commons.resources.backup_type_public_chats +import com.vitorpamplona.amethyst.commons.resources.backup_type_public_chats_explainer +import com.vitorpamplona.amethyst.commons.resources.backup_type_relay_feeds +import com.vitorpamplona.amethyst.commons.resources.backup_type_relay_feeds_explainer +import com.vitorpamplona.amethyst.commons.resources.backup_type_relay_groups +import com.vitorpamplona.amethyst.commons.resources.backup_type_relay_groups_explainer +import com.vitorpamplona.amethyst.commons.resources.backup_type_search_relays +import com.vitorpamplona.amethyst.commons.resources.backup_type_search_relays_explainer +import com.vitorpamplona.amethyst.commons.resources.backup_type_trust_providers +import com.vitorpamplona.amethyst.commons.resources.backup_type_trust_providers_explainer +import com.vitorpamplona.amethyst.commons.resources.backup_type_trusted_relays +import com.vitorpamplona.amethyst.commons.resources.backup_type_trusted_relays_explainer +import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListDiff import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListDiff @@ -41,10 +130,10 @@ import com.vitorpamplona.quartz.nip01Core.metadata.Birthday import com.vitorpamplona.quartz.nip01Core.metadata.MetadataDiff import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip02FollowList.ContactListDiff -import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListDiff +import com.vitorpamplona.quartz.nip28PublicChat.list.PublicChatListDiff import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListDiff import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListDiff -import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListDiff +import com.vitorpamplona.quartz.nip51Lists.interestList.InterestListDiff import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListDiff import com.vitorpamplona.quartz.nip51Lists.muteList.tags.EventTag import com.vitorpamplona.quartz.nip51Lists.muteList.tags.HashtagTag @@ -61,6 +150,7 @@ import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListDiff import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataDiff import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListDiff import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListDiff +import org.jetbrains.compose.resources.StringResource private const val MAX_VALUE_LENGTH = 80 @@ -104,9 +194,9 @@ fun countsOf(diff: EventDiff): ConflictCounts = is MuteListDiff -> diff.publicMutes.asCounts() + diff.privateItems.asCounts() is AdvertisedRelayListDiff -> diff.relays.asCounts() is RelayListDiff -> diff.relays.asCounts() + diff.privateRelays.asCounts() - is ChannelListDiff -> diff.channels.asCounts() + diff.privateItems.asCounts() + is PublicChatListDiff -> diff.channels.asCounts() + diff.privateItems.asCounts() is CommunityListDiff -> diff.communities.asCounts() + diff.privateItems.asCounts() - is HashtagListDiff -> diff.hashtags.asCounts() + diff.privateItems.asCounts() + is InterestListDiff -> diff.hashtags.asCounts() + diff.privateItems.asCounts() is GeohashListDiff -> diff.geohashes.asCounts() + diff.privateItems.asCounts() is FavoriteAlgoFeedsListDiff -> diff.feeds.asCounts() + diff.privateItems.asCounts() is EphemeralChatListDiff -> diff.rooms.asCounts() + diff.privateItems.asCounts() @@ -168,7 +258,7 @@ sealed interface ReviewItem { /** One labelled group of differences (people, relays, profile fields…) of a conflict. */ @Immutable class DiffGroup( - val label: Int, + val label: StringResource, val removed: List, val added: List, val changed: List, @@ -189,7 +279,7 @@ class DiffPresentation( /** A group from a [ListDiff] of an event's parsed items. */ private fun listGroup( - label: Int, + label: StringResource, diff: ListDiff, item: (T) -> ReviewItem, change: (ItemChange) -> ReviewItem, @@ -197,7 +287,7 @@ private fun listGroup( /** A group whose items can't change, only be added or removed (hashtags, plain relays…). */ private fun listGroup( - label: Int, + label: StringResource, diff: ListDiff, item: (T) -> ReviewItem, ) = listGroup(label, diff, item) { item(it.after) } @@ -212,7 +302,7 @@ private inline fun ListDiff<*>.only() = /** A group from single-valued fields: a field set only after is added, only before is removed. */ private fun fieldGroup( - label: Int, + label: StringResource, fields: List?>>, ): DiffGroup { val removed = mutableListOf() @@ -242,14 +332,53 @@ private fun arrow( */ @Composable fun rememberPresentation(diff: EventDiff): DiffPresentation { - val resources = LocalResources.current - return remember(diff, resources) { presentationOf(diff) { resources.getString(it) } } + // Compose resources resolve only in composition (or suspended), so the labels + // [presentationOf] writes into item text are resolved here and handed in. Only the + // diff types that write labels get any, so the rest pay for no resource reads. The + // map compares by value: a recomposition with the same locale keeps the cached result. + val labels = labelsWrittenBy(diff).associateWith { stringRes(it) } + return remember(diff, labels) { presentationOf(diff) { labels.getValue(it) } } } -/** Turns each event's own diff into typed, labelled groups. [str] resolves the labels. */ +/** Every label [presentationOf] resolves through its `str` parameter for [diff]. */ +internal fun labelsWrittenBy(diff: EventDiff): List = + when (diff) { + is MetadataDiff -> PROFILE_FIELD_LABELS + is AdvertisedRelayListDiff -> RELAY_MARKER_LABELS + else -> emptyList() + } + +private val PROFILE_FIELD_LABELS = + listOf( + Res.string.backup_profile_field_name, + Res.string.backup_profile_field_display_name, + Res.string.backup_profile_field_about, + Res.string.backup_profile_field_picture, + Res.string.backup_profile_field_banner, + Res.string.backup_profile_field_website, + Res.string.backup_profile_field_nip05, + Res.string.backup_profile_field_lud16, + Res.string.backup_profile_field_lud06, + Res.string.backup_profile_field_clink_offer, + Res.string.backup_profile_field_pronouns, + Res.string.backup_profile_field_birthday, + Res.string.backup_profile_field_bot, + ) + +private val RELAY_MARKER_LABELS = + listOf( + Res.string.backup_conflict_relay_read_write, + Res.string.backup_conflict_relay_read_only, + Res.string.backup_conflict_relay_write_only, + ) + +/** + * Turns each event's own diff into typed, labelled groups. [str] resolves the labels; a + * label it asks for must be in [labelsWrittenBy] for that diff type. + */ fun presentationOf( diff: EventDiff, - str: (Int) -> String, + str: (StringResource) -> String, ): DiffPresentation { val (groups, content) = when (diff) { @@ -258,31 +387,31 @@ fun presentationOf( val bot = diff.bot?.let { ValueChange(it.before?.toString(), it.after?.toString()) } listOf( fieldGroup( - R.string.backup_entry_profile_field, + Res.string.backup_entry_profile_field, listOf( - str(R.string.backup_profile_field_name) to diff.name, - str(R.string.backup_profile_field_display_name) to diff.displayName, - str(R.string.backup_profile_field_about) to diff.about, - str(R.string.backup_profile_field_picture) to diff.picture, - str(R.string.backup_profile_field_banner) to diff.banner, - str(R.string.backup_profile_field_website) to diff.website, - str(R.string.backup_profile_field_nip05) to diff.nip05, - str(R.string.backup_profile_field_lud16) to diff.lud16, - str(R.string.backup_profile_field_lud06) to diff.lud06, - str(R.string.backup_profile_field_clink_offer) to diff.clinkOffer, - str(R.string.backup_profile_field_pronouns) to diff.pronouns, - str(R.string.backup_profile_field_birthday) to birthday, - str(R.string.backup_profile_field_bot) to bot, + str(Res.string.backup_profile_field_name) to diff.name, + str(Res.string.backup_profile_field_display_name) to diff.displayName, + str(Res.string.backup_profile_field_about) to diff.about, + str(Res.string.backup_profile_field_picture) to diff.picture, + str(Res.string.backup_profile_field_banner) to diff.banner, + str(Res.string.backup_profile_field_website) to diff.website, + str(Res.string.backup_profile_field_nip05) to diff.nip05, + str(Res.string.backup_profile_field_lud16) to diff.lud16, + str(Res.string.backup_profile_field_lud06) to diff.lud06, + str(Res.string.backup_profile_field_clink_offer) to diff.clinkOffer, + str(Res.string.backup_profile_field_pronouns) to diff.pronouns, + str(Res.string.backup_profile_field_birthday) to birthday, + str(Res.string.backup_profile_field_bot) to bot, ), ), listGroup( - R.string.backup_entry_identity_claim, + Res.string.backup_entry_identity_claim, diff.identityClaims, { ReviewItem.Text(it.platformIdentity()) }, { ReviewItem.Text(it.after.platformIdentity(), arrow(it.before.proof, it.after.proof)) }, ), listGroup( - R.string.backup_entry_other, + Res.string.backup_entry_other, diff.otherFields, { ReviewItem.Text(it.first, clip(it.second)) }, { ReviewItem.Text(it.after.first, clip(it.before.second) + " → " + clip(it.after.second)) }, @@ -292,7 +421,7 @@ fun presentationOf( is ContactListDiff -> listOf( listGroup( - R.string.backup_entry_person, + Res.string.backup_entry_person, diff.follows, { ReviewItem.Person(it.pubKey, it.petname) }, { @@ -308,32 +437,32 @@ fun presentationOf( ) to ContentChange.NONE is MuteListDiff -> listOf( - listGroup(R.string.backup_entry_person, diff.publicMutes.only(), { ReviewItem.Person(it.pubKey) }), - listGroup(R.string.backup_entry_word, diff.publicMutes.only(), { ReviewItem.Text("\"" + it.word + "\"") }), - listGroup(R.string.backup_entry_hashtag, diff.publicMutes.only(), { ReviewItem.Text("#" + it.hashtag) }), - listGroup(R.string.backup_entry_thread, diff.publicMutes.only(), { ReviewItem.Thread(it.eventId) }), + listGroup(Res.string.backup_entry_person, diff.publicMutes.only(), { ReviewItem.Person(it.pubKey) }), + listGroup(Res.string.backup_entry_word, diff.publicMutes.only(), { ReviewItem.Text("\"" + it.word + "\"") }), + listGroup(Res.string.backup_entry_hashtag, diff.publicMutes.only(), { ReviewItem.Text("#" + it.hashtag) }), + listGroup(Res.string.backup_entry_thread, diff.publicMutes.only(), { ReviewItem.Thread(it.eventId) }), ) to diff.privateItems is AdvertisedRelayListDiff -> { val types = mapOf( - AdvertisedRelayType.BOTH to str(R.string.backup_conflict_relay_read_write), - AdvertisedRelayType.READ to str(R.string.backup_conflict_relay_read_only), - AdvertisedRelayType.WRITE to str(R.string.backup_conflict_relay_write_only), + AdvertisedRelayType.BOTH to str(Res.string.backup_conflict_relay_read_write), + AdvertisedRelayType.READ to str(Res.string.backup_conflict_relay_read_only), + AdvertisedRelayType.WRITE to str(Res.string.backup_conflict_relay_write_only), ) listOf( listGroup( - R.string.backup_entry_relay, + Res.string.backup_entry_relay, diff.relays, { ReviewItem.Relay(it.relayUrl, types[it.type]) }, { ReviewItem.Relay(it.after.relayUrl, arrow(types[it.before.type], types[it.after.type])) }, ), ) to ContentChange.NONE } - is RelayListDiff -> listOf(listGroup(R.string.backup_entry_relay, diff.relays, { ReviewItem.Relay(it) })) to diff.privateRelays - is ChannelListDiff -> + is RelayListDiff -> listOf(listGroup(Res.string.backup_entry_relay, diff.relays, { ReviewItem.Relay(it) })) to diff.privateRelays + is PublicChatListDiff -> listOf( listGroup( - R.string.backup_entry_public_chat, + Res.string.backup_entry_public_chat, diff.channels, { ReviewItem.PublicChat(it.eventId) }, { ReviewItem.PublicChat(it.after.eventId, arrow(it.before.relay?.url, it.after.relay?.url)) }, @@ -342,28 +471,28 @@ fun presentationOf( is CommunityListDiff -> listOf( listGroup( - R.string.backup_entry_community, + Res.string.backup_entry_community, diff.communities, { ReviewItem.Addressable(it.address) }, { ReviewItem.Addressable(it.after.address, arrow(it.before.relayHint?.url, it.after.relayHint?.url)) }, ), ) to diff.privateItems - is HashtagListDiff -> listOf(listGroup(R.string.backup_entry_hashtag, diff.hashtags, { ReviewItem.Text("#$it") })) to diff.privateItems - is GeohashListDiff -> listOf(listGroup(R.string.backup_entry_location, diff.geohashes, { ReviewItem.Text(it) })) to diff.privateItems + is InterestListDiff -> listOf(listGroup(Res.string.backup_entry_hashtag, diff.hashtags, { ReviewItem.Text("#$it") })) to diff.privateItems + is GeohashListDiff -> listOf(listGroup(Res.string.backup_entry_location, diff.geohashes, { ReviewItem.Text(it) })) to diff.privateItems is FavoriteAlgoFeedsListDiff -> listOf( listGroup( - R.string.backup_entry_algo_feed, + Res.string.backup_entry_algo_feed, diff.feeds, { ReviewItem.Addressable(it.address) }, { ReviewItem.Addressable(it.after.address, arrow(it.before.relayHint?.url, it.after.relayHint?.url)) }, ), ) to diff.privateItems - is EphemeralChatListDiff -> listOf(listGroup(R.string.backup_entry_chat_room, diff.rooms, { ReviewItem.ChatRoom(it) })) to diff.privateItems + is EphemeralChatListDiff -> listOf(listGroup(Res.string.backup_entry_chat_room, diff.rooms, { ReviewItem.ChatRoom(it) })) to diff.privateItems is SimpleGroupListDiff -> listOf( listGroup( - R.string.backup_entry_relay_group, + Res.string.backup_entry_relay_group, diff.groups, { ReviewItem.Text(it.name ?: it.groupId, it.relayUrl) }, { ReviewItem.Text(it.after.name ?: it.after.groupId, arrow(it.before.name, it.after.name)) }, @@ -372,7 +501,7 @@ fun presentationOf( is TrustProviderListDiff -> listOf( listGroup( - R.string.backup_entry_trust_provider, + Res.string.backup_entry_trust_provider, diff.providers, { ReviewItem.Person(it.pubkey, it.service.type) }, { ReviewItem.Person(it.after.pubkey, it.after.service.type + ": " + arrow(it.before.relayUrl.url, it.after.relayUrl.url)) }, @@ -381,19 +510,19 @@ fun presentationOf( is NutzapInfoDiff -> listOf( listGroup( - R.string.backup_entry_mint, + Res.string.backup_entry_mint, diff.mints, { ReviewItem.Text(it.mintUrl, it.units.joinToString().ifEmpty { null }) }, { ReviewItem.Text(it.after.mintUrl, arrow(it.before.units.joinToString(), it.after.units.joinToString())) }, ), - listGroup(R.string.backup_entry_relay, diff.relays, { ReviewItem.Relay(it) }), + listGroup(Res.string.backup_entry_relay, diff.relays, { ReviewItem.Relay(it) }), nutzapKeyGroup(diff.p2pkPubkey), ) to ContentChange.NONE is PaymentTargetsDiff -> listOf( - listGroup(R.string.backup_entry_payment_target, diff.targets, { ReviewItem.Text(it.type, clip(it.authority)) }), + listGroup(Res.string.backup_entry_payment_target, diff.targets, { ReviewItem.Text(it.type, clip(it.authority)) }), ) to ContentChange.NONE - is Bolt12OfferListDiff -> listOf(listGroup(R.string.backup_entry_offer, diff.offers, { ReviewItem.Text(clip(it)) })) to ContentChange.NONE + is Bolt12OfferListDiff -> listOf(listGroup(Res.string.backup_entry_offer, diff.offers, { ReviewItem.Text(clip(it)) })) to ContentChange.NONE is CashuWalletDiff -> emptyList() to diff.wallet is ConcordCommunityListDiff -> emptyList() to diff.communities is AppSpecificDataDiff -> emptyList() to diff.data @@ -404,13 +533,13 @@ fun presentationOf( private fun nutzapKeyGroup(change: ValueChange?): DiffGroup { val none = emptyList() - if (change == null) return DiffGroup(R.string.backup_entry_nutzap_key, none, none, none) + if (change == null) return DiffGroup(Res.string.backup_entry_nutzap_key, none, none, none) val before = change.before val after = change.after return when { - before != null && after == null -> DiffGroup(R.string.backup_entry_nutzap_key, listOf(ReviewItem.Text(before)), none, none) - before == null && after != null -> DiffGroup(R.string.backup_entry_nutzap_key, none, listOf(ReviewItem.Text(after)), none) - else -> DiffGroup(R.string.backup_entry_nutzap_key, none, none, listOf(ReviewItem.Text(arrow(before, after)))) + before != null && after == null -> DiffGroup(Res.string.backup_entry_nutzap_key, listOf(ReviewItem.Text(before)), none, none) + before == null && after != null -> DiffGroup(Res.string.backup_entry_nutzap_key, none, listOf(ReviewItem.Text(after)), none) + else -> DiffGroup(Res.string.backup_entry_nutzap_key, none, none, listOf(ReviewItem.Text(arrow(before, after)))) } } @@ -422,64 +551,64 @@ fun clip(text: String?): String { return if (oneLine.length > MAX_VALUE_LENGTH) oneLine.take(MAX_VALUE_LENGTH) + "…" else oneLine } -fun eventTypeName(type: BackupEventType): Int = +fun eventTypeName(type: BackupEventType): StringResource = when (type) { - BackupEventType.PROFILE -> R.string.backup_type_profile - BackupEventType.FOLLOW_LIST -> R.string.backup_type_follow_list - BackupEventType.MUTE_LIST -> R.string.backup_type_mute_list - BackupEventType.OUTBOX_INBOX_RELAYS -> R.string.backup_type_outbox_inbox_relays - BackupEventType.DM_RELAYS -> R.string.backup_type_dm_relays - BackupEventType.KEY_PACKAGE_RELAYS -> R.string.backup_type_key_package_relays - BackupEventType.SEARCH_RELAYS -> R.string.backup_type_search_relays - BackupEventType.INDEXER_RELAYS -> R.string.backup_type_indexer_relays - BackupEventType.RELAY_FEEDS -> R.string.backup_type_relay_feeds - BackupEventType.BLOCKED_RELAYS -> R.string.backup_type_blocked_relays - BackupEventType.TRUSTED_RELAYS -> R.string.backup_type_trusted_relays - BackupEventType.PRIVATE_OUTBOX_RELAYS -> R.string.backup_type_private_outbox_relays - BackupEventType.APP_SETTINGS -> R.string.backup_type_app_settings - BackupEventType.PUBLIC_CHATS -> R.string.backup_type_public_chats - BackupEventType.COMMUNITIES -> R.string.backup_type_communities - BackupEventType.HASHTAGS -> R.string.backup_type_hashtags - BackupEventType.GEOHASHES -> R.string.backup_type_geohashes - BackupEventType.FAVORITE_ALGO_FEEDS -> R.string.backup_type_favorite_algo_feeds - BackupEventType.EPHEMERAL_CHATS -> R.string.backup_type_ephemeral_chats - BackupEventType.RELAY_GROUPS -> R.string.backup_type_relay_groups - BackupEventType.CONCORD_COMMUNITIES -> R.string.backup_type_concord_communities - BackupEventType.TRUST_PROVIDERS -> R.string.backup_type_trust_providers - BackupEventType.CASHU_WALLET -> R.string.backup_type_cashu_wallet - BackupEventType.NUTZAP_INFO -> R.string.backup_type_nutzap_info - BackupEventType.PAYMENT_TARGETS -> R.string.backup_type_payment_targets - BackupEventType.BOLT12_OFFERS -> R.string.backup_type_bolt12_offers - BackupEventType.OTHER -> R.string.backup_type_other + BackupEventType.PROFILE -> Res.string.backup_type_profile + BackupEventType.FOLLOW_LIST -> Res.string.backup_type_follow_list + BackupEventType.MUTE_LIST -> Res.string.backup_type_mute_list + BackupEventType.OUTBOX_INBOX_RELAYS -> Res.string.backup_type_outbox_inbox_relays + BackupEventType.DM_RELAYS -> Res.string.backup_type_dm_relays + BackupEventType.KEY_PACKAGE_RELAYS -> Res.string.backup_type_key_package_relays + BackupEventType.SEARCH_RELAYS -> Res.string.backup_type_search_relays + BackupEventType.INDEXER_RELAYS -> Res.string.backup_type_indexer_relays + BackupEventType.RELAY_FEEDS -> Res.string.backup_type_relay_feeds + BackupEventType.BLOCKED_RELAYS -> Res.string.backup_type_blocked_relays + BackupEventType.TRUSTED_RELAYS -> Res.string.backup_type_trusted_relays + BackupEventType.PRIVATE_OUTBOX_RELAYS -> Res.string.backup_type_private_outbox_relays + BackupEventType.APP_SETTINGS -> Res.string.backup_type_app_settings + BackupEventType.PUBLIC_CHATS -> Res.string.backup_type_public_chats + BackupEventType.COMMUNITIES -> Res.string.backup_type_communities + BackupEventType.HASHTAGS -> Res.string.backup_type_hashtags + BackupEventType.GEOHASHES -> Res.string.backup_type_geohashes + BackupEventType.FAVORITE_ALGO_FEEDS -> Res.string.backup_type_favorite_algo_feeds + BackupEventType.EPHEMERAL_CHATS -> Res.string.backup_type_ephemeral_chats + BackupEventType.RELAY_GROUPS -> Res.string.backup_type_relay_groups + BackupEventType.CONCORD_COMMUNITIES -> Res.string.backup_type_concord_communities + BackupEventType.TRUST_PROVIDERS -> Res.string.backup_type_trust_providers + BackupEventType.CASHU_WALLET -> Res.string.backup_type_cashu_wallet + BackupEventType.NUTZAP_INFO -> Res.string.backup_type_nutzap_info + BackupEventType.PAYMENT_TARGETS -> Res.string.backup_type_payment_targets + BackupEventType.BOLT12_OFFERS -> Res.string.backup_type_bolt12_offers + BackupEventType.OTHER -> Res.string.backup_type_other } -fun eventTypeExplainer(type: BackupEventType): Int = +fun eventTypeExplainer(type: BackupEventType): StringResource = when (type) { - BackupEventType.PROFILE -> R.string.backup_type_profile_explainer - BackupEventType.FOLLOW_LIST -> R.string.backup_type_follow_list_explainer - BackupEventType.MUTE_LIST -> R.string.backup_type_mute_list_explainer - BackupEventType.OUTBOX_INBOX_RELAYS -> R.string.backup_type_outbox_inbox_relays_explainer - BackupEventType.DM_RELAYS -> R.string.backup_type_dm_relays_explainer - BackupEventType.KEY_PACKAGE_RELAYS -> R.string.backup_type_key_package_relays_explainer - BackupEventType.SEARCH_RELAYS -> R.string.backup_type_search_relays_explainer - BackupEventType.INDEXER_RELAYS -> R.string.backup_type_indexer_relays_explainer - BackupEventType.RELAY_FEEDS -> R.string.backup_type_relay_feeds_explainer - BackupEventType.BLOCKED_RELAYS -> R.string.backup_type_blocked_relays_explainer - BackupEventType.TRUSTED_RELAYS -> R.string.backup_type_trusted_relays_explainer - BackupEventType.PRIVATE_OUTBOX_RELAYS -> R.string.backup_type_private_outbox_relays_explainer - BackupEventType.APP_SETTINGS -> R.string.backup_type_app_settings_explainer - BackupEventType.PUBLIC_CHATS -> R.string.backup_type_public_chats_explainer - BackupEventType.COMMUNITIES -> R.string.backup_type_communities_explainer - BackupEventType.HASHTAGS -> R.string.backup_type_hashtags_explainer - BackupEventType.GEOHASHES -> R.string.backup_type_geohashes_explainer - BackupEventType.FAVORITE_ALGO_FEEDS -> R.string.backup_type_favorite_algo_feeds_explainer - BackupEventType.EPHEMERAL_CHATS -> R.string.backup_type_ephemeral_chats_explainer - BackupEventType.RELAY_GROUPS -> R.string.backup_type_relay_groups_explainer - BackupEventType.CONCORD_COMMUNITIES -> R.string.backup_type_concord_communities_explainer - BackupEventType.TRUST_PROVIDERS -> R.string.backup_type_trust_providers_explainer - BackupEventType.CASHU_WALLET -> R.string.backup_type_cashu_wallet_explainer - BackupEventType.NUTZAP_INFO -> R.string.backup_type_nutzap_info_explainer - BackupEventType.PAYMENT_TARGETS -> R.string.backup_type_payment_targets_explainer - BackupEventType.BOLT12_OFFERS -> R.string.backup_type_bolt12_offers_explainer - BackupEventType.OTHER -> R.string.backup_type_other_explainer + BackupEventType.PROFILE -> Res.string.backup_type_profile_explainer + BackupEventType.FOLLOW_LIST -> Res.string.backup_type_follow_list_explainer + BackupEventType.MUTE_LIST -> Res.string.backup_type_mute_list_explainer + BackupEventType.OUTBOX_INBOX_RELAYS -> Res.string.backup_type_outbox_inbox_relays_explainer + BackupEventType.DM_RELAYS -> Res.string.backup_type_dm_relays_explainer + BackupEventType.KEY_PACKAGE_RELAYS -> Res.string.backup_type_key_package_relays_explainer + BackupEventType.SEARCH_RELAYS -> Res.string.backup_type_search_relays_explainer + BackupEventType.INDEXER_RELAYS -> Res.string.backup_type_indexer_relays_explainer + BackupEventType.RELAY_FEEDS -> Res.string.backup_type_relay_feeds_explainer + BackupEventType.BLOCKED_RELAYS -> Res.string.backup_type_blocked_relays_explainer + BackupEventType.TRUSTED_RELAYS -> Res.string.backup_type_trusted_relays_explainer + BackupEventType.PRIVATE_OUTBOX_RELAYS -> Res.string.backup_type_private_outbox_relays_explainer + BackupEventType.APP_SETTINGS -> Res.string.backup_type_app_settings_explainer + BackupEventType.PUBLIC_CHATS -> Res.string.backup_type_public_chats_explainer + BackupEventType.COMMUNITIES -> Res.string.backup_type_communities_explainer + BackupEventType.HASHTAGS -> Res.string.backup_type_hashtags_explainer + BackupEventType.GEOHASHES -> Res.string.backup_type_geohashes_explainer + BackupEventType.FAVORITE_ALGO_FEEDS -> Res.string.backup_type_favorite_algo_feeds_explainer + BackupEventType.EPHEMERAL_CHATS -> Res.string.backup_type_ephemeral_chats_explainer + BackupEventType.RELAY_GROUPS -> Res.string.backup_type_relay_groups_explainer + BackupEventType.CONCORD_COMMUNITIES -> Res.string.backup_type_concord_communities_explainer + BackupEventType.TRUST_PROVIDERS -> Res.string.backup_type_trust_providers_explainer + BackupEventType.CASHU_WALLET -> Res.string.backup_type_cashu_wallet_explainer + BackupEventType.NUTZAP_INFO -> Res.string.backup_type_nutzap_info_explainer + BackupEventType.PAYMENT_TARGETS -> Res.string.backup_type_payment_targets_explainer + BackupEventType.BOLT12_OFFERS -> Res.string.backup_type_bolt12_offers_explainer + BackupEventType.OTHER -> Res.string.backup_type_other_explainer } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictReviewScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictReviewScreen.kt index 8b5d7b0150..70eb7ca711 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictReviewScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupConflictReviewScreen.kt @@ -59,11 +59,34 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.model.backups.BackupEventType import com.vitorpamplona.amethyst.commons.model.backups.ReplaceableBackupConflict import com.vitorpamplona.amethyst.commons.model.navigation.Route import com.vitorpamplona.amethyst.commons.model.navigation.routeFor +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.backup_action_block_again +import com.vitorpamplona.amethyst.commons.resources.backup_action_keep_count +import com.vitorpamplona.amethyst.commons.resources.backup_action_rejoin +import com.vitorpamplona.amethyst.commons.resources.backup_action_remute +import com.vitorpamplona.amethyst.commons.resources.backup_action_restore_count +import com.vitorpamplona.amethyst.commons.resources.backup_action_restore_feeds +import com.vitorpamplona.amethyst.commons.resources.backup_action_restore_places +import com.vitorpamplona.amethyst.commons.resources.backup_action_restore_profile +import com.vitorpamplona.amethyst.commons.resources.backup_action_restore_providers +import com.vitorpamplona.amethyst.commons.resources.backup_action_restore_relays +import com.vitorpamplona.amethyst.commons.resources.backup_action_restore_topics +import com.vitorpamplona.amethyst.commons.resources.backup_action_restore_wallet +import com.vitorpamplona.amethyst.commons.resources.backup_conflict_added +import com.vitorpamplona.amethyst.commons.resources.backup_conflict_changed +import com.vitorpamplona.amethyst.commons.resources.backup_conflict_keep_new +import com.vitorpamplona.amethyst.commons.resources.backup_conflict_private_added +import com.vitorpamplona.amethyst.commons.resources.backup_conflict_private_changed +import com.vitorpamplona.amethyst.commons.resources.backup_conflict_private_cleared +import com.vitorpamplona.amethyst.commons.resources.backup_conflict_removed +import com.vitorpamplona.amethyst.commons.resources.backup_conflict_restore_mine +import com.vitorpamplona.amethyst.commons.resources.backup_review_changed_by_other_app +import com.vitorpamplona.amethyst.commons.resources.backup_review_title_cleared +import com.vitorpamplona.amethyst.commons.resources.backup_review_title_updated import com.vitorpamplona.amethyst.commons.ui.insets.imePaddingSafe import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton @@ -86,10 +109,10 @@ import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListDif import com.vitorpamplona.quartz.nip01Core.diff.ContentChange import com.vitorpamplona.quartz.nip01Core.metadata.MetadataDiff import com.vitorpamplona.quartz.nip02FollowList.ContactListDiff -import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListDiff +import com.vitorpamplona.quartz.nip28PublicChat.list.PublicChatListDiff import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListDiff import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListDiff -import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListDiff +import com.vitorpamplona.quartz.nip51Lists.interestList.InterestListDiff import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListDiff import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayListDiff import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.SimpleGroupListDiff @@ -97,22 +120,23 @@ import com.vitorpamplona.quartz.nip60Cashu.wallet.CashuWalletDiff import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListDiff import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListDiff import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListDiff +import org.jetbrains.compose.resources.StringResource /** A row of the review list, flattened so hundreds of entries scroll lazily. */ @Immutable internal sealed interface ReviewRow { class Section( - val titleRes: Int, + val titleRes: StringResource, val count: Int, val tone: Tone, ) : ReviewRow class Note( - val textRes: Int, + val textRes: StringResource, ) : ReviewRow class Group( - val labelRes: Int, + val labelRes: StringResource, ) : ReviewRow class Entry( @@ -124,15 +148,15 @@ internal enum class Tone { REMOVED, ADDED, CHANGED } internal fun buildRows( presentation: DiffPresentation, - skipGroups: Set = emptySet(), + skipGroups: Set = emptySet(), ): List { val rows = mutableListOf() fun section( - titleRes: Int, + titleRes: StringResource, count: Int, tone: Tone, - contentNote: Int?, + contentNote: StringResource?, items: (DiffGroup) -> List, ) { if (count == 0 && contentNote == null) return @@ -149,9 +173,9 @@ internal fun buildRows( } val content = presentation.content - section(R.string.backup_conflict_removed, presentation.removedCount, Tone.REMOVED, R.string.backup_conflict_private_cleared.takeIf { content == ContentChange.CLEARED }) { it.removed } - section(R.string.backup_conflict_added, presentation.addedCount, Tone.ADDED, R.string.backup_conflict_private_added.takeIf { content == ContentChange.ADDED }) { it.added } - section(R.string.backup_conflict_changed, presentation.changedCount, Tone.CHANGED, R.string.backup_conflict_private_changed.takeIf { content == ContentChange.CHANGED }) { it.changed } + section(Res.string.backup_conflict_removed, presentation.removedCount, Tone.REMOVED, Res.string.backup_conflict_private_cleared.takeIf { content == ContentChange.CLEARED }) { it.removed } + section(Res.string.backup_conflict_added, presentation.addedCount, Tone.ADDED, Res.string.backup_conflict_private_added.takeIf { content == ContentChange.ADDED }) { it.added } + section(Res.string.backup_conflict_changed, presentation.changedCount, Tone.CHANGED, Res.string.backup_conflict_private_changed.takeIf { content == ContentChange.CHANGED }) { it.changed } return rows } @@ -268,7 +292,7 @@ internal fun LazyListScope.genericDiffItems( } @Composable -internal fun GroupLabel(labelRes: Int) { +internal fun GroupLabel(labelRes: StringResource) { Text( text = stringRes(labelRes), style = MaterialTheme.typography.labelLarge, @@ -339,7 +363,7 @@ internal fun eventTitle(conflict: ReplaceableBackupConflict): String { val name = stringRes(eventTypeName(conflict.eventType)).replaceFirstChar { it.uppercase() } // Says what happened to it, not just what it is: "Community list updated". val emptied = conflict.incoming.tags.isEmpty() && conflict.incoming.content.isEmpty() - return stringRes(if (emptied) R.string.backup_review_title_cleared else R.string.backup_review_title_updated, name) + return stringRes(if (emptied) Res.string.backup_review_title_cleared else Res.string.backup_review_title_updated, name) } /** The two choices, worded for what they do to this event: "Keep 120" / "Restore 523". */ @@ -348,48 +372,48 @@ private fun actionLabels(conflict: ReplaceableBackupConflict): Pair { val (saved, new) = conflict.followCounts - stringRes(R.string.backup_action_keep_count, new.toString()) to stringRes(R.string.backup_action_restore_count, saved.toString()) + stringRes(Res.string.backup_action_keep_count, new.toString()) to stringRes(Res.string.backup_action_restore_count, saved.toString()) } is MuteListDiff -> { val removed = diff.publicMutes.removed.size - stringRes(R.string.backup_conflict_keep_new) to - if (removed > 0) stringRes(R.string.backup_action_remute, removed.toString()) else stringRes(R.string.backup_conflict_restore_mine) + stringRes(Res.string.backup_conflict_keep_new) to + if (removed > 0) stringRes(Res.string.backup_action_remute, removed.toString()) else stringRes(Res.string.backup_conflict_restore_mine) } is SimpleGroupListDiff -> { val removed = diff.groups.removed.size - stringRes(R.string.backup_conflict_keep_new) to - if (removed > 0) stringRes(R.string.backup_action_rejoin, removed.toString()) else stringRes(R.string.backup_conflict_restore_mine) + stringRes(Res.string.backup_conflict_keep_new) to + if (removed > 0) stringRes(Res.string.backup_action_rejoin, removed.toString()) else stringRes(Res.string.backup_conflict_restore_mine) } - is MetadataDiff -> stringRes(R.string.backup_conflict_keep_new) to stringRes(R.string.backup_action_restore_profile) + is MetadataDiff -> stringRes(Res.string.backup_conflict_keep_new) to stringRes(Res.string.backup_action_restore_profile) is RelayListDiff -> if (conflict.eventType == BackupEventType.BLOCKED_RELAYS && diff.relays.removed.isNotEmpty()) { - stringRes(R.string.backup_conflict_keep_new) to + stringRes(Res.string.backup_conflict_keep_new) to stringRes( - R.string.backup_action_block_again, + Res.string.backup_action_block_again, diff.relays.removed.size .toString(), ) } else { - stringRes(R.string.backup_conflict_keep_new) to stringRes(R.string.backup_action_restore_relays) + stringRes(Res.string.backup_conflict_keep_new) to stringRes(Res.string.backup_action_restore_relays) } - is AdvertisedRelayListDiff -> stringRes(R.string.backup_conflict_keep_new) to stringRes(R.string.backup_action_restore_relays) - is ChannelListDiff -> rejoinLabels(diff.channels.removed.size) + is AdvertisedRelayListDiff -> stringRes(Res.string.backup_conflict_keep_new) to stringRes(Res.string.backup_action_restore_relays) + is PublicChatListDiff -> rejoinLabels(diff.channels.removed.size) is CommunityListDiff -> rejoinLabels(diff.communities.removed.size) is EphemeralChatListDiff -> rejoinLabels(diff.rooms.removed.size) - is FavoriteAlgoFeedsListDiff -> stringRes(R.string.backup_conflict_keep_new) to stringRes(R.string.backup_action_restore_feeds) - is HashtagListDiff -> stringRes(R.string.backup_conflict_keep_new) to stringRes(R.string.backup_action_restore_topics) + is FavoriteAlgoFeedsListDiff -> stringRes(Res.string.backup_conflict_keep_new) to stringRes(Res.string.backup_action_restore_feeds) + is InterestListDiff -> stringRes(Res.string.backup_conflict_keep_new) to stringRes(Res.string.backup_action_restore_topics) // Geohashes are places, not topics: the screen counts "places gone" under "Followed // locations", so the button has to agree with the page it sits on. - is GeohashListDiff -> stringRes(R.string.backup_conflict_keep_new) to stringRes(R.string.backup_action_restore_places) - is TrustProviderListDiff -> stringRes(R.string.backup_conflict_keep_new) to stringRes(R.string.backup_action_restore_providers) - is CashuWalletDiff -> stringRes(R.string.backup_conflict_keep_new) to stringRes(R.string.backup_action_restore_wallet) - else -> stringRes(R.string.backup_conflict_keep_new) to stringRes(R.string.backup_conflict_restore_mine) + is GeohashListDiff -> stringRes(Res.string.backup_conflict_keep_new) to stringRes(Res.string.backup_action_restore_places) + is TrustProviderListDiff -> stringRes(Res.string.backup_conflict_keep_new) to stringRes(Res.string.backup_action_restore_providers) + is CashuWalletDiff -> stringRes(Res.string.backup_conflict_keep_new) to stringRes(Res.string.backup_action_restore_wallet) + else -> stringRes(Res.string.backup_conflict_keep_new) to stringRes(Res.string.backup_conflict_restore_mine) } @Composable private fun rejoinLabels(removed: Int): Pair = - stringRes(R.string.backup_conflict_keep_new) to - if (removed > 0) stringRes(R.string.backup_action_rejoin, removed.toString()) else stringRes(R.string.backup_conflict_restore_mine) + stringRes(Res.string.backup_conflict_keep_new) to + if (removed > 0) stringRes(Res.string.backup_action_rejoin, removed.toString()) else stringRes(Res.string.backup_conflict_restore_mine) /** When the other app changed it, and what the event is for. */ @Composable @@ -399,7 +423,7 @@ private fun BackupConflictIntro( ) { Column(modifier, verticalArrangement = Arrangement.spacedBy(2.dp)) { Text( - text = stringRes(R.string.backup_review_changed_by_other_app, timeAgoNoDot(conflict.cause.createdAt)), + text = stringRes(Res.string.backup_review_changed_by_other_app, timeAgoNoDot(conflict.cause.createdAt)), style = MaterialTheme.typography.labelLarge, color = conflictTones().removed, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupItemOptions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupItemOptions.kt index b2a31af51a..4c31575212 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupItemOptions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupItemOptions.kt @@ -75,7 +75,7 @@ import com.vitorpamplona.amethyst.ui.note.types.EditState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.report.ReportNoteDialog import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -281,7 +281,7 @@ fun BookmarkGroupItemOptionsMenu( wantsToEditPost.value = true } } - } else if (note.event is LongTextNoteEvent && state.isLoggedUser) { + } else if (note.event is LongFormContentEvent && state.isLoggedUser) { M3ActionRow(icon = MaterialSymbols.Edit, text = stringRes(Res.string.edit_article)) { nav.nav { Route.NewLongFormPost(version = note.idHex) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupScreen.kt index 15c2317fcd..a951f6deb1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupScreen.kt @@ -101,7 +101,7 @@ fun BookmarkGroupScreen( bookmarkType, broadcastBookmarkGroup = { accountViewModel.launchSigner { - val groupNote = accountViewModel.account.labeledBookmarkLists.getLabeledBookmarkListNote(bookmarkIdentifier) + val groupNote = accountViewModel.account.bookmarkSets.getBookmarkSetNote(bookmarkIdentifier) groupNote?.let { accountViewModel.broadcast(it) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupViewModel.kt index 9cba1b5ad4..c23afd9fb3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupViewModel.kt @@ -40,8 +40,8 @@ class BookmarkGroupViewModel( val bookmarkGroupIdentifier: String, ) : ViewModel() { val selectedBookmarkGroupFlow = - account.labeledBookmarkLists - .getLabeledBookmarkListFlow(bookmarkGroupIdentifier) + account.bookmarkSets + .getBookmarkSetFlow(bookmarkGroupIdentifier) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(2500), null) fun publicPosts() = @@ -69,7 +69,7 @@ class BookmarkGroupViewModel( .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) suspend fun deleteBookmarkGroup(groupIdentifier: String) { - account.labeledBookmarkLists.deleteBookmarkList(groupIdentifier, account) + account.bookmarkSets.deleteBookmarkList(groupIdentifier, account) } suspend fun addBookmarkToGroup( @@ -77,7 +77,7 @@ class BookmarkGroupViewModel( bookmark: BookmarkIdTag, isPrivate: Boolean, ) { - account.labeledBookmarkLists.addBookmarkToList( + account.bookmarkSets.addBookmarkToList( bookmark, groupIdentifier, isPrivate, @@ -108,7 +108,7 @@ class BookmarkGroupViewModel( bookmark: BookmarkIdTag, isCurrentlyPrivate: Boolean, ) { - account.labeledBookmarkLists.moveBookmarkInList( + account.bookmarkSets.moveBookmarkInList( bookmark, groupIdentifier, isCurrentlyPrivate, @@ -147,7 +147,7 @@ class BookmarkGroupViewModel( bookmark: BookmarkIdTag, isPrivate: Boolean, ) { - account.labeledBookmarkLists.removeBookmarkFromList( + account.bookmarkSets.removeBookmarkFromList( bookmark, groupIdentifier, isPrivate, @@ -160,7 +160,7 @@ class BookmarkGroupViewModel( deletedEventIds: Set, deletedAddresses: Set
, ) { - account.labeledBookmarkLists.removeDeletedBookmarksFromList( + account.bookmarkSets.removeDeletedBookmarksFromList( groupIdentifier, deletedEventIds, deletedAddresses, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsScreen.kt index 18556e4e82..727d168bf8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsScreen.kt @@ -40,7 +40,7 @@ import com.vitorpamplona.amethyst.commons.model.nip51Lists.BookmarkType import com.vitorpamplona.amethyst.commons.model.nip51Lists.GitRepositoryListState import com.vitorpamplona.amethyst.commons.model.nip51Lists.OldBookmarkListState import com.vitorpamplona.amethyst.commons.model.nip51Lists.PinListState -import com.vitorpamplona.amethyst.commons.model.nip51Lists.labeledBookmarkLists.LabeledBookmarkList +import com.vitorpamplona.amethyst.commons.model.nip51Lists.bookmarkSets.BookmarkSet import com.vitorpamplona.amethyst.commons.nip51Lists.ui.ListOfBookmarkGroupsFeedView import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.bookmark_lists @@ -64,7 +64,7 @@ fun ListOfBookmarkGroupsScreen( oldBookmarks = accountViewModel.account.oldBookmarkState, pinnedNotes = accountViewModel.account.pinState, repositories = accountViewModel.account.gitRepositoryListState, - listSource = accountViewModel.account.labeledBookmarkLists.listFeedFlow, + listSource = accountViewModel.account.bookmarkSets.listFeedFlow, openDefaultBookmarks = { nav.nav(Route.Bookmarks) }, openOldBookmarks = { nav.nav(Route.OldBookmarks) }, openPinnedNotes = { nav.nav(Route.PinnedNotes) }, @@ -82,7 +82,7 @@ fun ListOfBookmarkGroupsScreen( }, cloneBookmarkGroup = { bookmarkGroup, customName, customDesc -> accountViewModel.launchSigner { - accountViewModel.account.labeledBookmarkLists.cloneBookmarkList( + accountViewModel.account.bookmarkSets.cloneBookmarkList( currentBookmarkList = bookmarkGroup, customCloneName = customName, customCloneDescription = customDesc, @@ -92,7 +92,7 @@ fun ListOfBookmarkGroupsScreen( }, deleteBookmarkGroup = { bookmarkGroup -> accountViewModel.launchSigner { - accountViewModel.account.labeledBookmarkLists.deleteBookmarkList( + accountViewModel.account.bookmarkSets.deleteBookmarkList( bookmarkListIdentifier = bookmarkGroup.identifier, account = accountViewModel.account, ) @@ -109,7 +109,7 @@ fun ListOfBookmarkGroupsFeed( oldBookmarks: OldBookmarkListState, pinnedNotes: PinListState, repositories: GitRepositoryListState, - listSource: StateFlow>, + listSource: StateFlow>, openDefaultBookmarks: () -> Unit, openOldBookmarks: () -> Unit, openPinnedNotes: () -> Unit, @@ -117,10 +117,10 @@ fun ListOfBookmarkGroupsFeed( openPodcasts: () -> Unit, addBookmarkGroup: () -> Unit, openBookmarkGroup: (identifier: String, bookmarkType: BookmarkType) -> Unit, - renameBookmarkGroup: (bookmarkGroup: LabeledBookmarkList) -> Unit, - changeBookmarkGroupDescription: (bookmarkGroup: LabeledBookmarkList) -> Unit, - cloneBookmarkGroup: (bookmarkGroup: LabeledBookmarkList, customName: String?, customDesc: String?) -> Unit, - deleteBookmarkGroup: (bookmarkGroup: LabeledBookmarkList) -> Unit, + renameBookmarkGroup: (bookmarkGroup: BookmarkSet) -> Unit, + changeBookmarkGroupDescription: (bookmarkGroup: BookmarkSet) -> Unit, + cloneBookmarkGroup: (bookmarkGroup: BookmarkSet, customName: String?, customDesc: String?) -> Unit, + deleteBookmarkGroup: (bookmarkGroup: BookmarkSet) -> Unit, accountViewModel: AccountViewModel, nav: INav, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/metadata/BookmarkGroupMetadataViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/metadata/BookmarkGroupMetadataViewModel.kt index d8e41c8398..1f2f4bc31a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/metadata/BookmarkGroupMetadataViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/metadata/BookmarkGroupMetadataViewModel.kt @@ -31,7 +31,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerType -import com.vitorpamplona.amethyst.commons.model.nip51Lists.labeledBookmarkLists.LabeledBookmarkList +import com.vitorpamplona.amethyst.commons.model.nip51Lists.bookmarkSets.BookmarkSet import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.avif_metadata_strip_failed import com.vitorpamplona.amethyst.commons.resources.failed_to_upload_media_no_details @@ -59,7 +59,7 @@ class BookmarkGroupMetadataViewModel : ViewModel() { private lateinit var accountViewModel: AccountViewModel private lateinit var account: Account - var bookmarkGroup by mutableStateOf(null) + var bookmarkGroup by mutableStateOf(null) val isNewList by derivedStateOf { bookmarkGroup == null } val name = mutableStateOf(TextFieldValue()) @@ -83,7 +83,7 @@ class BookmarkGroupMetadataViewModel : ViewModel() { } fun load(dTag: String) { - bookmarkGroup = account.labeledBookmarkLists.getBookmarkList(dTag) + bookmarkGroup = account.bookmarkSets.getBookmarkList(dTag) name.value = TextFieldValue(bookmarkGroup?.title ?: "") picture.value = TextFieldValue(bookmarkGroup?.image ?: "") description.value = TextFieldValue(bookmarkGroup?.description ?: "") @@ -95,14 +95,14 @@ class BookmarkGroupMetadataViewModel : ViewModel() { accountViewModel.launchSigner { val bookmarkGroup = bookmarkGroup if (bookmarkGroup == null) { - accountViewModel.account.labeledBookmarkLists.addLabeledBookmarkList( + accountViewModel.account.bookmarkSets.addBookmarkSet( listName = name.value.text, listDescription = description.value.text, listImage = picture.value.text, account = accountViewModel.account, ) } else { - accountViewModel.account.labeledBookmarkLists.updateMetadata( + accountViewModel.account.bookmarkSets.updateMetadata( listName = name.value.text, listDescription = description.value.text, listImage = picture.value.text, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/membershipManagement/ArticleBookmarkListManagementScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/membershipManagement/ArticleBookmarkListManagementScreen.kt index d1e3f9749c..c562d73658 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/membershipManagement/ArticleBookmarkListManagementScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/membershipManagement/ArticleBookmarkListManagementScreen.kt @@ -37,7 +37,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.commons.model.AddressableNote import com.vitorpamplona.amethyst.commons.model.navigation.Route import com.vitorpamplona.amethyst.commons.model.nip51Lists.BookmarkType -import com.vitorpamplona.amethyst.commons.model.nip51Lists.labeledBookmarkLists.LabeledBookmarkList +import com.vitorpamplona.amethyst.commons.model.nip51Lists.bookmarkSets.BookmarkSet import com.vitorpamplona.amethyst.commons.nip51Lists.ui.BookmarkGroupManagementItem import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.article_bookmark_management_title @@ -109,7 +109,7 @@ private fun ListManagementViewBody( accountViewModel: AccountViewModel, nav: INav, ) { - val bookmarkGroups by accountViewModel.account.labeledBookmarkLists.listFeedFlow + val bookmarkGroups by accountViewModel.account.bookmarkSets.listFeedFlow .collectAsStateWithLifecycle() val defaultBookmarks by accountViewModel.account.bookmarkState.bookmarks @@ -146,7 +146,7 @@ private fun ListManagementViewBody( ) } - itemsIndexed(items = bookmarkGroups, key = { _: Int, item: LabeledBookmarkList -> item.identifier }) { _, bookmarkList -> + itemsIndexed(items = bookmarkGroups, key = { _: Int, item: BookmarkSet -> item.identifier }) { _, bookmarkList -> val maybePublicBookmark = bookmarkList.publicArticleBookmarks.firstOrNull { it.address == note.address } val maybePrivateBookmark = bookmarkList.privateArticleBookmarks.firstOrNull { it.address == note.address } BookmarkGroupManagementItem( @@ -159,7 +159,7 @@ private fun ListManagementViewBody( onClick = { nav.nav(Route.BookmarkGroupView(bookmarkList.identifier, BookmarkType.ArticleBookmark)) }, onAddBookmarkToGroup = { shouldBePrivate -> accountViewModel.launchSigner { - accountViewModel.account.labeledBookmarkLists.addBookmarkToList( + accountViewModel.account.bookmarkSets.addBookmarkToList( bookmark = AddressBookmark(address = note.address, relayHint = note.relayHintUrl()), bookmarkListIdentifier = bookmarkList.identifier, isBookmarkPrivate = shouldBePrivate, @@ -169,7 +169,7 @@ private fun ListManagementViewBody( }, onRemoveBookmarkFromGroup = { accountViewModel.launchSigner { - accountViewModel.account.labeledBookmarkLists.removeBookmarkFromList( + accountViewModel.account.bookmarkSets.removeBookmarkFromList( bookmark = AddressBookmark(address = note.address), bookmarkListIdentifier = bookmarkList.identifier, isBookmarkPrivate = maybePrivateBookmark != null, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/membershipManagement/PostBookmarkListManagementScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/membershipManagement/PostBookmarkListManagementScreen.kt index 12e2a0860a..49564f47a5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/membershipManagement/PostBookmarkListManagementScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/membershipManagement/PostBookmarkListManagementScreen.kt @@ -37,7 +37,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.navigation.Route import com.vitorpamplona.amethyst.commons.model.nip51Lists.BookmarkType -import com.vitorpamplona.amethyst.commons.model.nip51Lists.labeledBookmarkLists.LabeledBookmarkList +import com.vitorpamplona.amethyst.commons.model.nip51Lists.bookmarkSets.BookmarkSet import com.vitorpamplona.amethyst.commons.nip51Lists.ui.BookmarkGroupManagementItem import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.bookmarks_title @@ -108,7 +108,7 @@ private fun ListManagementViewBody( accountViewModel: AccountViewModel, nav: INav, ) { - val bookmarkGroups by accountViewModel.account.labeledBookmarkLists.listFeedFlow + val bookmarkGroups by accountViewModel.account.bookmarkSets.listFeedFlow .collectAsStateWithLifecycle() val defaultBookmarks by accountViewModel.account.bookmarkState.bookmarks @@ -145,7 +145,7 @@ private fun ListManagementViewBody( ) } - itemsIndexed(items = bookmarkGroups, key = { _: Int, item: LabeledBookmarkList -> item.identifier }) { _, bookmarkList -> + itemsIndexed(items = bookmarkGroups, key = { _: Int, item: BookmarkSet -> item.identifier }) { _, bookmarkList -> val maybePublicBookmark = bookmarkList.publicPostBookmarks.firstOrNull { it.eventId == note.idHex } val maybePrivateBookmark = bookmarkList.privatePostBookmarks.firstOrNull { it.eventId == note.idHex } BookmarkGroupManagementItem( @@ -158,7 +158,7 @@ private fun ListManagementViewBody( onClick = { nav.nav(Route.BookmarkGroupView(bookmarkList.identifier, BookmarkType.PostBookmark)) }, onAddBookmarkToGroup = { shouldBePrivate -> accountViewModel.launchSigner { - accountViewModel.account.labeledBookmarkLists.addBookmarkToList( + accountViewModel.account.bookmarkSets.addBookmarkToList( bookmark = EventBookmark(eventId = note.idHex, relay = note.relayHintUrl(), author = note.author?.pubkeyHex), bookmarkListIdentifier = bookmarkList.identifier, isBookmarkPrivate = shouldBePrivate, @@ -168,7 +168,7 @@ private fun ListManagementViewBody( }, onRemoveBookmarkFromGroup = { accountViewModel.launchSigner { - accountViewModel.account.labeledBookmarkLists.removeBookmarkFromList( + accountViewModel.account.bookmarkSets.removeBookmarkFromList( bookmark = EventBookmark(eventId = note.idHex), bookmarkListIdentifier = bookmarkList.identifier, isBookmarkPrivate = maybePrivateBookmark != null, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/BrowserScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/BrowserScreen.kt index 06f038fb22..cc5430711b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/BrowserScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/BrowserScreen.kt @@ -75,6 +75,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.commons.browser.BrowserHistoryEntry import com.vitorpamplona.amethyst.commons.browser.DefaultWebClients import com.vitorpamplona.amethyst.commons.browser.OmniboxInput import com.vitorpamplona.amethyst.commons.browser.OmniboxSuggestions @@ -101,11 +102,7 @@ import com.vitorpamplona.amethyst.commons.resources.favorite_app_remove import com.vitorpamplona.amethyst.commons.resources.favorite_app_still_loading import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.note.ArrowBackIcon -import com.vitorpamplona.amethyst.favorites.BrowserHistoryEntry -import com.vitorpamplona.amethyst.favorites.BrowserHistoryRegistry -import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry import com.vitorpamplona.amethyst.favorites.FavoriteAppLauncher -import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry import com.vitorpamplona.amethyst.favorites.PreloadFavoriteNostrApps import com.vitorpamplona.amethyst.favorites.rememberNappletIconModel import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar @@ -154,9 +151,12 @@ private fun BrowserLauncher( ) { val context = LocalContext.current val appStillLoadingStr = stringRes(Res.string.favorite_app_still_loading) - val apps by FavoriteAppsRegistry.favorites.collectAsStateWithLifecycle() - val history by BrowserHistoryRegistry.history.collectAsStateWithLifecycle() - val iconKeys by BrowserIconRegistry.keys.collectAsStateWithLifecycle() + val apps by Amethyst.instance.favoriteApps.favorites + .collectAsStateWithLifecycle() + val history by Amethyst.instance.browserHistory.history + .collectAsStateWithLifecycle() + val iconKeys by Amethyst.instance.browserIcons.keys + .collectAsStateWithLifecycle() // Fetch favorited nsite/napplet manifests up front so tapping one launches immediately instead of // showing "isn't loaded yet" until the user happens to visit the nsite/napplet feed. @@ -235,10 +235,10 @@ private fun BrowserLauncher( label: String, ) { val id = "url:$url" - if (FavoriteAppsRegistry.isFavorite(id)) { - FavoriteAppsRegistry.remove(id) + if (Amethyst.instance.favoriteApps.isFavorite(id)) { + Amethyst.instance.favoriteApps.remove(id) } else { - FavoriteAppsRegistry.add(FavoriteApp.WebApp(url, label.ifBlank { OmniboxInput.hostOf(url) ?: url }, System.currentTimeMillis())) + Amethyst.instance.favoriteApps.add(FavoriteApp.WebApp(url, label.ifBlank { OmniboxInput.hostOf(url) ?: url }, System.currentTimeMillis())) } } @@ -290,7 +290,7 @@ private fun BrowserLauncher( historyUrls = historyUrls, onOpen = { open(it.url) }, onToggleFavorite = { toggleFavorite(it.url, it.label) }, - onRemoveFromHistory = { BrowserHistoryRegistry.remove(it) }, + onRemoveFromHistory = { Amethyst.instance.browserHistory.remove(it) }, modifier = contentModifier, ) else -> { @@ -306,11 +306,11 @@ private fun BrowserLauncher( nsites = followedNsites, napplets = followedNapplets, onOpenApp = { FavoriteAppLauncher.launch(context, it, appStillLoadingStr) }, - onRemoveApp = { FavoriteAppsRegistry.remove(it.id) }, - onAddApp = { FavoriteAppsRegistry.add(it) }, + onRemoveApp = { Amethyst.instance.favoriteApps.remove(it.id) }, + onAddApp = { Amethyst.instance.favoriteApps.add(it) }, onOpenUrl = { open(it) }, onToggleRecentFavorite = { entry -> toggleFavorite(entry.url, entry.title.ifBlank { entry.host }) }, - onRemoveRecent = { BrowserHistoryRegistry.remove(it) }, + onRemoveRecent = { Amethyst.instance.browserHistory.remove(it) }, modifier = contentModifier, ) } @@ -571,7 +571,7 @@ private fun SuggestedRow( onClick: () -> Unit, onAddFavorite: () -> Unit, ) { - val iconModel = remember(entry, iconKeys) { OmniboxInput.hostOf(entry.app.url)?.let(BrowserIconRegistry::iconModelFor) } + val iconModel = remember(entry, iconKeys) { OmniboxInput.hostOf(entry.app.url)?.let(Amethyst.instance.browserIcons::iconModelFor) } Row( modifier = Modifier @@ -798,7 +798,7 @@ private fun SiteIcon( iconKeys: Set, modifier: Modifier = Modifier, ) { - val model = remember(host, iconKeys) { BrowserIconRegistry.iconModelFor(host) } + val model = remember(host, iconKeys) { Amethyst.instance.browserIcons.iconModelFor(host) } val symbol = if (isFavorite) MaterialSymbols.Star else MaterialSymbols.Public val tint = MaterialTheme.colorScheme.onSurfaceVariant if (model == null) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/WebAppScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/WebAppScreen.kt index 2abb3ecdcc..946c2c9a40 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/WebAppScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/WebAppScreen.kt @@ -76,9 +76,7 @@ import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.browser_unsupported import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.stringRes -import com.vitorpamplona.amethyst.favorites.BrowserHistoryRegistry import com.vitorpamplona.amethyst.favorites.FavoriteAppLauncher -import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry import com.vitorpamplona.amethyst.favorites.WebShortcuts import com.vitorpamplona.amethyst.napplet.WebAppNetworkRegistry import com.vitorpamplona.amethyst.napplet.WebSitePermissionRegistry @@ -144,7 +142,8 @@ private fun EmbeddedWebAppTab( // can opt one out and it must stick). Only meaningful when Tor is actually available. var torOn by remember { mutableStateOf(proxyAvailable && WebAppNetworkRegistry.useTor(url)) } - val apps by FavoriteAppsRegistry.favorites.collectAsStateWithLifecycle() + val apps by Amethyst.instance.favoriteApps.favorites + .collectAsStateWithLifecycle() val isFavorite = remember(apps, currentUrl) { apps.any { it is FavoriteApp.WebApp && it.url == currentUrl } } val backgroundColor = MaterialTheme.colorScheme.background.toArgb() @@ -171,10 +170,11 @@ private fun EmbeddedWebAppTab( fun toggleFavorite() { val favId = "url:$currentUrl" - if (FavoriteAppsRegistry.isFavorite(favId)) { - FavoriteAppsRegistry.remove(favId) + val favorites = Amethyst.instance.favoriteApps + if (favorites.isFavorite(favId)) { + favorites.remove(favId) } else { - FavoriteAppsRegistry.add(FavoriteApp.WebApp(currentUrl, pageTitle ?: hostLabel(currentUrl), System.currentTimeMillis())) + favorites.add(FavoriteApp.WebApp(currentUrl, pageTitle ?: hostLabel(currentUrl), System.currentTimeMillis())) } } @@ -223,7 +223,8 @@ private fun EmbeddedWebAppTab( } // Favorites first, then history: what the address editor offers for what the user has typed. - val history by BrowserHistoryRegistry.history.collectAsStateWithLifecycle() + val history by Amethyst.instance.browserHistory.history + .collectAsStateWithLifecycle() val candidates = remember(apps, history) { buildList { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentAttestationScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentAttestationScreen.kt index eaad72c995..273f518dd9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentAttestationScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentAttestationScreen.kt @@ -181,7 +181,7 @@ fun AgentAttestationScreen( * Agent-side: paste an `auth` tag an owner issued to this account's key. [parseHeldAttestation] * turns it into a typed failure the field can show, and [BuzzHeldAttestations.put] re-checks the * signature before storing, so the auth coordinator attaches it when this account AUTHs to a Buzz - * relay. Persisted across restarts, per account, by `BuzzAttestationPreferences`. + * relay. Persisted across restarts, per account, by `BuzzAttestationStore`. */ @Composable private fun HoldAttestationSection( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarCollectionsTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarCollectionsTopBar.kt index 996453f5e7..c5c54cff10 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarCollectionsTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarCollectionsTopBar.kt @@ -36,7 +36,7 @@ import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar import com.vitorpamplona.amethyst.ui.screen.FeedDefinition import com.vitorpamplona.amethyst.ui.screen.TopNavFilterState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent +import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarCollectionEvent @Composable fun CalendarCollectionsTopBar( @@ -49,7 +49,7 @@ fun CalendarCollectionsTopBar( // The feed's own kind window, plus whatever the list spinner narrowed it to — a hashtag // or a geohash says itself as a token; a follow set does not, and seeds nothing. val me = accountViewModel.userProfile().pubkeyHex - val seed = remember(list, me) { SearchSeed.merge(SearchSeed.ofKinds(CalendarEvent.KIND), list.asSearchQuery(me)) } + val seed = remember(list, me) { SearchSeed.merge(SearchSeed.ofKinds(CalendarCollectionEvent.KIND), list.asSearchQuery(me)) } UserDrawerSearchTopBar(accountViewModel, nav, seed) { CalendarCollectionsTopNavFilterBar( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarCollectionsView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarCollectionsView.kt index 644d10c45e..c284b41922 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarCollectionsView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarCollectionsView.kt @@ -64,19 +64,19 @@ import com.vitorpamplona.amethyst.commons.resources.calendar_export_event import com.vitorpamplona.amethyst.commons.resources.calendar_export_share_title import com.vitorpamplona.amethyst.commons.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.commons.ui.feeds.ScrollStateKeys +import com.vitorpamplona.amethyst.commons.ui.feeds.WatchScrollToTop import com.vitorpamplona.amethyst.commons.ui.feeds.rememberForeverLazyListState import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.pluralStringRes import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.FeedPadding -import com.vitorpamplona.amethyst.ui.feeds.WatchScrollToTop import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.UserCardHeader import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent -import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent +import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarCollectionEvent import com.vitorpamplona.quartz.utils.TimeUtils @Composable @@ -153,7 +153,7 @@ fun CalendarCollectionCard( accountViewModel: AccountViewModel, nav: INav, ) { - val event = note.event as? CalendarEvent ?: return + val event = note.event as? CalendarCollectionEvent ?: return val title = remember(note.idHex) { event.title() } val description = remember(note.idHex) { event.content.take(180) } val count = remember(note.idHex) { event.calendarEventAddresses().size } @@ -241,7 +241,7 @@ fun CalendarCollectionCard( * arrived from relays yet or aren't appointments. Returns a list compatible with * [IcsExport.calendarToIcs]. */ -private fun collectMembers(calendar: CalendarEvent): List> = +private fun collectMembers(calendar: CalendarCollectionEvent): List> = calendar .calendarEventAddresses() .mapNotNull { addr -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarFeedView.kt index 443c0b0b0d..0d62fc715b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarFeedView.kt @@ -47,11 +47,11 @@ import com.vitorpamplona.amethyst.commons.resources.calendar_empty_feed_title import com.vitorpamplona.amethyst.commons.resources.calendar_section_past import com.vitorpamplona.amethyst.commons.resources.calendar_section_upcoming import com.vitorpamplona.amethyst.commons.ui.feeds.RefresheableBox +import com.vitorpamplona.amethyst.commons.ui.feeds.WatchScrollToTop import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.FeedPadding -import com.vitorpamplona.amethyst.ui.feeds.WatchScrollToTop import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.utils.TimeUtils diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarReminderSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarReminderSettingsScreen.kt index ea7a0d2f8b..1115110ab4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarReminderSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarReminderSettingsScreen.kt @@ -34,14 +34,15 @@ import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.preferences.CalendarReminderSettings import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.calendar_reminder_settings_enabled_subtitle import com.vitorpamplona.amethyst.commons.resources.calendar_reminder_settings_enabled_title @@ -54,20 +55,28 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.commons.ui.pluralStringRes import com.vitorpamplona.amethyst.commons.ui.stringRes -import com.vitorpamplona.amethyst.service.calendar.CalendarReminderPrefs import com.vitorpamplona.amethyst.service.calendar.CalendarReminderWorker +import com.vitorpamplona.amethyst.service.calendar.calendarReminderSettings import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsBlockTile import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsDivider import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsSection import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsSwitchTile +import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @Composable fun CalendarReminderSettingsScreen(nav: INav) { val context = LocalContext.current - val prefs = remember { CalendarReminderPrefs(context) } - var enabled by remember { mutableStateOf(prefs.isEnabled()) } - var leadMinutes by remember { mutableIntStateOf(prefs.leadMinutes()) } + val scope = rememberCoroutineScope() + val store = remember { calendarReminderSettings() } + + // DataStore reads are suspend, so the first frame renders the defaults and + // the stored values arrive right after. Collecting the flow rather than + // reading once also keeps the screen correct if the worker path or another + // screen changes a value while this one is open. + val settings by store.flow.collectAsStateWithLifecycle(CalendarReminderSettings()) + val enabled = settings.enabled + val leadMinutes = settings.leadMinutes Scaffold( topBar = { @@ -92,8 +101,7 @@ fun CalendarReminderSettingsScreen(nav: INav) { description = Res.string.calendar_reminder_settings_enabled_subtitle, checked = enabled, onCheckedChange = { - enabled = it - prefs.setEnabled(it) + scope.launch { store.setEnabled(it) } // Cancel eagerly on disable so the periodic worker stops waking the // process; re-enabling re-schedules immediately, and the ACCEPTED-RSVP // observer in AppModules re-schedules on the next relevant RSVP too. @@ -110,15 +118,14 @@ fun CalendarReminderSettingsScreen(nav: INav) { title = stringRes(Res.string.calendar_reminder_settings_lead_title), description = stringRes(Res.string.calendar_reminder_settings_lead_subtitle), ) { - val choices = CalendarReminderPrefs.LEAD_TIME_CHOICES + val choices = CalendarReminderSettings.LEAD_TIME_CHOICES SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { choices.forEachIndexed { index, choice -> SegmentedButton( selected = choice == leadMinutes, enabled = enabled, onClick = { - leadMinutes = choice - prefs.setLeadMinutes(choice) + scope.launch { store.setLeadMinutes(choice) } }, shape = SegmentedButtonDefaults.itemShape(index = index, count = choices.size), icon = {}, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarsViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarsViewModel.kt index dc74d818fa..5b41ea50f8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarsViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarsViewModel.kt @@ -39,7 +39,7 @@ import com.vitorpamplona.amethyst.commons.model.nip52Calendar.groupByDayKeyExpan import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter -import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent +import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarCollectionEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow @@ -162,13 +162,13 @@ class CalendarsViewModel : ViewModel() { * changes about once a week. */ @OptIn(ExperimentalCoroutinesApi::class) - val ownCalendars: StateFlow> = + val ownCalendars: StateFlow> = inputs .filterNotNull() .flatMapLatest { (myPubKey, _) -> LocalCache - .observeEvents( - Filter(kinds = listOf(CalendarEvent.KIND), authors = listOf(myPubKey)), + .observeEvents( + Filter(kinds = listOf(CalendarCollectionEvent.KIND), authors = listOf(myPubKey)), ).map { calendars -> calendars.sortedBy { it.title()?.lowercase() ?: "" } } } // The seed scan inside observeEvents walks the whole notes cache (LocalCache.filter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/create/NewCalendarCollectionViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/create/NewCalendarCollectionViewModel.kt index 62fc41a72a..24dace858c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/create/NewCalendarCollectionViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/create/NewCalendarCollectionViewModel.kt @@ -33,7 +33,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag import com.vitorpamplona.quartz.nip01Core.tags.aTag.aTags import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent -import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent +import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarCollectionEvent import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -62,7 +62,7 @@ class NewCalendarCollectionViewModel : ViewModel() { private var dTag: String? = null /** The full original event in edit mode; needed to publish a NIP-09 deletion. */ - private var loadedEvent: com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent? = null + private var loadedEvent: CalendarCollectionEvent? = null val selectedAddresses = mutableStateListOf
() val availableAppointments = mutableStateOf>(emptyList()) @@ -81,9 +81,9 @@ class NewCalendarCollectionViewModel : ViewModel() { dTag = editDTag editDTag?.let { existingDTag -> - val existingAddress = Address(CalendarEvent.KIND, account.userProfile().pubkeyHex, existingDTag) + val existingAddress = Address(CalendarCollectionEvent.KIND, account.userProfile().pubkeyHex, existingDTag) val existingNote = LocalCache.addressables.get(existingAddress) - (existingNote?.event as? CalendarEvent)?.let { existing -> + (existingNote?.event as? CalendarCollectionEvent)?.let { existing -> loadedEvent = existing title.value = existing.title().orEmpty() description.value = existing.content @@ -140,7 +140,7 @@ class NewCalendarCollectionViewModel : ViewModel() { account.signAndComputeBroadcast( if (effectiveDTag != null) { - CalendarEvent.build( + CalendarCollectionEvent.build( title = parsedTitle, content = parsedDescription, dTag = effectiveDTag, @@ -148,7 +148,7 @@ class NewCalendarCollectionViewModel : ViewModel() { if (selected.isNotEmpty()) aTags(selected.map { ATag(it) }) } } else { - CalendarEvent.build( + CalendarCollectionEvent.build( title = parsedTitle, content = parsedDescription, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/dal/CalendarCollectionsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/dal/CalendarCollectionsFeedFilter.kt index c26935be56..7396b10d5a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/dal/CalendarCollectionsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/dal/CalendarCollectionsFeedFilter.kt @@ -27,7 +27,7 @@ import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.ui.dal.FilterByListParams import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder -import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent +import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarCollectionEvent class CalendarCollectionsFeedFilter( val account: Account, @@ -49,7 +49,7 @@ class CalendarCollectionsFeedFilter( val notes = LocalCache.addressables.filterIntoSet { _, it -> val e = it.event - e is CalendarEvent && params.match(e, it.relays) + e is CalendarCollectionEvent && params.match(e, it.relays) } return sort(notes) } @@ -66,7 +66,7 @@ class CalendarCollectionsFeedFilter( val params = buildFilterParams(account) return collection.filterTo(HashSet()) { val e = it.event - e is CalendarEvent && params.match(e, it.relays) + e is CalendarCollectionEvent && params.match(e, it.relays) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/detail/AddToCalendarSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/detail/AddToCalendarSheet.kt index 8f9deda35d..6d878afb92 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/detail/AddToCalendarSheet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/detail/AddToCalendarSheet.kt @@ -53,7 +53,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag import com.vitorpamplona.quartz.nip01Core.tags.aTag.aTags -import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent +import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarCollectionEvent /** * Bottom sheet that lists the current user's own kind-31924 calendars with a checkbox per @@ -74,7 +74,7 @@ fun AddToCalendarSheet( val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val myPubKey = accountViewModel.userProfile().pubkeyHex - val ownCalendars by produceState>(initialValue = ownCalendars(myPubKey), myPubKey) { + val ownCalendars by produceState>(initialValue = ownCalendars(myPubKey), myPubKey) { LocalCache.live.newEventBundles.collect { value = ownCalendars(myPubKey) } @@ -152,17 +152,17 @@ private fun CalendarPickerRow( } } -private fun ownCalendars(myPubKey: String): List = +private fun ownCalendars(myPubKey: String): List = LocalCache.addressables .filterIntoSet { _, note -> val e = note.event - e is CalendarEvent && e.pubKey == myPubKey - }.mapNotNull { it.event as? CalendarEvent } + e is CalendarCollectionEvent && e.pubKey == myPubKey + }.mapNotNull { it.event as? CalendarCollectionEvent } .sortedBy { it.title()?.lowercase() ?: "" } private fun toggleMembership( accountViewModel: AccountViewModel, - calendar: CalendarEvent, + calendar: CalendarCollectionEvent, targetAddress: Address, isCurrentlyMember: Boolean, ) { @@ -177,7 +177,7 @@ private fun toggleMembership( } accountViewModel.launchSigner { accountViewModel.account.signAndComputeBroadcast( - CalendarEvent.build( + CalendarCollectionEvent.build( title = calendar.title().orEmpty(), content = calendar.content, dTag = calendar.dTag(), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/detail/CalendarEventDetailScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/detail/CalendarEventDetailScreen.kt index beece330fa..3072750bd7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/detail/CalendarEventDetailScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/detail/CalendarEventDetailScreen.kt @@ -116,7 +116,7 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent import com.vitorpamplona.quartz.nip52Calendar.appt.tags.RSVPStatusTag import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent -import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent +import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarCollectionEvent import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.Dispatchers @@ -287,7 +287,7 @@ fun CalendarEventDetailScreen( targetAddress = targetAddress, ) - is CalendarEvent -> + is CalendarCollectionEvent -> CollectionBody( note = targetNote, accountViewModel = accountViewModel, @@ -432,7 +432,7 @@ private fun CollectionBody( nav: INav, targetAddress: Address, ) { - val event = note.event as? CalendarEvent ?: return + val event = note.event as? CalendarCollectionEvent ?: return val title = remember(note.idHex) { event.title() } val memberAddresses = remember(note.idHex) { event.calendarEventAddresses() } @@ -736,7 +736,7 @@ private fun InCalendarsSection( .clickable { nav.nav( Route.CalendarEventDetail( - kind = CalendarEvent.KIND, + kind = CalendarCollectionEvent.KIND, pubKeyHex = calendar.pubKey, dTag = calendar.dTag(), ), @@ -889,11 +889,11 @@ private fun rememberRsvpsFor(targetAddress: Address): State> { +private fun rememberCalendarsContaining(targetAddress: Address): State> { val calendars = remember(targetAddress) { LocalCache - .observeEvents(Filter(kinds = listOf(CalendarEvent.KIND))) + .observeEvents(Filter(kinds = listOf(CalendarCollectionEvent.KIND))) .map { all -> all .filter { it.calendarEventAddresses().contains(targetAddress) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnComposer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnComposer.kt new file mode 100644 index 0000000000..c892f3d020 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnComposer.kt @@ -0,0 +1,297 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.cordnGroup + +import android.net.Uri +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.clearText +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.chats.ui.ThinSendButton +import com.vitorpamplona.amethyst.commons.model.cordnGroups.CordnGroupChatroom +import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState +import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiSuggestionState +import com.vitorpamplona.amethyst.commons.nip30CustomEmojis.ui.ShowEmojiSuggestionList +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.cordn_composer_hint +import com.vitorpamplona.amethyst.commons.ui.text.currentWord +import com.vitorpamplona.amethyst.commons.ui.text.replaceCurrentWord +import com.vitorpamplona.amethyst.commons.ui.theme.EditFieldBorder +import com.vitorpamplona.amethyst.commons.ui.theme.EditFieldModifier +import com.vitorpamplona.amethyst.commons.ui.theme.EditFieldTrailingIconModifier +import com.vitorpamplona.amethyst.commons.ui.theme.SuggestionListDefaultHeightChat +import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText +import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation +import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation +import com.vitorpamplona.amethyst.ui.actions.uploads.RecordingResult +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery +import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceMessagePreview +import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField +import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.WatchAndLoadMyEmojiList +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nipA0VoiceMessages.AudioMeta + +/** + * The cordn room's composer, on the same field every other Amethyst chat uses. + * + * [ThinPaddingTextField] + [ThinSendButton] inside [EditFieldModifier], with the shared + * mention machinery on top: [ShowUserSuggestionList] for `@` autocomplete, + * [MentionPreservingInputTransformation] so an IME cannot rewrite half a bech32, and + * [UrlUserTagOutputTransformation] so a pasted or completed mention reads as a name + * while you type it. Before this the room had a bare `OutlinedTextField` over a + * `String`: cordn could *render* mentions and highlight a message that named you, but + * the only way to enter one was to type `nostr:npub1…` by hand. + * + * Mentions are inserted as resolved `nostr:` URIs (`forceNostrUri`). See the parameter's + * KDoc — cordn has no send-time tagger, so the short `@npub1…` form would ship as text. + * + * Looking users up to offer them is the same safe direction as everywhere else on this + * screen: profiles are public relay data the cache already holds, and reading one puts + * no part of this conversation into it. + */ +@Composable +internal fun CordnComposer( + room: CordnGroupChatroom, + attaching: Boolean, + pendingVoice: RecordingResult?, + accountViewModel: AccountViewModel, + onAttach: (Uri) -> Unit, + onVoiceNote: (RecordingResult) -> Unit, + onRemoveVoice: () -> Unit, + onSend: (String) -> Unit, +) { + // `room.draft` is the persisted String — commons holds the draft and may not depend + // on Compose UI, so it cannot hold a TextFieldState. The field owns text *and* + // selection; these two effects keep them equal. Both writes are guarded on + // inequality, so neither direction can bounce off the other. + val draftState = remember(room.gid) { TextFieldState(room.draft.value) } + + val suggestions = + remember(room.gid, accountViewModel) { + UserSuggestionState( + accountViewModel.account, + accountViewModel.nip05ClientBuilder(), + // Members of this room rank above the rest of the address book: in a + // group, the person you are about to name is almost always in it. + priorityPubkeys = { room.members.value.toSet() }, + ) + } + + // `:shortcode:` completion, as the DM and Concord composers have. Cordn inserts the + // emoji's URL rather than its shortcode: a shortcode only resolves for a reader who + // also got the NIP-30 `emoji` tags, and a cordn message carries none — so everyone + // else would have read a literal ":shrug:". Same reasoning as forcing `nostr:` for + // user mentions, and the URL renders as the image for every reader. + val emojiSuggestions = remember(accountViewModel) { EmojiSuggestionState(accountViewModel.account.emoji) } + + WatchAndLoadMyEmojiList(accountViewModel) + + DisposableEffect(suggestions, emojiSuggestions) { + onDispose { + suggestions.reset() + emojiSuggestions.reset() + } + } + + LaunchedEffect(draftState, room) { + snapshotFlow { draftState.text.toString() }.collect { + if (room.draft.value != it) room.draft.value = it + } + } + + LaunchedEffect(draftState, room) { + room.draft.collect { external -> + // The screen writes the draft from outside on three paths: clearing it on + // send, restoring it when a send fails, and loading a message's text into + // it to edit. Cursor to the end, as editFromDraft does elsewhere. + if (external != draftState.text.toString()) { + if (external.isEmpty()) draftState.clearText() else draftState.setTextAndPlaceCursorAtEnd(external) + // onTextChanged only fires for typing, so a list left open by a + // half-typed "@na" survived the field being cleared on send. + suggestions.reset() + emojiSuggestions.reset() + } + } + } + + // A recorded voice note is something to send even with nothing typed; the text + // beside it rides along as its caption. + val canPost by remember(pendingVoice) { derivedStateOf { draftState.text.isNotBlank() || pendingVoice != null } } + + Column(modifier = EditFieldModifier) { + // The same preview the post screens use: listen back, re-record, or drop it. + // Recording used to send the moment you released the button, which is a hard + // thing to get right first time and impossible to take back afterwards. + pendingVoice?.let { recording -> + val meta = + remember(recording) { + AudioMeta( + // Empty: nothing is uploaded yet, so the preview plays the + // local file instead. + url = "", + mimeType = recording.mimeType, + duration = recording.duration, + waveform = recording.amplitudes, + ) + } + + VoiceMessagePreview( + voiceMetadata = meta, + localFile = recording.file, + onRemove = onRemoveVoice, + onReRecord = onVoiceNote, + isUploading = attaching, + modifier = Modifier.fillMaxWidth(), + ) + } + + ShowEmojiSuggestionList( + emojiSuggestions, + onSelect = { insertEmojiUrl(draftState, emojiSuggestions, it) }, + onFullSize = { insertEmojiUrl(draftState, emojiSuggestions, it) }, + modifier = SuggestionListDefaultHeightChat, + ) + + ShowUserSuggestionList( + suggestions, + onSelect = { user -> + suggestions.replaceCurrentWord(draftState, draftState.currentWord(), user, forceNostrUri = true) + suggestions.reset() + }, + accountViewModel = accountViewModel, + modifier = SuggestionListDefaultHeightChat, + ) + + ThinPaddingTextField( + state = draftState, + onTextChanged = { + // Only while the caret is a point: during a range selection the "current + // word" is whatever is highlighted, which is not something being typed. + if (draftState.selection.collapsed) { + val lastWord = draftState.currentWord() + when { + lastWord.startsWith("@") -> { + suggestions.processCurrentWord(lastWord) + emojiSuggestions.reset() + } + + lastWord.startsWith(":") -> { + emojiSuggestions.processCurrentWord(lastWord) + suggestions.reset() + } + + else -> { + suggestions.reset() + emojiSuggestions.reset() + } + } + } + }, + // Anything the keyboard or a paste hands over as content — a GIF, a shared + // image — takes the same encrypted-upload path as the file picker. + onContentReceived = { uri, _ -> onAttach(uri) }, + inputTransformation = MentionPreservingInputTransformation, + outputTransformation = UrlUserTagOutputTransformation(MaterialTheme.colorScheme.primary), + modifier = Modifier.fillMaxWidth(), + shape = EditFieldBorder, + placeholder = { + Text( + text = stringRes(Res.string.cordn_composer_hint), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + leadingIcon = { + // Same frame every other chat composer uses for its leading + // icons (`MarmotGalleryLeadingIcon`): 4.dp either side and the + // placeholder tint. Without it these two sat further from the + // edge and further apart than the rest of the app's composers, + // because a bare IconButton keeps its full 48.dp touch target + // and nothing was pulling the row back in. + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = 4.dp, end = 4.dp), + ) { + // The app's own gallery picker, so this composer offers the same + // affordance and the same multi-select as the DM and Marmot ones + // rather than a bare file dialog. Each pick is its own encrypted + // upload and its own message, which is what the send path does. + SelectFromGallery( + isUploading = attaching, + enabled = !attaching, + tint = MaterialTheme.colorScheme.placeholderText, + modifier = Modifier, + onImageChosen = { picked -> picked.forEach { onAttach(it.uri) } }, + ) + VoiceNoteButton(enabled = !attaching, onRecorded = onVoiceNote) + } + }, + trailingIcon = { + ThinSendButton( + isActive = canPost, + modifier = EditFieldTrailingIconModifier, + ) { + onSend(draftState.text.toString()) + } + }, + colors = + TextFieldDefaults.colors( + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + ), + ) + } +} + +/** + * Puts [item]'s URL where the half-typed `:shortcode:` was, and closes the list. + * + * [EmojiSuggestionState.autocompleteInto] inserts the shortcode, which is right for a + * composer whose send path attaches the matching `emoji` tags. Cordn's does not, so the + * URL goes in instead — see the state's comment above. + */ +private fun insertEmojiUrl( + field: TextFieldState, + state: EmojiSuggestionState, + item: EmojiPackState.EmojiMedia, +) { + field.replaceCurrentWord(item.link + " ") + state.reset() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnCreateGroupScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnCreateGroupScreen.kt new file mode 100644 index 0000000000..0b78b0ea5e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnCreateGroupScreen.kt @@ -0,0 +1,1037 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.cordnGroup + +import androidx.compose.foundation.clickable +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +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.selection.selectable +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.cordn.CoordinatorConfig +import com.vitorpamplona.amethyst.commons.cordn.DiscoveredCoordinator +import com.vitorpamplona.amethyst.commons.cordn.GroupExposure +import com.vitorpamplona.amethyst.commons.cordn.ui.CordnExposureCard +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.navigation.Route +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.back +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_discover_failed +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_discover_none +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_discover_seen +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_discover_seen_on +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_discover_unheard +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_hide_older +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_relays_more +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_show_all +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_show_fewer +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_show_older +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_stale_note +import com.vitorpamplona.amethyst.commons.resources.cordn_create_action +import com.vitorpamplona.amethyst.commons.resources.cordn_create_action_invite +import com.vitorpamplona.amethyst.commons.resources.cordn_create_admin_count +import com.vitorpamplona.amethyst.commons.resources.cordn_create_admin_only_me_on +import com.vitorpamplona.amethyst.commons.resources.cordn_create_coordinator_change +import com.vitorpamplona.amethyst.commons.resources.cordn_create_coordinator_discover +import com.vitorpamplona.amethyst.commons.resources.cordn_create_coordinator_hide +import com.vitorpamplona.amethyst.commons.resources.cordn_create_coordinator_new +import com.vitorpamplona.amethyst.commons.resources.cordn_create_coordinator_pubkey +import com.vitorpamplona.amethyst.commons.resources.cordn_create_coordinator_relays +import com.vitorpamplona.amethyst.commons.resources.cordn_create_coverage_all +import com.vitorpamplona.amethyst.commons.resources.cordn_create_coverage_partial +import com.vitorpamplona.amethyst.commons.resources.cordn_create_coverage_unknown +import com.vitorpamplona.amethyst.commons.resources.cordn_create_description +import com.vitorpamplona.amethyst.commons.resources.cordn_create_egalitarian +import com.vitorpamplona.amethyst.commons.resources.cordn_create_egalitarian_note +import com.vitorpamplona.amethyst.commons.resources.cordn_create_explainer +import com.vitorpamplona.amethyst.commons.resources.cordn_create_failed +import com.vitorpamplona.amethyst.commons.resources.cordn_create_name +import com.vitorpamplona.amethyst.commons.resources.cordn_create_no_coordinator +import com.vitorpamplona.amethyst.commons.resources.cordn_create_outcome_explainer +import com.vitorpamplona.amethyst.commons.resources.cordn_create_outcome_failed +import com.vitorpamplona.amethyst.commons.resources.cordn_create_outcome_link +import com.vitorpamplona.amethyst.commons.resources.cordn_create_outcome_no_welcome +import com.vitorpamplona.amethyst.commons.resources.cordn_create_outcome_open +import com.vitorpamplona.amethyst.commons.resources.cordn_create_outcome_skipped +import com.vitorpamplona.amethyst.commons.resources.cordn_create_outcome_title +import com.vitorpamplona.amethyst.commons.resources.cordn_create_outcome_waiting +import com.vitorpamplona.amethyst.commons.resources.cordn_create_people_none +import com.vitorpamplona.amethyst.commons.resources.cordn_create_step_admins +import com.vitorpamplona.amethyst.commons.resources.cordn_create_step_name +import com.vitorpamplona.amethyst.commons.resources.cordn_create_step_people +import com.vitorpamplona.amethyst.commons.resources.cordn_create_step_where +import com.vitorpamplona.amethyst.commons.resources.cordn_create_title +import com.vitorpamplona.amethyst.commons.resources.cordn_create_unreachable_count +import com.vitorpamplona.amethyst.commons.resources.cordn_group_unavailable +import com.vitorpamplona.amethyst.commons.resources.cordn_group_unavailable_detail +import com.vitorpamplona.amethyst.commons.resources.cordn_member_count +import com.vitorpamplona.amethyst.commons.ui.components.EmptyState +import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.model.cordn.CordnCoverage +import com.vitorpamplona.amethyst.model.cordn.CordnGroupCreation +import com.vitorpamplona.amethyst.ui.note.UserPicture +import com.vitorpamplona.amethyst.ui.note.timeAgoNoDot +import com.vitorpamplona.amethyst.ui.pluralStringRes +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.observeUserNameByHex +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.cordn.CoordinatorIdentityRow +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.cordn.coordinatorDisplayName +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.cordn.spec01GroupMetadata.CordnGroupMetadata +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.launch + +/** + * Starting a cordn group: pick the coordinator, name it, create it. + * + * ## The coordinator is the first field, not a setting + * + * `spec/00.md` §4 makes the coordinator the sole authority for a group's + * stream, and a `gid` means nothing outside the one that issued it. So + * "which coordinator" is not a preference that could sensibly default — it is + * half of the group's identity, chosen once and unchangeable afterwards. It is + * asked first, in the open, with the exposure card underneath saying what + * choosing it costs. + * + * ## Creating tells the coordinator nothing + * + * `CordnRuntime.createGroup` is local MLS work. The coordinator hears about + * the group when the first Commit or message is posted, which is why a group + * of one is still entirely private and why this screen works against a + * coordinator that is down. The exposure card is therefore about what will + * become true once someone is invited, not about what just happened. + * + * Inviting is not here. `CordnGroupManager.invite` exists and works, but its + * user-facing half — key-package discovery, share links, join requests — is + * Stage C of `amethyst/plans/2026-09-19-cordn-ui.md`. A member picker that + * could only offer accounts that happen to have published a KeyPackage to this + * exact coordinator would promise more than it can do. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CordnCreateGroupScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + val runtime = accountViewModel.account.cordnRuntime + + Scaffold( + topBar = { + TopAppBar( + navigationIcon = { + IconButton(onClick = { nav.popBack() }) { + Icon(MaterialSymbols.AutoMirrored.ArrowBack, contentDescription = stringRes(Res.string.back)) + } + }, + title = { Text(stringRes(Res.string.cordn_create_title)) }, + ) + }, + ) { padding -> + if (runtime == null) { + // The app's own empty state, centred and titled, rather than a + // sentence stranded in the top-left corner. + EmptyState( + title = stringRes(Res.string.cordn_group_unavailable), + description = stringRes(Res.string.cordn_group_unavailable_detail), + modifier = Modifier.padding(padding), + ) + return@Scaffold + } + + val known by runtime.coordinators.collectAsStateWithLifecycle() + // Collected, not read as `.value` below: the exposure card counts the + // groups this coordinator already carries, and reading the flow inside + // composition would leave that count frozen at whatever it was when the + // screen opened — the one number on the card that can change while you + // are looking at it, since creating a group is what changes it. + val rooms by runtime.groups.all.collectAsStateWithLifecycle() + val scope = rememberCoroutineScope() + + val me = accountViewModel.account.signer.pubKey + + // The draft outlives this screen: picking people is its own + // destination now, and a Navigation destination is disposed when you + // leave it, so `remember` here would lose a half-typed name on the way + // back from the draft.roster. + val draft = rememberCordnGroupDraft(accountViewModel) + var creation by remember { mutableStateOf(null) } + var error by remember { mutableStateOf(null) } + val failureFallback = stringRes(Res.string.cordn_create_failed) + var busy by remember { mutableStateOf(false) } + + // Coordinators this account has never used, from their CEP-6 + // announcements. Not added to the account by looking: picking one here + // is what commits to it, and `createGroup` opens the session. + var discovering by remember { mutableStateOf(false) } + val discoverFailed = stringRes(Res.string.cordn_coordinators_discover_failed) + + // Offers this account already holds are not offers; they are the choices + // above, and listing them twice would let the same coordinator be picked + // from two places with different relays. + val offers = + remember(draft.discovered, known) { + val knownKeys = known.mapTo(mutableSetOf()) { it.pubKey } + draft.discovered + ?.coordinators + ?.filter { it.pubKey !in knownKeys } + .orEmpty() + } + + val choices = remember(known, offers) { known + offers.map { it.toConfig() } } + + // A re-discovery can drop the coordinator that was picked -- it stopped + // announcing, or it is now in `known` and therefore not an offer. Left + // alone, `draft.selected` would name a coordinator no row shows: no radio + // checked, no exposure card, Create disabled, and nothing saying why. + // Falling back to the manual entry is the one state that explains itself, + // because its fields appear. + LaunchedEffect(choices) { + if (draft.selected != null && choices.none { it.pubKey == draft.selected }) draft.selected = null + } + + val config = remember(draft.selected, draft.pubKeyInput, draft.relaysInput, choices) { resolve(choices, draft.selected, draft.pubKeyInput, draft.relaysInput) } + + val coverage by rememberCordnCoverage(runtime, draft.roster) + + // The best-covering coordinator is offered, not imposed: the moment the + // user picks one themselves it stops moving under them, even if the + // roster later changes and a different one would now reach more people. + LaunchedEffect(coverage, draft.userPicked) { + if (draft.userPicked || coverage.isEmpty()) return@LaunchedEffect + val best = coverage.values.filter { it.answered }.maxByOrNull { it.reachable.size } + if (best != null && best.reachable.isNotEmpty()) draft.selected = best.coordinatorPubKey + } + + val chosenCoverage = draft.selected?.let { coverage[it] } + + Column( + modifier = + Modifier + .padding(padding) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = stringRes(Res.string.cordn_create_explainer), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + StepHeading(1, stringRes(Res.string.cordn_create_step_name)) + + OutlinedTextField( + value = draft.name, + onValueChange = { draft.name = it }, + label = { Text(stringRes(Res.string.cordn_create_name)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = draft.description, + onValueChange = { draft.description = it }, + label = { Text(stringRes(Res.string.cordn_create_description)) }, + singleLine = false, + modifier = Modifier.fillMaxWidth(), + ) + + StepHeading(2, stringRes(Res.string.cordn_create_step_people)) + + RosterSummary( + roster = draft.roster, + coverage = coverage, + onOpen = { nav.nav(Route.CordnCreateGroupMembers) }, + accountViewModel = accountViewModel, + nav = nav, + ) + + StepHeading(3, stringRes(Res.string.cordn_create_step_where)) + + // Collapsed to the one coordinator that will be used, because with a + // draft.roster in hand there is usually nothing left to decide. The whole + // list is still one tap away for the times there is. + CoordinatorSummary( + config = config, + coverage = chosenCoverage, + rosterSize = draft.roster.size, + expanded = draft.coordinatorOpen, + onToggle = { draft.coordinatorOpen = !draft.coordinatorOpen }, + accountViewModel = accountViewModel, + nav = nav, + ) + + if (draft.coordinatorOpen) { + known.forEach { coordinator -> + CoordinatorChoice( + label = coordinatorDisplayName(coordinator.pubKey, coordinator.label, accountViewModel), + pubKey = coordinator.pubKey, + selected = draft.selected == coordinator.pubKey, + onSelect = { + draft.selected = coordinator.pubKey + draft.userPicked = true + }, + relays = relayLabel(coordinator.relays), + accountViewModel = accountViewModel, + nav = nav, + ) + } + + // Split on staleness rather than listing everything flat. A run + // against a public relay returns a long tail of coordinators that + // last announced months ago, and creating a group on one that has + // gone away fails at the first call -- so the live ones come first + // and the rest sit behind a count. + val cutoff = TimeUtils.now() - TimeUtils.ONE_MONTH + val live = offers.filter { it.announcedAt >= cutoff } + val stale = offers.filter { it.announcedAt < cutoff } + val names = offers.map { resolvedName(it, accountViewModel) } + + // Bounded rather than lazy. The obvious fix for a long list is a + // LazyColumn, and it is the wrong one here: this screen is a form, + // and a lazy list disposes what scrolls off it -- which for the + // text fields below would throw away focus and IME state mid-typing. + // Capping the rows keeps composition bounded without putting a form + // inside a recycler. + val shownLive = if (draft.showAllLive) live else live.take(LIVE_PREVIEW) + + shownLive.forEach { offer -> + CoordinatorChoice( + // Its own word for itself, and only that: nothing here has + // verified the name a coordinator announces. + label = disambiguate(resolvedName(offer, accountViewModel), offer.pubKey, names), + pubKey = offer.pubKey, + selected = draft.selected == offer.pubKey, + onSelect = { + draft.selected = offer.pubKey + draft.userPicked = true + }, + detail = announcedLabel(offer.announcedAt), + relays = relayLabel(offer.relays), + about = offer.surface.about?.takeIf { it.isNotBlank() }, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + if (live.size > LIVE_PREVIEW) { + TextButton(onClick = { draft.showAllLive = !draft.showAllLive }) { + Text( + if (draft.showAllLive) { + stringRes(Res.string.cordn_coordinators_show_fewer) + } else { + stringRes(Res.string.cordn_coordinators_show_all, live.size) + }, + ) + } + } + + if (stale.isNotEmpty()) { + TextButton(onClick = { draft.showStale = !draft.showStale }) { + Text( + if (draft.showStale) { + stringRes(Res.string.cordn_coordinators_hide_older) + } else { + stringRes(Res.string.cordn_coordinators_show_older, stale.size) + }, + ) + } + } + + if (draft.showStale && stale.isNotEmpty()) { + Text( + text = stringRes(Res.string.cordn_coordinators_stale_note), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + stale.forEach { offer -> + CoordinatorChoice( + label = disambiguate(resolvedName(offer, accountViewModel), offer.pubKey, names), + pubKey = offer.pubKey, + selected = draft.selected == offer.pubKey, + onSelect = { + draft.selected = offer.pubKey + draft.userPicked = true + }, + detail = announcedLabel(offer.announcedAt), + relays = relayLabel(offer.relays), + about = offer.surface.about?.takeIf { it.isNotBlank() }, + dimmed = true, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + + CoordinatorChoice( + label = stringRes(Res.string.cordn_create_coordinator_new), + pubKey = null, + selected = draft.selected == null, + onSelect = { + draft.selected = null + draft.userPicked = true + }, + accountViewModel = accountViewModel, + nav = nav, + ) + + // Asks relays, never a coordinator: an announcement is an ordinary + // event, so looking costs nothing with any of them and tells none + // of them anything. + OutlinedButton( + onClick = { + discovering = true + error = null + scope.launch { + try { + draft.discovered = runtime.discover(accountViewModel.account.outboxRelays.flow.value) + } catch (e: Exception) { + error = e.message ?: discoverFailed + } finally { + discovering = false + } + } + }, + enabled = !discovering && !busy, + ) { + Text(stringRes(Res.string.cordn_create_coordinator_discover)) + } + + draft.discovered?.takeIf { offers.isEmpty() }?.let { result -> + Text( + text = + if (result.unreachable.isEmpty()) { + stringRes(Res.string.cordn_coordinators_discover_none) + } else { + // "Nobody is announcing" and "we were not told" are + // different answers and only one of them is final. + // Reporting the first for the second sends someone + // off to paste a pubkey by hand over a timeout. + stringRes(Res.string.cordn_coordinators_discover_unheard, result.unreachable.size) + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + if (draft.selected == null) { + OutlinedTextField( + value = draft.pubKeyInput, + onValueChange = { + draft.pubKeyInput = it + // Typing one in is choosing it. Without this the + // best-coverage effect would replace a hand-entered + // coordinator the moment the roster produced a number. + draft.userPicked = true + error = null + }, + label = { Text(stringRes(Res.string.cordn_create_coordinator_pubkey)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = draft.relaysInput, + onValueChange = { + draft.relaysInput = it + draft.userPicked = true + error = null + }, + label = { Text(stringRes(Res.string.cordn_create_coordinator_relays)) }, + // A coordinator has no address beyond its pubkey (§8.5), so + // the relays are how it is reached and more than one is + // ordinary. One per line rather than comma-separated, + // because a relay URL can contain a comma and a newline + // cannot be mistyped into one. + singleLine = false, + modifier = Modifier.fillMaxWidth(), + ) + } + } + + StepHeading(4, stringRes(Res.string.cordn_create_step_admins)) + + // spec/01.md §5.3: leaving the admin list empty is not "set it up + // later", it is choosing draft.egalitarian permanently -- so it is offered + // as a decision here rather than described as one. + // + // With a draft.roster in hand the choice is the real one the web client + // makes: which of the people being added can add and remove others. + // Choosing anybody at all must include yourself, or the group is + // born unadministrable, so "you" is never one of the toggles. + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Column(Modifier.weight(1f)) { + Text(stringRes(Res.string.cordn_create_egalitarian), style = MaterialTheme.typography.bodyMedium) + Text( + text = + if (draft.egalitarian) { + stringRes(Res.string.cordn_create_egalitarian_note) + } else if (draft.coAdmins.isEmpty()) { + stringRes(Res.string.cordn_create_admin_only_me_on) + } else { + pluralStringRes(Res.plurals.cordn_create_admin_count, draft.coAdmins.size + 1, draft.coAdmins.size + 1) + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch(checked = draft.egalitarian, onCheckedChange = { draft.egalitarian = it }) + } + + config?.let { + CordnExposureCard( + GroupExposure( + coordinator = it.pubKey, + // This group, plus whatever this coordinator already + // serves for this account: the disclosure is about what + // one coordinator can correlate, so a second group on + // the same one widens it. + linkedGroupCount = 1 + rooms.count { room -> room.coordinatorPubKey == it.pubKey }, + joinedFromShareLink = false, + publishedKeyPackage = false, + encryptionPinned = true, + ), + ) + } + + error?.let { + Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error) + } + + Button( + onClick = { + val target = config ?: return@Button + busy = true + error = null + scope.launch { + try { + // Only the people this coordinator can reach are + // attempted. An invitation is a Welcome left against + // a KeyPackage the invitee published *here*, so + // asking for one it does not hold is a call that can + // only fail -- and the draft.roster keeps them either way, + // for the link the outcome offers. + // takeIf(answered): an unanswered coordinator reports an + // EMPTY reachable set, so reading it directly would turn + // "we could not ask" into "invite nobody" and create the + // group silently empty. Unknown means attempt everyone + // and let each call say what went wrong. + val reachable = chosenCoverage?.takeIf { it.answered }?.reachable + val invitees = draft.roster.filter { reachable == null || it in reachable } + val result = + runtime.createGroupAndInvite( + config = target, + metadata = + CordnGroupMetadata( + name = draft.name.trim(), + description = draft.description.trim(), + adminPubkeys = draft.adminPubKeys(me), + ), + invitees = invitees, + ) + // Straight through when there is nothing to report: + // a dialog that only ever says "all five went out" + // is one nobody reads the sixth time. + if (result.failed.isEmpty() && invitees.size == draft.roster.size) { + draft.clear() + nav.nav(Route.CordnGroupChat(target.pubKey, result.gid)) + } else { + creation = result + } + } catch (e: Exception) { + error = e.message ?: failureFallback + } finally { + busy = false + } + } + }, + enabled = !busy && config != null && draft.name.isNotBlank(), + modifier = Modifier.fillMaxWidth(), + ) { + Text( + if (draft.roster.isEmpty()) { + stringRes(Res.string.cordn_create_action) + } else { + pluralStringRes(Res.plurals.cordn_create_action_invite, draft.roster.size, draft.roster.size) + }, + ) + } + } + + creation?.let { done -> + CordnCreationOutcome( + creation = done, + roster = draft.roster, + coordinatorPubKey = config?.pubKey, + accountViewModel = accountViewModel, + nav = nav, + // The draft is spent either way: the group exists, so a + // dismissal must not leave this roster behind to seed the next + // new group with the last one's people. + onDismiss = { + creation = null + draft.clear() + }, + ) + } + } +} + +@Composable +private fun CoordinatorChoice( + label: String, + pubKey: HexKey?, + selected: Boolean, + onSelect: () -> Unit, + accountViewModel: AccountViewModel, + nav: INav, + detail: String? = null, + /** Where it answers. A coordinator has no address beyond its pubkey (§8.5). */ + relays: String? = null, + /** Its own sentence about itself, if it published one. */ + about: String? = null, + /** Dimmed for a coordinator that stopped announcing long ago. */ + dimmed: Boolean = false, +) { + val fade = if (dimmed) 0.6f else 1f + Row( + modifier = Modifier.fillMaxWidth().selectable(selected = selected, onClick = onSelect).padding(vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton(selected = selected, onClick = onSelect) + // Null on the "use a different one" row, which names no coordinator + // yet, so there is no identity to show for it. + if (pubKey != null) { + // Spacing goes outside, not in `pictureModifier`. That one is the + // INNER modifier: it styles the image within a box already fixed at + // `size`, which is why the sibling screen uses it for a border. An + // end padding there took 8.dp off the drawable's width and none off + // its height, so every coordinator's avatar drew as a 16x24 oval. + UserPicture( + userHex = pubKey, + size = 24.dp, + accountViewModel = accountViewModel, + nav = nav, + ) + Spacer(Modifier.width(8.dp)) + } + Column(Modifier.weight(1f)) { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = fade), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + relays?.let { + Text( + text = it, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = fade), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + about?.let { + Text( + text = it, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = fade), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + detail?.let { + Text( + text = it, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = fade), + ) + } + } + } +} + +/** How many live coordinators a discovery run shows before it asks. */ +private const val LIVE_PREVIEW = 8 + +/** Its announced name, or null when it published none. */ +private fun DiscoveredCoordinator.announcedName(): String? = surface.name?.takeIf { it.isNotBlank() } + +/** + * What to call an offer: its announced name, else its profile, else a short + * npub -- never a hex prefix. The announcement still wins, because in a + * discovery list the CEP-6 surface is the thing being offered. + */ +@Composable +private fun resolvedName( + offer: DiscoveredCoordinator, + accountViewModel: AccountViewModel, +): String = coordinatorDisplayName(offer.pubKey, offer.announcedName(), accountViewModel) + +/** + * How long ago a coordinator last announced, in words that stay words. + * + * `timeAgoNoDot` answers with a span for something recent and an absolute date + * for anything past a month, and the caller used one template for both — so + * every stale coordinator on the discovery list read "Last announced Aug 7 + * ago". The date branch gets a template with no "ago" in it. + */ +@Composable +private fun announcedLabel(announcedAt: Long): String = + if (TimeUtils.now() - announcedAt > TimeUtils.ONE_MONTH) { + stringRes(Res.string.cordn_coordinators_discover_seen_on, timeAgoNoDot(announcedAt).trim()) + } else { + stringRes(Res.string.cordn_coordinators_discover_seen, timeAgoNoDot(announcedAt).trim()) + } + +/** The hosts it answers on, the first two and a count of the rest. */ +@Composable +private fun relayLabel(relays: List): String? { + if (relays.isEmpty()) return null + val hosts = relays.map { it.url.substringAfter("://").trim('/') }.distinct() + val shown = hosts.take(2).joinToString(", ") + return if (hosts.size <= 2) { + shown + } else { + stringRes(Res.string.cordn_coordinators_relays_more, shown, hosts.size - 2) + } +} + +/** + * Names are the coordinator's own word for itself and collide constantly — the + * reference server ships as "My coordinator", so a discovery run returns a + * dozen rows with that name and nothing to tell them apart. A pubkey prefix is + * added only to the ones that actually clash, so the common case stays clean. + */ +internal fun disambiguate( + name: String, + pubKey: HexKey, + allNames: List, +): String = if (allNames.count { it == name } > 1) "$name \u00b7 ${pubKey.take(8)}" else name + +/** + * The coordinator this screen would create against, or null while the form + * cannot name one. + * + * Null rather than a thrown error: an unfinished form is the normal state of a + * form, and the button is disabled off this rather than the user being told + * they are wrong while they type. `CoordinatorConfig` itself rejects a bad + * pubkey or an empty relay list in its `init`, so the checks here are only + * about not constructing one yet. + */ +private fun resolve( + known: List, + selected: String?, + pubKeyInput: String, + relaysInput: String, +): CoordinatorConfig? { + if (selected != null) return known.firstOrNull { it.pubKey == selected } + + // npub as well as hex: a coordinator pubkey is shared the same ways any + // other Nostr pubkey is, and refusing the bech32 form would just move the + // conversion to the user. + val pubKey = decodePublicKeyAsHexOrNull(pubKeyInput.trim()) ?: return null + val relays = relaysInput.lines().mapNotNull { RelayUrlNormalizer.normalizeOrNull(it.trim()) } + if (relays.isEmpty()) return null + + return CoordinatorConfig(pubKey, relays, CoordinatorConfig.Origin.MANUAL) +} + +/** A numbered step label, so the form reads as an order rather than a pile. */ +@Composable +private fun StepHeading( + step: Int, + title: String, +) { + Row( + Modifier.fillMaxWidth().padding(top = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = step.toString(), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + ) + Text(text = title, style = MaterialTheme.typography.titleSmall) + } +} + +/** + * Who is in the group, as a row that opens the screen for choosing them. + * + * A count and a few faces rather than the roster itself: the roster is where + * every later step's answer comes from, so it earns its own destination, and + * repeating it here would put two editors of the same list on screen at once. + */ +@Composable +private fun RosterSummary( + roster: List, + coverage: Map, + onOpen: () -> Unit, + accountViewModel: AccountViewModel, + nav: INav, +) { + Row( + Modifier.fillMaxWidth().clickable(onClick = onOpen).padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + if (roster.isEmpty()) { + Text( + text = stringRes(Res.string.cordn_create_people_none), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) + } else { + Row(horizontalArrangement = Arrangement.spacedBy(2.dp)) { + roster.take(FACES).forEach { + UserPicture(userHex = it, size = 28.dp, accountViewModel = accountViewModel, nav = nav) + } + } + Column(Modifier.weight(1f)) { + Text( + text = pluralStringRes(Res.plurals.cordn_member_count, roster.size + 1, roster.size + 1), + style = MaterialTheme.typography.bodyMedium, + ) + // Counted over coordinators that answered, so an unreachable one + // never turns into "nobody can be reached". + val unreachable = roster.count { member -> coverage.values.none { it.answered && member in it.reachable } } + if (unreachable > 0 && coverage.values.any { it.answered }) { + Text( + text = pluralStringRes(Res.plurals.cordn_create_unreachable_count, unreachable, unreachable), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.error, + ) + } + } + } + Icon( + symbol = MaterialSymbols.AutoMirrored.KeyboardArrowRight, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +/** How many faces the summary row shows before it just counts. */ +private const val FACES = 5 + +/** + * The coordinator that will be used, and why, in one row. + * + * Collapsed by default because with a roster entered there is usually nothing + * left to decide -- one coordinator reaches everybody and the rest do not. The + * full list is one tap away for when that is not true. + */ +@Composable +private fun CoordinatorSummary( + config: CoordinatorConfig?, + coverage: CordnCoverage?, + rosterSize: Int, + expanded: Boolean, + onToggle: () -> Unit, + accountViewModel: AccountViewModel, + nav: INav, +) { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (config == null) { + Text( + text = stringRes(Res.string.cordn_create_no_coordinator), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) + } else { + CoordinatorIdentityRow( + pubKey = config.pubKey, + label = config.label, + accountViewModel = accountViewModel, + nav = nav, + size = 32.dp, + modifier = Modifier.weight(1f), + ) { name -> + Column(Modifier.weight(1f)) { + Text(name, style = MaterialTheme.typography.bodyMedium) + if (rosterSize > 0) { + // Says which of the three it is: reaches everybody, + // leaves somebody out, or was never asked. The third is + // not the second. + val reached = coverage?.reachable?.size + Text( + text = + when { + coverage == null || !coverage.answered -> stringRes(Res.string.cordn_create_coverage_unknown) + reached == rosterSize -> stringRes(Res.string.cordn_create_coverage_all, rosterSize) + else -> stringRes(Res.string.cordn_create_coverage_partial, reached ?: 0, rosterSize) + }, + style = MaterialTheme.typography.labelSmall, + color = + if (coverage != null && coverage.answered && reached != rosterSize) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + } + } + } + + TextButton(onClick = onToggle) { + Text( + if (expanded) { + stringRes(Res.string.cordn_create_coordinator_hide) + } else { + stringRes(Res.string.cordn_create_coordinator_change) + }, + ) + } + } +} + +/** + * What happened after Create, when it was not simply "everything". + * + * Shown instead of a thrown error because the group exists either way: a + * failed invitation is one row, not a failed creation. Every unfinished person + * gets the action that would actually work for them -- a retry where the + * coordinator refused, a link where it holds no KeyPackage at all and no retry + * ever could. + */ +@Composable +private fun CordnCreationOutcome( + creation: CordnGroupCreation, + roster: List, + coordinatorPubKey: HexKey?, + accountViewModel: AccountViewModel, + nav: INav, + onDismiss: () -> Unit, +) { + val open: () -> Unit = { + onDismiss() + coordinatorPubKey?.let { nav.nav(Route.CordnGroupChat(it, creation.gid)) } + Unit + } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringRes(Res.string.cordn_create_outcome_title)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text( + text = stringRes(Res.string.cordn_create_outcome_explainer), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + val attempted = creation.outcomes.associateBy { it.pubKey } + roster.forEach { member -> + val outcome = attempted[member] + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + UserPicture(userHex = member, size = 26.dp, accountViewModel = accountViewModel, nav = nav) + Column(Modifier.weight(1f)) { + Text( + text = observeUserNameByHex(member, accountViewModel), + style = MaterialTheme.typography.bodySmall, + ) + Text( + text = + when { + outcome == null -> stringRes(Res.string.cordn_create_outcome_skipped) + outcome.sent -> stringRes(Res.string.cordn_create_outcome_waiting) + outcome.joinedWithoutWelcome -> stringRes(Res.string.cordn_create_outcome_no_welcome) + else -> outcome.failure?.message ?: stringRes(Res.string.cordn_create_outcome_failed) + }, + style = MaterialTheme.typography.labelSmall, + color = + if (outcome?.sent == true) { + MaterialTheme.colorScheme.onSurfaceVariant + } else { + MaterialTheme.colorScheme.error + }, + ) + } + // Nobody was told anything, so the only thing that can + // reach somebody with no KeyPackage here is a link they + // open themselves -- which needs the group to exist, + // and now it does. + if (outcome == null) { + TextButton(onClick = open) { Text(stringRes(Res.string.cordn_create_outcome_link)) } + } + } + } + } + }, + confirmButton = { TextButton(onClick = open) { Text(stringRes(Res.string.cordn_create_outcome_open)) } }, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnCreateMembersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnCreateMembersScreen.kt new file mode 100644 index 0000000000..1a09a1c890 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnCreateMembersScreen.kt @@ -0,0 +1,421 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.cordnGroup + +import androidx.activity.compose.LocalActivity +import androidx.compose.foundation.layout.Arrangement +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 +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.cordn_create_add_member +import com.vitorpamplona.amethyst.commons.resources.cordn_create_admin +import com.vitorpamplona.amethyst.commons.resources.cordn_create_people_done +import com.vitorpamplona.amethyst.commons.resources.cordn_create_people_empty +import com.vitorpamplona.amethyst.commons.resources.cordn_create_people_note +import com.vitorpamplona.amethyst.commons.resources.cordn_create_reach_count +import com.vitorpamplona.amethyst.commons.resources.cordn_create_reach_none +import com.vitorpamplona.amethyst.commons.resources.cordn_create_reach_unknown +import com.vitorpamplona.amethyst.commons.resources.cordn_create_step_people +import com.vitorpamplona.amethyst.commons.resources.cordn_create_you +import com.vitorpamplona.amethyst.commons.resources.cordn_group_unavailable +import com.vitorpamplona.amethyst.commons.resources.cordn_group_unavailable_detail +import com.vitorpamplona.amethyst.commons.resources.cordn_info_add_member_none +import com.vitorpamplona.amethyst.commons.resources.cordn_info_add_member_placeholder +import com.vitorpamplona.amethyst.commons.resources.cordn_info_has_key_package +import com.vitorpamplona.amethyst.commons.resources.cordn_info_remove_member +import com.vitorpamplona.amethyst.commons.resources.cordn_member_count +import com.vitorpamplona.amethyst.commons.ui.components.EmptyState +import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.commons.ui.theme.SuggestionListDefaultHeightChat +import com.vitorpamplona.amethyst.commons.viewmodels.CordnGroupDraft +import com.vitorpamplona.amethyst.model.cordn.CordnCoverage +import com.vitorpamplona.amethyst.model.cordn.CordnRuntime +import com.vitorpamplona.amethyst.ui.note.UserPicture +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState +import com.vitorpamplona.amethyst.ui.pluralStringRes +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.observeUserNameByHex +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** + * Who the new group is for. + * + * ## Why this is a screen and not a field on the create form + * + * Choosing people is the step every later one depends on. cordn can only add a + * member by spending a KeyPackage they published **to the coordinator being + * used**, so until the roster exists "which coordinator?" has no answer worth + * giving -- and once it exists the answer is arithmetic. Putting it inline made + * the create form a scroll again, with a search field, a results list and a + * roster competing with the group's own name. + * + * ## Reachability is counted across coordinators, not for the chosen one + * + * A person reachable nowhere cannot be helped by changing the choice on the + * previous screen, and that is a different fact from "not reachable on the one + * currently selected". Counted only over coordinators that actually answered: + * an unreachable server contributes nothing rather than a zero, because a + * network failure must never be rendered as a claim about a person. + */ +@Composable +fun CordnCreateMembersScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + val runtime = accountViewModel.account.cordnRuntime + val draft = rememberCordnGroupDraft(accountViewModel) + + Scaffold( + topBar = { TopBarWithBackButton(stringRes(Res.string.cordn_create_step_people), nav) }, + ) { padding -> + if (runtime == null) { + EmptyState( + title = stringRes(Res.string.cordn_group_unavailable), + description = stringRes(Res.string.cordn_group_unavailable_detail), + modifier = Modifier.padding(padding), + ) + return@Scaffold + } + + val me = accountViewModel.account.signer.pubKey + val userSuggestions = + remember { + UserSuggestionState(accountViewModel.account, accountViewModel.nip05ClientBuilder()) + } + var search by remember { mutableStateOf("") } + + val coverage by rememberCordnCoverage(runtime, draft.roster) + + // The badge on a SEARCH RESULT cannot come from coverage: coverage is + // the roster intersected with what a coordinator holds, so somebody not + // yet added is never in it. This is the unfiltered set, which is what + // the question "could this person be added at all" needs. + val reachable by rememberReachableIdentities(runtime) + + Column( + modifier = + Modifier + .padding(padding) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = stringRes(Res.string.cordn_create_people_note), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + OutlinedTextField( + value = search, + onValueChange = { + search = it + if (it.length > 2) userSuggestions.processCurrentWord(it) else userSuggestions.reset() + }, + label = { Text(stringRes(Res.string.cordn_create_add_member)) }, + placeholder = { Text(stringRes(Res.string.cordn_info_add_member_placeholder)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + // Three characters, as everywhere else this list appears: fewer + // matches most of the address book and is never what someone meant. + if (search.length > 2) { + ShowUserSuggestionList( + userSuggestions = userSuggestions, + onSelect = { user: User -> + search = "" + userSuggestions.reset() + if (user.pubkeyHex != me) draft.add(user.pubkeyHex) + }, + accountViewModel = accountViewModel, + modifier = SuggestionListDefaultHeightChat, + onEmpty = { + Text( + text = stringRes(Res.string.cordn_info_add_member_none), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + }, + trailingContent = { user -> + // Marks only what the coordinator told us. An unmarked row + // covers both "has not published here" and "we could not + // ask", so it claims nothing either way. + Row(verticalAlignment = Alignment.CenterVertically) { + if (user.pubkeyHex in reachable) { + Icon( + symbol = MaterialSymbols.Key, + contentDescription = stringRes(Res.string.cordn_info_has_key_package), + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.primary, + ) + } + IconButton(onClick = { + search = "" + userSuggestions.reset() + if (user.pubkeyHex != me) draft.add(user.pubkeyHex) + }) { + Icon( + symbol = MaterialSymbols.PersonAdd, + contentDescription = stringRes(Res.string.cordn_create_add_member), + tint = MaterialTheme.colorScheme.primary, + ) + } + } + }, + ) + } + + HorizontalDivider() + + Text( + text = pluralStringRes(Res.plurals.cordn_member_count, draft.roster.size + 1, draft.roster.size + 1), + style = MaterialTheme.typography.titleSmall, + ) + + // You are always in it and always able to administer it, so there is + // nothing to toggle or remove on this row -- it is here so the count + // above is not one short of what the group will look like. + CreatorRow(me, draft.egalitarian, accountViewModel, nav) + + draft.roster.forEach { member -> + MemberRow( + member = member, + isAdmin = member in draft.coAdmins, + egalitarian = draft.egalitarian, + coverage = coverage, + onToggleAdmin = { draft.toggleAdmin(member) }, + onRemove = { draft.remove(member) }, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + if (draft.roster.isEmpty()) { + Text( + text = stringRes(Res.string.cordn_create_people_empty), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Button( + onClick = { nav.popBack() }, + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), + ) { + Text(stringRes(Res.string.cordn_create_people_done)) + } + } + } +} + +/** The creator's own row: in the group, administering it, not editable. */ +@Composable +private fun CreatorRow( + me: HexKey, + egalitarian: Boolean, + accountViewModel: AccountViewModel, + nav: INav, +) { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + UserPicture(userHex = me, size = 32.dp, accountViewModel = accountViewModel, nav = nav) + Column(Modifier.weight(1f)) { + Text(observeUserNameByHex(me, accountViewModel), style = MaterialTheme.typography.bodyMedium) + Text( + text = stringRes(Res.string.cordn_create_you), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (!egalitarian) { + Text( + text = stringRes(Res.string.cordn_create_admin), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + ) + } + } +} + +/** One person in the roster, with what a coordinator could do about them. */ +@Composable +private fun MemberRow( + member: HexKey, + isAdmin: Boolean, + egalitarian: Boolean, + coverage: Map, + onToggleAdmin: () -> Unit, + onRemove: () -> Unit, + accountViewModel: AccountViewModel, + nav: INav, +) { + val answered = coverage.values.count { it.answered } + val reaching = coverage.values.count { it.answered && member in it.reachable } + + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + UserPicture(userHex = member, size = 32.dp, accountViewModel = accountViewModel, nav = nav) + Column(Modifier.weight(1f)) { + Text(observeUserNameByHex(member, accountViewModel), style = MaterialTheme.typography.bodyMedium) + Text( + text = + when { + answered == 0 -> stringRes(Res.string.cordn_create_reach_unknown) + reaching > 0 -> pluralStringRes(Res.plurals.cordn_create_reach_count, reaching, reaching) + else -> stringRes(Res.string.cordn_create_reach_none) + }, + style = MaterialTheme.typography.labelSmall, + color = + if (answered > 0 && reaching == 0) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + + // Hidden in egalitarian mode: there the admin list is empty by + // definition, so a toggle would offer a choice nothing can record. + if (!egalitarian) { + TextButton(onClick = onToggleAdmin) { + Text( + text = stringRes(Res.string.cordn_create_admin), + style = MaterialTheme.typography.labelMedium, + color = if (isAdmin) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + IconButton(onClick = onRemove) { + Icon( + symbol = MaterialSymbols.PersonRemove, + contentDescription = stringRes(Res.string.cordn_info_remove_member), + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +/** + * The one draft both create screens fill in. + * + * Scoped to the Activity, the way the chess lobby and board share theirs: a + * Navigation destination is disposed when you leave it, so a draft kept in + * `remember` would lose the group's name on the way to picking people. Keyed + * per account so switching accounts starts a new draft rather than inheriting + * a roster built under another key. + */ +@Composable +fun rememberCordnGroupDraft(accountViewModel: AccountViewModel): CordnGroupDraft { + val activity = LocalActivity.current as FragmentActivity + return viewModel( + viewModelStoreOwner = activity, + key = "CordnGroupDraft-${accountViewModel.account.signer.pubKey}", + ) +} + +/** + * What each coordinator this account uses could do with [roster]. + * + * Asked of coordinators there is already a session with and no others: opening + * one is what commits to a coordinator, so a screen that is only looking must + * not open one on the user's behalf. Cheap to call from more than one screen -- + * `CordnKeyPackages` reuses its `kp_list` snapshot for a minute, so the second + * screen's copy of this question usually costs nothing. + */ +@Composable +fun rememberCordnCoverage( + runtime: CordnRuntime, + roster: List, +): State> { + val known by runtime.coordinators.collectAsStateWithLifecycle() + return produceState>(emptyMap(), runtime, known, roster) { + value = + if (roster.isEmpty()) { + emptyMap() + } else { + val target = roster.toSet() + known.associate { it.pubKey to runtime.coverage(it.pubKey, target) } + } + } +} + +/** + * Every identity any coordinator this account uses holds a KeyPackage for. + * + * The union, unfiltered, for answering "could this person be added anywhere" + * about somebody who is not in the roster yet -- which + * [rememberCordnCoverage] cannot answer, because it intersects with the roster + * by construction. Empty also covers "nothing answered", so callers must only + * ever use membership in it to mark a yes, never absence to mark a no. + */ +@Composable +fun rememberReachableIdentities(runtime: CordnRuntime): State> { + val known by runtime.coordinators.collectAsStateWithLifecycle() + return produceState>(emptySet(), runtime, known) { + val all = mutableSetOf() + known.forEach { runtime.identitiesWithKeyPackages(it.pubKey)?.let(all::addAll) } + value = all + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnGroupChatScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnGroupChatScreen.kt new file mode 100644 index 0000000000..b884f56dbd --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnGroupChatScreen.kt @@ -0,0 +1,1337 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.cordnGroup + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.net.Uri +import android.provider.OpenableColumns +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +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.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.commons.cordn.CordnGroupManager +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.cordnGroups.CordnGroupChatroom +import com.vitorpamplona.amethyst.commons.model.navigation.Route +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.back +import com.vitorpamplona.amethyst.commons.resources.cancel +import com.vitorpamplona.amethyst.commons.resources.cordn_action_editing +import com.vitorpamplona.amethyst.commons.resources.cordn_action_unpin +import com.vitorpamplona.amethyst.commons.resources.cordn_chat_empty_description +import com.vitorpamplona.amethyst.commons.resources.cordn_chat_empty_title +import com.vitorpamplona.amethyst.commons.resources.cordn_group_info +import com.vitorpamplona.amethyst.commons.resources.cordn_group_unavailable +import com.vitorpamplona.amethyst.commons.resources.cordn_group_untitled +import com.vitorpamplona.amethyst.commons.resources.cordn_media_download_failed +import com.vitorpamplona.amethyst.commons.resources.cordn_media_no_server +import com.vitorpamplona.amethyst.commons.resources.cordn_media_unreadable +import com.vitorpamplona.amethyst.commons.resources.cordn_media_upload_failed +import com.vitorpamplona.amethyst.commons.resources.cordn_member_count +import com.vitorpamplona.amethyst.commons.resources.cordn_message_deleted +import com.vitorpamplona.amethyst.commons.resources.cordn_pinned_by +import com.vitorpamplona.amethyst.commons.resources.cordn_pinned_count +import com.vitorpamplona.amethyst.commons.resources.cordn_pinned_next +import com.vitorpamplona.amethyst.commons.resources.cordn_pinned_previous +import com.vitorpamplona.amethyst.commons.resources.cordn_pinned_show_all +import com.vitorpamplona.amethyst.commons.resources.cordn_pinned_title +import com.vitorpamplona.amethyst.commons.resources.cordn_send_failed +import com.vitorpamplona.amethyst.commons.resources.cordn_send_no_session +import com.vitorpamplona.amethyst.commons.resources.cordn_voice_record +import com.vitorpamplona.amethyst.commons.resources.cordn_voice_stop +import com.vitorpamplona.amethyst.commons.richtext.EncryptedMediaUrlImage +import com.vitorpamplona.amethyst.commons.richtext.EncryptedMediaUrlVideo +import com.vitorpamplona.amethyst.commons.ui.components.EmptyState +import com.vitorpamplona.amethyst.commons.ui.loadStringRes +import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.commons.ui.theme.FeedPadding +import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText +import com.vitorpamplona.amethyst.model.cordn.CordnMediaService +import com.vitorpamplona.amethyst.service.playback.composable.WaveformData +import com.vitorpamplona.amethyst.service.uploads.MediaCompressor +import com.vitorpamplona.amethyst.service.uploads.MetadataStripper +import com.vitorpamplona.amethyst.ui.actions.uploads.RecordingResult +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia +import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceMessageRecorder +import com.vitorpamplona.amethyst.ui.components.ZoomableContentView +import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.note.NonClickableUserPictures +import com.vitorpamplona.amethyst.ui.note.UserPicture +import com.vitorpamplona.amethyst.ui.note.types.RenderAudioWaveformPlayer +import com.vitorpamplona.amethyst.ui.pluralStringRes +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.AutoScrollToNewest +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.observeUserNameByHex +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadDialog +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadState +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.cordn.appEncryptedMedia.CordnBlobUpload +import com.vitorpamplona.quartz.cordn.appEncryptedMedia.CordnMediaAttachment +import com.vitorpamplona.quartz.cordn.appEncryptedMedia.CordnMediaCipher +import com.vitorpamplona.quartz.cordn.appEncryptedMedia.CordnMediaEncryption +import com.vitorpamplona.quartz.cordn.appEncryptedMedia.CordnMediaTag +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnAnnotationIndex +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnDeliveredMessage +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnMessageReferences +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag +import com.vitorpamplona.quartz.utils.Log +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * One cordn room. + * + * Every kind `spec/02.md` defines can now be both sent and read here: text, + * thread replies, reactions, edits, deletions and pins, plus encrypted + * attachments and voice notes. The rule that got us here is worth keeping — + * a composer must never be able to send a kind this room cannot render, or + * the sender's own messages appear as blanks to them. + * + * It renders the room's own envelopes rather than `Note`s, so nothing on this + * screen goes through `LocalCache`. That is the point — a cordn message is not + * a Nostr event that happens to be encrypted, it is an MLS payload that never + * touched a relay, and giving it a Note would make it searchable and + * notifiable alongside things that were actually published. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CordnGroupChatScreen( + coordinatorPubKey: HexKey, + gid: String, + accountViewModel: AccountViewModel, + nav: INav, +) { + val runtime = accountViewModel.account.cordnRuntime + val room = remember(coordinatorPubKey, gid) { runtime?.groups?.get(coordinatorPubKey, gid) } + + if (room == null) { + // No room means no session for this coordinator, which is a real state + // (it was forgotten, or never opened) and not an error to throw at the + // user as a blank screen. + Column(Modifier.fillMaxSize().padding(24.dp), verticalArrangement = Arrangement.Center) { + Text(stringRes(Res.string.cordn_group_unavailable), style = MaterialTheme.typography.bodyLarge) + } + return + } + + CordnGroupChat(room, accountViewModel, nav) +} + +@Composable +private fun CordnGroupChat( + room: CordnGroupChatroom, + accountViewModel: AccountViewModel, + nav: INav, +) { + val messages by room.messages.collectAsStateWithLifecycle() + val annotations by room.annotations.collectAsStateWithLifecycle() + val name by room.name.collectAsStateWithLifecycle() + val members by room.members.collectAsStateWithLifecycle() + + val listState = rememberLazyListState() + val scope = rememberCoroutineScope() + val me = accountViewModel.account.signer.pubKey + + val draft by room.draft.collectAsStateWithLifecycle() + var replyingTo by remember { mutableStateOf(null) } + var editing by remember { mutableStateOf(null) } + var attaching by remember { mutableStateOf(false) } + // The same upload state every other chat's dialog is built on — caption, media + // quality, metadata stripping and the server to put it on. + val uploadState = + remember(room.gid) { + ChatFileUploadState( + accountViewModel.account.settings.defaultFileServer, + accountViewModel.account.settings.stripLocationOnUpload, + ) + } + var pendingVoice by remember { mutableStateOf(null) } + + DisposableEffect(room.gid) { + onDispose { + // The recorder writes plaintext audio to cacheDir and sendVoiceNote deletes + // it after sending. A note recorded and then abandoned — the screen closed, + // the room switched — never reached that, so it stayed on disk: an + // unencrypted copy of a message that was never even sent. + pendingVoice?.file?.delete() + } + } + var attachError by remember { mutableStateOf(null) } + + // Why sending says anything at all when it fails: `manager()` is null-safe + // all the way down, so a room whose coordinator has no open session + // swallowed every send, reaction, edit, delete and pin without a word — + // and the composer had already cleared the draft, so the text went with it. + // From the outside that is indistinguishable from a message that was sent + // and simply never arrived. + var sendError by remember { mutableStateOf(null) } + val context = LocalContext.current + val uploadFailed = stringRes(Res.string.cordn_media_upload_failed) + val sendFailed = stringRes(Res.string.cordn_send_failed) + val noSession = stringRes(Res.string.cordn_send_no_session) + + fun manager() = + accountViewModel.account.cordnRuntime + ?.sessionOrNull(room.coordinatorPubKey) + ?.manager + + // One place for every outbound action, so none of them can go quiet again. + // Returns false when it failed, which is what lets the composer put the + // draft back rather than eat it. + suspend fun trySend(block: suspend (CordnGroupManager) -> CordnDeliveredMessage?): Boolean { + val manager = manager() + if (manager == null) { + sendError = noSession + return false + } + return try { + // Shown the moment the coordinator takes it, rather than when the + // echo comes back — which, for your own traffic, it never does as + // a message: the sync loop recognises it by cursor and reports it + // as Delivery.Echo, whose branch adds nothing to the room. So a + // sent message used to leave no trace in the room that sent it. + // + // add() is keyed on the envelope id and idempotent, so a later + // re-sync that does hand the message back cannot double it. + block(manager)?.let { room.add(it) } + sendError = null + true + } catch (e: Exception) { + Log.w("CordnGroupChat", "send failed in ${room.gid}: ${e.message}", e) + sendError = e.message ?: sendFailed + false + } + } + + val runtime = accountViewModel.account.cordnRuntime + + // Opening a room is what marks it read, and the read position and draft + // are written when leaving it. Saving on every keystroke would rewrite an + // encrypted file per character; saving on dispose loses nothing a process + // death would not have lost anyway. + DisposableEffect(room) { + onDispose { + room.markRead() + runtime?.let { + CoroutineScope(Dispatchers.IO).launch { + it.saveRoomState(room.coordinatorPubKey, room.gid) + } + } + } + } + + // Where the divider goes, taken once per visit. markRead() below moves the live + // cursor to the newest message, so reading it per frame would erase the line at + // exactly the moment it starts being useful. Restored state first, because the + // cursor this reads is the one that was persisted. + var unreadFrom by remember(room.gid) { mutableStateOf(null) } + + LaunchedEffect(room) { + runtime?.restoreRoomState(room.coordinatorPubKey, room.gid) + unreadFrom = room.lastReadCursor.value.takeIf { room.unreadCount.value > 0 } + room.markRead() + } + + // Tapping a reply's quote jumps to the message it answers and flashes it; the + // bubble clears this itself once the flash is done. + var highlighted by remember(room.gid) { mutableStateOf(null) } + + // The same scaffold every other chat screen uses. A bare Scaffold gave this room + // neither of the two things it provides: the bars' scroll behaviour, and the IME + // inset — without which the composer sat *under* the soft keyboard. + DisappearingScaffold( + isInvertedLayout = true, + topBar = { + CordnChatTopBar( + title = name?.takeIf { it.isNotBlank() } ?: stringRes(Res.string.cordn_group_untitled, room.gid.take(8)), + members = members, + accountViewModel = accountViewModel, + onBack = { nav.popBack() }, + onInfo = { nav.nav(Route.CordnGroupInfo(room.coordinatorPubKey, room.gid)) }, + ) + }, + accountViewModel = accountViewModel, + allowBarHide = false, + ) { padding -> + Column(Modifier.fillMaxSize().padding(padding)) { + // One clock for every divider in the room, so they cannot disagree. + val today = rememberToday() + + // Reversed once, into a val: `asReversed()` is a view, and indexing it + // per item to find the neighbour is how a list like this quietly becomes + // quadratic. Hoisted out of the LazyColumn because the jump below needs to + // find a message's row by id. + val rows = remember(messages) { messages.asReversed() } + + // The oldest message this visit had not seen. Own traffic is excluded for + // the same reason unreadCount excludes it: a message of yours coming back + // as an echo is not news, and a divider above it would say it was. + val firstUnreadId = + remember(rows, unreadFrom, me) { + unreadFrom?.let { readUpTo -> + rows.lastOrNull { it.cursor > readUpTo && it.envelope.pubKey != me }?.envelope?.id + } + } + + val jumpTo: (HexKey) -> Unit = { id -> + val index = rows.indexOfFirst { it.envelope.id == id } + // Not found means the quoted message is older than what is loaded. + // Flashing nothing is better than scrolling somewhere arbitrary. + if (index >= 0) { + highlighted = id + scope.launch { listState.animateScrollToItem(index) } + } + } + + // Pinned messages sit above the conversation rather than inside it. + // A pin is a claim about a message's importance, not a message, and + // leaving it only in place means the thing someone pinned scrolls + // away exactly like everything else. + PinnedRibbon( + annotations = annotations, + scope = scope, + accountViewModel = accountViewModel, + nav = nav, + onJumpTo = jumpTo, + ) { message -> + trySend { + it.post( + gid = room.gid, + pinTo = message.target(), + pinOp = CordnMessageReferences.PinOp.REMOVE, + ) + } + } + + // The same rule every other chat follows: your own message always + // pulls the view onto it, someone else's only while you are already + // at the bottom, and history you scrolled up to read is left alone. + val newest = rows.firstOrNull()?.envelope + AutoScrollToNewest(listState, newest?.id, mine = newest?.pubKey == me) + + // A room with nothing in it said nothing at all, where every other chat + // crossfades a real empty state. There is no error or loading branch to + // match: the session opens behind `restoreRoomState` and a room that cannot + // reach its coordinator says so through the send banner below, on the + // action that actually needed it. + if (rows.isEmpty()) { + EmptyState( + title = stringRes(Res.string.cordn_chat_empty_title), + description = stringRes(Res.string.cordn_chat_empty_description), + modifier = Modifier.weight(1f), + ) + } + + LazyColumn( + state = listState, + // Anchored at the bottom like every other chat: a room opens on + // its newest message. `messages` is oldest-first, so the rows + // are reversed to match. + reverseLayout = true, + contentPadding = FeedPadding, + // No weight while the empty state holds the space, or the two would + // split the screen and the placeholder would sit in half of it. + modifier = + if (rows.isEmpty()) { + Modifier.fillMaxWidth() + } else { + Modifier.weight(1f).fillMaxWidth() + }.padding(horizontal = 12.dp), + ) { + itemsIndexed(rows, key = { _, it -> it.envelope.id }) { index, message -> + // `rows` runs newest-first and the list is reverse-laid-out, + // so the next index is the older message and renders above. + val older = rows.getOrNull(index + 1) + val newer = rows.getOrNull(index - 1) + + // Send/arrival motion, as in the shared feed: a new row fades + // in and the ones above slide to make room, so a sent message + // enters instead of appearing. + val itemModifier = + if (accountViewModel.settings.isPerformanceMode()) { + Modifier + } else { + Modifier.animateItem() + } + + // One Column, so the item is a single placeable. `reverseLayout` + // mirrors each of an item's placeables about the main axis, which + // inverts the order of siblings emitted side by side — but it does + // not reach inside a layout, so this Column still reads top to + // bottom. Both headers are therefore composed BEFORE the bubble + // they introduce: the date above the whole day, then the unread + // line immediately against the message. + Column(modifier = itemModifier) { + // Starts a new day when it differs from the older row, which + // under reverseLayout is the one rendered above. + if (!message.sameDayAs(older)) { + DaySeparator(message.envelope.createdAt, today) + } + + if (message.envelope.id == firstUnreadId) { + UnreadDivider() + } + + CordnMessageRow( + message = message, + room = room, + annotations = annotations, + me = me, + // Grouped when the same person keeps talking inside the + // shared chat window: one avatar and name per burst + // instead of per line, and the bubbles of a burst square + // off against each other — which is most of what makes a + // wall of messages readable. + groupPosition = + remember(newer?.envelope?.id, message.envelope.id, older?.envelope?.id) { + cordnGroupPositionFor(newer, message, older) + }, + // The edit if there is one, and nothing at all if the + // message was withdrawn: rendering the original text of + // a deleted message would defeat the deletion. + text = if (annotations.isDeleted(message.envelope.id)) null else annotations.contentOf(message.envelope.id), + isEdited = annotations.isEdited(message.envelope.id), + shouldHighlight = highlighted == message.envelope.id, + accountViewModel = accountViewModel, + nav = nav, + onHighlightFinished = { highlighted = null }, + onScrollToMessage = jumpTo, + onReply = { + replyingTo = message + editing = null + }, + onEdit = { + editing = message + replyingTo = null + room.draft.value = annotations.contentOf(message.envelope.id).orEmpty() + }, + onDelete = { + // Through trySend like the rest: a deletion is an + // annotation, and an annotation of your own comes back + // as an Echo too, so deleting your own message used to + // look like nothing had happened until someone else's + // traffic refreshed the fold. + scope.launch { trySend { it.post(room.gid, deleteTo = message.target()) } } + }, + onTogglePin = { + val pinned = annotations.isPinned(message.envelope.id) + scope.launch { + trySend { + it.post( + gid = room.gid, + pinTo = message.target(), + pinOp = if (pinned) CordnMessageReferences.PinOp.REMOVE else CordnMessageReferences.PinOp.ADD, + ) + } + } + }, + onReact = { emoji -> + scope.launch { trySend { it.post(room.gid, emoji, reactionTo = message.target()) } } + }, + ) + } + } + } + + (attachError ?: sendError)?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(horizontal = 12.dp), + ) + } + + // The app's upload dialog, as Marmot, Concord, the DMs, minichat and Nests + // all use it. cordn keeps its own uploader behind it — encrypted blobs to + // Blossom, not a nostr media post — which is exactly how Marmot uses it too. + if (uploadState.multiOrchestrator != null) { + ChatFileUploadDialog( + state = uploadState, + title = { Text(name?.takeIf { it.isNotBlank() } ?: stringRes(Res.string.cordn_group_untitled, room.gid.take(8))) }, + upload = { + scope.launch { + attaching = true + attachError = null + try { + sendAttachment(context, accountViewModel, room, uploadState) + // Only on success: a failed upload leaves the dialog up + // with what you picked still in it, so retrying is one + // tap rather than the picker again. + uploadState.reset() + } catch (e: Exception) { + // A failed attachment left no trace anywhere; the + // banner tells the person, this tells whoever has + // to work out why. + Log.w("CordnGroupChat", "attachment failed in ${room.gid}: ${e.message}", e) + attachError = e.message ?: uploadFailed + } finally { + attaching = false + } + } + }, + onCancel = uploadState::reset, + accountViewModel = accountViewModel, + nav = nav, + // cordn tells its blob host a fixed set of constants on purpose and + // its imeta tag has no field for a warning, so the switch would be + // a control with nowhere to put the answer. + showContentWarning = false, + ) + } + + val replyPreview = replyingTo + if (replyPreview != null) { + // The same quote block the bubbles use, so what you are answering looks + // the same while you write it as it does once it is sent. + Row( + Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(Modifier.weight(1f)) { + CordnQuotedMessage( + parent = replyPreview, + annotations = annotations, + me = me, + accountViewModel = accountViewModel, + nav = nav, + onClick = { jumpTo(replyPreview.envelope.id) }, + ) + } + IconButton(onClick = { replyingTo = null }) { + Icon(MaterialSymbols.Close, contentDescription = stringRes(Res.string.cancel)) + } + } + } + if (editing != null) { + ComposerBanner( + label = stringRes(Res.string.cordn_action_editing), + onCancel = { + editing = null + room.draft.value = "" + }, + ) + } + + CordnComposer( + room = room, + accountViewModel = accountViewModel, + // Picking a file no longer sends it. A cordn attachment is encrypted, + // uploaded and announced to the room in one irreversible action, so it + // gets the same confirm-first treatment as anything else that cannot be + // taken back. + onAttach = { uri -> + uploadState.load(persistentListOf(SelectedMedia(uri, context.contentResolver.getType(uri)))) + }, + // Recording no longer sends. It goes to the preview above the field, + // where it can be played back, re-recorded or dropped first. + pendingVoice = pendingVoice, + onVoiceNote = { recording -> + // Re-recording replaces what was there; the old file is a temp + // file this screen owns, so it goes now rather than being leaked. + pendingVoice?.file?.delete() + pendingVoice = recording + }, + onRemoveVoice = { + pendingVoice?.file?.delete() + pendingVoice = null + }, + attaching = attaching, + // The field hands its own text over rather than the screen reading + // `room.draft`: the two are kept in step by a snapshot collector, which + // settles a frame later, and a send must use what is on screen now. + onSend = { typed -> + val text = typed.trim() + + val voice = pendingVoice + if (voice != null) { + pendingVoice = null + room.draft.value = "" + scope.launch { + attaching = true + attachError = null + try { + sendVoiceNote(context, accountViewModel, room, voice, text) + } catch (e: Exception) { + Log.w("CordnGroupChat", "voice note failed in ${room.gid}: ${e.message}", e) + attachError = e.message ?: uploadFailed + } finally { + attaching = false + } + } + return@CordnComposer + } + + if (text.isEmpty()) return@CordnComposer + + val reply = replyingTo + val edit = editing + room.draft.value = "" + replyingTo = null + editing = null + + scope.launch { + val sent = + trySend { + it.post( + gid = room.gid, + content = text, + replyTo = reply?.target(), + editTo = edit?.target(), + ) + } + if (!sent) { + // Hand the message back rather than lose it, and put + // the reply or edit it belonged to back with it, so + // trying again means pressing send and nothing else. + room.draft.value = text + replyingTo = reply + editing = edit + } + } + }, + ) + } + } +} + +/** The target fields `CordnMessageReferences` needs, straight off a delivery. */ +private fun CordnDeliveredMessage.target() = + CordnMessageReferences.Target( + id = envelope.id, + pubKey = envelope.pubKey, + kind = envelope.kind, + // The target's own tags, so replying to a reply keeps the original + // thread root instead of starting a new thread at the reply. + tags = envelope.tags, + ) + +/** + * The pinned messages, one line tall however many there are. + * + * Stacking them was the obvious shape and the wrong one: the ribbon sits above a + * `weight(1f)` conversation, so an unweighted Column of one row per pin is + * measured at its full height first and takes that space off the conversation -- + * eight pins and there is little chat left, with no way to scroll or collapse + * the strip. cordn-web's shape fixes the height by construction instead: one pin + * shown, a position counter, and arrows to step through the rest. + * + * Stepping wraps, and the index is clamped on read rather than corrected in an + * effect -- the list shrinks under it whenever anybody unpins, and a clamp that + * happens at read time cannot lag behind the data the way a correction does. + */ +@Composable +private fun PinnedRibbon( + annotations: CordnAnnotationIndex, + scope: CoroutineScope, + accountViewModel: AccountViewModel, + nav: INav, + onJumpTo: (HexKey) -> Unit, + // Hoisted rather than handed a manager: unpinning has to go through the + // caller's trySend so it reports a failure and lands in the room, and a + // ribbon that posted for itself could do neither. + onUnpin: suspend (CordnDeliveredMessage) -> Unit, +) { + val pinned = annotations.pinnedIds().mapNotNull { annotations.byId[it] } + if (pinned.isEmpty()) return + + var index by remember { mutableIntStateOf(0) } + var showingAll by remember { mutableStateOf(false) } + + val at = index.coerceIn(0, pinned.lastIndex) + val current = pinned[at] + + Column(Modifier.fillMaxWidth()) { + Row( + Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + symbol = MaterialSymbols.PushPin, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + PinnedLine( + message = current, + annotations = annotations, + accountViewModel = accountViewModel, + modifier = + Modifier + .weight(1f) + .clickable { onJumpTo(current.envelope.id) } + .padding(horizontal = 6.dp, vertical = 2.dp), + ) + + // Only when there is somewhere to step to. + if (pinned.size > 1) { + IconButton(onClick = { index = (at - 1 + pinned.size) % pinned.size }, modifier = Modifier.size(28.dp)) { + Icon( + symbol = MaterialSymbols.AutoMirrored.KeyboardArrowLeft, + contentDescription = stringRes(Res.string.cordn_pinned_previous), + modifier = Modifier.size(18.dp), + ) + } + Text( + text = "${at + 1}/${pinned.size}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + IconButton(onClick = { index = (at + 1) % pinned.size }, modifier = Modifier.size(28.dp)) { + Icon( + symbol = MaterialSymbols.AutoMirrored.KeyboardArrowRight, + contentDescription = stringRes(Res.string.cordn_pinned_next), + modifier = Modifier.size(18.dp), + ) + } + } + + IconButton(onClick = { showingAll = true }, modifier = Modifier.size(28.dp)) { + Icon( + symbol = MaterialSymbols.AutoMirrored.List, + contentDescription = stringRes(Res.string.cordn_pinned_show_all), + modifier = Modifier.size(18.dp), + ) + } + } + + HorizontalDivider() + } + + if (showingAll) { + AllPinnedDialog( + pinned = pinned, + annotations = annotations, + accountViewModel = accountViewModel, + nav = nav, + onDismiss = { showingAll = false }, + onJumpTo = { + showingAll = false + onJumpTo(it) + }, + onUnpin = { scope.launch { onUnpin(it) } }, + ) + } +} + +/** + * Who said it and what it said, on one line. + * + * The author is half of what makes a pin worth reading -- a line of text with no + * name on it says nothing about why it was kept. + */ +@Composable +private fun PinnedLine( + message: CordnDeliveredMessage, + annotations: CordnAnnotationIndex, + accountViewModel: AccountViewModel, + modifier: Modifier = Modifier, +) { + Row(modifier, verticalAlignment = Alignment.CenterVertically) { + Text( + text = observeUserNameByHex(message.envelope.pubKey, accountViewModel), + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.widthIn(max = 120.dp), + ) + Text( + // A deleted message that is still pinned shows as deleted, not as + // its old text: a pin must not outlive the withdrawal of what it + // points at. + text = + if (annotations.isDeleted(message.envelope.id)) { + stringRes(Res.string.cordn_message_deleted) + } else { + annotations.contentOf(message.envelope.id).orEmpty() + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(start = 6.dp), + ) + } +} + +/** + * Every pinned message, for when the strip's one line is not enough. + * + * The only place `pinnedBy` is shown. The fold has always carried it and + * nothing ever displayed it, yet in a group where any member may pin (spec/01.md + * section 5.1) who did the pinning is often the point. + */ +@Composable +private fun AllPinnedDialog( + pinned: List, + annotations: CordnAnnotationIndex, + accountViewModel: AccountViewModel, + nav: INav, + onDismiss: () -> Unit, + onJumpTo: (HexKey) -> Unit, + onUnpin: (CordnDeliveredMessage) -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(MaterialSymbols.PushPin, contentDescription = null, modifier = Modifier.size(18.dp)) + Text( + text = stringRes(Res.string.cordn_pinned_title), + modifier = Modifier.padding(start = 8.dp), + ) + } + }, + text = { + Column { + Text( + text = pluralStringRes(Res.plurals.cordn_pinned_count, pinned.size, pinned.size), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + // Bounded and scrollable: this list is as long as the group made + // it, and a dialog that grows past the screen cannot be dismissed. + LazyColumn(Modifier.heightIn(max = 360.dp).padding(top = 8.dp)) { + items(pinned, key = { it.envelope.id }) { message -> + Column( + Modifier + .fillMaxWidth() + .clickable { onJumpTo(message.envelope.id) } + .padding(vertical = 8.dp), + ) { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + UserPicture( + userHex = message.envelope.pubKey, + size = 24.dp, + accountViewModel = accountViewModel, + nav = nav, + ) + Text( + text = observeUserNameByHex(message.envelope.pubKey, accountViewModel), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f).padding(start = 8.dp), + ) + TextButton(onClick = { onUnpin(message) }) { + Text(stringRes(Res.string.cordn_action_unpin), style = MaterialTheme.typography.labelSmall) + } + } + + annotations.pins[message.envelope.id]?.pinnedBy?.let { pinnedBy -> + Text( + text = + stringRes( + Res.string.cordn_pinned_by, + observeUserNameByHex(pinnedBy, accountViewModel), + ), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Text( + text = + if (annotations.isDeleted(message.envelope.id)) { + stringRes(Res.string.cordn_message_deleted) + } else { + annotations.contentOf(message.envelope.id).orEmpty() + }, + style = MaterialTheme.typography.bodySmall, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 2.dp), + ) + } + HorizontalDivider() + } + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { Text(stringRes(Res.string.cancel)) } + }, + ) +} + +@Composable +private fun ComposerBanner( + label: String, + onCancel: () -> Unit, +) { + Row( + Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(label, style = MaterialTheme.typography.labelMedium, modifier = Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis) + TextButton(onClick = onCancel) { Text(stringRes(Res.string.cancel), style = MaterialTheme.typography.labelSmall) } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun CordnChatTopBar( + title: String, + members: List, + accountViewModel: AccountViewModel, + onBack: () -> Unit, + onInfo: () -> Unit, +) { + TopAppBar( + title = { + // The whole title is the way in to group info, as it is in every other + // group chat — the faces say who is in the room before you open it. + Row( + modifier = Modifier.clickable(onClick = onInfo), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (members.isNotEmpty()) { + NonClickableUserPictures( + userHexList = members, + size = 36.dp, + accountViewModel = accountViewModel, + ) + } + Column { + Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis) + if (members.isNotEmpty()) { + Text( + text = pluralStringRes(Res.plurals.cordn_member_count, members.size, members.size), + style = MaterialTheme.typography.bodySmall, + ) + } + } + } + }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + symbol = MaterialSymbols.AutoMirrored.ArrowBack, + contentDescription = stringRes(Res.string.back), + ) + } + }, + actions = { + // GroupAdd rather than Info, as in Marmot: the same screen, and the thing + // people come to it for is adding someone. + IconButton(onClick = onInfo) { + Icon(MaterialSymbols.GroupAdd, contentDescription = stringRes(Res.string.cordn_group_info)) + } + }, + ) +} + +/** A reason an attachment did not go, in words meant for the person who tried. */ +private class CordnAttachmentException( + message: String, +) : Exception(message) + +/** + * Encrypts what the upload dialog is holding and sends it as an attachment. + * + * cordn does not go through [com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator] + * like the nostr surfaces do: its `imeta` tag carries the hash of the *plaintext* and a + * per-file key, which the orchestrator neither produces nor surfaces. So the dialog's + * two byte-level choices are applied here by hand, against the same helpers the + * orchestrator uses, and the encryption and upload stay in [CordnMediaService]. + */ +private suspend fun sendAttachment( + context: Context, + accountViewModel: AccountViewModel, + room: CordnGroupChatroom, + state: ChatFileUploadState, +) { + // Every step here used to `?: return`, which reads as "nothing to do" and + // behaves as "the attach button does nothing at all": the picker closed, + // the spinner ended, and no message and no error appeared. Each one is now + // a reason a person can act on. + val session = + accountViewModel.account.cordnRuntime?.sessionOrNull(room.coordinatorPubKey) + ?: throw CordnAttachmentException(loadStringRes(Res.string.cordn_send_no_session)) + val group = + session.manager.group(room.gid) + ?: throw CordnAttachmentException(loadStringRes(Res.string.cordn_send_no_session)) + + // The gallery inside the dialog can delete what was picked, which leaves an empty + // orchestrator rather than a null one. canPost() now refuses that, but indexing it + // blindly here crashed, so it is checked where the index happens too. + val orchestrator = state.multiOrchestrator + if (orchestrator == null || orchestrator.size() == 0) return + + val item = orchestrator.get(0) + val uri = item.media.uri + val declaredMime = item.media.mimeType ?: context.contentResolver.getType(uri) ?: CordnBlobUpload.OPAQUE + + // Marks the dialog busy: its Send button reads canPost(), which is false while a + // tracker says an upload is running. Without this the button stayed live for the + // whole upload and a second tap encrypted, uploaded and posted the file twice. + state.mediaUploadTracker.startUpload(orchestrator.hasNonMedia()) + + try { + // The media-quality slider. + // + // Off the main thread, like the strip and the read below it. + // `sendAttachment` is called from the composition's scope, so it + // inherits Main, and `MediaCompressor.compress` opens with + // `checkNotInMainThread()` — so every image attachment threw + // `OnMainThreadException` before it ever reached the uploader. The + // voice path never hit it because it posts its recording already + // encoded and skips compression entirely. + val compressed = + withContext(Dispatchers.IO) { + item.orchestrator.compressIfNeeded( + uri = uri, + mimeType = declaredMime, + compressionQuality = MediaCompressor.intToCompressorQuality(state.mediaQualitySlider), + context = context, + ) + } + val mime = compressed.contentType ?: declaredMime + + // The strip-metadata switch. A file type the stripper does not handle comes back + // untouched and says so, which is not a failure — there was nothing to strip. + val finalUri = + if (state.stripMetadata) { + withContext(Dispatchers.IO) { MetadataStripper.strip(compressed.uri, mime, context) }.uri + } else { + compressed.uri + } + + try { + // Name comes from OpenableColumns: `uri.lastPathSegment` is a document id + // on a content:// URI, not a filename. The query is a disk read, so it + // goes off the main thread with the rest — StrictMode flags it otherwise. + val name = withContext(Dispatchers.IO) { resolveDisplayName(context, uri) } + val bytes = + withContext(Dispatchers.IO) { context.contentResolver.openInputStream(finalUri)?.use { it.readBytes() } } + ?: throw CordnAttachmentException(loadStringRes(Res.string.cordn_media_unreadable)) + + // Null means the chosen host has no base URL, which is a setting the person can + // change — the one failure here that is entirely actionable. + val tag = + CordnMediaService(accountViewModel.account) + .upload(group, bytes, mime, name, context, state.selectedServer.baseUrl) + ?: throw CordnAttachmentException(loadStringRes(Res.string.cordn_media_no_server)) + + // Into the room as well, for the same reason every other send is: an + // attachment of your own echoes back as an Echo and would otherwise be + // invisible to the person who sent it. + // + // The dialog's description is the message's own content, so an attachment with + // something written about it is one message rather than two. + room.add(session.manager.send(room.gid, content = state.caption.trim(), tags = arrayOf(tag))) + } finally { + // Compressing and stripping each write a new file, and both hold the + // attachment in the clear. Leaving them in the cache would keep plaintext + // copies of a message that is end-to-end encrypted everywhere else — the + // same reason the voice path deletes its recording. Both calls no-op on the + // user's own file. + item.orchestrator.deleteTempUri(finalUri, uri) + item.orchestrator.deleteTempUri(compressed.uri, uri) + } + } finally { + // Always, not just on success: a failure leaves the dialog up to retry from, + // and a tracker stuck "uploading" would keep its Send button dead forever. + state.mediaUploadTracker.finishUpload() + } +} + +/** + * The file's real name. + * + * `uri.lastPathSegment` is a document id on a `content://` URI, not a name — it is what + * every cordn attachment has been named until now. + */ +private fun resolveDisplayName( + context: Context, + uri: Uri, +): String = + runCatching { + context.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor -> + if (cursor.moveToFirst() && !cursor.isNull(0)) cursor.getString(0) else null + } + }.getOrNull() + ?: uri.lastPathSegment?.substringAfterLast('/') + ?: "file" + +/** + * One attachment, drawn by the pipeline every other chat's media goes through. + * + * Registering a [CordnMediaCipher] against the blob URL lets + * `EncryptedBlobInterceptor` decrypt the download in flight, so the bytes reach + * [ZoomableContentView] already in the clear and cordn gets the app's real + * image, video and voice-note rendering — zoom, the pager, the content-warning + * gate, thumbhash backdrops — instead of its own. + * + * It used to fetch and decrypt by hand and then draw a bare `Image`, an + * OutlinedButton reading "Open ", and its own audio player, on the + * argument that a blob fetch tells the host that a particular person opened a + * particular message and so should wait for a tap. The concern is real; a + * cordn-only tap gate was the wrong place to answer it. Auto-loading media is + * an app-wide setting that the shared renderer already honours, and every other + * encrypted chat — Marmot included — routes through it, so the one chat that + * opted out was also the one whose media did not look like the app. + */ +@Composable +internal fun CordnAttachment( + attachment: CordnMediaAttachment, + room: CordnGroupChatroom, + accountViewModel: AccountViewModel, +) { + // Derived, not carried: spec/applications/encrypted-media.md §3.1. Null + // means this device cannot open the group at all, which is the one case + // there is nothing to draw for. + val mediaKey = + remember(room.gid, attachment.url) { + accountViewModel.account.cordnRuntime + ?.sessionOrNull(room.coordinatorPubKey) + ?.manager + ?.group(room.gid) + ?.let { CordnMediaEncryption.mediaKey(it) } + } + + if (mediaKey == null) { + Text( + text = stringRes(Res.string.cordn_media_download_failed), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.error, + ) + return + } + + val cipher = remember(attachment, mediaKey) { CordnMediaCipher(mediaKey, attachment) } + Amethyst.instance.keyCache.add(attachment.url, cipher, attachment.mimeType) + + // A voice note is audio, and audio has no picture: sent down the video + // branch below it plays on a blank video surface. The Note-free player is + // the same one the other chats reach, which cordn cannot get to through a + // Note because its messages never enter LocalCache. + if (attachment.isAudio) { + // Present when the sender wrote the hint; the player falls back to a + // placeholder when nobody did, so the bars are never simply missing. + val bars = remember(attachment) { attachment.waveform?.let { WaveformData(it) } } + Box(Modifier.padding(top = 6.dp)) { + RenderAudioWaveformPlayer( + mediaUrl = attachment.url, + title = attachment.filename, + mimeType = attachment.mimeType, + waveform = bars, + authorName = null, + callbackUri = null, + accountViewModel = accountViewModel, + ) + } + return + } + + val content = + remember(attachment, mediaKey) { + val dim = attachment.dimensions?.let { DimensionTag.parse(it) } + if (attachment.isImage) { + EncryptedMediaUrlImage( + url = attachment.url, + description = attachment.filename, + hash = attachment.plaintextHash, + blurhash = attachment.blurhash, + dim = dim, + mimeType = attachment.mimeType, + encryptionAlgo = CordnMediaTag.VERSION_V1, + encryptionKey = mediaKey, + encryptionNonce = attachment.nonceBytes, + ) + } else { + EncryptedMediaUrlVideo( + url = attachment.url, + description = attachment.filename, + hash = attachment.plaintextHash, + blurhash = attachment.blurhash, + dim = dim, + mimeType = attachment.mimeType, + encryptionAlgo = CordnMediaTag.VERSION_V1, + encryptionKey = mediaKey, + encryptionNonce = attachment.nonceBytes, + ) + } + } + + Box(Modifier.padding(top = 6.dp)) { + ZoomableContentView( + content = content, + roundedCorner = true, + contentScale = ContentScale.FillWidth, + accountViewModel = accountViewModel, + ) + } +} + +/** + * Hold to record, release to send. + * + * The permission is requested on the first press rather than when the room + * opens: opening a chat is not consent to use the microphone, and a dialog + * that appears before anyone reached for it trains people to dismiss it. + */ +@Composable +internal fun VoiceNoteButton( + enabled: Boolean, + onRecorded: (RecordingResult) -> Unit, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val recorder = remember { VoiceMessageRecorder() } + var recording by remember { mutableStateOf(false) } + var granted by remember { mutableStateOf(hasMicPermission(context)) } + + val permission = + rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted = it } + + IconButton( + enabled = enabled, + onClick = { + if (!granted) { + permission.launch(Manifest.permission.RECORD_AUDIO) + return@IconButton + } + if (recording) { + recording = false + // Null when the press was too short to be a message. Dropping + // it silently is right: an accidental tap should not send a + // zero-second voice note to a group. + recorder.stop()?.let(onRecorded) + } else { + recording = true + recorder.start(context, scope) + } + }, + ) { + Icon( + symbol = if (recording) MaterialSymbols.Stop else MaterialSymbols.Mic, + contentDescription = stringRes(if (recording) Res.string.cordn_voice_stop else Res.string.cordn_voice_record), + // Matches the attach icon beside it; red only while recording, + // which is the one state worth pulling the eye. + tint = if (recording) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.placeholderText, + ) + } +} + +private fun hasMicPermission(context: Context) = ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED + +/** + * Sends a recording through exactly the same encrypted path as any other file. + * + * A voice note is a file: same codec, same Blossom upload, same `imeta` + * descriptor inside the envelope. The only cordn-specific part is deleting + * the cache file afterwards — the recorder writes plaintext audio to + * `cacheDir`, and leaving it there would keep an unencrypted copy of a + * message that was end-to-end encrypted everywhere else. + */ +private suspend fun sendVoiceNote( + context: Context, + accountViewModel: AccountViewModel, + room: CordnGroupChatroom, + recording: RecordingResult, + caption: String, +) { + // Reported rather than returned, for the reason sendAttachment is: a voice + // note that goes nowhere and says nothing is the same bug twice. + val session = + accountViewModel.account.cordnRuntime?.sessionOrNull(room.coordinatorPubKey) + ?: throw CordnAttachmentException(loadStringRes(Res.string.cordn_send_no_session)) + val group = + session.manager.group(room.gid) + ?: throw CordnAttachmentException(loadStringRes(Res.string.cordn_send_no_session)) + + try { + val bytes = withContext(Dispatchers.IO) { recording.file.readBytes() } + val tag = + CordnMediaService(accountViewModel.account) + .upload( + group = group, + bytes = bytes, + mimeType = recording.mimeType, + filename = recording.file.name, + context = context, + // The recorder measured these while it was recording; the + // composer's own preview already draws them. Carrying them + // is what makes the bubble match the preview. + waveform = recording.amplitudes, + ) + ?: throw CordnAttachmentException(loadStringRes(Res.string.cordn_media_no_server)) + // Into the room as well, for the same reason every other send is: an + // attachment of your own echoes back as an Echo and would otherwise be + // invisible to the person who sent it. + room.add(session.manager.send(room.gid, content = caption.trim(), tags = arrayOf(tag))) + } finally { + withContext(Dispatchers.IO) { recording.file.delete() } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnGroupInfoScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnGroupInfoScreen.kt new file mode 100644 index 0000000000..e134442396 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnGroupInfoScreen.kt @@ -0,0 +1,1078 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.cordnGroup + +import android.util.Log +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.cordn.CordnGroupException +import com.vitorpamplona.amethyst.commons.cordn.GroupExposure +import com.vitorpamplona.amethyst.commons.cordn.ui.CordnExposureCard +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.model.cache.LocalCache +import com.vitorpamplona.amethyst.commons.model.cordnGroups.CordnGroupChatroom +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.back +import com.vitorpamplona.amethyst.commons.resources.cancel +import com.vitorpamplona.amethyst.commons.resources.copy_npub_to_clipboard +import com.vitorpamplona.amethyst.commons.resources.cordn_admin_action_failed +import com.vitorpamplona.amethyst.commons.resources.cordn_admin_coordinator_silent +import com.vitorpamplona.amethyst.commons.resources.cordn_admin_no_key_package +import com.vitorpamplona.amethyst.commons.resources.cordn_admin_no_self_remove +import com.vitorpamplona.amethyst.commons.resources.cordn_admin_no_welcome +import com.vitorpamplona.amethyst.commons.resources.cordn_admin_not_a_member +import com.vitorpamplona.amethyst.commons.resources.cordn_admin_someone +import com.vitorpamplona.amethyst.commons.resources.cordn_admin_wrong_key_package +import com.vitorpamplona.amethyst.commons.resources.cordn_create_description +import com.vitorpamplona.amethyst.commons.resources.cordn_create_name +import com.vitorpamplona.amethyst.commons.resources.cordn_exposure_close +import com.vitorpamplona.amethyst.commons.resources.cordn_exposure_open +import com.vitorpamplona.amethyst.commons.resources.cordn_group_info +import com.vitorpamplona.amethyst.commons.resources.cordn_group_unavailable +import com.vitorpamplona.amethyst.commons.resources.cordn_group_unavailable_detail +import com.vitorpamplona.amethyst.commons.resources.cordn_group_untitled +import com.vitorpamplona.amethyst.commons.resources.cordn_info_add_member +import com.vitorpamplona.amethyst.commons.resources.cordn_info_add_member_none +import com.vitorpamplona.amethyst.commons.resources.cordn_info_add_member_note +import com.vitorpamplona.amethyst.commons.resources.cordn_info_add_member_placeholder +import com.vitorpamplona.amethyst.commons.resources.cordn_info_admin_badge +import com.vitorpamplona.amethyst.commons.resources.cordn_info_admins_kept +import com.vitorpamplona.amethyst.commons.resources.cordn_info_coordinator +import com.vitorpamplona.amethyst.commons.resources.cordn_info_coordinator_key +import com.vitorpamplona.amethyst.commons.resources.cordn_info_coordinator_nprofile +import com.vitorpamplona.amethyst.commons.resources.cordn_info_copy_nprofile +import com.vitorpamplona.amethyst.commons.resources.cordn_info_edit_details +import com.vitorpamplona.amethyst.commons.resources.cordn_info_egalitarian +import com.vitorpamplona.amethyst.commons.resources.cordn_info_epoch +import com.vitorpamplona.amethyst.commons.resources.cordn_info_gid +import com.vitorpamplona.amethyst.commons.resources.cordn_info_has_key_package +import com.vitorpamplona.amethyst.commons.resources.cordn_info_members +import com.vitorpamplona.amethyst.commons.resources.cordn_info_remove_confirm_body +import com.vitorpamplona.amethyst.commons.resources.cordn_info_remove_confirm_title +import com.vitorpamplona.amethyst.commons.resources.cordn_info_remove_member +import com.vitorpamplona.amethyst.commons.resources.cordn_info_save +import com.vitorpamplona.amethyst.commons.resources.cordn_info_technical +import com.vitorpamplona.amethyst.commons.resources.cordn_member_count +import com.vitorpamplona.amethyst.commons.resources.cordn_requests_accept +import com.vitorpamplona.amethyst.commons.resources.cordn_requests_admin_only +import com.vitorpamplona.amethyst.commons.resources.cordn_requests_check +import com.vitorpamplona.amethyst.commons.resources.cordn_requests_decline +import com.vitorpamplona.amethyst.commons.resources.cordn_requests_failed +import com.vitorpamplona.amethyst.commons.resources.cordn_requests_none +import com.vitorpamplona.amethyst.commons.resources.cordn_requests_title +import com.vitorpamplona.amethyst.commons.resources.cordn_send_no_session +import com.vitorpamplona.amethyst.commons.resources.cordn_share_copied +import com.vitorpamplona.amethyst.commons.resources.cordn_share_copy +import com.vitorpamplona.amethyst.commons.resources.cordn_share_explainer +import com.vitorpamplona.amethyst.commons.resources.cordn_share_title +import com.vitorpamplona.amethyst.commons.ui.components.EmptyState +import com.vitorpamplona.amethyst.commons.ui.loadStringRes +import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.commons.ui.theme.SuggestionListDefaultHeightChat +import com.vitorpamplona.amethyst.commons.util.toShortDisplay +import com.vitorpamplona.amethyst.model.cordn.CordnRuntime +import com.vitorpamplona.amethyst.ui.note.UserPicture +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState +import com.vitorpamplona.amethyst.ui.pluralStringRes +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.observeUserNameByHex +import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.QrCodeDrawer +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SectionCollapse +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SectionExpand +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.cordn.CoordinatorIdentityRow +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.cordn.spec00Coordinator.JoinRequest +import com.vitorpamplona.quartz.cordn.spec01GroupMetadata.CordnGroupMetadata +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.launch + +/** + * What this room is, and what the coordinator can see of it. + * + * The **exposure card's real home**. The link screen shows the same disclosure + * before joining, when it is a decision; this shows it after, when it is a + * fact worth being able to check. §8 is only meaningful if it is available at + * both moments — a privacy property nobody can look up again is a claim, not a + * property. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CordnGroupInfoScreen( + coordinatorPubKey: HexKey, + gid: String, + accountViewModel: AccountViewModel, + nav: INav, +) { + val runtime = accountViewModel.account.cordnRuntime + val room = remember(coordinatorPubKey, gid) { runtime?.groups?.get(coordinatorPubKey, gid) } + + if (room == null) { + Scaffold(topBar = { InfoTopBar(nav) }) { padding -> + // The app's own empty state, centred and titled, rather than a + // sentence stranded in the top-left corner. + EmptyState( + title = stringRes(Res.string.cordn_group_unavailable), + description = stringRes(Res.string.cordn_group_unavailable_detail), + modifier = Modifier.padding(padding), + ) + } + return + } + + CordnGroupInfo(room, coordinatorPubKey, accountViewModel, nav) +} + +/** + * The bar both states share, with a slot for the one action only a loaded + * group can offer. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun InfoTopBar( + nav: INav, + actions: @Composable RowScope.() -> Unit = {}, +) { + TopAppBar( + navigationIcon = { + IconButton(onClick = { nav.popBack() }) { + Icon(MaterialSymbols.AutoMirrored.ArrowBack, contentDescription = stringRes(Res.string.back)) + } + }, + title = { Text(stringRes(Res.string.cordn_group_info)) }, + actions = actions, + ) +} + +@Composable +private fun CordnGroupInfo( + room: CordnGroupChatroom, + coordinatorPubKey: HexKey, + accountViewModel: AccountViewModel, + nav: INav, +) { + val name by room.name.collectAsStateWithLifecycle() + val description by room.description.collectAsStateWithLifecycle() + val members by room.members.collectAsStateWithLifecycle() + val admins by room.adminPubkeys.collectAsStateWithLifecycle() + val epoch by room.epoch.collectAsStateWithLifecycle() + + val me = accountViewModel.account.signer.pubKey + // The same rule the policy enforces, asked here so the screen offers only + // what would actually go through: empty is egalitarian and everyone + // administers, otherwise it is the named set. + val canAdminister = admins.isEmpty() || me in admins + + val scope = rememberCoroutineScope() + val runtime = accountViewModel.account.cordnRuntime + val manager = runtime?.sessionOrNull(coordinatorPubKey)?.manager + + // The user's own name for it, when they gave it one. Read from the stored + // config, never from the coordinator: serverInfo() is a live MCP request over + // kind 25910, and a screen that fired one on open would tell the coordinator + // every time somebody glanced at a group (spec/00.md §8). The announced name + // and the profile are read from the cache by CoordinatorIdentityRow itself. + val coordinatorLabel = + runtime + ?.coordinators + ?.collectAsStateWithLifecycle() + ?.value + ?.firstOrNull { it.pubKey == coordinatorPubKey } + ?.label + var adminError by remember(room.gid) { mutableStateOf(null) } + var busy by remember(room.gid) { mutableStateOf(false) } + var renaming by remember(room.gid) { mutableStateOf(false) } + var removing by remember(room.gid) { mutableStateOf(null) } + var memberSearch by remember(room.gid) { mutableStateOf("") } + + // The app's own people-finder, not a second one: the same state object the + // composers and the sibling group screen drive, so a name, an npub and a + // NIP-05 address all resolve here exactly as they do everywhere else. + val userSuggestions = + remember { + UserSuggestionState(accountViewModel.account, accountViewModel.nip05ClientBuilder()) + } + + DisposableEffect(Unit) { + onDispose { userSuggestions.reset() } + } + val failed = stringRes(Res.string.cordn_admin_action_failed) + val noSession = stringRes(Res.string.cordn_send_no_session) + + // Computed, never asserted: hardcoding these made the card look like a + // disclosure while reporting the same four values for every group. + val exposure by + produceState(null, runtime, coordinatorPubKey, room.gid) { + value = + runtime + ?.sessionOrNull(coordinatorPubKey) + ?.runCatching { exposure(room.gid) } + ?.getOrNull() + } + var showExposure by remember { mutableStateOf(false) } + + /** + * Runs an admin commit, reporting rather than swallowing what it costs. + * + * A room whose coordinator has no open session cannot commit anything, and + * saying so beats a button that does nothing. + * + * Hands over the **runtime**, not the manager. The manager commits and posts + * but does not touch the [CordnGroupChatroom] these rows are drawn from, so + * reaching it directly left every one of these actions invisible until + * something else refreshed the room: a member removed here stayed in the + * roster, and a rename stayed the old name. The runtime's own methods pair + * each commit with that refresh. + */ + fun runAdmin( + who: String? = null, + block: suspend (CordnRuntime) -> Unit, + ) { + val target = runtime + if (target == null || manager == null) { + adminError = noSession + return + } + busy = true + adminError = null + scope.launch { + try { + block(target) + } catch (e: Exception) { + // Not e.message. Those are written for a log and name pubkeys + // and gids in full, so the screen that had just shown a face + // answered with 64 hex characters. + Log.w("CordnGroupInfo", "admin action failed in ${room.gid}: ${e.message}", e) + adminError = adminFailureText(e, who, failed) + } finally { + busy = false + } + } + } + + Scaffold( + topBar = { + InfoTopBar(nav) { + // Where the sibling group-info screen keeps it. It was the only + // action on the page, so it was also the only reason the body + // held a button between the roster and the share block. + if (canAdminister) { + IconButton(onClick = { renaming = true }, enabled = !busy) { + Icon( + symbol = MaterialSymbols.Edit, + contentDescription = stringRes(Res.string.cordn_info_edit_details), + ) + } + } + } + }, + ) { padding -> + Column( + Modifier + .padding(padding) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + ) { + Text( + text = name?.takeIf { it.isNotBlank() } ?: stringRes(Res.string.cordn_group_untitled, room.gid.take(8)), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + ) + description?.takeIf { it.isNotBlank() }?.let { + Text( + text = it, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp), + ) + } + // A subtitle, not a labelled row above a list that is itself the count. + Text( + text = pluralStringRes(Res.plurals.cordn_member_count, members.size, members.size), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp), + ) + + HorizontalDivider(Modifier.padding(vertical = 16.dp)) + + // First, because it is the fact that shapes everything below it: one + // server carries every message in this group, in order, and which one + // that is matters more than any of the group's own settings. It used + // to sit below Members, Share and Requests, where a reader met the + // people before the machine that sees all of them. + // + // A coordinator is a Nostr identity, not an opaque service address: + // ContextVM addresses it with ordinary p-tags, so it has a kind 0 like + // anyone else and the app can already resolve it to a name, an avatar + // and a profile to tap through to. + Text(stringRes(Res.string.cordn_info_coordinator), style = MaterialTheme.typography.titleMedium) + + Row( + Modifier.fillMaxWidth().padding(top = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + CoordinatorIdentityRow( + pubKey = coordinatorPubKey, + label = coordinatorLabel, + accountViewModel = accountViewModel, + nav = nav, + modifier = Modifier.weight(1f), + ) { name -> + Text( + text = name, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + ) + } + + // What it can see is a paragraph, and one nobody reads twice. As a + // card it took a screenful on every visit; behind an (i) it is one + // tap away the once it is wanted. + IconButton(onClick = { showExposure = true }, enabled = exposure != null) { + Icon( + symbol = MaterialSymbols.Info, + contentDescription = stringRes(Res.string.cordn_exposure_open), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + HorizontalDivider(Modifier.padding(vertical = 16.dp)) + + Text(stringRes(Res.string.cordn_info_members), style = MaterialTheme.typography.titleMedium) + + // Said once, and only when it is true. An empty admin set is not "none + // configured" -- spec/01.md makes it permanently egalitarian, so the + // roster's missing badges are a decision rather than a group waiting to + // be set up. With admins present the badges below say who they are, and + // a count of them adds nothing. + if (admins.isEmpty()) { + Text( + text = stringRes(Res.string.cordn_info_egalitarian), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 2.dp, bottom = 4.dp), + ) + } + + members.forEach { member -> + Row( + Modifier.fillMaxWidth().padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + UserPicture(userHex = member, size = 28.dp, accountViewModel = accountViewModel, nav = nav) + Text( + text = observeUserNameByHex(member, accountViewModel), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + ) + if (member in admins) { + Text( + text = stringRes(Res.string.cordn_info_admin_badge), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + ) + } + + // Never against yourself: cordn has no self-removal, and the + // manager refuses it, so offering the button would only be a + // way to be told no. + if (canAdminister && member != me) { + IconButton(onClick = { removing = member }, enabled = !busy) { + Icon( + symbol = MaterialSymbols.PersonRemove, + contentDescription = stringRes(Res.string.cordn_info_remove_member), + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.error, + ) + } + } + } + } + // Admins only: CordnGroupPolicy refuses an `add` from anyone else once + // a group names admins, in both directions, so offering this to + // everybody would build commits the rest of the group drops on + // receipt. + if (canAdminister) { + // Asked only once the field is in use. `kp_list` is unpaginated, + // so opening this screen must not download the coordinator's + // whole table on behalf of somebody who never types. + val searching = memberSearch.length > 2 + val reachable by + produceState?>(null, runtime, coordinatorPubKey, searching) { + if (searching) value = runtime?.identitiesWithKeyPackages(coordinatorPubKey) + } + CordnAddMember( + userSuggestions = userSuggestions, + search = memberSearch, + reachable = reachable, + onSearchChange = { + memberSearch = it + adminError = null + if (it.length > 2) userSuggestions.processCurrentWord(it) else userSuggestions.reset() + }, + busy = busy, + accountViewModel = accountViewModel, + onInvite = { user -> + memberSearch = "" + userSuggestions.reset() + runAdmin(user.toBestDisplayName()) { it.invite(coordinatorPubKey, room.gid, user.pubkeyHex) } + }, + ) + } + + adminError?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(top = 8.dp), + ) + } + + if (renaming) { + EditGroupDetailsDialog( + name = name.orEmpty(), + description = description.orEmpty(), + admins = admins, + onDismiss = { renaming = false }, + onSave = { newName, newDescription -> + renaming = false + runAdmin { + // The whole metadata travels together, admin list + // included, because the extension is replaced whole. + it.updateGroupMetadata( + coordinatorPubKey, + room.gid, + CordnGroupMetadata(name = newName, description = newDescription, adminPubkeys = admins), + ) + } + }, + ) + } + + removing?.let { target -> + val removingName = observeUserNameByHex(target, accountViewModel) + + // A plain confirm rather than the shared quick-action dialog: that + // one offers "don't ask again", which for an irreversible removal + // would be a setting nobody should be nudged into. + AlertDialog( + onDismissRequest = { removing = null }, + title = { Text(stringRes(Res.string.cordn_info_remove_confirm_title)) }, + text = { + Text(stringRes(Res.string.cordn_info_remove_confirm_body, removingName)) + }, + confirmButton = { + TextButton(onClick = { + removing = null + runAdmin(removingName) { it.removeMember(coordinatorPubKey, room.gid, target) } + }) { + Text( + text = stringRes(Res.string.cordn_info_remove_member), + color = MaterialTheme.colorScheme.error, + ) + } + }, + dismissButton = { + TextButton(onClick = { removing = null }) { Text(stringRes(Res.string.cancel)) } + }, + ) + } + + HorizontalDivider(Modifier.padding(vertical = 16.dp)) + + ShareGroup(room, coordinatorPubKey, accountViewModel) + + // Only where they could be accepted. The manager already asks the + // coordinator only for groups this account administers, so a non-admin + // would otherwise see an empty list that reads as "nobody has asked" + // rather than "these are not yours to answer". + if (canAdminister) { + HorizontalDivider(Modifier.padding(vertical = 16.dp)) + + JoinRequests(room, coordinatorPubKey, accountViewModel, nav) + } + + if (showExposure) { + exposure?.let { + AlertDialog( + onDismissRequest = { showExposure = false }, + text = { CordnExposureCard(it) }, + confirmButton = { + TextButton(onClick = { showExposure = false }) { + Text(stringRes(Res.string.cordn_exposure_close)) + } + }, + ) + } + } + + // The exact values, for filing a bug or telling two devices apart. + // The key stays here as well as above because the two answer + // different questions: the row above says who the coordinator is, + // this says which bytes to compare against a share ref, a device + // document or a log -- all of which speak hex. + TechnicalDetails(coordinatorPubKey, room.gid, epoch) + } + } +} + +/** + * Finding someone to add, the way the rest of the app finds people. + * + * [ShowUserSuggestionList] over [UserSuggestionState] -- the same pair the + * composers and the sibling group-info screen use -- rather than a field + * wanting 64 hex characters. A name, an npub and a NIP-05 address all work, + * because that state already resolves all three. + * + * ## The search finding somebody is not a promise + * + * cordn can only add a member by spending a KeyPackage they published **to + * this coordinator** (`spec/00.md`). [reachable] is who the coordinator says + * did, so the common disappointment is visible before the tap rather than + * after it, and `CordnGroupManager.invite` still says which of the two went + * wrong when one does -- "the coordinator holds no KeyPackage for ..." is a + * different problem from a name that matched nobody. + * + * ## Why the badge marks the yes and says nothing about the no + * + * [reachable] has three states, not two: a set, an empty set, and null for a + * lookup that never came back (`CordnRuntime.identitiesWithKeyPackages`). Only + * membership in it is something we were told; absence covers both "has not + * published here" and "we could not ask". Marking the yes therefore asserts + * exactly what we verified, and an unmarked row asserts nothing -- where a + * "cannot be added" marker would turn an unreachable coordinator into a claim + * about a person. Unmarked rows stay fully tappable for the same reason: the + * listing can be up to a minute stale, and the coordinator gets the last word. + */ +@Composable +private fun CordnAddMember( + userSuggestions: UserSuggestionState, + search: String, + reachable: Set?, + onSearchChange: (String) -> Unit, + busy: Boolean, + accountViewModel: AccountViewModel, + onInvite: (User) -> Unit, +) { + Column(Modifier.fillMaxWidth().padding(top = 12.dp)) { + OutlinedTextField( + value = search, + onValueChange = onSearchChange, + label = { Text(stringRes(Res.string.cordn_info_add_member)) }, + placeholder = { Text(stringRes(Res.string.cordn_info_add_member_placeholder)) }, + singleLine = true, + enabled = !busy, + modifier = Modifier.fillMaxWidth(), + ) + + Text( + text = stringRes(Res.string.cordn_info_add_member_note), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp), + ) + + // Three characters, as everywhere else this list appears: fewer matches + // most of the address book and is never what someone meant. + if (!busy && search.length > 2) { + ShowUserSuggestionList( + userSuggestions = userSuggestions, + onSelect = onInvite, + accountViewModel = accountViewModel, + modifier = SuggestionListDefaultHeightChat, + onEmpty = { + Text( + text = stringRes(Res.string.cordn_info_add_member_none), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + }, + trailingContent = { user -> + Row(verticalAlignment = Alignment.CenterVertically) { + if (reachable?.contains(user.pubkeyHex) == true) { + Icon( + symbol = MaterialSymbols.Key, + contentDescription = stringRes(Res.string.cordn_info_has_key_package), + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.primary, + ) + } + IconButton(onClick = { onInvite(user) }) { + Icon( + symbol = MaterialSymbols.PersonAdd, + contentDescription = stringRes(Res.string.cordn_info_add_member), + tint = MaterialTheme.colorScheme.primary, + ) + } + } + }, + ) + } + } +} + +/** + * What to tell somebody when an admin action failed. + * + * `CordnGroupException` carries a [CordnGroupException.Reason] precisely so this + * can be a `when` rather than a search for substrings in English. [who] is the + * display name already on screen; the exception only knows the pubkey, and a + * person who just tapped a face should not be answered with 64 hex characters. + * + * Anything with no better wording falls back to the message, which is still + * better than silence — and the caller has logged the throwable in full either + * way. + */ +private suspend fun adminFailureText( + e: Throwable, + who: String?, + fallback: String, +): String { + val name = who ?: loadStringRes(Res.string.cordn_admin_someone) + return when (e) { + is CordnGroupException -> + when (e.reason) { + CordnGroupException.Reason.NO_KEY_PACKAGE -> loadStringRes(Res.string.cordn_admin_no_key_package, name) + CordnGroupException.Reason.WRONG_KEY_PACKAGE_OWNER -> loadStringRes(Res.string.cordn_admin_wrong_key_package, name) + CordnGroupException.Reason.NO_WELCOME -> loadStringRes(Res.string.cordn_admin_no_welcome, name) + CordnGroupException.Reason.NOT_A_MEMBER -> loadStringRes(Res.string.cordn_admin_not_a_member, name) + CordnGroupException.Reason.OTHER -> e.message ?: fallback + } + + // The UI hides Remove against yourself, so reaching this means the + // roster and the button disagreed rather than that anyone tried. + is IllegalArgumentException -> + if (e.message?.contains("self-removal") == true) { + loadStringRes(Res.string.cordn_admin_no_self_remove) + } else { + e.message ?: fallback + } + + // A coordinator that does not answer is the single commonest failure + // here and says nothing useful in its own words. + is TimeoutCancellationException -> loadStringRes(Res.string.cordn_admin_coordinator_silent) + + else -> e.message ?: fallback + } +} + +/** + * The three values that only matter when something is wrong. + * + * Collapsed by default and selectable when open: the reason to look at a + * 64-character coordinator key is to copy it somewhere else, never to read it. + */ +@Composable +private fun TechnicalDetails( + coordinatorPubKey: HexKey, + gid: String, + epoch: Long, +) { + var expanded by remember { mutableStateOf(false) } + + HorizontalDivider(Modifier.padding(vertical = 16.dp)) + + Row( + Modifier + .fillMaxWidth() + .clickable { expanded = !expanded } + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringRes(Res.string.cordn_info_technical), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.weight(1f), + ) + Icon( + symbol = if (expanded) MaterialSymbols.ExpandLess else MaterialSymbols.ExpandMore, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + AnimatedVisibility(visible = expanded, enter = SectionExpand, exit = SectionCollapse) { + Column { + // Through the User, in the encodings the rest of the app uses for a + // person's key. A profile screen shows a short npub with a button that + // copies the full one, then does the same for the nprofile + // (DrawAdditionalInfo); nothing user-facing anywhere shows raw hex, + // and there was no reason for a coordinator to be the exception. The + // nprofile earns its place here more than on a profile, because it + // carries the relay hints that are how a coordinator is reached at all. + val coordinator = remember(coordinatorPubKey) { LocalCache.getOrCreateUser(coordinatorPubKey) } + + CopyableKeyRow( + label = stringRes(Res.string.cordn_info_coordinator_key), + shown = coordinator.pubkeyDisplayHex(), + copied = coordinator.pubkeyNpub(), + copyDescription = stringRes(Res.string.copy_npub_to_clipboard), + ) + CopyableKeyRow( + label = stringRes(Res.string.cordn_info_coordinator_nprofile), + shown = coordinator.toNProfile().toShortDisplay(6), + copied = coordinator.toNProfile(), + copyDescription = stringRes(Res.string.cordn_info_copy_nprofile), + ) + + SelectionContainer { + Column { + InfoRow(stringRes(Res.string.cordn_info_gid), gid) + InfoRow(stringRes(Res.string.cordn_info_epoch), epoch.toString()) + } + } + } + } +} + +/** + * A key, short enough to read, with a button that copies the whole thing. + * + * The shape a profile uses for the same job: nobody reads a bech32 string off a + * screen, they copy it, so the visible half is there to confirm which key it is + * and the button is there to do the actual work. + */ +@Composable +internal fun CopyableKeyRow( + label: String, + shown: String, + copied: String, + copyDescription: String, +) { + val clipboard = LocalClipboardManager.current + + Column(Modifier.fillMaxWidth().padding(vertical = 6.dp)) { + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + ) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = shown, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + IconButton( + onClick = { clipboard.setText(AnnotatedString(copied)) }, + modifier = Modifier.size(24.dp).padding(start = 4.dp), + ) { + Icon( + symbol = MaterialSymbols.ContentCopy, + contentDescription = copyDescription, + modifier = Modifier.size(15.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +@Composable +private fun InfoRow( + label: String, + value: String, +) { + Column(Modifier.fillMaxWidth().padding(vertical = 6.dp)) { + Text( + label, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text(value, style = MaterialTheme.typography.bodyMedium) + } +} + +/** + * Who is asking to get in, and the two buttons that answer them. + * + * ## Admins only, because the commit would be refused anyway + * + * `CordnGroupPolicy.authorizeCommit` rejects an `add` from a non-admin once a + * group carries `admin_pubkeys`, in both directions — so a non-admin pressing + * accept would build a commit the other members drop on receipt. The manager + * matches it: `pendingJoinRequests` only asks the coordinator about groups + * this account administers. An empty admin set is egalitarian, which makes + * every member an admin and shows this to everyone. + * + * ## Fetched on a tap, never polled + * + * Same reason as the invitations screen: every call to a coordinator is + * metadata (`spec/00.md` §8), so a live count of pending requests would be a + * steady heartbeat telling the coordinator this group is open on someone's + * screen. + */ +@Composable +private fun JoinRequests( + room: CordnGroupChatroom, + coordinatorPubKey: HexKey, + accountViewModel: AccountViewModel, + nav: INav, +) { + val runtime = accountViewModel.account.cordnRuntime ?: return + val scope = rememberCoroutineScope() + val failed = stringRes(Res.string.cordn_requests_failed) + + var requests by remember { mutableStateOf?>(null) } + var busy by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } + + suspend fun reload() { + busy = true + error = null + try { + requests = runtime.joinRequests(coordinatorPubKey, room.gid) + } catch (e: Exception) { + error = e.message ?: failed + } finally { + busy = false + } + } + + Text(stringRes(Res.string.cordn_requests_title), style = MaterialTheme.typography.titleMedium) + Text( + text = stringRes(Res.string.cordn_requests_admin_only), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + OutlinedButton(onClick = { scope.launch { reload() } }, enabled = !busy) { + Text(stringRes(Res.string.cordn_requests_check)) + } + + error?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error) } + + val loaded = requests + if (loaded != null && !busy) { + if (loaded.isEmpty()) { + Text( + text = stringRes(Res.string.cordn_requests_none), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + loaded.forEach { request -> + Row( + Modifier.fillMaxWidth().padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + UserPicture(userHex = request.pubKey, size = 28.dp, accountViewModel = accountViewModel, nav = nav) + Text( + // Admitting someone to an encrypted group off sixteen hex + // characters is not a decision anyone can actually make. + text = observeUserNameByHex(request.pubKey, accountViewModel), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + ) + Button(onClick = { + scope.launch { + try { + runtime.acceptJoinRequest(coordinatorPubKey, request) + } catch (e: Exception) { + error = e.message ?: failed + } + reload() + } + }) { + Text(stringRes(Res.string.cordn_requests_accept)) + } + TextButton(onClick = { + scope.launch { + try { + runtime.declineJoinRequest(coordinatorPubKey, request) + } catch (e: Exception) { + error = e.message ?: failed + } + reload() + } + }) { + Text(stringRes(Res.string.cordn_requests_decline)) + } + } + } + } +} + +/** + * The group's `cordn1…` ref, as text and as a QR. + * + * The ref is built from the live MLS group (`CordnGroupManager.shareRef`), so + * it carries the `gid` and the coordinator that actually serves it rather than + * whatever this screen was navigated with. It is public by design — `spec/02.md` + * gives it no secret — and holding one makes nobody a member: the most it does + * is let someone ask, which is what the line under it says. + * + * A QR because a ref is a long bech32 string nobody wants to read aloud, and + * because the person you are handing a group to is usually in the room. + */ +@Composable +private fun ShareGroup( + room: CordnGroupChatroom, + coordinatorPubKey: HexKey, + accountViewModel: AccountViewModel, +) { + val runtime = accountViewModel.account.cordnRuntime ?: return + val ref = + remember(room.gid, coordinatorPubKey) { + runtime + .sessionOrNull(coordinatorPubKey) + ?.manager + ?.runCatching { shareRef(room.gid).encode() } + ?.getOrNull() + } ?: return + + val clipboard = LocalClipboardManager.current + var copied by remember { mutableStateOf(false) } + + Text(stringRes(Res.string.cordn_share_title), style = MaterialTheme.typography.titleMedium) + Text( + text = stringRes(Res.string.cordn_share_explainer), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + // The bech32 itself is not shown. It is three lines of characters nobody + // reads, and both things a person actually does with it are already here: + // point a camera at the QR, or press Copy link. + QrCodeDrawer(ref, Modifier.padding(top = 8.dp).size(220.dp)) + + OutlinedButton( + onClick = { + clipboard.setText(AnnotatedString(ref)) + copied = true + }, + modifier = Modifier.padding(top = 8.dp), + ) { + Text(stringRes(if (copied) Res.string.cordn_share_copied else Res.string.cordn_share_copy)) + } +} + +/** + * Renames a group, or rewrites its description. + * + * [admins] rides through untouched. A GroupContextExtensions proposal replaces + * the whole `cordn_group_metadata` extension, so a save that dropped the admin + * list would quietly turn an administered group egalitarian — which nobody can + * undo from inside an egalitarian group's own rules. + */ +@Composable +private fun EditGroupDetailsDialog( + name: String, + description: String, + admins: List, + onDismiss: () -> Unit, + onSave: (String, String) -> Unit, +) { + var draftName by remember { mutableStateOf(name) } + var draftDescription by remember { mutableStateOf(description) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringRes(Res.string.cordn_info_edit_details)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = draftName, + onValueChange = { draftName = it }, + label = { Text(stringRes(Res.string.cordn_create_name)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = draftDescription, + onValueChange = { draftDescription = it }, + label = { Text(stringRes(Res.string.cordn_create_description)) }, + singleLine = false, + modifier = Modifier.fillMaxWidth(), + ) + if (admins.isNotEmpty()) { + Text( + text = pluralStringRes(Res.plurals.cordn_info_admins_kept, admins.size, admins.size), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + }, + confirmButton = { + TextButton( + onClick = { onSave(draftName.trim(), draftDescription.trim()) }, + enabled = draftName.isNotBlank(), + ) { + Text(stringRes(Res.string.cordn_info_save)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringRes(Res.string.cancel)) } + }, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnGroupListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnGroupListScreen.kt new file mode 100644 index 0000000000..13876ffbe1 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnGroupListScreen.kt @@ -0,0 +1,326 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.cordnGroup + +import androidx.compose.foundation.layout.Arrangement +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 +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.cordn.CoordinatorHealth +import com.vitorpamplona.amethyst.commons.cordn.ui.CoordinatorHealthRow +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.cordnGroups.CordnGroupChatroom +import com.vitorpamplona.amethyst.commons.model.navigation.Route +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.cordn_group_unavailable +import com.vitorpamplona.amethyst.commons.resources.cordn_group_unavailable_detail +import com.vitorpamplona.amethyst.commons.resources.cordn_groups_count +import com.vitorpamplona.amethyst.commons.resources.cordn_groups_down_note +import com.vitorpamplona.amethyst.commons.resources.cordn_groups_manage +import com.vitorpamplona.amethyst.commons.resources.cordn_groups_none +import com.vitorpamplona.amethyst.commons.resources.cordn_groups_none_detail +import com.vitorpamplona.amethyst.commons.resources.cordn_groups_start +import com.vitorpamplona.amethyst.commons.resources.cordn_groups_title +import com.vitorpamplona.amethyst.commons.ui.components.EmptyState +import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.model.cordn.CordnRuntime +import com.vitorpamplona.amethyst.ui.pluralStringRes +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.CordnGroupRoomCompose +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.cordn.CoordinatorIdentityRow +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.coroutines.flow.MutableStateFlow + +/** + * Every cordn group this account is in, grouped by the coordinator serving it. + * + * ## Why it groups rather than lists + * + * A flat list here would be the Messages inbox with everything else removed, + * which is not worth a screen. What the inbox structurally cannot show is the + * thing that decides whether a cordn group works at all: a cordn message never + * arrives over a relay, it arrives because one coordinator answered a call, so + * when a group goes quiet the question is always "is that server up" and never + * "which relay". Sectioning by coordinator puts the answer above the groups it + * explains. + * + * ## The health is free + * + * `CordnRuntime.health` is a local StateFlow of what the calls the sync loop + * was making anyway observed -- it never polls, because a poll is a call and + * every call to a coordinator is metadata (`spec/00.md` §8). So this screen + * costs no network at all, and "unknown" means nothing has been asked of that + * coordinator yet, which is shown differently from "down". + * + * Key-package health is deliberately NOT here even though an empty pool means + * nobody can invite you: the only truthful source is `kp_list`, which is + * unpaginated, and fetching it per coordinator to decorate a list would be the + * cost this branch has otherwise been careful to avoid. It stays one tap away + * on the coordinators screen. + */ +@Composable +fun CordnGroupListScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + val runtime = accountViewModel.account.cordnRuntime + + Scaffold( + topBar = { + TopBarWithBackButton(stringRes(Res.string.cordn_groups_title), nav) { + IconButton(onClick = { nav.nav(Route.CordnCoordinators) }) { + Icon( + symbol = MaterialSymbols.Dns, + contentDescription = stringRes(Res.string.cordn_groups_manage), + modifier = Modifier.size(22.dp), + ) + } + IconButton(onClick = { nav.nav(Route.CordnCreateGroup) }) { + Icon( + symbol = MaterialSymbols.Add, + contentDescription = stringRes(Res.string.cordn_groups_start), + modifier = Modifier.size(24.dp), + ) + } + } + }, + ) { padding -> + if (runtime == null) { + EmptyState( + title = stringRes(Res.string.cordn_group_unavailable), + description = stringRes(Res.string.cordn_group_unavailable_detail), + modifier = Modifier.padding(padding), + ) + return@Scaffold + } + + // `revision` rather than `all`: a room's newest message changes INSIDE + // the room object, which `all` cannot see, so ordering off `all` alone + // would freeze after each group's first message. See + // CordnGroupList.revision. + val revision by runtime.groups.revision.collectAsStateWithLifecycle() + + // Coordinators ordered by their liveliest group, groups ordered within + // them the same way: the section that just received something rises, + // which is the order somebody opening this screen is looking for. + val sections = + remember(revision) { + runtime.groups.all.value + .groupBy { it.coordinatorPubKey } + .map { (coordinator, rooms) -> coordinator to rooms.sortedByDescending { it.newestAt() } } + .sortedByDescending { (_, rooms) -> rooms.firstOrNull()?.newestAt() ?: 0L } + } + + if (sections.isEmpty()) { + EmptyState( + title = stringRes(Res.string.cordn_groups_none), + description = stringRes(Res.string.cordn_groups_none_detail), + modifier = Modifier.padding(padding), + ) + return@Scaffold + } + + LazyColumn( + modifier = Modifier.padding(padding).fillMaxSize(), + state = rememberLazyListState(), + ) { + sections.forEach { (coordinator, rooms) -> + item(key = "head-$coordinator") { + CoordinatorSection( + coordinatorPubKey = coordinator, + runtime = runtime, + groupCount = rooms.size, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + itemsIndexed(rooms, key = { _, room -> coordinator + room.gid }) { index, room -> + CoordinatorGroupRow( + room = room, + coordinatorPubKey = coordinator, + runtime = runtime, + isLast = index == rooms.lastIndex, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + } + } +} + +/** When this room last heard anything, for ordering. */ +private fun CordnGroupChatroom.newestAt(): Long = newest.value?.envelope?.createdAt ?: 0L + +/** + * One coordinator, above the groups it serves. + * + * Tinted by its own health rather than decorated uniformly: a coordinator that + * has stopped answering is the reason every group under it looks idle, and + * saying so here is the whole argument for this screen existing. + */ +@Composable +private fun CoordinatorSection( + coordinatorPubKey: HexKey, + runtime: CordnRuntime, + groupCount: Int, + accountViewModel: AccountViewModel, + nav: INav, +) { + val health = healthOf(runtime, coordinatorPubKey) + + Column( + Modifier + .fillMaxWidth() + .padding(start = 12.dp, end = 12.dp, top = 16.dp, bottom = 6.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + CoordinatorIdentityRow( + pubKey = coordinatorPubKey, + label = runtime.sessionOrNull(coordinatorPubKey)?.config?.label, + accountViewModel = accountViewModel, + nav = nav, + size = 26.dp, + ) { name -> + Column(Modifier.weight(1f)) { + Text( + text = name, + style = MaterialTheme.typography.titleSmall, + color = + if (health.isDown) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurface + }, + ) + Text( + text = pluralStringRes(Res.plurals.cordn_groups_count, groupCount, groupCount), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + CoordinatorHealthRow(health, Modifier.padding(start = 34.dp)) + + // Said once per coordinator rather than once per group: the groups are + // not each broken, the one server they share is. + if (health.isDown) { + Row( + Modifier.fillMaxWidth().padding(start = 34.dp, top = 2.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.Top, + ) { + Text( + text = stringRes(Res.string.cordn_groups_down_note), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + } + } +} + +/** + * One group row, dimmed while its coordinator is down. + * + * Dimmed and still tappable: the history already on this device reads fine + * offline, and blocking the tap would hide what the user came for. The dimming + * is there so a row that cannot be receiving does not look like one that is. + */ +@Composable +private fun CoordinatorGroupRow( + room: CordnGroupChatroom, + coordinatorPubKey: HexKey, + runtime: CordnRuntime, + isLast: Boolean, + accountViewModel: AccountViewModel, + nav: INav, +) { + val health = healthOf(runtime, coordinatorPubKey) + + Column(Modifier.fillMaxWidth()) { + Row(Modifier.fillMaxWidth().alpha(if (health.isDown) DIMMED else 1f)) { + // The inbox's own row, not a second rendering of it: it already + // reads the live name, preview, annotations and unread count off + // the room, and two of those drifting apart would be a bug nobody + // would look for in a list screen. + CordnGroupRoomCompose(room, accountViewModel, nav) + } + // Between rows only. A divider after the last one leaves a rule hanging + // under the section, where the next section's own spacing already + // separates them. + if (!isLast) HorizontalDivider(thickness = DividerThickness) + } +} + +/** + * This coordinator's health, or a blank state when there is no session. + * + * Blank rather than absent so the caller can render unconditionally: a + * coordinator with no open session has told us nothing, which is exactly what + * [CoordinatorHealth.State.isUnknown] means. + * + * The null is resolved into a flow BEFORE collecting, never by skipping the + * collect. `collectAsStateWithLifecycle` behind a `?.` is a conditional + * @Composable call, and this is a branch that flips at runtime -- a session + * opening mid-screen would change the shape of the slot table under Compose. + * Keyed on the coordinator list so it re-resolves when a session does open. + */ +@Composable +private fun healthOf( + runtime: CordnRuntime, + coordinatorPubKey: HexKey, +): CoordinatorHealth.State { + val coordinators by runtime.coordinators.collectAsStateWithLifecycle() + val flow = + remember(runtime, coordinatorPubKey, coordinators) { + runtime.health(coordinatorPubKey) ?: MutableStateFlow(CoordinatorHealth.State()) + } + return flow.collectAsStateWithLifecycle().value +} + +/** How much a group under a coordinator that stopped answering fades. */ +private const val DIMMED = 0.55f diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnInvitationsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnInvitationsScreen.kt new file mode 100644 index 0000000000..b145042c85 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnInvitationsScreen.kt @@ -0,0 +1,393 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.cordnGroup + +import androidx.compose.foundation.layout.Arrangement +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 +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.navigation.Route +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.back +import com.vitorpamplona.amethyst.commons.resources.cordn_group_unavailable +import com.vitorpamplona.amethyst.commons.resources.cordn_group_unavailable_detail +import com.vitorpamplona.amethyst.commons.resources.cordn_group_untitled +import com.vitorpamplona.amethyst.commons.resources.cordn_invitations_accept +import com.vitorpamplona.amethyst.commons.resources.cordn_invitations_decline +import com.vitorpamplona.amethyst.commons.resources.cordn_invitations_decline_warning +import com.vitorpamplona.amethyst.commons.resources.cordn_invitations_explainer +import com.vitorpamplona.amethyst.commons.resources.cordn_invitations_load_failed +import com.vitorpamplona.amethyst.commons.resources.cordn_invitations_members +import com.vitorpamplona.amethyst.commons.resources.cordn_invitations_members_more +import com.vitorpamplona.amethyst.commons.resources.cordn_invitations_no_coordinators +import com.vitorpamplona.amethyst.commons.resources.cordn_invitations_none +import com.vitorpamplona.amethyst.commons.resources.cordn_invitations_refresh +import com.vitorpamplona.amethyst.commons.resources.cordn_invitations_skipped +import com.vitorpamplona.amethyst.commons.resources.cordn_invitations_title +import com.vitorpamplona.amethyst.commons.resources.cordn_invitations_unreachable +import com.vitorpamplona.amethyst.commons.resources.cordn_invitations_via +import com.vitorpamplona.amethyst.commons.ui.components.EmptyState +import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.model.cordn.CordnInvitation +import com.vitorpamplona.amethyst.model.cordn.CordnInvitations +import com.vitorpamplona.amethyst.ui.note.UserPicture +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.observeUserNameByHex +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.cordn.CoordinatorIdentityRow +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.cordn.coordinatorDisplayName +import com.vitorpamplona.amethyst.ui.stringRes +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.pluralStringResource + +/** + * Invitations to cordn groups, waiting to be answered. + * + * ## Why this is a screen you open, not a badge that appears + * + * Finding out whether anyone has invited you means asking each coordinator, + * and every call to a coordinator is metadata (`spec/00.md` §8). A live badge + * would tell each of them how often this account opens the app, forever, to + * save a tap. So the fetch happens when someone comes here, and the screen + * says what it is showing and when it looked. + * + * ## What an invitation can honestly say + * + * The group's name, who is already in it, and which coordinator it came + * through — all read out of the Welcome. Not "X invited you": a Welcome + * carries the ratchet tree, not the identity of whoever signed the Commit + * that produced it, so naming an inviter would be a guess presented as a + * fact. + * + * Declining is permanent, and the screen says so rather than discovering it + * afterwards. A retired Welcome is gone from the coordinator and the + * KeyPackage it was addressed to has been spent; getting back in means being + * invited again. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CordnInvitationsScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + val runtime = accountViewModel.account.cordnRuntime + val scope = rememberCoroutineScope() + + var invitations by remember { mutableStateOf(null) } + var busy by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } + val loadFailed = stringRes(Res.string.cordn_invitations_load_failed) + + suspend fun reload() { + busy = true + error = null + try { + invitations = runtime?.invitations() + } catch (e: Exception) { + error = e.message ?: loadFailed + } finally { + busy = false + } + } + + LaunchedEffect(runtime) { reload() } + + Scaffold( + topBar = { + TopAppBar( + navigationIcon = { + IconButton(onClick = { nav.popBack() }) { + Icon(MaterialSymbols.AutoMirrored.ArrowBack, contentDescription = stringRes(Res.string.back)) + } + }, + title = { Text(stringRes(Res.string.cordn_invitations_title)) }, + actions = { + IconButton(onClick = { scope.launch { reload() } }, enabled = !busy) { + Icon(MaterialSymbols.Refresh, contentDescription = stringRes(Res.string.cordn_invitations_refresh)) + } + }, + ) + }, + ) { padding -> + Column( + modifier = + Modifier + .padding(padding) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (runtime == null) { + EmptyState( + title = stringRes(Res.string.cordn_group_unavailable), + description = stringRes(Res.string.cordn_group_unavailable_detail), + ) + return@Column + } + + // Collected rather than read as `.value` further down: the empty + // state says either "you have no coordinators" or "nobody has + // invited you", and adding a coordinator in another tab has to + // change which of those two this screen is showing. + val coordinators by runtime.coordinators.collectAsStateWithLifecycle() + + Text( + text = stringRes(Res.string.cordn_invitations_explainer), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + if (busy) CircularProgressIndicator() + + error?.let { + Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error) + } + + val loaded = invitations + if (loaded != null && !busy) { + loaded.pending.forEach { invitation -> + InvitationCard( + invitation = invitation, + accountViewModel = accountViewModel, + nav = nav, + onAccept = { + scope.launch { + try { + val gid = runtime.accept(invitation) + nav.nav(Route.CordnGroupChat(invitation.coordinator.pubKey, gid)) + } catch (e: Exception) { + error = e.message ?: loadFailed + reload() + } + } + }, + onDecline = { + scope.launch { + try { + runtime.decline(invitation) + } catch (e: Exception) { + error = e.message ?: loadFailed + } + reload() + } + }, + ) + } + + // A coordinator that did not answer is its own row. Folding it + // into "nothing waiting" would tell someone they have no + // invitations when the truth is that nobody asked. + loaded.unreachable.forEach { + NoticeCard( + title = + stringRes( + Res.string.cordn_invitations_unreachable, + coordinatorDisplayName(it.coordinator.pubKey, it.coordinator.label, accountViewModel), + ), + detail = it.reason, + isError = true, + ) + } + + // Left on the coordinator for another device of this account. + // Shown rather than hidden, because otherwise the invitation a + // friend swears they sent simply does not exist here. + loaded.skipped.forEach { + NoticeCard( + title = stringRes(Res.string.cordn_invitations_skipped), + detail = it.reason, + isError = false, + ) + } + + if (loaded.isEmpty) { + Text( + text = + if (coordinators.isEmpty()) { + stringRes(Res.string.cordn_invitations_no_coordinators) + } else { + stringRes(Res.string.cordn_invitations_none) + }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } +} + +/** Faces on the invitation card before it folds into "+N". */ +private const val MEMBER_FACES = 5 + +/** How many of them are also named in full underneath. */ +private const val MEMBER_NAMES = 3 + +@Composable +private fun InvitationCard( + invitation: CordnInvitation, + accountViewModel: AccountViewModel, + nav: INav, + onAccept: () -> Unit, + onDecline: () -> Unit, +) { + val welcome = invitation.welcome + + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + text = welcome.metadata?.name?.takeIf { it.isNotBlank() } ?: stringRes(Res.string.cordn_group_untitled, welcome.gid.take(8)), + style = MaterialTheme.typography.titleMedium, + ) + welcome.metadata?.description?.takeIf { it.isNotBlank() }?.let { + Text(it, style = MaterialTheme.typography.bodyMedium) + } + + // "Already in it", never "invited you" — see the screen KDoc. + Text( + text = pluralStringResource(Res.plurals.cordn_invitations_members, welcome.members.size, welcome.members.size), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + // Who, not just how many. This is the moment someone decides + // whether to join an encrypted group, and the roster is already in + // the Welcome — printing only its size withheld the one fact the + // decision actually turns on. + if (welcome.members.isNotEmpty()) { + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + welcome.members.take(MEMBER_FACES).forEach { member -> + UserPicture( + userHex = member, + size = 28.dp, + accountViewModel = accountViewModel, + nav = nav, + ) + } + if (welcome.members.size > MEMBER_FACES) { + Text( + text = stringRes(Res.string.cordn_invitations_members_more, welcome.members.size - MEMBER_FACES), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + // Resolved through `map`, which is inline and so keeps the + // composable context; joinToString's transform is not. + val names = welcome.members.take(MEMBER_NAMES).map { observeUserNameByHex(it, accountViewModel) } + Text( + // Named in full for the first few, so a decision does not + // rest on recognising an avatar. + text = names.joinToString(), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + // Accepting is a decision about the coordinator as much as about + // the group -- it is the party that will hold the membership and + // serve the messages -- so it is named and pictured rather than + // abbreviated to a key. + CoordinatorIdentityRow( + pubKey = invitation.coordinator.pubKey, + label = invitation.coordinator.label, + accountViewModel = accountViewModel, + nav = nav, + size = 20.dp, + ) { name -> + Text( + text = stringRes(Res.string.cordn_invitations_via, name), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Row( + Modifier.fillMaxWidth().padding(top = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Button(onClick = onAccept) { Text(stringRes(Res.string.cordn_invitations_accept)) } + TextButton(onClick = onDecline) { Text(stringRes(Res.string.cordn_invitations_decline)) } + } + Text( + text = stringRes(Res.string.cordn_invitations_decline_warning), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun NoticeCard( + title: String, + detail: String, + isError: Boolean, +) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = + if (isError) { + MaterialTheme.colorScheme.errorContainer + } else { + MaterialTheme.colorScheme.surfaceVariant + }, + ), + ) { + Column(Modifier.padding(12.dp)) { + Text(title, style = MaterialTheme.typography.titleSmall) + Text(detail, style = MaterialTheme.typography.bodySmall) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnMessageRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnMessageRow.kt new file mode 100644 index 0000000000..031512faea --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnMessageRow.kt @@ -0,0 +1,1051 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.cordnGroup + +import android.content.Intent +import android.widget.Toast +import androidx.compose.foundation.background +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.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.chats.ui.ChatDivisor +import com.vitorpamplona.amethyst.commons.chats.ui.UserDisplayNameLayout +import com.vitorpamplona.amethyst.commons.cordn.CordnMentions +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.EmptyTagList +import com.vitorpamplona.amethyst.commons.model.cordnGroups.CordnGroupChatroom +import com.vitorpamplona.amethyst.commons.model.navigation.Route +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.copied_to_clipboard +import com.vitorpamplona.amethyst.commons.resources.copy_text +import com.vitorpamplona.amethyst.commons.resources.cordn_action_delete +import com.vitorpamplona.amethyst.commons.resources.cordn_action_edit +import com.vitorpamplona.amethyst.commons.resources.cordn_action_go_to_message +import com.vitorpamplona.amethyst.commons.resources.cordn_action_pin +import com.vitorpamplona.amethyst.commons.resources.cordn_action_reply +import com.vitorpamplona.amethyst.commons.resources.cordn_action_unpin +import com.vitorpamplona.amethyst.commons.resources.cordn_chat_unread_divider +import com.vitorpamplona.amethyst.commons.resources.cordn_chat_yesterday +import com.vitorpamplona.amethyst.commons.resources.cordn_delete_confirm_body +import com.vitorpamplona.amethyst.commons.resources.cordn_delete_confirm_title +import com.vitorpamplona.amethyst.commons.resources.cordn_delivery_accepted +import com.vitorpamplona.amethyst.commons.resources.cordn_message_deleted +import com.vitorpamplona.amethyst.commons.resources.cordn_message_details_coordinator +import com.vitorpamplona.amethyst.commons.resources.cordn_message_details_cursor +import com.vitorpamplona.amethyst.commons.resources.cordn_message_details_sent_at +import com.vitorpamplona.amethyst.commons.resources.cordn_message_details_title +import com.vitorpamplona.amethyst.commons.resources.cordn_message_edited +import com.vitorpamplona.amethyst.commons.resources.cordn_reactions_title +import com.vitorpamplona.amethyst.commons.resources.quick_action_share +import com.vitorpamplona.amethyst.commons.resources.today +import com.vitorpamplona.amethyst.commons.ui.components.ClickableBox +import com.vitorpamplona.amethyst.commons.ui.components.util.setText +import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.commons.ui.theme.Font12SP +import com.vitorpamplona.amethyst.commons.ui.theme.Size20dp +import com.vitorpamplona.amethyst.commons.ui.theme.StdHorzSpacer +import com.vitorpamplona.amethyst.commons.ui.theme.allGoodColor +import com.vitorpamplona.amethyst.commons.ui.theme.isLight +import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText +import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer +import com.vitorpamplona.amethyst.ui.note.QuickActionAlertDialog +import com.vitorpamplona.amethyst.ui.note.UserPicture +import com.vitorpamplona.amethyst.ui.note.elements.TimeAgoStyle +import com.vitorpamplona.amethyst.ui.note.elements.ToggleableTimeAgoText +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.ActionTile +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.ChatChipFlowRow +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.MoreActionsToggle +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.ReactionChip +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.ReactionChipView +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.SectionDivider +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.TileRow +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.authorNameColorFor +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.jumboEmojiCount +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.jumboEmojiFontSize +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.CHAT_GROUP_WINDOW_SECONDS +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.ChatBubbleLayout +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.ChatGroupPosition +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.observeUserNameByHex +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.cordn.coordinatorDisplayName +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.cordn.appEncryptedMedia.CordnMediaTag +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnAnnotationIndex +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnDeliveredMessage +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnMessageReferences +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import java.time.Duration +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter +import kotlin.math.abs + +/** + * A cordn message, drawn as an Amethyst chat bubble. + * + * Everything structural here is the app's shared chat furniture — [ChatBubbleLayout] + * for the bubble, its grouping shapes and its whole gesture vocabulary (long-press for + * the action sheet, double-tap to react, swipe toward the centre to reply), + * [ReactionChipView] for the engagement strip, [ActionTile] for the sheet. A cordn room + * used to draw its own flat rows, which meant the one chat in the app where a tap + * opened a menu and nothing could be swiped. + * + * What cordn cannot share is the *content* of those slots. The shared fillings + * ([com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.ChatReactionChips], + * `ChatMessageActionSheet`, `ChatMessageFooter`, `DrawAuthorInfo`) all take a `Note`, + * and a `Note` comes from `LocalCache` — which nothing in a cordn room may enter, see + * the screen's KDoc. So the slots are filled from the envelope and the annotation fold + * instead, and the layout above them is the same one every other chat uses. + */ +@Composable +internal fun CordnMessageRow( + message: CordnDeliveredMessage, + room: CordnGroupChatroom, + annotations: CordnAnnotationIndex, + me: HexKey, + groupPosition: ChatGroupPosition, + text: String?, + isEdited: Boolean, + shouldHighlight: Boolean, + accountViewModel: AccountViewModel, + nav: INav, + onHighlightFinished: () -> Unit, + onScrollToMessage: (HexKey) -> Unit, + onReply: () -> Unit, + onEdit: () -> Unit, + onDelete: () -> Unit, + onTogglePin: () -> Unit, + onReact: (String) -> Unit, +) { + val isMine = message.envelope.pubKey == me + val isPinned = annotations.isPinned(message.envelope.id) + val reactions = annotations.reactions[message.envelope.id].orEmpty() + + // A deleted message keeps its place in the conversation but stops accepting + // annotations: replying to, editing or reacting to a withdrawal is meaningless, + // and the manager would refuse most of it anyway. + val isLive = text != null + + // A mention is the one reason to pick a message out of a wall of them. Read from + // the content rather than from a `p` tag: a tag is a claim the sender makes about + // who they addressed, while the text is what everyone in the room actually sees. + val mentionsMe = remember(text, me) { text != null && me in CordnMentions.mentioned(text) } + val jumboCount = remember(text) { if (text != null) jumboEmojiCount(text) else 0 } + + val mentionTint = MaterialTheme.colorScheme.primary.copy(alpha = MENTION_TINT_ALPHA) + + Box(if (mentionsMe) Modifier.fillMaxWidth().background(mentionTint) else Modifier.fillMaxWidth()) { + ChatBubbleLayout( + isLoggedInUser = isMine, + isDraft = false, + innerQuote = false, + // Your own bubbles are right-aligned and tinted, so naming yourself over + // every burst of them is noise. Everyone else is named once per burst. + drawAuthorInfo = groupPosition.isFirstOfGroup && !isMine, + groupPosition = groupPosition, + transparentBubble = jumboCount > 0, + shouldHighlight = shouldHighlight, + onHighlightFinished = onHighlightFinished, + // A plain tap is a no-op, exactly as in every other Amethyst chat. + onClick = { false }, + // The account's own first choice, as `reactToOrDelete` uses in every other + // chat — a hardcoded thumb ignored the palette the user configured, and + // disagreed with cordn's own action sheet, which already reads it. + onDoubleTap = + if (isLive) { + { onReact(accountViewModel.reactionChoices().firstOrNull() ?: DEFAULT_REACTION) } + } else { + null + }, + onSwipeReply = + if (isLive) { + onReply + } else { + null + }, + onAuthorClick = { nav.nav(Route.Profile(message.envelope.pubKey)) }, + actionMenu = { onDismiss -> + CordnMessageActionSheet( + body = text.orEmpty(), + isMine = isMine, + isPinned = isPinned, + isLive = isLive, + accountViewModel = accountViewModel, + onDismiss = onDismiss, + onReply = onReply, + onEdit = onEdit, + onDelete = onDelete, + onTogglePin = onTogglePin, + onReact = onReact, + ) + }, + reactionsRow = + if (reactions.isEmpty()) { + null + } else { + { CordnReactionChips(reactions, me, accountViewModel, nav, onReact) } + }, + // Mirrors chatFooterHasMeta: the footer earns its row on the last message + // of a burst (for the time) or on any message carrying a marker of its own. + footerRow = + if (groupPosition.isLastOfGroup || isEdited || isPinned) { + { + CordnMessageFooter( + message = message, + isMine = isMine, + coordinatorPubKey = room.coordinatorPubKey, + accountViewModel = accountViewModel, + isEdited = isEdited, + isPinned = isPinned, + showTime = groupPosition.isLastOfGroup, + ) + } + } else { + null + }, + drawAuthorLine = { CordnAuthorLine(message.envelope.pubKey, accountViewModel, nav) }, + ) { bgColor -> + CordnBubbleContents( + message = message, + room = room, + annotations = annotations, + me = me, + text = text, + jumboCount = jumboCount, + bubbleColor = bgColor, + accountViewModel = accountViewModel, + nav = nav, + onScrollToMessage = onScrollToMessage, + ) + } + } +} + +/** What a double-tap sends, and the fallback when the account lists no reaction choices. */ +private const val DEFAULT_REACTION = "👍" + +/** How strongly a message that mentions you tints its row. */ +private const val MENTION_TINT_ALPHA = 0.10f + +@Composable +private fun CordnBubbleContents( + message: CordnDeliveredMessage, + room: CordnGroupChatroom, + annotations: CordnAnnotationIndex, + me: HexKey, + text: String?, + jumboCount: Int, + bubbleColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, + onScrollToMessage: (HexKey) -> Unit, +) { + val thread = remember(message.envelope.id) { CordnMessageReferences.thread(message.envelope.tags) } + val parent = thread?.let { annotations.byId[it.parentId] } + if (parent != null) { + CordnQuotedMessage( + parent = parent, + annotations = annotations, + me = me, + accountViewModel = accountViewModel, + nav = nav, + parentBackgroundColor = bubbleColor, + onClick = { onScrollToMessage(parent.envelope.id) }, + ) + } + + when { + text == null -> + Text( + text = stringRes(Res.string.cordn_message_deleted), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + // Emoji-only messages render bare and large, over a transparent bubble. + jumboCount > 0 -> Text(text = text.trim(), fontSize = jumboEmojiFontSize(jumboCount)) + + // The app's own message renderer, which is what every other chat's bubble + // reaches through RenderRegularTextNote. Cordn hand-rolled plain Text plus a + // row of mention spans, so a link, an image URL, a hashtag, a `nostr:` entity + // and — since the composer started inserting them — a custom emoji's URL all + // arrived as raw text. Nothing here needed a Note: the viewer takes a String, + // the bubble's colour and an id. + // + // `tags` is EmptyTagList deliberately. A cordn envelope's tags are cordn's own + // (§4 media, references), not NIP-92/NIP-30, so handing them over would ask the + // viewer to read them as something they are not. + // + // canPreview = true is what an unblocked note gets in a DM, which is equally + // end-to-end encrypted. It does not force previews: whether one is fetched is + // still `automaticallyShowUrlPreview`, the account's own Tor-aware setting. + else -> + TranslatableRichTextViewer( + content = text, + canPreview = true, + quotesLeft = 1, + modifier = Modifier, + tags = EmptyTagList, + backgroundColor = bubbleColor, + id = message.envelope.id, + authorPubKey = message.envelope.pubKey, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + // Only on a live message: a deleted one must not keep offering its attachment, + // and the blob is still on the host either way. + if (text != null) { + CordnMediaTag.parseAll(message.envelope.tags).forEach { attachment -> + CordnAttachment(attachment, room, accountViewModel) + } + } +} + +/** + * Name and face on the first bubble of a burst, in the shared chat author layout — so a + * cordn sender is drawn exactly like a DM sender, colour included. [authorNameColorFor] + * derives a stable hue from the pubkey, which is what makes authors scannable in a + * fast-moving room. + * + * [observeUserNameByHex] falls back to a hex prefix until the profile arrives. Looking a + * sender up for a display name is safe: a profile is public relay data the cache already + * holds, and reading one puts no part of this conversation into it. + */ +@Composable +private fun CordnAuthorLine( + pubKey: HexKey, + accountViewModel: AccountViewModel, + nav: INav, +) { + val name = observeUserNameByHex(pubKey, accountViewModel) + val isLightTheme = MaterialTheme.colorScheme.isLight + val nameColor = remember(pubKey, isLightTheme) { authorNameColorFor(pubKey, isLightTheme) } + + UserDisplayNameLayout( + picture = { + UserPicture( + userHex = pubKey, + size = Size20dp, + accountViewModel = accountViewModel, + nav = nav, + ) + }, + name = { + Text( + text = name, + color = nameColor, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + ) +} + +/** + * The engagement strip riding the bubble's bottom border. + * + * A reaction is a set of pubkeys per emoji in the fold, so the count is the set size — a + * member who reacted twice with the same emoji counts once, which is what the index + * already guarantees and what a naive message count would get wrong on a re-sync. + */ +@Composable +private fun CordnReactionChips( + reactions: Map>, + me: HexKey, + accountViewModel: AccountViewModel, + nav: INav, + onReact: (String) -> Unit, +) { + val chips = + remember(reactions, me) { + reactions + .map { (emoji, who) -> ReactionChip(emoji, who.size, me in who) } + .sortedByDescending { it.count } + } + + var showWho by remember { mutableStateOf(false) } + + if (showWho) { + CordnReactionDetailSheet( + reactions = reactions, + accountViewModel = accountViewModel, + nav = nav, + onDismiss = { showWho = false }, + ) + } + + ChatChipFlowRow { + chips.forEach { chip -> + ReactionChipView( + chip = chip, + onClick = { onReact(chip.type) }, + // Long press opens who reacted, as the DM strip does. + onLongClick = { showWho = true }, + ) + } + } +} + +/** + * Who reacted, and with what. + * + * The fold keys reactions by emoji to a *set* of senders, so this is the whole truth + * the room holds about them — there is no separate receipt to open, and no count that + * could disagree with the list under it. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun CordnReactionDetailSheet( + reactions: Map>, + accountViewModel: AccountViewModel, + nav: INav, + onDismiss: () -> Unit, +) { + // Most-reacted first, matching the order of the chips that opened this. + val groups = remember(reactions) { reactions.entries.sortedByDescending { it.value.size } } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + ) { + Column(Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(bottom = 24.dp)) { + Text( + text = stringRes(Res.string.cordn_reactions_title), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp), + ) + + groups.forEach { (emoji, who) -> + Row( + Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text(text = emoji, style = MaterialTheme.typography.titleMedium) + Text( + text = who.size.toString(), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + who.forEach { pubKey -> + Row( + Modifier.fillMaxWidth().padding(start = 36.dp, end = 20.dp, top = 4.dp, bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + UserPicture( + userHex = pubKey, + size = Size20dp, + accountViewModel = accountViewModel, + nav = nav, + ) + Text( + text = observeUserNameByHex(pubKey, accountViewModel), + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } + } +} + +/** + * The message a reply is answering, drawn above it as a nested bubble. + * + * The same [ChatBubbleLayout] in `innerQuote` mode that every other chat uses for a + * quote, so what a reply answers reads identically in a cordn room and a DM: the + * quoted author's own bubble shape and tint, their name in their colour, and a tap + * that takes you to the message. Cordn drew a bespoke accent-bar card here, which was + * the one place in the app where a quote did not look like a quote. + * + * [parentBackgroundColor] is the enclosing bubble's fill. The layout composites the + * nested bubble over it, which is what keeps a quote legible inside both a sent and a + * received bubble instead of being tinted against the screen background. + */ +@Composable +internal fun CordnQuotedMessage( + parent: CordnDeliveredMessage, + annotations: CordnAnnotationIndex, + me: HexKey, + accountViewModel: AccountViewModel, + nav: INav, + parentBackgroundColor: MutableState? = null, + onClick: (() -> Unit)? = null, +) { + val isMine = parent.envelope.pubKey == me + + val body = + if (annotations.isDeleted(parent.envelope.id)) { + stringRes(Res.string.cordn_message_deleted) + } else { + annotations.contentOf(parent.envelope.id).orEmpty() + } + + ChatBubbleLayout( + isLoggedInUser = isMine, + isDraft = false, + innerQuote = true, + // Same rule as the bubbles: your own quoted message needs no name on it. + drawAuthorInfo = !isMine, + parentBackgroundColor = parentBackgroundColor, + // Returning true marks the tap as handled, which is how the shared layout + // distinguishes a quote (goes somewhere) from a bubble (a tap does nothing). + onClick = { + onClick?.invoke() + onClick != null + }, + onAuthorClick = { nav.nav(Route.Profile(parent.envelope.pubKey)) }, + // The shared layout does not gate long-press on innerQuote, so a quote that + // offered no menu would swallow the gesture. Only what makes sense on a + // preview: where it came from, and its text. + actionMenu = { onDismiss -> + CordnQuoteActionSheet( + body = body, + onDismiss = onDismiss, + onGoToMessage = onClick, + ) + }, + drawAuthorLine = { CordnAuthorLine(parent.envelope.pubKey, accountViewModel, nav) }, + ) { _ -> + Text( + text = body, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } +} + +/** Long-press on a quote: go to what it quotes, or take its text. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun CordnQuoteActionSheet( + body: String, + onDismiss: () -> Unit, + onGoToMessage: (() -> Unit)?, +) { + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + ) { + Column(Modifier.padding(bottom = 24.dp)) { + TileRow { + if (onGoToMessage != null) { + ActionTile(MaterialSymbols.ArrowUpward, stringRes(Res.string.cordn_action_go_to_message)) { + onGoToMessage() + onDismiss() + } + } + CopyTextTile(body, onDismiss) + } + } + } +} + +/** + * The line where the messages you have already read end. + * + * Drawn from a cursor snapshotted when the room opened: `markRead()` runs on open, so a + * divider read from the live cursor would vanish the moment it became useful. + */ +@Composable +internal fun UnreadDivider() { + ChatDivisor(stringRes(Res.string.cordn_chat_unread_divider), MaterialTheme.colorScheme.primary) +} + +/** + * The bubble's bottom-corner footer: the markers this message carries, then the time on + * the last bubble of a burst. Until this existed a cordn room showed no per-message + * time at all — only the day separator — so nothing said when anything was said. + */ +@Composable +private fun CordnMessageFooter( + message: CordnDeliveredMessage, + isMine: Boolean, + coordinatorPubKey: HexKey, + accountViewModel: AccountViewModel, + isEdited: Boolean, + isPinned: Boolean, + showTime: Boolean, +) { + var showDetails by remember(message.envelope.id) { mutableStateOf(false) } + + Row(verticalAlignment = Alignment.CenterVertically) { + if (isPinned) { + Icon( + symbol = MaterialSymbols.PushPin, + contentDescription = stringRes(Res.string.cordn_action_pin), + modifier = Modifier.size(12.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(StdHorzSpacer) + } + + if (isEdited) { + Text( + text = stringRes(Res.string.cordn_message_edited), + fontSize = Font12SP, + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 1, + ) + Spacer(StdHorzSpacer) + } + + if (showTime) { + ClickableBox(onClick = { showDetails = true }) { + Row(verticalAlignment = Alignment.CenterVertically) { + ToggleableTimeAgoText( + timestamp = message.envelope.createdAt, + style = TimeAgoStyle.Short, + color = MaterialTheme.colorScheme.placeholderText, + fontSize = Font12SP, + // The tap belongs to the details sheet, as it does in the shared + // footer. Left toggleable, the same tap flipped relative/absolute + // here and opened delivery detail everywhere else; the absolute + // time is in the sheet instead. + toggleable = false, + ) + + // Anything of yours that is in this list was accepted by the + // coordinator: `post` returns the cursor it was filed under, and + // nothing enters the room until it does. So this says what the + // shared ticks say, for the one hop cordn has. + if (isMine) { + Spacer(StdHorzSpacer) + Icon( + symbol = MaterialSymbols.Done, + contentDescription = stringRes(Res.string.cordn_delivery_accepted), + modifier = Modifier.size(12.dp), + tint = MaterialTheme.colorScheme.allGoodColor, + ) + } + } + } + } + } + + if (showDetails) { + CordnMessageDetailsSheet( + message = message, + isMine = isMine, + coordinatorPubKey = coordinatorPubKey, + accountViewModel = accountViewModel, + onDismiss = { showDetails = false }, + ) + } +} + +/** + * Where a message came from and when, behind a tap on its timestamp. + * + * The shared footer puts relay acceptance here. Cordn has one hop instead of a relay + * set — the coordinator that filed it — and the cursor it was filed under, which is the + * only ordering the room has and the thing to quote when a message looks out of place. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun CordnMessageDetailsSheet( + message: CordnDeliveredMessage, + isMine: Boolean, + coordinatorPubKey: HexKey, + accountViewModel: AccountViewModel, + onDismiss: () -> Unit, +) { + val sentAt = + remember(message.envelope.createdAt) { + ZonedDateTime + .ofInstant(Instant.ofEpochSecond(message.envelope.createdAt), ZoneId.systemDefault()) + .format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")) + } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + ) { + Column( + Modifier.padding(start = 16.dp, end = 16.dp, bottom = 24.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text( + text = stringRes(Res.string.cordn_message_details_title), + style = MaterialTheme.typography.titleSmall, + ) + DetailLine(stringRes(Res.string.cordn_message_details_sent_at), sentAt) + DetailLine(stringRes(Res.string.cordn_message_details_cursor), message.cursor.toString()) + DetailLine( + stringRes(Res.string.cordn_message_details_coordinator), + coordinatorDisplayName(coordinatorPubKey, label = null, accountViewModel = accountViewModel), + ) + if (isMine) { + Text( + text = stringRes(Res.string.cordn_delivery_accepted), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.allGoodColor, + ) + } + } + } +} + +@Composable +private fun DetailLine( + label: String, + value: String, +) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text(label, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(value, style = MaterialTheme.typography.bodySmall, maxLines = 1, overflow = TextOverflow.Ellipsis) + } +} + +/** Copies [text] and says so, the way every other copy affordance in the app does. */ +@Composable +private fun CopyTextTile( + text: String, + onDismiss: () -> Unit, +) { + val context = LocalContext.current + val clipboard = LocalClipboard.current + val scope = rememberCoroutineScope() + val copied = stringRes(Res.string.copied_to_clipboard) + + ActionTile(MaterialSymbols.ContentCopy, stringRes(Res.string.copy_text)) { + scope.launch { + clipboard.setText(text) + Toast.makeText(context, copied, Toast.LENGTH_SHORT).show() + } + onDismiss() + } +} + +/** + * The long-press surface, built from the same [ActionTile]s as the DM sheet. + * + * It opens with the account's own reaction palette, because the strip below a bubble now + * appears only once a message *has* a reaction — without the palette, double-tap would + * be the only way to leave the first one, and an undiscoverable gesture is not an + * affordance. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun CordnMessageActionSheet( + body: String, + isMine: Boolean, + isPinned: Boolean, + isLive: Boolean, + accountViewModel: AccountViewModel, + onDismiss: () -> Unit, + onReply: () -> Unit, + onEdit: () -> Unit, + onDelete: () -> Unit, + onTogglePin: () -> Unit, + onReact: (String) -> Unit, +) { + var showAllActions by remember { mutableStateOf(false) } + var confirmingDelete by remember { mutableStateOf(false) } + + // Same gate the shared sheet uses, and the same setting, so someone who has + // already said "don't ask again" is not asked again here either. + val performDelete = { + onDelete() + onDismiss() + } + + if (confirmingDelete) { + QuickActionAlertDialog( + title = stringRes(Res.string.cordn_delete_confirm_title), + textContent = stringRes(Res.string.cordn_delete_confirm_body), + buttonIcon = MaterialSymbols.Delete, + buttonText = stringRes(Res.string.cordn_action_delete), + onClickDoOnce = performDelete, + onClickDontShowAgain = { + accountViewModel.account.settings.setHideDeleteRequestDialog() + performDelete() + }, + onDismiss = { confirmingDelete = false }, + ) + } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + ) { + Column( + modifier = Modifier.verticalScroll(rememberScrollState()).padding(bottom = 24.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (isLive) { + val choices by accountViewModel.reactionChoicesFlow().collectAsStateWithLifecycle() + val palette = choices.ifEmpty { listOf(DEFAULT_REACTION) } + + // Never drawn as "mine": cordn has no un-react, so this row adds a + // reaction rather than toggling one, and highlighting a choice you + // already used would promise a second tap that takes it back. + ChatChipFlowRow { + palette.forEach { emoji -> + ReactionChipView( + chip = ReactionChip(emoji, 1, false), + onClick = { + onReact(emoji) + onDismiss() + }, + onLongClick = {}, + ) + } + } + + SectionDivider() + } + + TileRow { + if (isLive) { + ActionTile(MaterialSymbols.AutoMirrored.Chat, stringRes(Res.string.cordn_action_reply)) { + onReply() + onDismiss() + } + } + + // Pinning is any member's (spec/01.md §5.1), so it is offered on every + // message rather than only on your own. + ActionTile( + MaterialSymbols.PushPin, + stringRes(if (isPinned) Res.string.cordn_action_unpin else Res.string.cordn_action_pin), + ) { + onTogglePin() + onDismiss() + } + + // Edit and delete are author-only, and the manager refuses them for + // anyone else. Hiding them here is the same rule, stated where it stops + // being a surprise. + if (isMine && isLive) { + ActionTile(MaterialSymbols.Edit, stringRes(Res.string.cordn_action_edit)) { + onEdit() + onDismiss() + } + ActionTile(MaterialSymbols.Delete, stringRes(Res.string.cordn_action_delete), isDestructive = true) { + // A withdrawal cannot be taken back and the coordinator keeps + // the ciphertext either way, so it is worth one question — + // unless the account has already opted out of being asked. + if (accountViewModel.account.settings.hideDeleteRequestDialog) { + performDelete() + } else { + confirmingDelete = true + } + } + } + } + + // Stage two, exactly as the shared sheet splits it: the actions native to + // this chat sit up front, and the generic what-you-can-do-with-any-message + // inventory lives behind the toggle so the sheet opens compact. + SectionDivider() + MoreActionsToggle(expanded = showAllActions, onToggle = { showAllActions = !showAllActions }) + + if (showAllActions) { + SectionDivider() + TileRow { + CopyTextTile(body, onDismiss) + ShareTextTile(body, onDismiss) + } + } + } + } +} + +/** + * Hands the message text to the system share sheet. + * + * The shared sheet shares a note by its nostr address, which a cordn message does not + * have — it lives on a coordinator, not a relay, and there is no URI that would resolve + * for anyone else. So this shares the text itself, which is what the reader can + * actually pass on. + */ +@Composable +private fun ShareTextTile( + text: String, + onDismiss: () -> Unit, +) { + val context = LocalContext.current + + ActionTile(MaterialSymbols.Share, stringRes(Res.string.quick_action_share)) { + val send = + Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, text) + } + context.startActivity(Intent.createChooser(send, null)) + onDismiss() + } +} + +/** + * Where [message] sits inside a run of consecutive bubbles by the same sender, in the + * shared [ChatGroupPosition] vocabulary — so a cordn burst gets the same squared-off + * corners and tightened spacing as a DM burst, not just a hidden author line. + * + * The feed is reverse-laid-out: [newer] is the message rendered below, [older] above. + */ +internal fun cordnGroupPositionFor( + newer: CordnDeliveredMessage?, + message: CordnDeliveredMessage, + older: CordnDeliveredMessage?, +): ChatGroupPosition { + val connectedAbove = older != null && groupsWith(message, older) + val connectedBelow = newer != null && groupsWith(newer, message) + + return when { + connectedAbove && connectedBelow -> ChatGroupPosition.MIDDLE + connectedAbove -> ChatGroupPosition.BOTTOM + connectedBelow -> ChatGroupPosition.TOP + else -> ChatGroupPosition.SINGLE + } +} + +/** + * Whether [newer] continues the run [older] started — same sender, close in time, same + * day. Time as well as sender, because a reply hours later to your own last message is a + * new thought, and joining it to the run reads as though the conversation never paused. + * + * The window is the shared [CHAT_GROUP_WINDOW_SECONDS], so a cordn burst and a DM burst + * break in the same place. + */ +private fun groupsWith( + newer: CordnDeliveredMessage, + older: CordnDeliveredMessage, +): Boolean { + if (newer.envelope.pubKey != older.envelope.pubKey) return false + if (abs(newer.envelope.createdAt - older.envelope.createdAt) > CHAT_GROUP_WINDOW_SECONDS) return false + // A day separator between the two breaks the run, exactly as a date divisor does in + // the DM feed. + return newer.sameDayAs(older) +} + +/** Whether both fall on the same local calendar day. A null [older] is a new day. */ +internal fun CordnDeliveredMessage.sameDayAs(older: CordnDeliveredMessage?): Boolean { + if (older == null) return false + return localDayOf(envelope.createdAt) == localDayOf(older.envelope.createdAt) +} + +private fun localDayOf(epochSeconds: Long): LocalDate = Instant.ofEpochSecond(epochSeconds).atZone(ZoneId.systemDefault()).toLocalDate() + +/** + * The day a run of messages belongs to. + * + * Without one, a conversation is an undivided column and "yesterday evening" and "this + * morning" sit flush against each other. + */ +@Composable +internal fun DaySeparator( + createdAt: Long, + today: LocalDate, +) { + val day = remember(createdAt) { localDayOf(createdAt) } + + val label = + when (day) { + today -> stringRes(Res.string.today) + today.minusDays(1) -> stringRes(Res.string.cordn_chat_yesterday) + // Year included only when it is not this one: printing 2026 on every + // divider all year is noise. + else -> + day.format( + DateTimeFormatter.ofPattern( + if (day.year == today.year) "d MMM" else "d MMM yyyy", + ), + ) + } + + ChatDivisor(label) +} + +/** Upper bound on how long a stale "Today" can survive a clock correction. */ +private const val TODAY_POLL_MS = 60_000L + +/** + * Today, as a value that stops being today when it stops being today. + * + * [DaySeparator] used to hold `remember { LocalDate.now() }` of its own. That is a + * snapshot of the wall clock with nothing to invalidate it, and each separator keeps a + * separate one, so they can disagree: a separator composed before midnight goes on + * saying "Today" while the one for the new day says it too. Seen on the tablet — one + * room, two "Today" dividers. + * + * Polling rather than a single sleep to the next midnight, because a device clock does + * not only advance: it is corrected, and the tablet this was found on jumped nine hours + * in one step. Re-assigning an equal [LocalDate] is not a change, so a quiet minute + * costs no recomposition. + */ +@Composable +internal fun rememberToday(): LocalDate { + val zone = remember { ZoneId.systemDefault() } + return produceState(LocalDate.now(zone), zone) { + while (true) { + val now = ZonedDateTime.now(zone) + val untilMidnight = Duration.between(now, now.toLocalDate().plusDays(1).atStartOfDay(zone)).toMillis() + delay(untilMidnight.coerceIn(1_000L, TODAY_POLL_MS)) + value = LocalDate.now(zone) + } + }.value +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/AutoScrollToNewest.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/AutoScrollToNewest.kt new file mode 100644 index 0000000000..d2d06805f7 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/AutoScrollToNewest.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed + +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect + +/** + * Keeps a reverse-laid-out chat sitting on its newest message. + * + * [newest] is whatever identifies the bottom-most row — a note, a message id, + * anything whose equality changes when a message arrives. In a `reverseLayout` + * list the newest row is index 0, and a list anchors itself to the row that was + * already first visible, so without this a sent message lands just below the + * viewport and the sender never sees it. + * + * Someone else's message only pulls the view down when the reader is already at + * the bottom. A reader who scrolled up into history is deliberately left there: + * yanking them away because somebody else typed is worse than a missed arrival, + * which the unread divider covers anyway. Index 1 counts as "at the bottom" + * because a row that just arrived has already pushed the previous newest up one. + * + * [mine] lifts that guard for the reader's own message, and is the whole reason + * this takes a flag. Sending is an explicit act with an obvious expectation — + * every chat app puts you on what you just sent — so someone who scrolls up, + * types and sends must land on it rather than be left reading history with the + * message they just wrote somewhere off-screen below. + */ +@Composable +internal fun AutoScrollToNewest( + listState: LazyListState, + newest: Any?, + mine: Boolean = false, +) { + LaunchedEffect(newest) { + if (mine || listState.firstVisibleItemIndex <= 1) { + listState.animateScrollToItem(0) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt index ba50867242..d58a1422a7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt @@ -206,11 +206,8 @@ fun ChatFeedLoaded( // reorders no longer re-fire paging. The per-gap markers below are pure UI. sentinels?.invoke(items.list, listState) - LaunchedEffect(items.list.firstOrNull()) { - if (listState.firstVisibleItemIndex <= 1) { - listState.animateScrollToItem(0) - } - } + val newest = items.list.firstOrNull() + AutoScrollToNewest(listState, newest, mine = accountViewModel.isLoggedUser(newest?.author?.pubkeyHex)) val scope = rememberCoroutineScope() val highlightedNoteId = remember { mutableStateOf(null) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt index 59a5c2f773..72b483ee4e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt @@ -425,7 +425,7 @@ private fun RelayGroupPinTile( * zap, or a reply) and reveals everything else on demand. */ @Composable -private fun MoreActionsToggle( +internal fun MoreActionsToggle( expanded: Boolean, onToggle: () -> Unit, ) { @@ -487,7 +487,7 @@ private fun ChatOnlyRow( @OptIn(ExperimentalLayoutApi::class) @Composable -private fun TileRow(content: @Composable () -> Unit) { +internal fun TileRow(content: @Composable () -> Unit) { FlowRow( modifier = Modifier @@ -501,7 +501,7 @@ private fun TileRow(content: @Composable () -> Unit) { } @Composable -private fun ActionTile( +internal fun ActionTile( symbol: MaterialSymbol, label: String, isDestructive: Boolean = false, @@ -540,7 +540,7 @@ private fun ActionTile( private const val SECTION_DIVIDER_ALPHA = 0.5f @Composable -private fun SectionDivider() { +internal fun SectionDivider() { HorizontalDivider( thickness = DividerThickness, color = MaterialTheme.colorScheme.placeholderText.copy(alpha = SECTION_DIVIDER_ALPHA), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt index ccb19c4c2d..9ecb9c8776 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt @@ -87,7 +87,7 @@ import com.vitorpamplona.quartz.buzz.stream.SystemMessageEvent import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotAppEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip04Dm.messages.EncryptedDmEvent import com.vitorpamplona.quartz.nip10Notes.BaseNoteEvent import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent @@ -97,7 +97,7 @@ import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent import com.vitorpamplona.quartz.nip53LiveActivities.raid.LiveActivitiesRaidEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent import com.vitorpamplona.quartz.nip57Zaps.splits.hasZapSplitSetup import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount @@ -156,7 +156,7 @@ fun ChatroomMessageCompose( } val event = baseNote.event - if (event is LnZapEvent) { + if (event is ZapReceiptEvent) { RenderChatZap(baseNote, accountViewModel, nav) } else if (event is LiveActivitiesRaidEvent) { RenderChatRaid(baseNote, accountViewModel, nav) @@ -248,10 +248,10 @@ fun NormalChatNote( when { // Own messages: normally no name; in a multi-identity chat, show it (unless a DM, // which never draws the user's own author info). - isLoggedInUser -> showSelfAuthorName && noteEvent !is PrivateDmEvent + isLoggedInUser -> showSelfAuthorName && noteEvent !is EncryptedDmEvent // never shows the user's pictures - noteEvent is PrivateDmEvent -> false + noteEvent is EncryptedDmEvent -> false // one-on-one, never shows it. // only shows in a group chat. @@ -588,7 +588,7 @@ private fun RenderReply( is ChatMessageEvent, is ChatMessageEncryptedFileHeaderEvent -> { { LoadingReplyNote(DmReplyProtocol.NIP17, accountViewModel) } } - is PrivateDmEvent -> { + is EncryptedDmEvent -> { { LoadingReplyNote(DmReplyProtocol.NIP04, accountViewModel) } } else -> null diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatReactionChips.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatReactionChips.kt index c605fae8e3..cee05e166e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatReactionChips.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatReactionChips.kt @@ -83,7 +83,7 @@ import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @Immutable -private data class ReactionChip( +internal data class ReactionChip( val type: String, val count: Int, val includesMe: Boolean, @@ -212,7 +212,25 @@ fun ChatReactionChips( } } +/** + * The strip of engagement chips that rides a chat bubble's bottom border. Shared so + * every chat surface lays its chips out identically — [ChatReactionChips] fills it from + * a [Note]'s reactions and zaps, cordn fills it from its own annotation fold. + */ @OptIn(ExperimentalLayoutApi::class) +@Composable +internal fun ChatChipFlowRow(content: @Composable () -> Unit) { + FlowRow( + // Inset from the bubble's edge so overlapping chips ride the border without + // poking past the bubble's rounded corners. + modifier = Modifier.padding(horizontal = 8.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + content() + } +} + @Composable private fun RenderChatReactionChips( chips: ImmutableList, @@ -225,13 +243,7 @@ private fun RenderChatReactionChips( ) { if (chips.isEmpty() && zapAmount.isBlank() && minichatCount <= 0 && !isZapping) return - FlowRow( - // Inset from the bubble's edge so overlapping chips ride the border without - // poking past the bubble's rounded corners. - modifier = Modifier.padding(horizontal = 8.dp), - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { + ChatChipFlowRow { if (zapAmount.isNotBlank()) { ZapChip(zapAmount, onClick = onOpenDetails) } else if (isZapping) { @@ -290,7 +302,7 @@ private fun MinichatChip( @OptIn(ExperimentalFoundationApi::class) @Composable -private fun ReactionChipView( +internal fun ReactionChipView( chip: ReactionChip, onClick: () -> Unit, onLongClick: () -> Unit, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatTimeAgo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatTimeAgo.kt index 7f0627d365..3f0c1a3f0d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatTimeAgo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatTimeAgo.kt @@ -51,7 +51,7 @@ import com.vitorpamplona.amethyst.ui.note.elements.ToggleableTimeAgoText import com.vitorpamplona.amethyst.ui.note.timeAheadNoDot import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip01Core.tags.geohash.geoHashOrScope -import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip04Dm.messages.EncryptedDmEvent import com.vitorpamplona.quartz.nip13Pow.strongPoWOrNull import com.vitorpamplona.quartz.nip40Expiration.expiration @@ -84,7 +84,7 @@ fun chatFooterHasMeta( ): Boolean { val event = note.event ?: return false val geo = event.geoHashOrScope() - return event is PrivateDmEvent || + return event is EncryptedDmEvent || event.expiration() != null || (geo != null && geo != suppressGeohash) || event.strongPoWOrNull() != null || @@ -132,7 +132,7 @@ fun ChatMessageFooter( } if (showTime) { - val hasGlyph = note.isPinnedInRelayGroup() || event is PrivateDmEvent || event?.expiration() != null || geo != null || pow != null + val hasGlyph = note.isPinnedInRelayGroup() || event is EncryptedDmEvent || event?.expiration() != null || geo != null || pow != null if (hasGlyph) Spacer(StdHorzSpacer) // Drafts aren't published, so no relay/delivery detail; everything else gets diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/JumboEmoji.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/JumboEmoji.kt index 9e6934ca77..84d66d72b3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/JumboEmoji.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/JumboEmoji.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.commons.util.codePointAtKmp import com.vitorpamplona.amethyst.commons.util.codePointCharCount @@ -137,3 +139,19 @@ private fun isEmojiBase(cp: Int): Boolean = cp in 0x3297..0x3299 || // circled ideographs cp == 0x3030 || // wavy dash cp == 0x303D // part alternation mark + +// Jumbo sizes step down as the emoji count grows so up to three still fit a line. +private val JumboEmojiSingle = 50.sp +private val JumboEmojiPair = 40.sp +private val JumboEmojiTriple = 32.sp + +/** + * How large to draw an emoji-only message of [jumboCount] emoji. Shared by every chat + * surface that renders one, so a jumbo message is the same size wherever it appears. + */ +fun jumboEmojiFontSize(jumboCount: Int): TextUnit = + when (jumboCount) { + 1 -> JumboEmojiSingle + 2 -> JumboEmojiPair + else -> JumboEmojiTriple + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/ChatGroupPosition.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/ChatGroupPosition.kt index 2f7c439a38..fbbf82afd3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/ChatGroupPosition.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/ChatGroupPosition.kt @@ -41,7 +41,7 @@ import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent import com.vitorpamplona.quartz.nip53LiveActivities.raid.LiveActivitiesRaidEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent import kotlin.math.abs /** @@ -88,7 +88,7 @@ fun chatBubbleShapeFor( } /** Messages more than this far apart never group, even from the same author. */ -private const val GROUP_WINDOW_SECONDS = 10 * 60L +internal const val CHAT_GROUP_WINDOW_SECONDS = 10 * 60L /** * Event kinds that don't render as regular bubbles (zaps, raids, clips) or that @@ -96,7 +96,7 @@ private const val GROUP_WINDOW_SECONDS = 10 * 60L */ private fun isGroupableEvent(event: Event?): Boolean = event != null && - event !is LnZapEvent && + event !is ZapReceiptEvent && event !is LiveActivitiesRaidEvent && event !is LiveActivitiesClipEvent && event !is ChannelCreateEvent && @@ -120,7 +120,7 @@ private fun groupsWith( val olderAuthor = older.author?.pubkeyHex ?: return false if (newerAuthor != olderAuthor) return false - if (abs(newerEvent.createdAt - olderEvent.createdAt) > GROUP_WINDOW_SECONDS) return false + if (abs(newerEvent.createdAt - olderEvent.createdAt) > CHAT_GROUP_WINDOW_SECONDS) return false // A subject header renders as a divisor above the newer message. if (newerEvent.subject() != null) return false diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChatZap.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChatZap.kt index ee5645fa71..c52b22a690 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChatZap.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChatZap.kt @@ -55,7 +55,7 @@ import com.vitorpamplona.amethyst.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.ui.note.ZapAmountCommentNotification import com.vitorpamplona.amethyst.ui.note.showAmountInteger import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent private const val BIG_ZAP_THRESHOLD_SATS = 50_000L @@ -65,7 +65,7 @@ fun RenderChatZap( accountViewModel: AccountViewModel, nav: INav, ) { - val zapEvent = baseNote.event as? LnZapEvent ?: return + val zapEvent = baseNote.event as? ZapReceiptEvent ?: return val card by produceState( ZapAmountCommentNotification(user = null, comment = null, amount = null), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderEncryptedFile.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderEncryptedFile.kt index c7b2241d50..bfc0d136ee 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderEncryptedFile.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderEncryptedFile.kt @@ -39,8 +39,10 @@ import com.vitorpamplona.amethyst.commons.richtext.EncryptedMediaUrlVideo import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.stringRes +import com.vitorpamplona.amethyst.service.playback.composable.WaveformData import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.components.ZoomableContentView +import com.vitorpamplona.amethyst.ui.note.types.RenderAudioWithWaveform import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent import com.vitorpamplona.quartz.nip31Alts.alt @@ -64,6 +66,25 @@ fun RenderEncryptedFile( if (algo == AESGCM.NAME && key != null && nonce != null) { Amethyst.instance.keyCache.add(noteEvent.content, AESGCM(key, nonce), mimeType) + // Audio is neither an image nor a video, and the image-or-video split + // below would hand it to the video player: a picture-less surface where + // the voice UI belongs. Checked before that split rather than inside it, + // since the whole ZoomableContentView pipeline is about visual media. + if (RichTextParser.isAudioContent(mimeType, noteEvent.content)) { + val waveform = remember(noteEvent) { noteEvent.waveform()?.let { WaveformData(it) } } + + RenderAudioWithWaveform( + mediaUrl = noteEvent.content, + title = noteEvent.alt(), + mimeType = mimeType, + waveform = waveform, + note = note, + accountViewModel = accountViewModel, + nav = nav, + ) + return + } + val content by remember(noteEvent) { val isImage = mimeType?.startsWith("image/") == true || RichTextParser.isImageUrl(noteEvent.content) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderMarmotEncryptedMedia.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderMarmotEncryptedMedia.kt index 688ee380ca..a084b2d178 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderMarmotEncryptedMedia.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderMarmotEncryptedMedia.kt @@ -41,6 +41,7 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.components.ZoomableContentView +import com.vitorpamplona.amethyst.ui.note.types.RenderAudioWithWaveform import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.marmot.appComponents.EncryptedMediaPolicyV2 import com.vitorpamplona.quartz.marmot.appComponents.EncryptedMediaReferenceV2 @@ -130,6 +131,24 @@ fun RenderEncryptedMediaV2( Amethyst.instance.keyCache.add(url, cipher, reference.mediaType) val description = event.alt() + + // Audio would otherwise fall to the video branch below and play on a + // picture-less video surface. MIP-04 carries no waveform, so the player + // shows its transport without bars — still the voice UI rather than a + // video one. + if (RichTextParser.isAudioContent(reference.mediaType, url)) { + RenderAudioWithWaveform( + mediaUrl = url, + title = description, + mimeType = reference.mediaType, + waveform = null, + note = note, + accountViewModel = accountViewModel, + nav = nav, + ) + return + } + val dim = reference.dim?.let { DimensionTag.parse(it) } val content by remember(reference) { mutableStateOf( @@ -236,6 +255,21 @@ private fun RenderMip04Content( Amethyst.instance.keyCache.add(meta.url, cipher, meta.mimeType) val description = note.event?.alt() + + // Same reason as the reference path above. + if (RichTextParser.isAudioContent(meta.mimeType, meta.url)) { + RenderAudioWithWaveform( + mediaUrl = meta.url, + title = description, + mimeType = meta.mimeType, + waveform = null, + note = note, + accountViewModel = accountViewModel, + nav = nav, + ) + return + } + val isImage = meta.mimeType.startsWith("image/") || RichTextParser.isImageUrl(meta.url) val dim = meta.dimensions?.let { DimensionTag.parse(it) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderRegularTextNote.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderRegularTextNote.kt index 7252ebb663..eec5965811 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderRegularTextNote.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderRegularTextNote.kt @@ -26,7 +26,6 @@ import androidx.compose.runtime.MutableState import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.commons.model.EmptyTagList import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists @@ -37,14 +36,12 @@ import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.ui.components.SensitivityWarning import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.note.LoadDecryptedContentOrNull +import com.vitorpamplona.amethyst.ui.note.types.RenderAudioFromIMeta import com.vitorpamplona.amethyst.ui.note.types.appendMissingImetaUrls +import com.vitorpamplona.amethyst.ui.note.types.getAudioMetaWithWaveform import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.jumboEmojiCount - -// Jumbo sizes step down as the emoji count grows so up to three still fit a line. -private val JumboEmojiSingle = 50.sp -private val JumboEmojiPair = 40.sp -private val JumboEmojiTriple = 32.sp +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.jumboEmojiFontSize @Composable fun RenderRegularTextNote( @@ -63,17 +60,23 @@ fun RenderRegularTextNote( ) { val jumboCount = remember(eventContent) { jumboEmojiCount(eventContent) } - if (jumboCount > 0) { + // A voice message is an audio URL whose imeta carries the waveform. + // The main feed's note renderer short-circuits those to the waveform + // player; without the same check here the generic media pipeline + // takes them and a recorded message arrives as a player surface + // instead of the waveform it was previewed as. Matched against the + // DECRYPTED content rather than `isAudioOnlyContent()`, because in a + // chat the note's own `content` may still be the sealed payload. + val audioMeta = remember(note.event) { note.event?.getAudioMetaWithWaveform() } + + if (audioMeta != null && eventContent.trim() == audioMeta.url) { + RenderAudioFromIMeta(note, accountViewModel, nav) + } else if (jumboCount > 0) { // Emoji-only messages render as jumbo emoji (the bubble behind // them is transparent — see NormalChatNote). Text( text = eventContent.trim(), - fontSize = - when (jumboCount) { - 1 -> JumboEmojiSingle - 2 -> JumboEmojiPair - else -> JumboEmojiTriple - }, + fontSize = jumboEmojiFontSize(jumboCount), ) } else { val tags = remember(note.event) { note.event?.tags?.toImmutableListOfLists() ?: EmptyTagList } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/geohashChat/GeohashChatViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/geohashChat/GeohashChatViewModel.kt index e653bad481..7a40654504 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/geohashChat/GeohashChatViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/geohashChat/GeohashChatViewModel.kt @@ -29,7 +29,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal -import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -122,7 +122,7 @@ class GeohashChatViewModel : ViewModel() { val signer = NostrSignerInternal(keyPair) val template = if (mine.isNotEmpty()) { - DeletionEvent.build(mine) + DeletionRequestEvent.build(mine) } else { val hint = note.toEventHint() ?: return@launch ReactionEvent.build(reaction, hint) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt index 4a9c15f240..14f7c0528b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt @@ -71,7 +71,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayPagingProgress import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey -import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.nip17Dm.settings.DmRelayListEvent import com.vitorpamplona.quartz.utils.Log import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.delay @@ -130,7 +130,7 @@ fun ChatroomView( // Reactively check if recipients have DM relays for NIP-17 delivery for (userHex in room.users) { LoadAddressableNote( - ChatMessageRelayListEvent.createAddress(userHex), + DmRelayListEvent.createAddress(userHex), ) { note -> if (note != null) { EventFinderFilterAssemblerSubscription(note, accountViewModel) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/FilterNip04DMs.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/FilterNip04DMs.kt index c203189fd2..2254624aee 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/FilterNip04DMs.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/FilterNip04DMs.kt @@ -27,7 +27,7 @@ import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip04Dm.messages.EncryptedDmEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent /** @@ -114,7 +114,7 @@ private fun toMeFilter( filter = ExplainedFilter( purpose = SubPurpose.DIRECT_MESSAGES, - kinds = listOf(PrivateDmEvent.KIND), + kinds = listOf(EncryptedDmEvent.KIND), authors = authors.toList(), tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)), since = since, @@ -135,7 +135,7 @@ private fun fromMeFilter( filter = ExplainedFilter( purpose = SubPurpose.DIRECT_MESSAGES, - kinds = listOf(PrivateDmEvent.KIND), + kinds = listOf(EncryptedDmEvent.KIND), authors = listOf(account.userProfile().pubkeyHex), tags = mapOf("p" to pTags.toList()), since = since, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt index 3bb0e5dce0..ca797a2b12 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt @@ -68,7 +68,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohash import com.vitorpamplona.quartz.nip01Core.tags.geohash.getGeoHash import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags import com.vitorpamplona.quartz.nip01Core.tags.references.references -import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip04Dm.messages.EncryptedDmEvent import com.vitorpamplona.quartz.nip10Notes.content.findHashtags import com.vitorpamplona.quartz.nip10Notes.content.findNostrEventUris import com.vitorpamplona.quartz.nip10Notes.content.findURLs @@ -78,7 +78,7 @@ import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.nip17Dm.base.NIP17Group import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent -import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.nip17Dm.settings.DmRelayListEvent import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes import com.vitorpamplona.quartz.nip19Bech32.toNpub import com.vitorpamplona.quartz.nip30CustomEmoji.emojis @@ -184,7 +184,7 @@ class ChatNewMessageViewModel : combine(dmRelayListNoteFlows) { dmRelayListNotes -> dmRelayListNotes .mapNotNull { noteState -> - val noteEvent = noteState.note.event as? ChatMessageRelayListEvent + val noteEvent = noteState.note.event as? DmRelayListEvent if (noteEvent == null || noteEvent.relays().isEmpty()) { noteState.note.author } else { @@ -409,7 +409,7 @@ class ChatNewMessageViewModel : if (replyId != null) { replyTo.value = LocalCache.checkGetOrCreateNote(replyId) } - } else if (draftEvent is PrivateDmEvent) { + } else if (draftEvent is EncryptedDmEvent) { val recipientNPub = draftEvent.verifiedRecipientPubKey()?.let { Hex.decode(it).toNpub() } toUsers.setTextAndPlaceCursorAtEnd("@$recipientNPub") @@ -420,7 +420,7 @@ class ChatNewMessageViewModel : } val draftText = - if (draftEvent is PrivateDmEvent) { + if (draftEvent is EncryptedDmEvent) { accountViewModel.account.privateDMDecryptionCache.cachedDM(draftEvent) ?: "" } else { draftEvent.content diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/LiveActivityChannelScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/LiveActivityChannelScreen.kt index 3ff4e70d03..bf3b19c433 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/LiveActivityChannelScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/LiveActivityChannelScreen.kt @@ -27,8 +27,8 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import com.vitorpamplona.amethyst.commons.model.cache.LocalCache +import com.vitorpamplona.amethyst.commons.service.OnlineChecker import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav -import com.vitorpamplona.amethyst.service.OnlineChecker import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold import com.vitorpamplona.amethyst.ui.note.LoadLiveActivityChannel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/header/LiveStreamGoalHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/header/LiveStreamGoalHeader.kt index 8ff9d9a3fd..c8f2739522 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/header/LiveStreamGoalHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/header/LiveStreamGoalHeader.kt @@ -48,7 +48,7 @@ import com.vitorpamplona.amethyst.ui.components.LoadNote import com.vitorpamplona.amethyst.ui.note.ZapReaction import com.vitorpamplona.amethyst.ui.note.types.GoalProgressBar import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent +import com.vitorpamplona.quartz.nip75ZapGoals.ZapGoalEvent /** * Compact header rendered above the live-activity chat feed when the stream @@ -76,7 +76,7 @@ private fun GoalHeaderContent( accountViewModel: AccountViewModel, nav: INav, ) { - val goal = goalNote.event as? GoalEvent ?: return + val goal = goalNote.event as? ZapGoalEvent ?: return val title = remember(goal) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt index 7ce0a5aed3..c6e2de0394 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt @@ -74,6 +74,8 @@ import com.vitorpamplona.amethyst.commons.model.navigation.Route import com.vitorpamplona.amethyst.commons.model.navigation.routeFor import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupDeletions +import com.vitorpamplona.amethyst.commons.relays.nip11RelayInfo.isRelaySignedRelayGroup +import com.vitorpamplona.amethyst.commons.relays.nip11RelayInfo.looksLikeNonNip29Relay import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.buzz_channel_create_title import com.vitorpamplona.amethyst.commons.resources.buzz_community_add_people @@ -103,8 +105,6 @@ import com.vitorpamplona.amethyst.commons.ui.screen.LocalDisplaySettings import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.warningColor import com.vitorpamplona.amethyst.commons.util.sortedBySnapshot -import com.vitorpamplona.amethyst.model.nip11RelayInfo.isRelaySignedRelayGroup -import com.vitorpamplona.amethyst.model.nip11RelayInfo.looksLikeNonNip29Relay import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserName import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupDiscoveryScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupDiscoveryScreen.kt index afdf3562b4..93ea1bb03c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupDiscoveryScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupDiscoveryScreen.kt @@ -250,7 +250,7 @@ private fun WatchAccountForRelayGroupDiscovery( .collectAsStateWithLifecycle() val joinedServers by accountViewModel.account.relayGroupList.liveRelayGroupServers .collectAsStateWithLifecycle() - val favoriteRelays by accountViewModel.account.relayFeedsList.flow + val favoriteRelays by accountViewModel.account.favoriteRelayList.flow .collectAsStateWithLifecycle() // Discovery only shows groups whose 39000 is signed by the host relay's own key (NIP-29's @@ -349,7 +349,7 @@ private fun RelayRailHeader( val info = loadRelayInfo(relay) val host = relay.displayUrl() val name = info.value.name?.takeIf { it.isNotBlank() } ?: host - val favoriteRelays by accountViewModel.account.relayFeedsList.flow + val favoriteRelays by accountViewModel.account.favoriteRelayList.flow .collectAsStateWithLifecycle() val isFavorite = relay in favoriteRelays diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMembersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMembersScreen.kt index 9d9b4b5e5c..d94934ee10 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMembersScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMembersScreen.kt @@ -408,7 +408,7 @@ private fun RelayGroupMemberRow( ) } DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { - val declaredRoles = channel.supportedRoles + val declaredRoles = channel.groupRoles if (declaredRoles.isNotEmpty()) { // The relay declares its own role set (kind 39003) — offer exactly those // instead of the built-in admin/moderator pair. Roles are privilege grants, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupParentPicker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupParentPicker.kt index 0fc9ff32be..d0da74a4c0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupParentPicker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupParentPicker.kt @@ -64,6 +64,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel +import com.vitorpamplona.amethyst.commons.relays.nip11RelayInfo.isRelaySignedRelayGroup import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.relay_group_member_count import com.vitorpamplona.amethyst.commons.resources.relay_group_parent_desc @@ -80,7 +81,6 @@ import com.vitorpamplona.amethyst.commons.ui.pluralStringRes import com.vitorpamplona.amethyst.commons.ui.screen.LocalDisplaySettings import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.util.sortedBySnapshot -import com.vitorpamplona.amethyst.model.nip11RelayInfo.isRelaySignedRelayGroup import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupCardWarmupSubscription diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupServerList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupServerList.kt index b471fe199c..8489a8e868 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupServerList.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupServerList.kt @@ -37,6 +37,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.relays.nip11RelayInfo.looksLikeNonNip29Relay import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.relay_group_relay_not_nip29 import com.vitorpamplona.amethyst.commons.ui.screen.LocalDisplaySettings @@ -44,7 +45,6 @@ import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.LargeRelayIconModifier import com.vitorpamplona.amethyst.commons.ui.theme.warningColor import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo -import com.vitorpamplona.amethyst.model.nip11RelayInfo.looksLikeNonNip29Relay import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsDiscoverySubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsDiscoverySubAssembler.kt index 8d0897ae01..23715d84f2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsDiscoverySubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsDiscoverySubAssembler.kt @@ -100,7 +100,7 @@ class RelayGroupsDiscoverySubAssembler( account.relayGroupList.liveRelayGroupServers.value.forEach { server -> RelayUrlNormalizer.normalizeOrNull(server)?.let(::add) } - addAll(account.relayFeedsList.flow.value) + addAll(account.favoriteRelayList.flow.value) } override fun user(key: RelayGroupsDiscoveryQueryState) = key.account.userProfile() @@ -156,7 +156,7 @@ class RelayGroupsDiscoverySubAssembler( } }, key.account.scope.launch(Dispatchers.IO) { - key.account.relayFeedsList.flow.sample(500).collectLatest { + key.account.favoriteRelayList.flow.sample(500).collectLatest { invalidateFilters() } }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt index 2bd7b241c0..f758b7bdcc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt @@ -46,6 +46,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.commons.concord.ui.ConcordCommunityPill +import com.vitorpamplona.amethyst.commons.cordn.CoordinatorConfig import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols @@ -54,6 +55,7 @@ import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.model.chatMessageMarksRoomAsRead import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel +import com.vitorpamplona.amethyst.commons.model.cordnGroups.CordnGroupChatroom import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel import com.vitorpamplona.amethyst.commons.model.geohashChat.GeohashChatChannel import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom @@ -78,6 +80,14 @@ import com.vitorpamplona.amethyst.commons.resources.chat_preview_decrypting import com.vitorpamplona.amethyst.commons.resources.chat_preview_you_prefix import com.vitorpamplona.amethyst.commons.resources.concord_home_title import com.vitorpamplona.amethyst.commons.resources.concord_server_label +import com.vitorpamplona.amethyst.commons.resources.cordn_group_no_messages_yet +import com.vitorpamplona.amethyst.commons.resources.cordn_group_untitled +import com.vitorpamplona.amethyst.commons.resources.cordn_group_via_coordinator +import com.vitorpamplona.amethyst.commons.resources.cordn_preview_deleted +import com.vitorpamplona.amethyst.commons.resources.cordn_preview_file +import com.vitorpamplona.amethyst.commons.resources.cordn_preview_photo +import com.vitorpamplona.amethyst.commons.resources.cordn_preview_video +import com.vitorpamplona.amethyst.commons.resources.cordn_preview_voice_note import com.vitorpamplona.amethyst.commons.resources.could_not_decrypt_the_message import com.vitorpamplona.amethyst.commons.resources.ephemeral_relay_chat import com.vitorpamplona.amethyst.commons.resources.geohash_chat @@ -96,7 +106,9 @@ import com.vitorpamplona.amethyst.commons.resources.relay_group_server_label import com.vitorpamplona.amethyst.commons.resources.remove_from_messages import com.vitorpamplona.amethyst.commons.resources.unmute_notifications import com.vitorpamplona.amethyst.commons.resources.unpin_conversation +import com.vitorpamplona.amethyst.commons.ui.components.NewItemsBubble import com.vitorpamplona.amethyst.commons.ui.components.RobohashFallbackAsyncImage +import com.vitorpamplona.amethyst.commons.ui.layouts.ChatHeaderLayout import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.note.HeaderPill import com.vitorpamplona.amethyst.commons.ui.screen.LocalDisplaySettings @@ -108,7 +120,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.Size15Modifier import com.vitorpamplona.amethyst.commons.ui.theme.Size55dp import com.vitorpamplona.amethyst.commons.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.commons.ui.theme.grayText -import com.vitorpamplona.amethyst.commons.ui.theme.newItemBubbleModifier import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText import com.vitorpamplona.amethyst.model.buzz.toMembershipNotice import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo @@ -116,7 +127,6 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observe import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteHasEvent import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderByParentFilterAssemblerSubscription import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserName -import com.vitorpamplona.amethyst.ui.layouts.ChatHeaderLayout import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.note.BlankNote import com.vitorpamplona.amethyst.ui.note.LoadDecryptedContentOrNull @@ -142,9 +152,12 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayG import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.relayGroupServerHasUnreadFlow import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal.ConcordServerRoomNote import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal.RelayGroupServerRoomNote +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.cordn.coordinatorDisplayName import com.vitorpamplona.quartz.buzz.notifications.MemberAddedNotificationEvent +import com.vitorpamplona.quartz.cordn.appEncryptedMedia.CordnMediaTag import com.vitorpamplona.quartz.experimental.bitchat.geohash.GeohashChatEvent import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent +import com.vitorpamplona.quartz.nip01Core.core.TagArray import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable @@ -155,6 +168,7 @@ import com.vitorpamplona.quartz.nip29RelayGroups.GroupId import com.vitorpamplona.quartz.nip29RelayGroups.groupId import com.vitorpamplona.quartz.nip29RelayGroups.isGroupScoped import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.emptyFlow import org.jetbrains.compose.resources.StringResource @@ -168,12 +182,21 @@ fun ChatroomHeaderCompose( // joined Marmot/NIP-29 group with no messages yet (an event-less placeholder carrying its channel // as a gatherer). Render these directly instead of waiting for an event that never arrives, which // would blank the row. + // + // A cordn room has no event *ever*, not just before its first message: its + // messages are MLS envelopes that never become Notes (§3.3 of + // amethyst/plans/2026-09-19-cordn-ui.md), so the row is only ever a carrier + // for the room in `inGatherers`. Leaving it out of this list sent every + // cordn room down the branch that waits for an event and drew `BlankNote()` + // instead — the room was in the feed, correctly, and simply had no pixels. val rendersWithoutEvent = baseNote is RelayGroupServerRoomNote || baseNote is ConcordServerRoomNote || ( baseNote.event == null && - baseNote.inGatherers?.any { it is MarmotGroupChatroom || it is RelayGroupChannel || it is ConcordChannel } == true + baseNote.inGatherers?.any { + it is MarmotGroupChatroom || it is RelayGroupChannel || it is ConcordChannel || it is CordnGroupChatroom + } == true ) if (baseNote.event != null || rendersWithoutEvent) { @@ -228,6 +251,12 @@ private fun ChatroomEntry( return } + val cordnGroup = lastMessage.inGatherers?.firstNotNullOfOrNull { it as? CordnGroupChatroom } + if (cordnGroup != null) { + CordnGroupRoomCompose(cordnGroup, accountViewModel, nav) + return + } + val relayGroup = lastMessage.inGatherers?.firstNotNullOfOrNull { it as? RelayGroupChannel } if (relayGroup != null) { RelayGroupRoomCompose(lastMessage, relayGroup, accountViewModel, nav) @@ -527,6 +556,110 @@ private fun MarmotGroupRoomCompose( ) } +/** + * What to call a message whose whole content is an attachment. + * + * The first attachment decides, which is what the eye does too: a row is one + * line and a message with a photo and a file in it is, to a reader skimming the + * inbox, a message with a photo in it. + */ +private fun attachmentLabelFor(tags: TagArray): StringResource { + val first = CordnMediaTag.parseAll(tags).firstOrNull() ?: return Res.string.cordn_preview_file + return when { + first.isAudio -> Res.string.cordn_preview_voice_note + first.isImage -> Res.string.cordn_preview_photo + first.mimeType.startsWith("video/") -> Res.string.cordn_preview_video + else -> Res.string.cordn_preview_file + } +} + +/** + * One cordn room in the Messages list. + * + * Takes the room rather than the Note, unlike every other row here. The Note is + * only a carrier: cordn messages are MLS envelopes that never reach LocalCache, + * so there is no `lastMessage.event` to read a preview from and the live data + * is on the room. Reading the Note would render an empty row forever. + */ +@Composable +internal fun CordnGroupRoomCompose( + chatroom: CordnGroupChatroom, + accountViewModel: AccountViewModel, + nav: INav, +) { + val name by chatroom.name.collectAsStateWithLifecycle() + val newest by chatroom.newest.collectAsStateWithLifecycle() + val annotations by chatroom.annotations.collectAsStateWithLifecycle() + val unread by chatroom.unreadCount.collectAsStateWithLifecycle() + + val groupName = name?.takeIf { it.isNotBlank() } ?: stringRes(Res.string.cordn_group_untitled, chatroom.gid.take(8)) + + // Who carries this conversation. "cordn" was the same word on every cordn + // row and told a reader nothing they could act on; the coordinator is the + // one server that sees every message in the group, so naming it is the + // thing worth the space. + val coordinators by ( + accountViewModel.account.cordnRuntime?.coordinators + ?: remember { MutableStateFlow(emptyList()) } + ).collectAsStateWithLifecycle() + val coordinatorName = + coordinatorDisplayName( + pubKey = chatroom.coordinatorPubKey, + label = coordinators.firstOrNull { it.pubKey == chatroom.coordinatorPubKey }?.label, + accountViewModel = accountViewModel, + ) + + val lastContent = + newest?.let { message -> + val authorName by observeUserName(LocalCache.getOrCreateUser(message.envelope.pubKey), accountViewModel) + // The edited text when there is one: showing the original in the + // inbox while the room shows the edit is the kind of mismatch that + // reads as a sync bug. + val text = annotations.contentOf(message.envelope.id) ?: message.envelope.content + // A voice note or a photo carries no text, so the row used to read + // "Someone: " and trail off — an attachment looked like an empty + // message. Name what was sent instead, the way the bubble does. + val preview = + text.takeIf { it.isNotBlank() } + ?: if (annotations.isDeleted(message.envelope.id)) { + stringRes(Res.string.cordn_preview_deleted) + } else { + stringRes(attachmentLabelFor(message.envelope.tags)) + } + "$authorName: ${preview.take(200)}" + } ?: stringRes(Res.string.cordn_group_no_messages_yet) + + ChannelName( + channelIdHex = chatroom.gid, + channelPicture = null, + channelTitle = { modifier -> + ChannelTitleWithLabelInfo( + channelName = groupName, + labelIcon = MaterialSymbols.Dns, + labelText = coordinatorName, + modifier = modifier, + // The pill no longer says "cordn", so the kind of room has to + // reach a screen reader some other way. + labelContentDescription = stringRes(Res.string.cordn_group_via_coordinator, coordinatorName), + ) + }, + channelLastTime = newest?.envelope?.createdAt, + channelLastContent = lastContent, + // Counted against the read position the room persists, on the + // coordinator's cursor rather than the sender's clock -- see + // CordnGroupChatroom.unreadCount for why a clock cannot be trusted + // with this. Annotations and this account's own echoes do not count. + hasNewMessages = unread > 0, + loadProfilePicture = accountViewModel.settings.showProfilePictures(), + loadRobohash = accountViewModel.settings.isNotPerformanceMode(), + autoPlayGif = + accountViewModel.settings.autoPlayVideosFlow + .collectAsStateWithLifecycle() + .value, + onClick = { nav.nav(Route.CordnGroupChat(chatroom.coordinatorPubKey, chatroom.gid)) }, + ) +} + @Composable private fun RelayGroupRoomCompose( lastMessage: Note, @@ -944,6 +1077,23 @@ private fun ChannelTitleWithLabelInfo( label: StringResource, modifier: Modifier, labelContentDescription: String? = null, +) = ChannelTitleWithLabelInfo(channelName, labelIcon, stringRes(id = label), modifier, labelContentDescription) + +/** + * As above, for a pill whose text is a name rather than a fixed word. + * + * A cordn room's pill carries its coordinator, which is a value and not a + * string resource: the coordinator is the one server that carries every message + * in that group, so which one it is tells a reader more than being told twice + * that this is a cordn chat. + */ +@Composable +private fun ChannelTitleWithLabelInfo( + channelName: String, + labelIcon: MaterialSymbol, + labelText: String, + modifier: Modifier, + labelContentDescription: String? = null, ) { Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) { Text( @@ -957,7 +1107,7 @@ private fun ChannelTitleWithLabelInfo( Spacer(Modifier.width(6.dp)) HeaderPill( symbol = labelIcon, - text = stringRes(id = label), + text = labelText, modifier = Modifier.widthIn(max = ChatLabelMaxWidth), contentDescription = labelContentDescription, ) @@ -1227,8 +1377,3 @@ private fun TimeAgo(channelLastTime: Long?) { color = MaterialTheme.colorScheme.grayText, ) } - -@Composable -fun NewItemsBubble() { - Box(MaterialTheme.colorScheme.newItemBubbleModifier) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt index 950fd43ec0..1ba516916f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt @@ -43,7 +43,7 @@ import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer -import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip04Dm.messages.EncryptedDmEvent import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent @@ -59,8 +59,8 @@ class ChatroomListKnownFeedFilter( private fun isEnabled(type: ChatFeedType): Boolean = type in account.settings.enabledChatFeeds.value - /** A room note is NIP-04 when its event is a [PrivateDmEvent], otherwise it is a NIP-17 message. */ - private fun isDmEnabled(note: Note): Boolean = isEnabled(if (note.event is PrivateDmEvent) ChatFeedType.NIP04 else ChatFeedType.NIP17) + /** A room note is NIP-04 when its event is a [EncryptedDmEvent], otherwise it is a NIP-17 message. */ + private fun isDmEnabled(note: Note): Boolean = isEnabled(if (note.event is EncryptedDmEvent) ChatFeedType.NIP04 else ChatFeedType.NIP17) // returns the last Note of each user. override fun feed(): List { @@ -142,6 +142,26 @@ class ChatroomListKnownFeedFilter( } } + // cordn groups. Every one this account holds is a room it is IN — a + // cordn group is joined by accepting a Welcome, so there is no + // "known vs new" split to make here the way there is for a DM from a + // stranger. (The pending side is the Welcome inbox, Stage C.) + // + // One row per room, carried by the room's own Note: cordn messages are + // MLS envelopes and never enter LocalCache, so the row reads its name + // and preview from the room through Note.inGatherers. + val cordnGroups = + if (!isEnabled(ChatFeedType.CORDN)) { + emptyList() + } else { + account.cordnRuntime + ?.groups + ?.all + ?.value + ?.map { it.inboxRow() } + .orEmpty() + } + // NIP-29 relay groups the user joined (kind 10009). In INLINE view mode each group is its // own row tagged with its host relay; in GROUPED mode all the groups on one relay collapse // to a single relay row positioned by that relay's newest message. Both interleave with the @@ -210,7 +230,12 @@ class ChatroomListKnownFeedFilter( } } - return sort((privateMessages + publicChannels + ephemeralChats + geohashChannels + marmotGroups + relayGroups + concordChannels).toSet()) + return sort( + ( + privateMessages + publicChannels + ephemeralChats + geohashChannels + + marmotGroups + cordnGroups + relayGroups + concordChannels + ).toSet(), + ) } override fun updateListWith( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListNewFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListNewFeedFilter.kt index 86acd5c0cf..14ef60fd83 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListNewFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListNewFeedFilter.kt @@ -27,7 +27,7 @@ import com.vitorpamplona.amethyst.commons.model.chats.ChatFeedType import com.vitorpamplona.amethyst.commons.util.replace import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder -import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip04Dm.messages.EncryptedDmEvent import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable @@ -38,8 +38,8 @@ class ChatroomListNewFeedFilter( private fun isEnabled(type: ChatFeedType): Boolean = type in account.settings.enabledChatFeeds.value - /** A room note is NIP-04 when its event is a [PrivateDmEvent], otherwise it is a NIP-17 message. */ - private fun isDmEnabled(note: Note): Boolean = isEnabled(if (note.event is PrivateDmEvent) ChatFeedType.NIP04 else ChatFeedType.NIP17) + /** A room note is NIP-04 when its event is a [EncryptedDmEvent], otherwise it is a NIP-17 message. */ + private fun isDmEnabled(note: Note): Boolean = isEnabled(if (note.event is EncryptedDmEvent) ChatFeedType.NIP04 else ChatFeedType.NIP17) // returns the last Note of each user. override fun feed(): List { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsFromMe.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsFromMe.kt index 534ddcbb6e..516c98b011 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsFromMe.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsFromMe.kt @@ -25,7 +25,7 @@ import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFil import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip04Dm.messages.EncryptedDmEvent fun filterNip04DMsFromMe( user: User, @@ -39,7 +39,7 @@ fun filterNip04DMsFromMe( filter = ExplainedFilter( purpose = SubPurpose.DIRECT_MESSAGES, - kinds = listOf(PrivateDmEvent.KIND), + kinds = listOf(EncryptedDmEvent.KIND), authors = listOf(user.pubkeyHex), since = since, until = until, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsToMe.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsToMe.kt index 4ebe2b0077..d7b68ba331 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsToMe.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FilterNip04DMsToMe.kt @@ -25,7 +25,7 @@ import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFil import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip04Dm.messages.EncryptedDmEvent fun filterNip04DMsToMe( user: User, @@ -39,7 +39,7 @@ fun filterNip04DMsToMe( filter = ExplainedFilter( purpose = SubPurpose.DIRECT_MESSAGES, - kinds = listOf(PrivateDmEvent.KIND), + kinds = listOf(EncryptedDmEvent.KIND), tags = mapOf("p" to listOf(user.pubkeyHex)), since = since, until = until, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt index eefcbb1888..502d424d8c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListFeedView.kt @@ -70,7 +70,7 @@ import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayPagingProgress import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip04Dm.messages.EncryptedDmEvent import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent @@ -180,8 +180,8 @@ private fun FeedLoaded( val user = accountViewModel.userProfile() val nip17Name = stringRes(Res.string.chats_history_proto_nip17) val nip04Name = stringRes(Res.string.chats_history_proto_nip04) - val oldestNip17Index = items.list.indexOfLast { it.event is ChatroomKeyable && it.event !is PrivateDmEvent } - val oldestNip04Index = items.list.indexOfLast { it.event is PrivateDmEvent } + val oldestNip17Index = items.list.indexOfLast { it.event is ChatroomKeyable && it.event !is EncryptedDmEvent } + val oldestNip04Index = items.list.indexOfLast { it.event is EncryptedDmEvent } // Each relay's window limit, carrying the advance() that pulls its OWN next page. Placed in the list // at its reached depth as a sentinel (see RelayReachMarkers): a relay pages only while its diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadDialog.kt index 140818f591..637f91bb53 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadDialog.kt @@ -99,6 +99,14 @@ fun ChatFileUploadDialog( accountViewModel: AccountViewModel, nav: INav, isNip17: Boolean = false, + /** + * Whether the sensitive-content switch is offered. + * + * False for a surface with nowhere to put the answer: cordn's blob host is told a + * fixed set of constants on purpose (see CordnBlobUpload), and its `imeta` tag has + * no field for a warning, so the switch would be a control that does nothing. + */ + showContentWarning: Boolean = true, ) { val scrollState = rememberScrollState() @@ -150,7 +158,7 @@ fun ChatFileUploadDialog( ) { Column(Modifier.fillMaxSize().padding(start = 10.dp, end = 10.dp, bottom = 10.dp)) { Column(Modifier.fillMaxWidth().verticalScroll(scrollState)) { - ImageVideoPostChat(state, accountViewModel, isNip17) + ImageVideoPostChat(state, accountViewModel, isNip17, showContentWarning) } } } @@ -163,6 +171,7 @@ private fun ImageVideoPostChat( fileUploadState: ChatFileUploadState, accountViewModel: AccountViewModel, isNip17: Boolean = false, + showContentWarning: Boolean = true, ) { val fileServers by accountViewModel.account.blossomServers.hostNameFlow .collectAsState() @@ -201,13 +210,15 @@ private fun ImageVideoPostChat( ), ) - SettingSwitchItem( - title = Res.string.add_sensitive_content_label, - description = Res.string.add_sensitive_content_description, - modifier = Modifier.fillMaxWidth().padding(top = 8.dp), - checked = fileUploadState.contentWarning, - onCheckedChange = fileUploadState::updateContentWarning, - ) + if (showContentWarning) { + SettingSwitchItem( + title = Res.string.add_sensitive_content_label, + description = Res.string.add_sensitive_content_description, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + checked = fileUploadState.contentWarning, + onCheckedChange = fileUploadState::updateContentWarning, + ) + } if (isNip17) { SettingSwitchItem( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadState.kt index 5f7ca6d6a3..ac0c7a21c3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadState.kt @@ -81,7 +81,12 @@ class ChatFileUploadState( multiOrchestrator?.remove(selected) } - fun canPost(): Boolean = !mediaUploadTracker.isUploading && multiOrchestrator != null + /** + * Deleting the last picked item leaves an *empty* orchestrator, not a null one, so + * a non-null check alone kept Send live with nothing to send. Callers that loop + * over the items then post nothing; one that indexes item 0 crashes. + */ + fun canPost(): Boolean = !mediaUploadTracker.isUploading && (multiOrchestrator?.size() ?: 0) > 0 fun hasPickedMedia() = multiOrchestrator != null diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessDismissedGamesData.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessDismissedGamesData.kt new file mode 100644 index 0000000000..a0cba559e4 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessDismissedGamesData.kt @@ -0,0 +1,36 @@ +/* + * 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.chess + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import com.vitorpamplona.amethyst.Amethyst + +/** + * Where Android keeps the dismissed-chess-games store. + * + * A new file rather than a migration of `chess_dismissed_games`: the dismissed + * list is a convenience, and chess has few enough users that carrying the old + * data over is not worth the code. Anyone who had dismissed a game sees it once + * more and dismisses it again. + */ +internal val chessDismissedGamesData: DataStore + get() = Amethyst.instance.appStores.getDataStore("chess_dismissed_games_v2") diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelNew.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelNew.kt index 893c23fb17..9bd3819de4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelNew.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelNew.kt @@ -25,7 +25,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.commons.nip64Chess.ChessBroadcastStatus import com.vitorpamplona.amethyst.commons.nip64Chess.ChessChallenge -import com.vitorpamplona.amethyst.commons.nip64Chess.ChessDismissedGamesStorage +import com.vitorpamplona.amethyst.commons.nip64Chess.ChessDismissedGamesStore import com.vitorpamplona.amethyst.commons.nip64Chess.ChessLobbyLogic import com.vitorpamplona.amethyst.commons.nip64Chess.ChessPollingDefaults import com.vitorpamplona.amethyst.commons.nip64Chess.ChessSyncStatus @@ -61,7 +61,7 @@ class ChessViewModelNew( private val publisher = AndroidChessPublisher(account) private val fetcher = AndroidRelayFetcher(account) private val metadataProvider = AndroidMetadataProvider() - private val dismissedStorage = ChessDismissedGamesStorage.create(application) + private val dismissedStorage = ChessDismissedGamesStore(chessDismissedGamesData) // Shared business logic (creates its own ChessLobbyState internally) private val logic = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/ChannelCardCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/ChannelCardCompose.kt index dc20c61fcd..f4e4212c37 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/ChannelCardCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/ChannelCardCompose.kt @@ -43,9 +43,9 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivitie import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities.RenderCommunitiesThumb import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.RenderContentDVMThumb import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.RenderClassifiedsThumb -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent -import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import com.vitorpamplona.quartz.nip51Lists.starterPack.StarterPackEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent @@ -157,8 +157,8 @@ fun InnerChannelCardWithReactions( is ChannelCreateEvent -> InnerCardRow(baseNote, accountViewModel, nav) is ClassifiedsEvent -> InnerCardBox(baseNote, accountViewModel, nav) is AppDefinitionEvent -> InnerCardRow(baseNote, accountViewModel, nav) - is FollowListEvent -> InnerCardRow(baseNote, accountViewModel, nav) - is LongTextNoteEvent -> InnerCardRow(baseNote, accountViewModel, nav) + is StarterPackEvent -> InnerCardRow(baseNote, accountViewModel, nav) + is LongFormContentEvent -> InnerCardRow(baseNote, accountViewModel, nav) } } @@ -211,7 +211,7 @@ private fun RenderNoteRow( is CommunityDefinitionEvent -> RenderCommunitiesThumb(baseNote, accountViewModel, nav) is ChannelCreateEvent -> RenderPublicChatChannelThumb(baseNote, accountViewModel, nav) is AppDefinitionEvent -> RenderContentDVMThumb(baseNote, accountViewModel, nav) - is FollowListEvent -> RenderFollowSetThumb(baseNote, accountViewModel, nav) - is LongTextNoteEvent -> RenderLongFormThumb(baseNote, accountViewModel, nav) + is StarterPackEvent -> RenderFollowSetThumb(baseNote, accountViewModel, nav) + is LongFormContentEvent -> RenderLongFormThumb(baseNote, accountViewModel, nav) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoverScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoverScreen.kt index bb061315f3..f85d225362 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoverScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoverScreen.kt @@ -86,9 +86,9 @@ import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.datasource.DiscoveryFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.TabItem -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent -import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import com.vitorpamplona.quartz.nip51Lists.starterPack.StarterPackEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent @@ -140,14 +140,14 @@ fun DiscoverScreen( discoveryFollowSetsFeedContentState, "DiscoverFollowSets", ScrollStateKeys.DISCOVER_FOLLOWS, - FollowListEvent.KIND, + StarterPackEvent.KIND, ), TabItem( Res.string.discover_reads, discoveryReadsFeedContentState, "DiscoverReads", ScrollStateKeys.DISCOVER_READS, - LongTextNoteEvent.KIND, + LongFormContentEvent.KIND, ), TabItem( Res.string.discover_content_v2, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/DiscoverLongFormFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/DiscoverLongFormFeedFilter.kt index 4ac6cca8ed..33fdf104ce 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/DiscoverLongFormFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/DiscoverLongFormFeedFilter.kt @@ -28,7 +28,7 @@ import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.ui.dal.FilterByListParams import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent open class DiscoverLongFormFeedFilter( val account: Account, @@ -50,9 +50,9 @@ open class DiscoverLongFormFeedFilter( override fun feed(): List { val params = buildFilterParams(account) val notes = - LocalCache.addressables.filterIntoSet(LongTextNoteEvent.KIND) { _, it -> + LocalCache.addressables.filterIntoSet(LongFormContentEvent.KIND) { _, it -> val noteEvent = it.event - noteEvent is LongTextNoteEvent && params.match(noteEvent, it.relays) + noteEvent is LongFormContentEvent && params.match(noteEvent, it.relays) } return sort(notes) } @@ -70,7 +70,7 @@ open class DiscoverLongFormFeedFilter( return collection.filterTo(HashSet()) { val noteEvent = it.event - noteEvent is LongTextNoteEvent && params.match(noteEvent, it.relays) + noteEvent is LongFormContentEvent && params.match(noteEvent, it.relays) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormCard.kt index 226c1c0675..e780927f4a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormCard.kt @@ -27,7 +27,7 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent import com.vitorpamplona.amethyst.ui.note.types.LongFormHeader import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent @Composable fun RenderLongFormThumb( @@ -35,7 +35,7 @@ fun RenderLongFormThumb( accountViewModel: AccountViewModel, nav: INav, ) { - val noteEvent by observeNoteEvent(baseNote, accountViewModel) + val noteEvent by observeNoteEvent(baseNote, accountViewModel) noteEvent?.let { LongFormHeader( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt index d03ab0074b..e076080373 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt @@ -91,7 +91,7 @@ import com.vitorpamplona.quartz.nip10Notes.content.findNostrUris import com.vitorpamplona.quartz.nip10Notes.content.findURLs import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes import com.vitorpamplona.quartz.nip22Comments.CommentEvent -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent import com.vitorpamplona.quartz.nip30CustomEmoji.emojis import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarningReason @@ -266,7 +266,7 @@ class LongFormPostViewModel : } else { val noteEvent = version?.event - if (noteEvent is LongTextNoteEvent) { + if (noteEvent is LongFormContentEvent) { title = TextFieldValue(noteEvent.title() ?: "") summary = TextFieldValue(noteEvent.summary() ?: "") publishedAt = noteEvent.publishedAt() ?: noteEvent.createdAt @@ -300,12 +300,12 @@ class LongFormPostViewModel : private fun loadFromDraft(draft: Note) { val draftEvent = draft.event ?: return - if (draftEvent is LongTextNoteEvent) { + if (draftEvent is LongFormContentEvent) { loadFromDraft(draftEvent) } } - private fun loadFromDraft(draftEvent: LongTextNoteEvent) { + private fun loadFromDraft(draftEvent: LongFormContentEvent) { title = TextFieldValue(draftEvent.title() ?: "") summary = TextFieldValue(draftEvent.summary() ?: "") publishedAt = draftEvent.publishedAt() ?: draftEvent.createdAt @@ -441,7 +441,7 @@ class LongFormPostViewModel : val contentWarningReason = if (wantsToMarkAsSensitive) contentWarningDescription else null val localExpirationDate = if (wantsExpirationDate) expirationDate else null - return LongTextNoteEvent.build( + return LongFormContentEvent.build( description = tagger.message, title = title.text.trim(), summary = summary.text.trim().ifBlank { null }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/RenderPublicChatChannelThumb.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/RenderPublicChatChannelThumb.kt index 4623df34fe..fda82b5723 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/RenderPublicChatChannelThumb.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/RenderPublicChatChannelThumb.kt @@ -53,6 +53,7 @@ import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.Mute import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsByProxyTopNavFilter import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.chat_about_topic +import com.vitorpamplona.amethyst.commons.ui.layouts.LeftPictureLayout import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.HalfTopPadding @@ -61,7 +62,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.RowColSpacing import com.vitorpamplona.amethyst.commons.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.commons.ui.theme.grayText import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel -import com.vitorpamplona.amethyst.ui.layouts.LeftPictureLayout import com.vitorpamplona.amethyst.ui.note.DisplayAuthorBanner import com.vitorpamplona.amethyst.ui.note.Gallery import com.vitorpamplona.amethyst.ui.note.LikeReaction diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/DiscoverFollowSetsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/DiscoverFollowSetsFeedFilter.kt index 13de523110..1e0fdcad4d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/DiscoverFollowSetsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/DiscoverFollowSetsFeedFilter.kt @@ -28,7 +28,7 @@ import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.ui.dal.FilterByListParams import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder -import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import com.vitorpamplona.quartz.nip51Lists.starterPack.StarterPackEvent open class DiscoverFollowSetsFeedFilter( val account: Account, @@ -49,9 +49,9 @@ open class DiscoverFollowSetsFeedFilter( val params = buildFilterParams(account) val notes = - LocalCache.addressables.filterIntoSet(FollowListEvent.KIND) { _, it -> + LocalCache.addressables.filterIntoSet(StarterPackEvent.KIND) { _, it -> val noteEvent = it.event - noteEvent is FollowListEvent && params.match(noteEvent, it.relays) + noteEvent is StarterPackEvent && params.match(noteEvent, it.relays) } return sort(notes) @@ -70,7 +70,7 @@ open class DiscoverFollowSetsFeedFilter( return collection.filterTo(HashSet()) { val noteEvent = it.event - noteEvent is FollowListEvent && params.match(noteEvent, it.relays) + noteEvent is StarterPackEvent && params.match(noteEvent, it.relays) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/FollowSetCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/FollowSetCard.kt index 282e4abe69..3e249d4a59 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/FollowSetCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/FollowSetCard.kt @@ -63,7 +63,7 @@ import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageBannerBackground import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import com.vitorpamplona.quartz.nip51Lists.starterPack.StarterPackEvent import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList @@ -82,7 +82,7 @@ fun RenderFollowSetThumb( nav: INav, ) { val card by observeNoteAndMap(baseNote, accountViewModel) { - val noteEvent = it.event as? FollowListEvent + val noteEvent = it.event as? StarterPackEvent FollowSetCard( name = noteEvent?.title()?.ifBlank { null } ?: noteEvent?.dTag() ?: "", @@ -111,7 +111,7 @@ fun RenderFollowSetThumbPreview() { val nav = EmptyNav() val followCard = - FollowListEvent( + StarterPackEvent( id = "eca31634fce7c9068b56fa8db9f387da70bdcceb3986a77ca1a9844f3128eb5f", pubKey = "3c39a7b53dec9ac85acf08b267637a9841e6df7b7b0f5e2ac56a8cf107de37da", createdAt = 1761736286, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/DiscoverLiveFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/DiscoverLiveFeedFilter.kt index 3f1dd2d396..8b7e5cb5e5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/DiscoverLiveFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/DiscoverLiveFeedFilter.kt @@ -32,8 +32,8 @@ import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.Aut import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavFilter import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsByOutboxTopNavFilter import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsByProxyTopNavFilter +import com.vitorpamplona.amethyst.commons.service.OnlineChecker import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.service.OnlineChecker import com.vitorpamplona.amethyst.ui.dal.FilterByListParams import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/CommunityCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/CommunityCard.kt index 0a4dc47515..43dfdebcbe 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/CommunityCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/CommunityCard.kt @@ -55,6 +55,7 @@ import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.Mute import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsByProxyTopNavFilter import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.community_about_topic +import com.vitorpamplona.amethyst.commons.ui.layouts.LeftPictureLayout import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.HalfTopPadding @@ -63,7 +64,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.RowColSpacing import com.vitorpamplona.amethyst.commons.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.commons.ui.theme.grayText import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent -import com.vitorpamplona.amethyst.ui.layouts.LeftPictureLayout import com.vitorpamplona.amethyst.ui.note.DisplayAuthorBanner import com.vitorpamplona.amethyst.ui.note.Gallery import com.vitorpamplona.amethyst.ui.note.LikeReaction diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DVMCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DVMCard.kt index 64b35607fc..89efcd5e61 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DVMCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DVMCard.kt @@ -41,6 +41,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.commons.model.AddressableNote import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.ui.layouts.LeftPictureLayout import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.theme.HalfTopPadding import com.vitorpamplona.amethyst.commons.ui.theme.RowColSpacing5dp @@ -51,7 +52,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.grayText import com.vitorpamplona.amethyst.commons.ui.theme.nip05 import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.components.MyAsyncImage -import com.vitorpamplona.amethyst.ui.layouts.LeftPictureLayout import com.vitorpamplona.amethyst.ui.note.LikeReaction import com.vitorpamplona.amethyst.ui.note.ZapReaction import com.vitorpamplona.amethyst.ui.note.elements.BannerImage diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt index 5bcbfa664a..a0f559056c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt @@ -87,13 +87,13 @@ import com.vitorpamplona.amethyst.ui.screen.RenderFeedState import com.vitorpamplona.amethyst.ui.screen.SaveableFeedState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.DVMCard -import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.dal.NIP90ContentDiscoveryFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.dal.DvmContentDiscoveryFeedViewModel import com.vitorpamplona.quartz.lightning.LnInvoiceUtil import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppMetadata -import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent -import com.vitorpamplona.quartz.nip90Dvms.status.NIP90StatusEvent +import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.DvmContentDiscoveryResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.status.DvmStatusEvent import kotlinx.coroutines.launch @Composable @@ -190,9 +190,9 @@ fun ObserverContentDiscoveryResponse( val resultFlow = remember(dvmRequestId) { accountViewModel.account.cache - .observeLatestEvent( + .observeLatestEvent( Filter( - kinds = listOf(NIP90ContentDiscoveryResponseEvent.KIND), + kinds = listOf(DvmContentDiscoveryResponseEvent.KIND), tags = mapOf("e" to listOf(dvmRequestId.idHex)), limit = 1, ), @@ -231,9 +231,9 @@ fun ObserverDvmStatusResponse( val statusFlow = remember(dvmRequestId) { accountViewModel.account.cache - .observeLatestEvent( + .observeLatestEvent( Filter( - kinds = listOf(NIP90StatusEvent.KIND), + kinds = listOf(DvmStatusEvent.KIND), tags = mapOf("e" to listOf(dvmRequestId)), limit = 1, ), @@ -258,27 +258,27 @@ fun ObserverDvmStatusResponse( fun PrepareViewContentDiscoveryModels( dvm: User, dvmRequestId: String, - latestResponse: NIP90ContentDiscoveryResponseEvent, + latestResponse: DvmContentDiscoveryResponseEvent, onRefresh: () -> Unit, accountViewModel: AccountViewModel, nav: INav, ) { - val resultFeedViewModel: NIP90ContentDiscoveryFeedViewModel = + val resultFeedViewModel: DvmContentDiscoveryFeedViewModel = viewModel( key = "NostrNIP90ContentDiscoveryFeedViewModel${dvm.pubkeyHex}$dvmRequestId", - factory = NIP90ContentDiscoveryFeedViewModel.Factory(accountViewModel.account, dvmKey = dvm.pubkeyHex, requestId = dvmRequestId), + factory = DvmContentDiscoveryFeedViewModel.Factory(accountViewModel.account, dvmKey = dvm.pubkeyHex, requestId = dvmRequestId), ) LaunchedEffect(key1 = dvmRequestId, latestResponse.id) { resultFeedViewModel.invalidateData() } - RenderNostrNIP90ContentDiscoveryScreen(resultFeedViewModel, onRefresh, accountViewModel, nav) + RenderNostrDvmContentDiscoveryScreen(resultFeedViewModel, onRefresh, accountViewModel, nav) } @Composable -fun RenderNostrNIP90ContentDiscoveryScreen( - resultFeedViewModel: NIP90ContentDiscoveryFeedViewModel, +fun RenderNostrDvmContentDiscoveryScreen( + resultFeedViewModel: DvmContentDiscoveryFeedViewModel, onRefresh: () -> Unit, accountViewModel: AccountViewModel, nav: INav, @@ -303,7 +303,7 @@ fun RenderNostrNIP90ContentDiscoveryScreen( @Composable fun FeedDVM( appDefinitionNote: Note, - latestStatus: NIP90StatusEvent, + latestStatus: DvmStatusEvent, accountViewModel: AccountViewModel, nav: INav, ) { @@ -374,7 +374,7 @@ fun FeedDVM( @Composable fun DvmPaymentActions( - latestStatus: NIP90StatusEvent, + latestStatus: DvmStatusEvent, accountViewModel: AccountViewModel, nav: INav, onStatusUpdate: (String) -> Unit, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/FavoriteAlgoFeedToggle.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/FavoriteAlgoFeedToggle.kt index 1f0670563d..2eb0e01e0b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/FavoriteAlgoFeedToggle.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/FavoriteAlgoFeedToggle.kt @@ -38,7 +38,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNo import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent -import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryRequest.NIP90ContentDiscoveryRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryRequest.DvmContentDiscoveryRequestEvent /** * Inline star toggle that follows / unfollows a NIP-90 content-discovery DVM. @@ -60,7 +60,7 @@ fun FavoriteAlgoFeedToggle( ) { val supportsContentDiscovery by observeNoteAndMap(appDefinitionNote, accountViewModel) { note -> - (note.event as? AppDefinitionEvent)?.includeKind(NIP90ContentDiscoveryRequestEvent.KIND) == true + (note.event as? AppDefinitionEvent)?.includeKind(DvmContentDiscoveryRequestEvent.KIND) == true } if (!supportsContentDiscovery) return diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/dal/NIP90ContentDiscoveryFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/dal/DvmContentDiscoveryFeedViewModel.kt similarity index 89% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/dal/NIP90ContentDiscoveryFeedViewModel.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/dal/DvmContentDiscoveryFeedViewModel.kt index 5bf18acf92..6e16027d13 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/dal/NIP90ContentDiscoveryFeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/dal/DvmContentDiscoveryFeedViewModel.kt @@ -27,17 +27,17 @@ import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.ui.screen.AndroidFeedViewModel @Stable -class NIP90ContentDiscoveryFeedViewModel( +class DvmContentDiscoveryFeedViewModel( val account: Account, dvmKey: String, requestId: String, -) : AndroidFeedViewModel(NIP90ContentDiscoveryResponseFilter(account, dvmKey, requestId)) { +) : AndroidFeedViewModel(DvmContentDiscoveryResponseFilter(account, dvmKey, requestId)) { class Factory( val account: Account, val dvmKey: String, val requestId: String, ) : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): T = NIP90ContentDiscoveryFeedViewModel(account, dvmKey, requestId) as T + override fun create(modelClass: Class): T = DvmContentDiscoveryFeedViewModel(account, dvmKey, requestId) as T } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/dal/NIP90ContentDiscoveryResponseFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/dal/DvmContentDiscoveryResponseFilter.kt similarity index 90% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/dal/NIP90ContentDiscoveryResponseFilter.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/dal/DvmContentDiscoveryResponseFilter.kt index 286a7e321a..b9c8653a49 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/dal/NIP90ContentDiscoveryResponseFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/dal/DvmContentDiscoveryResponseFilter.kt @@ -27,9 +27,9 @@ import com.vitorpamplona.amethyst.commons.model.observables.CreatedAtComparator import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.ui.dal.FilterByListParams import com.vitorpamplona.quartz.nip01Core.tags.events.isTaggedEvent -import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.DvmContentDiscoveryResponseEvent -open class NIP90ContentDiscoveryResponseFilter( +open class DvmContentDiscoveryResponseFilter( val account: Account, val dvmkey: String, val request: String, @@ -40,7 +40,7 @@ open class NIP90ContentDiscoveryResponseFilter( fun acceptableEvent(note: Note): Boolean { val noteEvent = note.event - return noteEvent is NIP90ContentDiscoveryResponseEvent && noteEvent.isTaggedEvent(request) + return noteEvent is DvmContentDiscoveryResponseEvent && noteEvent.isTaggedEvent(request) } override fun feed(): List { @@ -54,7 +54,7 @@ open class NIP90ContentDiscoveryResponseFilter( comparator = CreatedAtComparator, ) - val noteEvent = latestNote?.event as? NIP90ContentDiscoveryResponseEvent ?: return listOf() + val noteEvent = latestNote?.event as? DvmContentDiscoveryResponseEvent ?: return listOf() return noteEvent.innerTags().mapNotNull { LocalCache.checkGetOrCreateNote(it) @@ -78,7 +78,7 @@ open class NIP90ContentDiscoveryResponseFilter( latestNote = maxNote } - val noteEvent = latestNote?.event as? NIP90ContentDiscoveryResponseEvent ?: return setOf() + val noteEvent = latestNote?.event as? DvmContentDiscoveryResponseEvent ?: return setOf() return noteEvent .innerTags() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabPreloadSweeper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabPreloadSweeper.kt index 48c143ee19..8c68f970f1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabPreloadSweeper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabPreloadSweeper.kt @@ -23,8 +23,8 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.embed import android.content.Context import android.os.Build import androidx.annotation.RequiresApi +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.model.navigation.favoriteIds -import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry import com.vitorpamplona.amethyst.napplet.NappletNetworkRegistry import com.vitorpamplona.amethyst.napplet.WebAppNetworkRegistry import kotlinx.coroutines.CoroutineScope @@ -113,7 +113,9 @@ object EmbeddedTabPreloadSweeper { NappletNetworkRegistry.awaitReady() var attempt = 0 while (isActive) { - val byId = FavoriteAppsRegistry.favorites.value.associateBy { it.id } + val byId = + Amethyst.instance.favoriteApps.favorites.value + .associateBy { it.id } var stillPending = false for (id in favoriteIds) { val app = byId[id] ?: continue diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/FavoriteAppManifestPreloader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/FavoriteAppManifestPreloader.kt index b38eee524f..9edabf3bdb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/FavoriteAppManifestPreloader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/FavoriteAppManifestPreloader.kt @@ -26,9 +26,9 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.compose.runtime.remember import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp import com.vitorpamplona.amethyst.commons.model.cache.LocalCache -import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip01Core.core.Event @@ -60,7 +60,8 @@ private const val MANIFEST_OFFLINE_FALLBACK_MS = 2_000L */ @Composable fun FavoriteAppManifestPreloader(accountViewModel: AccountViewModel) { - val favorites by FavoriteAppsRegistry.favorites.collectAsStateWithLifecycle() + val favorites by Amethyst.instance.favoriteApps.favorites + .collectAsStateWithLifecycle() val coordinates = remember(favorites) { favorites.filterIsInstance().map { it.coordinate } @@ -91,7 +92,7 @@ private fun WatchFavoriteManifest( LaunchedEffect(event?.id) { val resolved = event ?: return@LaunchedEffect withContext(Dispatchers.IO) { - FavoriteAppsRegistry.cacheManifest(coordinate, resolved.toJson()) + Amethyst.instance.favoriteApps.cacheManifest(coordinate, resolved.toJson()) } } @@ -103,7 +104,7 @@ private fun WatchFavoriteManifest( delay(MANIFEST_OFFLINE_FALLBACK_MS) if (LocalCache.getAddressableNoteIfExists(coordinate)?.event != null) return@LaunchedEffect withContext(Dispatchers.IO) { - val cached = FavoriteAppsRegistry.cachedManifest(coordinate) ?: return@withContext + val cached = Amethyst.instance.favoriteApps.cachedManifest(coordinate) ?: return@withContext Event.fromJsonOrNull(cached)?.let { LocalCache.justConsume(it, null, false) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/BrowseEmojiSetsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/BrowseEmojiSetsScreen.kt index 4aa7c25131..ddcb0b073f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/BrowseEmojiSetsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/BrowseEmojiSetsScreen.kt @@ -46,12 +46,12 @@ import com.vitorpamplona.amethyst.commons.ui.feeds.FeedError import com.vitorpamplona.amethyst.commons.ui.feeds.LoadingFeed import com.vitorpamplona.amethyst.commons.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.commons.ui.feeds.ScrollStateKeys +import com.vitorpamplona.amethyst.commons.ui.feeds.StickToTopOnPrepend import com.vitorpamplona.amethyst.commons.ui.feeds.WatchLifecycleAndUpdateModel +import com.vitorpamplona.amethyst.commons.ui.feeds.WatchScrollToTop import com.vitorpamplona.amethyst.commons.ui.feeds.rememberForeverLazyGridState import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav -import com.vitorpamplona.amethyst.ui.feeds.StickToTopOnPrepend -import com.vitorpamplona.amethyst.ui.feeds.WatchScrollToTop import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/list/ListOfEmojiPacksScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/list/ListOfEmojiPacksScreen.kt index 3d1af6ad19..e385a772aa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/list/ListOfEmojiPacksScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/list/ListOfEmojiPacksScreen.kt @@ -315,7 +315,7 @@ private fun EmojiListToggleRow( LoadAddressableNote(packAddress) { it?.let { packNote -> LoadAddressableNote( - accountViewModel.account.emoji.getEmojiPackSelectionAddress(), + accountViewModel.account.emoji.getEmojiListAddress(), ) { selectionNote -> selectionNote?.let { usersEmojiList -> val hasAddedThis by observeNoteAndMap(usersEmojiList, accountViewModel) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/membershipManagement/EmojiPackSelectionScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/membershipManagement/EmojiPackSelectionScreen.kt index 798e89e207..e3774aa912 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/membershipManagement/EmojiPackSelectionScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/membershipManagement/EmojiPackSelectionScreen.kt @@ -121,7 +121,7 @@ private fun EmojiPackSelectionBody( ) { item { LoadAddressableNote( - address = accountViewModel.account.emoji.getEmojiPackSelectionAddress(), + address = accountViewModel.account.emoji.getEmojiListAddress(), ) { selectionNote -> selectionNote?.let { val hasAddedThis by observeNoteAndMap(it, accountViewModel) { currentNote -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/membershipManagement/MyEmojiListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/membershipManagement/MyEmojiListScreen.kt index 6f5feccd25..b23457dc69 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/membershipManagement/MyEmojiListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/membershipManagement/MyEmojiListScreen.kt @@ -66,7 +66,7 @@ import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent -import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEvent +import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiListEvent import com.vitorpamplona.quartz.nip30CustomEmoji.taggedEmojis // Screen that lets the logged-in user inspect the contents of their kind 10030 selection @@ -79,7 +79,7 @@ fun MyEmojiListScreen( nav: INav, ) { LoadAddressableNote( - address = accountViewModel.account.emoji.getEmojiPackSelectionAddress(), + address = accountViewModel.account.emoji.getEmojiListAddress(), ) { selectionNote -> selectionNote?.let { MyEmojiListView( @@ -102,7 +102,7 @@ private fun MyEmojiListView( TopBarWithBackButton(caption = stringRes(Res.string.my_emoji_list_title), nav) }, ) { contentPadding -> - val packAddresses by observeNoteEventAndMap>( + val packAddresses by observeNoteEventAndMap>( selectionNote, accountViewModel, ) { event -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/FavoriteAppsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/FavoriteAppsScreen.kt index b29f7462c2..f3e9e9f896 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/FavoriteAppsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/FavoriteAppsScreen.kt @@ -59,6 +59,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.browser.OmniboxInput import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon @@ -72,9 +73,7 @@ import com.vitorpamplona.amethyst.commons.resources.favorite_apps import com.vitorpamplona.amethyst.commons.resources.favorite_apps_empty import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.stringRes -import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry import com.vitorpamplona.amethyst.favorites.FavoriteAppLauncher -import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry import com.vitorpamplona.amethyst.favorites.PreloadFavoriteNostrApps import com.vitorpamplona.amethyst.favorites.rememberNappletIconModel import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar @@ -94,7 +93,8 @@ fun FavoriteAppsScreen( ) { val appStillLoadingStr = stringRes(Res.string.favorite_app_still_loading) val context = LocalContext.current - val apps by FavoriteAppsRegistry.favorites.collectAsStateWithLifecycle() + val apps by Amethyst.instance.favoriteApps.favorites + .collectAsStateWithLifecycle() // Fetch favorited nsite/napplet manifests up front so a tap launches immediately instead of showing // "isn't loaded yet" until the user happens to visit the nsite/napplet feed. @@ -126,7 +126,7 @@ fun FavoriteAppsScreen( FavoriteAppsGrid( apps = apps, onOpen = { FavoriteAppLauncher.launch(context, it, appStillLoadingStr) }, - onRemove = { FavoriteAppsRegistry.remove(it.id) }, + onRemove = { Amethyst.instance.favoriteApps.remove(it.id) }, modifier = Modifier .fillMaxSize() @@ -189,12 +189,13 @@ internal fun FavoriteAppCell( // For a plain web favorite, prefer the favicon captured when its site was opened; an nsite/napplet uses // the verified icon blob bundled in its own content. Observing the key set recomputes the model as a // captured favicon arrives. - val iconKeys by BrowserIconRegistry.keys.collectAsStateWithLifecycle() + val iconKeys by Amethyst.instance.browserIcons.keys + .collectAsStateWithLifecycle() val faviconModel = when (app) { is FavoriteApp.WebApp -> remember(app, iconKeys) { - OmniboxInput.hostOf(app.url)?.let(BrowserIconRegistry::iconModelFor) + OmniboxInput.hostOf(app.url)?.let(Amethyst.instance.browserIcons::iconModelFor) } is FavoriteApp.NostrApp -> rememberNappletIconModel(app.coordinate) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/FavoriteToggleButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/FavoriteToggleButton.kt index b85cf1126c..0afea73440 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/FavoriteToggleButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/FavoriteToggleButton.kt @@ -27,6 +27,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols @@ -34,7 +35,6 @@ import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.favorite_app_add import com.vitorpamplona.amethyst.commons.resources.favorite_app_remove import com.vitorpamplona.amethyst.commons.ui.stringRes -import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry /** * A star toggle that pins/unpins an nsite or napplet (a [FavoriteApp.NostrApp]) by its addressable @@ -47,16 +47,17 @@ fun FavoriteToggleButton( label: String, iconUrl: String? = null, ) { - val apps by FavoriteAppsRegistry.favorites.collectAsStateWithLifecycle() + val apps by Amethyst.instance.favoriteApps.favorites + .collectAsStateWithLifecycle() val id = "nostr:$coordinate" val isFavorite = remember(apps, id) { apps.any { it.id == id } } IconButton( onClick = { if (isFavorite) { - FavoriteAppsRegistry.remove(id) + Amethyst.instance.favoriteApps.remove(id) } else { - FavoriteAppsRegistry.add(FavoriteApp.NostrApp(coordinate, label, System.currentTimeMillis(), iconUrl)) + Amethyst.instance.favoriteApps.add(FavoriteApp.NostrApp(coordinate, label, System.currentTimeMillis(), iconUrl)) } }, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/NostrAppScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/NostrAppScreen.kt index 3a7012cdcc..953adc5305 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/NostrAppScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/NostrAppScreen.kt @@ -57,6 +57,7 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.browser.BrowserChrome import com.vitorpamplona.amethyst.commons.browser.ui.pill.AccessInfoSheet import com.vitorpamplona.amethyst.commons.browser.ui.pill.BrowserPillEvent @@ -75,7 +76,6 @@ import com.vitorpamplona.amethyst.commons.resources.favorite_notice_uploaded import com.vitorpamplona.amethyst.commons.ui.loadStringRes import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.favorites.FavoriteAppLauncher -import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry import com.vitorpamplona.amethyst.napplet.NappletNetworkRegistry import com.vitorpamplona.amethyst.napplethost.HostProfile import com.vitorpamplona.amethyst.napplethost.NappletEmbedContract @@ -157,7 +157,8 @@ private fun EmbeddedNostrAppTab( var showAccess by remember { mutableStateOf(false) } var textZoom by remember(coordinate) { mutableIntStateOf(BrowserChrome.DEFAULT_TEXT_ZOOM) } - val apps by FavoriteAppsRegistry.favorites.collectAsStateWithLifecycle() + val apps by Amethyst.instance.favoriteApps.favorites + .collectAsStateWithLifecycle() val isFavorite = remember(apps, coordinate) { apps.any { it.id == "nostr:$coordinate" } } val controller = @@ -217,10 +218,11 @@ private fun EmbeddedNostrAppTab( BrowserChrome.Action.SITE_SETTINGS -> nav.nav(Route.ConnectedAppDetail(permissionCoordinate)) BrowserChrome.Action.FAVORITE -> { val favId = "nostr:$coordinate" - if (FavoriteAppsRegistry.isFavorite(favId)) { - FavoriteAppsRegistry.remove(favId) + val favorites = Amethyst.instance.favoriteApps + if (favorites.isFavorite(favId)) { + favorites.remove(favId) } else { - FavoriteAppsRegistry.add(FavoriteApp.NostrApp(coordinate, title, System.currentTimeMillis())) + favorites.add(FavoriteApp.NostrApp(coordinate, title, System.currentTimeMillis())) } } else -> Unit diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/FollowPackFeedScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/FollowPackFeedScreen.kt index 5d9412c8fb..cad3be3193 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/FollowPackFeedScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/FollowPackFeedScreen.kt @@ -85,7 +85,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.dal.Follow import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.dal.FollowPackMembersUserFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.datasource.FollowPackFeedFilterAssemblerSubscription import com.vitorpamplona.quartz.nip01Core.core.Address -import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import com.vitorpamplona.quartz.nip51Lists.starterPack.StarterPackEvent import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @@ -309,7 +309,7 @@ private fun DisplayBanner( modifier: Modifier = Modifier, accountViewModel: AccountViewModel, ) { - val noteEvent by observeNoteEvent(baseNote, accountViewModel) + val noteEvent by observeNoteEvent(baseNote, accountViewModel) noteEvent?.image()?.let { AsyncImage( @@ -327,7 +327,7 @@ fun FollowPackHeader( accountViewModel: AccountViewModel, nav: INav, ) { - val noteEvent by observeNoteEvent(baseNote, accountViewModel) + val noteEvent by observeNoteEvent(baseNote, accountViewModel) Text( text = noteEvent?.title() ?: baseNote.dTag(), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackFeedConversationsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackFeedConversationsFeedFilter.kt index 352fc2da65..2a56243e86 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackFeedConversationsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackFeedConversationsFeedFilter.kt @@ -37,7 +37,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent -import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import com.vitorpamplona.quartz.nip51Lists.starterPack.StarterPackEvent import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent @@ -62,7 +62,7 @@ class FollowPackFeedConversationsFeedFilter( ) } - val followPackEvent = followPackNote.event as? FollowListEvent + val followPackEvent = followPackNote.event as? StarterPackEvent val follows = followPackEvent?.followIdSet() ?: emptySet() override fun applyFilter(newItems: Set): Set = innerApplyFilter(newItems) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackFeedNewThreadFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackFeedNewThreadFeedFilter.kt index e721fbdb88..26a3f6bd6f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackFeedNewThreadFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackFeedNewThreadFeedFilter.kt @@ -45,9 +45,9 @@ import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent -import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent -import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent +import com.vitorpamplona.quartz.nip51Lists.starterPack.StarterPackEvent +import com.vitorpamplona.quartz.nip54Wiki.WikiArticleEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent @@ -66,16 +66,16 @@ class FollowPackFeedNewThreadFeedFilter( MusicPlaylistEvent.KIND, PodcastMetadataEvent.KIND, InteractiveStoryPrologueEvent.KIND, - WikiNoteEvent.KIND, + WikiArticleEvent.KIND, NipTextEvent.KIND, ClassifiedsEvent.KIND, FundraiserEvent.KIND, BirdexEvent.KIND, - LongTextNoteEvent.KIND, + LongFormContentEvent.KIND, ) } - val followPackEvent = followPackNote.event as? FollowListEvent + val followPackEvent = followPackNote.event as? StarterPackEvent val follows = followPackEvent?.followIdSet() ?: emptySet() override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + followPackNote.idHex @@ -143,8 +143,8 @@ class FollowPackFeedNewThreadFeedFilter( noteEvent is BirdexEvent || noteEvent is BirdDetectionEvent || noteEvent.isRenderableRepost() || - (noteEvent is LongTextNoteEvent && noteEvent.content.isNotEmpty()) || - (noteEvent is WikiNoteEvent && noteEvent.content.isNotEmpty()) || + (noteEvent is LongFormContentEvent && noteEvent.content.isNotEmpty()) || + (noteEvent is WikiArticleEvent && noteEvent.content.isNotEmpty()) || noteEvent is ZapPollEvent || noteEvent is HighlightEvent || noteEvent is InteractiveStoryPrologueEvent || diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackMembersFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackMembersFeedFilter.kt index 033edb6744..0e6b1ee160 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackMembersFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackMembersFeedFilter.kt @@ -25,7 +25,7 @@ import com.vitorpamplona.amethyst.commons.model.AddressableNote import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.amethyst.commons.model.cache.LocalCache.checkGetOrCreateUser import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import com.vitorpamplona.quartz.nip51Lists.starterPack.StarterPackEvent class FollowPackMembersFeedFilter( val followPackNote: AddressableNote, @@ -33,10 +33,10 @@ class FollowPackMembersFeedFilter( ) : FeedFilter() { override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + followPackNote.idHex - val cache: MutableMap> = mutableMapOf() + val cache: MutableMap> = mutableMapOf() override fun feed(): List { - val followPackEvent = followPackNote.event as? FollowListEvent ?: return emptyList() + val followPackEvent = followPackNote.event as? StarterPackEvent ?: return emptyList() val previousList = cache[followPackEvent] if (previousList != null) return previousList diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/datasource/FollowPackFeedFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/datasource/FollowPackFeedFilterSubAssembler.kt index d392e9ceb3..13c6fe051f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/datasource/FollowPackFeedFilterSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/datasource/FollowPackFeedFilterSubAssembler.kt @@ -27,7 +27,7 @@ import com.vitorpamplona.amethyst.commons.relayClient.home.nip65Follows.filterHo import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import com.vitorpamplona.quartz.nip51Lists.starterPack.StarterPackEvent class FollowPackFeedFilterSubAssembler( client: INostrClient, @@ -40,7 +40,7 @@ class FollowPackFeedFilterSubAssembler( if (keys.isEmpty()) return emptyList() return keys.flatMap { val followPack = it.followPack.event - if (followPack is FollowListEvent) { + if (followPack is StarterPackEvent) { val filter = if (it.account.proxyRelayList.flow.value .isEmpty() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/list/FollowPacksFeedLoaded.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/list/FollowPacksFeedLoaded.kt index 9b77a2d811..5937c2290a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/list/FollowPacksFeedLoaded.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/list/FollowPacksFeedLoaded.kt @@ -38,7 +38,7 @@ import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness import com.vitorpamplona.amethyst.commons.ui.theme.FeedPadding import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.ChannelCardCompose -import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import com.vitorpamplona.quartz.nip51Lists.starterPack.StarterPackEvent @OptIn(ExperimentalFoundationApi::class) @Composable @@ -64,7 +64,7 @@ fun FollowPacksFeedLoaded( baseNote = item, routeForLastRead = "FollowPacksFeed", modifier = Modifier.fillMaxWidth(), - forceEventKind = FollowListEvent.KIND, + forceEventKind = StarterPackEvent.KIND, accountViewModel = accountViewModel, nav = nav, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/list/FollowPacksTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/list/FollowPacksTopBar.kt index 4775dd8a8c..644b0e02b0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/list/FollowPacksTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/list/FollowPacksTopBar.kt @@ -36,7 +36,7 @@ import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar import com.vitorpamplona.amethyst.ui.screen.FeedDefinition import com.vitorpamplona.amethyst.ui.screen.TopNavFilterState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import com.vitorpamplona.quartz.nip51Lists.starterPack.StarterPackEvent @Composable fun FollowPacksTopBar( @@ -49,7 +49,7 @@ fun FollowPacksTopBar( // The feed's own kind window, plus whatever the list spinner narrowed it to — a hashtag // or a geohash says itself as a token; a follow set does not, and seeds nothing. val me = accountViewModel.userProfile().pubkeyHex - val seed = remember(list, me) { SearchSeed.merge(SearchSeed.ofKinds(FollowListEvent.KIND), list.asSearchQuery(me)) } + val seed = remember(list, me) { SearchSeed.merge(SearchSeed.ofKinds(StarterPackEvent.KIND), list.asSearchQuery(me)) } UserDrawerSearchTopBar(accountViewModel, nav, seed) { FollowPacksTopNavFilterBar( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/dal/GeoHashFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/dal/GeoHashFeedFilter.kt index 5e3caa267d..3322db8e7d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/dal/GeoHashFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/dal/GeoHashFeedFilter.kt @@ -34,12 +34,12 @@ import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.geohash.isTaggedGeoHash -import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip04Dm.messages.EncryptedDmEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent -import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent +import com.vitorpamplona.quartz.nip54Wiki.WikiArticleEvent import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent @@ -79,10 +79,10 @@ class GeoHashFeedFilter( ): Boolean = ( event is TextNoteEvent || - event is LongTextNoteEvent || - event is WikiNoteEvent || + event is LongFormContentEvent || + event is WikiArticleEvent || event is ChannelMessageEvent || - event is PrivateDmEvent || + event is EncryptedDmEvent || event is ZapPollEvent || event is AudioHeaderEvent || event is MusicTrackEvent || diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/dal/HashtagFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/dal/HashtagFeedFilter.kt index 99c78942fd..0c7d4f80cc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/dal/HashtagFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/dal/HashtagFeedFilter.kt @@ -34,12 +34,12 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.hashtags.isTaggedHash -import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip04Dm.messages.EncryptedDmEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent -import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent +import com.vitorpamplona.quartz.nip54Wiki.WikiArticleEvent import com.vitorpamplona.quartz.nip73ExternalIds.topics.HashtagId import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent @@ -104,10 +104,10 @@ class HashtagFeedFilter( ( event is TextNoteEvent || event.isRenderableRepost() || - event is LongTextNoteEvent || - event is WikiNoteEvent || + event is LongFormContentEvent || + event is WikiArticleEvent || event is ChannelMessageEvent || - event is PrivateDmEvent || + event is EncryptedDmEvent || event is ZapPollEvent || event is AudioHeaderEvent || event is MusicTrackEvent || diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/FilterPostsByHashtags.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/FilterPostsByHashtags.kt index bfd839f1fa..2586985599 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/FilterPostsByHashtags.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/FilterPostsByHashtags.kt @@ -36,10 +36,10 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtagAlts import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent -import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent +import com.vitorpamplona.quartz.nip54Wiki.WikiArticleEvent import com.vitorpamplona.quartz.nip73ExternalIds.topics.HashtagId import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent @@ -51,12 +51,12 @@ val PostsByHashtagsKinds = listOf( TextNoteEvent.KIND, ChannelMessageEvent.KIND, - LongTextNoteEvent.KIND, + LongFormContentEvent.KIND, PollEvent.KIND, LiveActivitiesChatMessageEvent.KIND, ClassifiedsEvent.KIND, HighlightEvent.KIND, - WikiNoteEvent.KIND, + WikiArticleEvent.KIND, CommentEvent.KIND, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt index b3fb2e01f5..14b4ff9783 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt @@ -61,6 +61,8 @@ import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.tooling.preview.Preview import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.commons.feeds.ChannelFeedContentState +import com.vitorpamplona.amethyst.commons.feeds.ChannelFeedState import com.vitorpamplona.amethyst.commons.feeds.FeedContentState import com.vitorpamplona.amethyst.commons.feeds.FeedState import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel @@ -74,6 +76,7 @@ import com.vitorpamplona.amethyst.commons.resources.feed_is_empty import com.vitorpamplona.amethyst.commons.resources.home_tab_everything import com.vitorpamplona.amethyst.commons.resources.new_threads import com.vitorpamplona.amethyst.commons.resources.refresh +import com.vitorpamplona.amethyst.commons.service.OnlineChecker import com.vitorpamplona.amethyst.commons.ui.components.CrossfadeIfEnabled import com.vitorpamplona.amethyst.commons.ui.feeds.FeedError import com.vitorpamplona.amethyst.commons.ui.feeds.LoadingFeed @@ -94,10 +97,7 @@ import com.vitorpamplona.amethyst.commons.ui.theme.Size5dp import com.vitorpamplona.amethyst.commons.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.commons.ui.theme.TabRowHeight import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonRow -import com.vitorpamplona.amethyst.service.OnlineChecker import com.vitorpamplona.amethyst.service.location.LocationState -import com.vitorpamplona.amethyst.ui.feeds.ChannelFeedContentState -import com.vitorpamplona.amethyst.ui.feeds.ChannelFeedState import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt index 3297759834..1cf99188dd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt @@ -49,16 +49,16 @@ import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent -import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent +import com.vitorpamplona.quartz.nip54Wiki.WikiArticleEvent import com.vitorpamplona.quartz.nip64Chess.end.LiveChessGameEndEvent import com.vitorpamplona.quartz.nip64Chess.game.ChessGameEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent -import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent +import com.vitorpamplona.quartz.nip71Video.AddressableNormalVideoEvent +import com.vitorpamplona.quartz.nip71Video.AddressableShortVideoEvent import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent import com.vitorpamplona.quartz.nip71Video.VideoShortEvent -import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent @@ -77,15 +77,15 @@ class HomeNewThreadFeedFilter( MusicPlaylistEvent.KIND, PodcastMetadataEvent.KIND, InteractiveStoryPrologueEvent.KIND, - WikiNoteEvent.KIND, + WikiArticleEvent.KIND, ClassifiedsEvent.KIND, FundraiserEvent.KIND, BirdexEvent.KIND, - LongTextNoteEvent.KIND, + LongFormContentEvent.KIND, LiveChessGameEndEvent.KIND, AttestationEvent.KIND, - VideoHorizontalEvent.KIND, - VideoVerticalEvent.KIND, + AddressableNormalVideoEvent.KIND, + AddressableShortVideoEvent.KIND, EntityRatingEvent.KIND, ) } @@ -147,8 +147,8 @@ class HomeNewThreadFeedFilter( noteEvent is BirdexEvent || noteEvent is BirdDetectionEvent || noteEvent.isRenderableRepost() || - (noteEvent is LongTextNoteEvent && noteEvent.content.isNotEmpty()) || - (noteEvent is WikiNoteEvent && noteEvent.content.isNotEmpty()) || + (noteEvent is LongFormContentEvent && noteEvent.content.isNotEmpty()) || + (noteEvent is WikiArticleEvent && noteEvent.content.isNotEmpty()) || noteEvent is ZapPollEvent || noteEvent is PollEvent || noteEvent is HighlightEvent || @@ -166,8 +166,8 @@ class HomeNewThreadFeedFilter( noteEvent is PictureEvent || noteEvent is VideoNormalEvent || noteEvent is VideoShortEvent || - noteEvent is VideoHorizontalEvent || - noteEvent is VideoVerticalEvent || + noteEvent is AddressableNormalVideoEvent || + noteEvent is AddressableShortVideoEvent || noteEvent is TorrentEvent || noteEvent is AttestationEvent || noteEvent is AttestationRequestEvent || diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/live/LiveStatusIndicator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/live/LiveStatusIndicator.kt index 85f932fee3..69679d2ea5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/live/LiveStatusIndicator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/live/LiveStatusIndicator.kt @@ -32,7 +32,7 @@ import com.vitorpamplona.amethyst.commons.model.Channel import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel -import com.vitorpamplona.amethyst.service.OnlineChecker +import com.vitorpamplona.amethyst.commons.service.OnlineChecker import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent import com.vitorpamplona.quartz.utils.Log diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/nip75Goals/NewGoalViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/nip75Goals/NewGoalViewModel.kt index 22aac53186..1696cdae66 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/nip75Goals/NewGoalViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/nip75Goals/NewGoalViewModel.kt @@ -32,7 +32,7 @@ import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate -import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent +import com.vitorpamplona.quartz.nip75ZapGoals.ZapGoalEvent import com.vitorpamplona.quartz.utils.TimeUtils @Stable @@ -89,7 +89,7 @@ class NewGoalViewModel : ViewModel() { val sum = summary.text.ifBlank { null } val web = websiteUrl.text.ifBlank { null } - return GoalEvent.build( + return ZapGoalEvent.build( description = description.text, amount = amountMillisats, relays = relays, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/lists/PeopleListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/lists/PeopleListViewModel.kt index dc141e4b72..dcc16a37a9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/lists/PeopleListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/lists/PeopleListViewModel.kt @@ -31,7 +31,7 @@ import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent +import com.vitorpamplona.quartz.nip51Lists.followSet.FollowSetEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow @@ -57,12 +57,12 @@ class PeopleListViewModel : ViewModel() { selectedDTag .transformLatest { emitAll( - account.peopleLists.selectListFlow(it).flowOn(Dispatchers.IO), + account.followSets.selectListFlow(it).flowOn(Dispatchers.IO), ) }.flowOn(Dispatchers.IO) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) - fun selectedAddress() = PeopleListEvent.createAddress(account.userProfile().pubkeyHex, selectedDTag.value) + fun selectedAddress() = FollowSetEvent.createAddress(account.userProfile().pubkeyHex, selectedDTag.value) fun selectedNote() = account.cache.getOrCreateAddressableNote(selectedAddress()) @@ -79,23 +79,23 @@ class PeopleListViewModel : ViewModel() { } suspend fun deleteFollowSet() { - account.peopleLists.deleteFollowSet(selectedDTag.value, account) + account.followSets.deleteFollowSet(selectedDTag.value, account) } - fun loadNote(): AddressableNote? = account.peopleLists.getPeopleListNote(selectedDTag.value) + fun loadNote(): AddressableNote? = account.followSets.getPeopleListNote(selectedDTag.value) suspend fun removeUserFromSet( user: User, isPrivate: Boolean, ) { - account.peopleLists.removeUserFromSet(user, isPrivate, selectedDTag.value, account) + account.followSets.removeUserFromSet(user, isPrivate, selectedDTag.value, account) } suspend fun addUserToSet( user: User, isPrivate: Boolean, ) { - account.peopleLists.addUserToSet(user, selectedDTag.value, isPrivate, account) + account.followSets.addUserToSet(user, selectedDTag.value, isPrivate, account) } fun hasUserFlow( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/packs/FollowPackViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/packs/FollowPackViewModel.kt index 62948d2075..002eb5f4a6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/packs/FollowPackViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/packs/FollowPackViewModel.kt @@ -31,7 +31,7 @@ import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import com.vitorpamplona.quartz.nip51Lists.starterPack.StarterPackEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow @@ -57,12 +57,12 @@ class FollowPackViewModel : ViewModel() { selectedDTag .transformLatest { emitAll( - account.followLists.selectListFlow(it).flowOn(Dispatchers.IO), + account.starterPacks.selectListFlow(it).flowOn(Dispatchers.IO), ) }.flowOn(Dispatchers.IO) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) - fun selectedAddress() = FollowListEvent.createAddress(account.userProfile().pubkeyHex, selectedDTag.value) + fun selectedAddress() = StarterPackEvent.createAddress(account.userProfile().pubkeyHex, selectedDTag.value) fun selectedNote() = account.cache.getOrCreateAddressableNote(selectedAddress()) @@ -79,17 +79,17 @@ class FollowPackViewModel : ViewModel() { } suspend fun deleteFollowSet() { - account.followLists.deleteFollowSet(selectedDTag.value, account) + account.starterPacks.deleteFollowSet(selectedDTag.value, account) } - fun loadNote(): AddressableNote? = account.followLists.getPeopleListNote(selectedDTag.value) + fun loadNote(): AddressableNote? = account.starterPacks.getPeopleListNote(selectedDTag.value) suspend fun removeUserFromSet(user: User) { - account.followLists.removeUserFromSet(user, selectedDTag.value, account) + account.starterPacks.removeUserFromSet(user, selectedDTag.value, account) } suspend fun addUserToSet(user: User) { - account.followLists.addUserToSet(user, selectedDTag.value, account) + account.starterPacks.addUserToSet(user, selectedDTag.value, account) } fun hasUserFlow(user: User): Flow = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/FollowPackViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/FollowPackViewModel.kt index ad08cf8121..a2bdd2eb2d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/FollowPackViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/FollowPackViewModel.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list import androidx.compose.runtime.Stable import androidx.lifecycle.ViewModel -import com.vitorpamplona.amethyst.commons.model.nip51Lists.peopleList.PeopleList +import com.vitorpamplona.amethyst.commons.model.nip51Lists.followSets.PeopleList import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @Stable @@ -33,7 +33,7 @@ class FollowPackViewModel : ViewModel() { this.accountViewModel = accountViewModel } - fun listFlow() = accountViewModel.account.followLists.uiListFlow + fun listFlow() = accountViewModel.account.starterPacks.uiListFlow fun cloneItem( followSet: PeopleList, @@ -41,7 +41,7 @@ class FollowPackViewModel : ViewModel() { customDescription: String?, ) { accountViewModel.launchSigner { - accountViewModel.account.followLists.cloneFollowSet( + accountViewModel.account.starterPacks.cloneFollowSet( currentFollowPack = followSet, customCloneName = customName, customCloneDescription = customDescription, @@ -52,7 +52,7 @@ class FollowPackViewModel : ViewModel() { fun deleteItem(followSet: PeopleList) { accountViewModel.launchSigner { - accountViewModel.account.followLists.deleteFollowSet( + accountViewModel.account.starterPacks.deleteFollowSet( identifierTag = followSet.identifierTag, account = accountViewModel.account, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/PeopleListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/PeopleListViewModel.kt index 94e853c494..808b175ada 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/PeopleListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/PeopleListViewModel.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list import androidx.compose.runtime.Stable import androidx.lifecycle.ViewModel -import com.vitorpamplona.amethyst.commons.model.nip51Lists.peopleList.PeopleList +import com.vitorpamplona.amethyst.commons.model.nip51Lists.followSets.PeopleList import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @Stable @@ -33,7 +33,7 @@ class PeopleListViewModel : ViewModel() { this.accountViewModel = accountViewModel } - fun listFlow() = accountViewModel.account.peopleLists.uiListFlow + fun listFlow() = accountViewModel.account.followSets.uiListFlow fun cloneItem( followSet: PeopleList, @@ -41,7 +41,7 @@ class PeopleListViewModel : ViewModel() { customDescription: String?, ) { accountViewModel.launchSigner { - accountViewModel.account.peopleLists.cloneFollowSet( + accountViewModel.account.followSets.cloneFollowSet( currentPeopleList = followSet, customCloneName = customName, customCloneDescription = customDescription, @@ -52,7 +52,7 @@ class PeopleListViewModel : ViewModel() { fun deleteItem(followSet: PeopleList) { accountViewModel.launchSigner { - accountViewModel.account.peopleLists.deleteFollowSet( + accountViewModel.account.followSets.deleteFollowSet( identifierTag = followSet.identifierTag, account = accountViewModel.account, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/metadata/FollowPackMetadataViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/metadata/FollowPackMetadataViewModel.kt index 695e5e0985..89c0ab56f6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/metadata/FollowPackMetadataViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/metadata/FollowPackMetadataViewModel.kt @@ -31,7 +31,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerType -import com.vitorpamplona.amethyst.commons.model.nip51Lists.peopleList.PeopleList +import com.vitorpamplona.amethyst.commons.model.nip51Lists.followSets.PeopleList import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.avif_metadata_strip_failed import com.vitorpamplona.amethyst.commons.resources.failed_to_upload_media_no_details @@ -83,7 +83,7 @@ class FollowPackMetadataViewModel : ViewModel() { } fun load(dTag: String) { - peopleList = account.followLists.selectList(dTag) + peopleList = account.starterPacks.selectList(dTag) name.value = TextFieldValue(peopleList?.title ?: "") picture.value = TextFieldValue(peopleList?.image ?: "") description.value = TextFieldValue(peopleList?.description ?: "") @@ -94,14 +94,14 @@ class FollowPackMetadataViewModel : ViewModel() { val peopleList = peopleList if (peopleList == null) { val newListIdentifier = - accountViewModel.account.followLists.addFollowList( + accountViewModel.account.starterPacks.addFollowList( name = name.value.text, desc = description.value.text, image = picture.value.text, account = accountViewModel.account, ) } else { - accountViewModel.account.followLists.updateMetadata( + accountViewModel.account.starterPacks.updateMetadata( name = name.value.text, desc = description.value.text, image = picture.value.text, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/metadata/PeopleListMetadataViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/metadata/PeopleListMetadataViewModel.kt index 9e5ebac387..70a193bb50 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/metadata/PeopleListMetadataViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/metadata/PeopleListMetadataViewModel.kt @@ -31,7 +31,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerType -import com.vitorpamplona.amethyst.commons.model.nip51Lists.peopleList.PeopleList +import com.vitorpamplona.amethyst.commons.model.nip51Lists.followSets.PeopleList import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.avif_metadata_strip_failed import com.vitorpamplona.amethyst.commons.resources.failed_to_upload_media_no_details @@ -83,7 +83,7 @@ class PeopleListMetadataViewModel : ViewModel() { } fun load(dTag: String) { - peopleList = account.peopleLists.selectList(dTag) + peopleList = account.followSets.selectList(dTag) name.value = TextFieldValue(peopleList?.title ?: "") picture.value = TextFieldValue(peopleList?.image ?: "") description.value = TextFieldValue(peopleList?.description ?: "") @@ -95,14 +95,14 @@ class PeopleListMetadataViewModel : ViewModel() { accountViewModel.launchSigner { val peopleList = peopleList if (peopleList == null) { - accountViewModel.account.peopleLists.addFollowList( + accountViewModel.account.followSets.addFollowList( listName = name.value.text, listDescription = description.value.text, listImage = picture.value.text, account = accountViewModel.account, ) } else { - accountViewModel.account.peopleLists.updateMetadata( + accountViewModel.account.followSets.updateMetadata( listName = name.value.text, listDescription = description.value.text, listImage = picture.value.text, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/memberEdit/FollowListAndPackAndUserView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/memberEdit/FollowListAndPackAndUserView.kt index 7f61e68033..4dbd832a96 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/memberEdit/FollowListAndPackAndUserView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/memberEdit/FollowListAndPackAndUserView.kt @@ -63,9 +63,9 @@ fun FollowListAndPackAndUserView( accountViewModel: AccountViewModel, nav: INav, ) { - val followSetsState by accountViewModel.account.peopleLists.uiListFlow + val followSetsState by accountViewModel.account.followSets.uiListFlow .collectAsStateWithLifecycle() - val followPackFeedState by accountViewModel.account.followLists.uiListFlow + val followPackFeedState by accountViewModel.account.starterPacks.uiListFlow .collectAsStateWithLifecycle() val userName by observeUserName(userToAddOrRemove, accountViewModel) @@ -114,7 +114,7 @@ fun FollowListAndPackAndUserView( userIsPublicMember = list.publicMembers.contains(userToAddOrRemove), onRemoveUser = { accountViewModel.launchSigner { - accountViewModel.account.peopleLists.removeUserFromSet( + accountViewModel.account.followSets.removeUserFromSet( userToAddOrRemove, isPrivate = list.privateMembers.contains(userToAddOrRemove), list.identifierTag, @@ -129,7 +129,7 @@ fun FollowListAndPackAndUserView( }, onAddUserToList = { userShouldBePrivate -> accountViewModel.launchSigner { - accountViewModel.account.peopleLists.addUserToSet( + accountViewModel.account.followSets.addUserToSet( userToAddOrRemove, list.identifierTag, userShouldBePrivate, @@ -189,7 +189,7 @@ fun FollowListAndPackAndUserView( }, onRemoveUser = { accountViewModel.launchSigner { - accountViewModel.account.followLists.removeUserFromSet( + accountViewModel.account.starterPacks.removeUserFromSet( userToAddOrRemove, list.identifierTag, accountViewModel.account, @@ -199,7 +199,7 @@ fun FollowListAndPackAndUserView( memberSize = list.publicMembers.size, onAddUserToList = { accountViewModel.launchSigner { - accountViewModel.account.followLists.addUserToSet( + accountViewModel.account.starterPacks.addUserToSet( userToAddOrRemove, list.identifierTag, accountViewModel.account, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/livestreams/dal/LiveStreamsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/livestreams/dal/LiveStreamsFeedFilter.kt index 6c1bc81034..5fe9419106 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/livestreams/dal/LiveStreamsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/livestreams/dal/LiveStreamsFeedFilter.kt @@ -32,8 +32,8 @@ import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.Aut import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavFilter import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsByOutboxTopNavFilter import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsByProxyTopNavFilter +import com.vitorpamplona.amethyst.commons.service.OnlineChecker import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.service.OnlineChecker import com.vitorpamplona.amethyst.ui.dal.FilterByListParams import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/LongsTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/LongsTopBar.kt index 9e87884561..898057185a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/LongsTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/LongsTopBar.kt @@ -36,7 +36,7 @@ import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar import com.vitorpamplona.amethyst.ui.screen.FeedDefinition import com.vitorpamplona.amethyst.ui.screen.TopNavFilterState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent +import com.vitorpamplona.quartz.nip71Video.AddressableNormalVideoEvent @Composable fun LongsTopBar( @@ -49,7 +49,7 @@ fun LongsTopBar( // The feed's own kind window, plus whatever the list spinner narrowed it to — a hashtag // or a geohash says itself as a token; a follow set does not, and seeds nothing. val me = accountViewModel.userProfile().pubkeyHex - val seed = remember(list, me) { SearchSeed.merge(SearchSeed.ofKinds(VideoHorizontalEvent.KIND), list.asSearchQuery(me)) } + val seed = remember(list, me) { SearchSeed.merge(SearchSeed.ofKinds(AddressableNormalVideoEvent.KIND), list.asSearchQuery(me)) } UserDrawerSearchTopBar(accountViewModel, nav, seed) { LongsTopNavFilterBar( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/dal/LongsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/dal/LongsFeedFilter.kt index cfe0cbc3b5..138d6b8e34 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/dal/LongsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/longs/dal/LongsFeedFilter.kt @@ -33,7 +33,7 @@ import com.vitorpamplona.amethyst.ui.dal.FilterByListParams import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.dal.SupportedContent import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent -import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent +import com.vitorpamplona.quartz.nip71Video.AddressableNormalVideoEvent import com.vitorpamplona.quartz.nip71Video.VideoMeta import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent @@ -60,7 +60,7 @@ class LongsFeedFilter( LocalCache.notes.filterIntoSet { _, it -> acceptableEvent(it, params) } + - LocalCache.addressables.filterIntoSet(VideoHorizontalEvent.KIND) { _, it -> + LocalCache.addressables.filterIntoSet(AddressableNormalVideoEvent.KIND) { _, it -> acceptableEvent(it, params) } return sort(notes) @@ -91,7 +91,7 @@ class LongsFeedFilter( fun acceptableVideoiMetas(iMetas: List): Boolean = iMetas.any { videoFeedSupport.acceptableUrl(it.url, it.mimeType) } - fun acceptanceEvent(noteEvent: VideoHorizontalEvent) = acceptableVideoiMetas(noteEvent.imetaTags()) + fun acceptanceEvent(noteEvent: AddressableNormalVideoEvent) = acceptableVideoiMetas(noteEvent.imetaTags()) fun acceptanceEvent(noteEvent: VideoNormalEvent) = acceptableVideoiMetas(noteEvent.imetaTags()) @@ -106,7 +106,7 @@ class LongsFeedFilter( } return ( - (noteEvent is VideoHorizontalEvent && acceptanceEvent(noteEvent)) || + (noteEvent is AddressableNormalVideoEvent && acceptanceEvent(noteEvent)) || (noteEvent is VideoNormalEvent && acceptanceEvent(noteEvent)) ) && params.match(noteEvent, note.relays) && diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt index fa9308a6be..5386f09d5a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt @@ -113,7 +113,6 @@ import com.vitorpamplona.amethyst.commons.resources.nip46_signer_reconnecting import com.vitorpamplona.amethyst.commons.resources.nip46_signer_remote_app import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton -import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry import com.vitorpamplona.amethyst.favorites.rememberManifestIconModel import com.vitorpamplona.amethyst.favorites.rememberWebAppIconModel import com.vitorpamplona.amethyst.napplet.NappletBrokerService @@ -867,7 +866,7 @@ private suspend fun loadDetailState( val (title, iconUrl) = if (author == "browser") { val host = OmniboxInput.hostOf(identifier) ?: identifier - host to BrowserIconRegistry.iconModelFor(host) + host to Amethyst.instance.browserIcons.iconModelFor(host) } else { resolveNappletMeta(author, identifier, untitled) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/NestRoomFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/NestRoomFilterAssembler.kt index 3923973d8f..ca4c53d621 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/NestRoomFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/NestRoomFilterAssembler.kt @@ -41,7 +41,7 @@ import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent /** @@ -100,7 +100,7 @@ class NestRoomFilterSubAssembler( LiveActivitiesChatMessageEvent.KIND, MeetingRoomPresenceEvent.KIND, ReactionEvent.KIND, - LnZapEvent.KIND, + ZapReceiptEvent.KIND, Bolt12ZapEvent.KIND, ), tags = mapOf("a" to listOf(key.note.idHex)), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/lifecycle/NestRoomEventCollectors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/lifecycle/NestRoomEventCollectors.kt index 6e3fb4d01b..a18200cfdd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/lifecycle/NestRoomEventCollectors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/lifecycle/NestRoomEventCollectors.kt @@ -32,7 +32,7 @@ import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.delay @@ -153,7 +153,7 @@ private fun ReactionsCollector( * floating zap-overlay aggregator. The same note flows two places: * * 1. [NestViewModel.onChatEvent] — `ChatroomMessageCompose` routes - * `LnZapEvent` notes through `RenderChatZap`, which is the same + * `ZapReceiptEvent` notes through `RenderChatZap`, which is the same * card live streams use. Sharing the chat ledger keeps zap * cards interleaved with kind-1311 chat messages in time order. * @@ -169,14 +169,14 @@ private fun ZapsCollector( LaunchedEffect(viewModel, roomATag) { val filter = Filter( - kinds = listOf(LnZapEvent.KIND, Bolt12ZapEvent.KIND), + kinds = listOf(ZapReceiptEvent.KIND, Bolt12ZapEvent.KIND), tags = mapOf("a" to listOf(roomATag)), ) LocalCache.observeNotes(filter).collect { notes -> val nowSec = System.currentTimeMillis() / 1000 notes.forEach { note -> viewModel.onChatEvent(note) - (note.event as? LnZapEvent)?.let { viewModel.onZapEvent(it, nowSec) } + (note.event as? ZapReceiptEvent)?.let { viewModel.onZapEvent(it, nowSec) } (note.event as? Bolt12ZapEvent)?.let { viewModel.onZapEvent(it, nowSec) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedContentState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedContentState.kt index 25f3230185..a219448a67 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedContentState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedContentState.kt @@ -45,14 +45,14 @@ import com.vitorpamplona.amethyst.ui.dal.NotificationFeedOrderCard import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.dal.NotificationFeedFilter import com.vitorpamplona.quartz.buzz.notifications.MemberAddedNotificationEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip04Dm.messages.EncryptedDmEvent import com.vitorpamplona.quartz.nip17Dm.base.NIP17Group import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent import com.vitorpamplona.quartz.utils.Log @@ -183,7 +183,7 @@ class CardFeedContentState( val zapsPerUser = mutableMapOf>() val zapsPerEvent = mutableMapOf>() notes - .filter { it.event is LnZapEvent } + .filter { it.event is ZapReceiptEvent } .forEach { zapEvent -> val zappedPost = zapEvent.replyTo?.lastOrNull() if (zappedPost != null) { @@ -200,7 +200,7 @@ class CardFeedContentState( .add(CombinedZap(zapRequest, zapEvent)) } } else { - val event = (zapEvent.event as LnZapEvent) + val event = (zapEvent.event as ZapReceiptEvent) val author = event.zappedAuthor().firstNotNullOfOrNull { LocalCache.getUserIfExists(it) // don't create user if it doesn't exist @@ -361,7 +361,7 @@ class CardFeedContentState( it.event !is ReactionEvent && it.event !is RepostEvent && it.event !is GenericRepostEvent && - it.event !is LnZapEvent && + it.event !is ZapReceiptEvent && // Nutzaps are already grouped into nutzapsPerEvent (MultiSetCard) // or nutzapsPerUser (NutzapUserSetCard) above, exactly like lightning // zaps. Without this exclusion a nutzap would ALSO fall through to a @@ -372,7 +372,7 @@ class CardFeedContentState( val pendingInvite = if (it.event is MemberAddedNotificationEvent) pendingInvites[it.idHex] else null if (pendingInvite != null) { ChannelInviteCard(it, pendingInvite) - } else if (it.event is PrivateDmEvent || it.event is NIP17Group || it.isInMarmotGroup()) { + } else if (it.event is EncryptedDmEvent || it.event is NIP17Group || it.isInMarmotGroup()) { MessageSetCard(it) } else if (it.event is BadgeAwardEvent) { BadgeCard(it) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt index e471e8f3c7..bec6b7c1fa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt @@ -60,6 +60,7 @@ import com.vitorpamplona.amethyst.commons.ui.feeds.LoadingFeed import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachCursor import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachDetailDialog import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachMarkers +import com.vitorpamplona.amethyst.commons.ui.feeds.StickToTopOnPrepend import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.note.CloseIcon @@ -73,7 +74,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.Size10dp import com.vitorpamplona.amethyst.commons.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.commons.ui.theme.imageModifier import com.vitorpamplona.amethyst.logTime -import com.vitorpamplona.amethyst.ui.feeds.StickToTopOnPrepend import com.vitorpamplona.amethyst.ui.note.BadgeCompose import com.vitorpamplona.amethyst.ui.note.MessageSetCompose import com.vitorpamplona.amethyst.ui.note.MultiSetCompose diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSummaryState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSummaryState.kt index 8b86a5c5da..e1cc54cc4a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSummaryState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSummaryState.kt @@ -39,7 +39,7 @@ import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent import com.vitorpamplona.quartz.utils.Log @@ -117,7 +117,7 @@ class NotificationSummaryState( } } - noteEvent is LnZapEvent -> { + noteEvent is ZapReceiptEvent -> { // the user might be sending his own receipts noteEvent.pubKey != currentUser if (noteEvent.isTaggedUser(currentUser)) { val netDate = formatDate(noteEvent.createdAt) @@ -212,7 +212,7 @@ class NotificationSummaryState( } } - noteEvent is LnZapEvent -> { + noteEvent is ZapReceiptEvent -> { if (noteEvent.isTaggedUser(currentUser)) { // && noteEvent.pubKey != currentUser User might be sending his own receipts val netDate = formatDate(noteEvent.createdAt) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt index c9735bb746..433d8d8183 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt @@ -52,7 +52,7 @@ import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser -import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip04Dm.messages.EncryptedDmEvent import com.vitorpamplona.quartz.nip10Notes.BaseNoteEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent @@ -60,7 +60,7 @@ import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent @@ -78,17 +78,17 @@ import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent -import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip54Wiki.WikiArticleEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent import com.vitorpamplona.quartz.nip64Chess.challenge.accept.LiveChessGameAcceptEvent import com.vitorpamplona.quartz.nip64Chess.move.LiveChessMoveEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent -import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent +import com.vitorpamplona.quartz.nip71Video.AddressableNormalVideoEvent +import com.vitorpamplona.quartz.nip71Video.AddressableShortVideoEvent import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent import com.vitorpamplona.quartz.nip71Video.VideoShortEvent -import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent import com.vitorpamplona.quartz.nip72ModCommunities.communityAddress import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent @@ -131,11 +131,11 @@ class NotificationFeedFilter( LiveActivitiesEvent.KIND, LiveChessGameAcceptEvent.KIND, LiveChessMoveEvent.KIND, - LongTextNoteEvent.KIND, + LongFormContentEvent.KIND, NipTextEvent.KIND, - VideoVerticalEvent.KIND, - VideoHorizontalEvent.KIND, - WikiNoteEvent.KIND, + AddressableShortVideoEvent.KIND, + AddressableNormalVideoEvent.KIND, + WikiArticleEvent.KIND, AttestationRequestEvent.KIND, ) @@ -198,7 +198,7 @@ class NotificationFeedFilter( TextNoteEvent.KIND, ReactionEvent.KIND, RepostEvent.KIND, - LnZapEvent.KIND, + ZapReceiptEvent.KIND, NutzapEvent.KIND, OnchainZapEvent.KIND, Bolt12ZapEvent.KIND, @@ -206,7 +206,7 @@ class NotificationFeedFilter( PictureEvent.KIND, PollEvent.KIND, ZapPollEvent.KIND, - PrivateDmEvent.KIND, + EncryptedDmEvent.KIND, PublicMessageEvent.KIND, VideoNormalEvent.KIND, VideoShortEvent.KIND, @@ -322,9 +322,9 @@ class NotificationFeedFilter( // proves the comment targets a zap; the reply's explicit p tag // on the user marks it as theirs. val targetsZapReceipt = - event.hasScopeKind(LnZapEvent.KIND.toString()) || + event.hasScopeKind(ZapReceiptEvent.KIND.toString()) || event.hasScopeKind(Bolt12ZapEvent.KIND.toString()) || - note.replyTo?.any { it.event is LnZapEvent || it.event is Bolt12ZapEvent } == true + note.replyTo?.any { it.event is ZapReceiptEvent || it.event is Bolt12ZapEvent } == true if (targetsZapReceipt && event.isTaggedUser(authorHex)) { return true @@ -578,13 +578,13 @@ class NotificationFeedFilter( ( noteEvent is ChatMessageEvent || noteEvent is ChatMessageEncryptedFileHeaderEvent || - noteEvent is PrivateDmEvent + noteEvent is EncryptedDmEvent ) ) { return false } val notifAuthor = - if (noteEvent is LnZapEvent) { + if (noteEvent is ZapReceiptEvent) { val zapRequest = noteEvent.zapRequest if (zapRequest != null) { if (noteEvent.zapRequest?.isPrivateZap() == true) { @@ -608,7 +608,7 @@ class NotificationFeedFilter( // Reactions/zaps/reposts target a note via `replyTo`, not via thread-root tags, // so isNotInMutedThread on the wrapper event misses them. - if (noteEvent is ReactionEvent || noteEvent is LnZapEvent || noteEvent is Bolt12ZapEvent || + if (noteEvent is ReactionEvent || noteEvent is ZapReceiptEvent || noteEvent is Bolt12ZapEvent || noteEvent is RepostEvent || noteEvent is GenericRepostEvent ) { val target = it.replyTo?.lastOrNull() @@ -679,11 +679,11 @@ class NotificationFeedFilter( // relevance check (tagsAnEventByUser is skipped below); it still scopes // to genuine replies, so unrelated channel chatter never leaks through. return noteEvent?.kind in NOTIFICATION_KINDS && - (noteEvent is LnZapEvent || noteEvent is Bolt12ZapEvent || notifAuthor != loggedInUserHex) && + (noteEvent is ZapReceiptEvent || noteEvent is Bolt12ZapEvent || notifAuthor != loggedInUserHex) && (isChessEvent || isConcord || isReactionToMe || isThreadReplyToMe || filterParams.isGlobal() || notifAuthor == null || filterParams.isAuthorInFollows(notifAuthor)) && (noteEvent?.isTaggedUser(loggedInUserHex) == true || isNotifiablePublicChatReply(it, loggedInUserHex) || isReactionToMe || isThreadReplyToMe) && (filterParams.isHiddenList || notifAuthor == null || !account.isHidden(notifAuthor)) && - (noteEvent !is PrivateDmEvent || !account.isDecryptedContentHidden(noteEvent)) && + (noteEvent !is EncryptedDmEvent || !account.isDecryptedContentHidden(noteEvent)) && // For a Concord note the explicit p-tag above IS the relevance signal (the reply/reaction/ // mention targets me directly), so skip the per-kind heuristic — which for a reaction would // otherwise need my target message already loaded to resolve replyTo. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastTopSupporters.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastTopSupporters.kt index 9ce804d10e..5b8fdcda06 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastTopSupporters.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastTopSupporters.kt @@ -61,7 +61,7 @@ import com.vitorpamplona.amethyst.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.ui.note.showAmountInteger import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser -import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent import java.math.BigDecimal private val Gold = Color(0xFFFFC300) @@ -86,7 +86,7 @@ fun PodcastTopSupporters( remember(zapState) { val contributions = note.zaps.mapNotNull { (_, receiptNote) -> - val receipt = receiptNote?.event as? LnZapEvent ?: return@mapNotNull null + val receipt = receiptNote?.event as? ZapReceiptEvent ?: return@mapNotNull null val request = receipt.zapRequest ?: return@mapNotNull null val sats = receipt.amount()?.toLong() ?: return@mapNotNull null // Anon/private zaps carry an `anon` tag; collapse them into the shared bucket. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/privacy/PrivacyOptionsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/privacy/PrivacyOptionsScreen.kt index da8585ea2e..5ecce348ac 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/privacy/PrivacyOptionsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/privacy/PrivacyOptionsScreen.kt @@ -89,7 +89,10 @@ import com.vitorpamplona.amethyst.commons.resources.tor_use_videos_explainer import com.vitorpamplona.amethyst.commons.resources.use_internal_tor import com.vitorpamplona.amethyst.commons.resources.use_internal_tor_explainer import com.vitorpamplona.amethyst.commons.tor.TorPresetType +import com.vitorpamplona.amethyst.commons.tor.TorSettingsFlow import com.vitorpamplona.amethyst.commons.tor.TorType +import com.vitorpamplona.amethyst.commons.tor.explainerId +import com.vitorpamplona.amethyst.commons.tor.resourceId import com.vitorpamplona.amethyst.commons.tor.torDefaultPreset import com.vitorpamplona.amethyst.commons.tor.torFullyPrivate import com.vitorpamplona.amethyst.commons.tor.torOnlyWhenNeededPreset @@ -109,9 +112,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsDivider import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsSection import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsSwitchTile import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow -import com.vitorpamplona.amethyst.ui.tor.explainerId -import com.vitorpamplona.amethyst.ui.tor.resourceId import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.MutableStateFlow import org.jetbrains.compose.resources.StringResource @@ -136,7 +136,7 @@ fun PrivacyOptionsScreen( } // Every control writes straight to [TorSettingsFlow] via `tryEmit`; a debounced collector in -// TorSharedPreferences persists the change automatically, so this screen has no Save/Cancel — the +// TorSettingsStore persists the change automatically, so this screen has no Save/Cancel — the // back arrow is the only chrome and the state is already saved by the time the user leaves. @Composable fun PrivacyOptionsContent( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/followers/FollowersTabHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/followers/FollowersTabHeader.kt index 8f5a8fe47f..ce6c0e9dc2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/followers/FollowersTabHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/followers/FollowersTabHeader.kt @@ -28,7 +28,7 @@ import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.number_followers import com.vitorpamplona.amethyst.commons.ui.stringRes -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserContactCardsFollowerCount +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserAssertionsFollowerCount import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.followers.dal.UserProfileFollowersUserFeedViewModel @@ -38,7 +38,7 @@ fun FollowersTabHeader( followersFeedViewModel: UserProfileFollowersUserFeedViewModel, accountViewModel: AccountViewModel, ) { - val followerCount by observeUserContactCardsFollowerCount(baseUser, accountViewModel) + val followerCount by observeUserAssertionsFollowerCount(baseUser, accountViewModel) if (followerCount == "--") { FollowersTabHeaderLocal(followersFeedViewModel, accountViewModel) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/dal/UserProfileGalleryFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/dal/UserProfileGalleryFeedFilter.kt index b6742d40aa..aef3178ea3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/dal/UserProfileGalleryFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/dal/UserProfileGalleryFeedFilter.kt @@ -32,10 +32,10 @@ import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent +import com.vitorpamplona.quartz.nip71Video.AddressableNormalVideoEvent +import com.vitorpamplona.quartz.nip71Video.AddressableShortVideoEvent import com.vitorpamplona.quartz.nip71Video.AddressableVideoEvent import com.vitorpamplona.quartz.nip71Video.RegularVideoEvent -import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent -import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent class UserProfileGalleryFeedFilter( val user: User, @@ -57,7 +57,7 @@ class UserProfileGalleryFeedFilter( // Both NIP-71 addressable kinds: acceptableEvent() takes any // AddressableVideoEvent, so listing 34236 twice silently kept every // 34235 (horizontal) video out of the gallery. - listOf(VideoVerticalEvent.KIND, VideoHorizontalEvent.KIND), + listOf(AddressableShortVideoEvent.KIND, AddressableNormalVideoEvent.KIND), user.pubkeyHex, ) { _, it -> acceptableEvent(it, params, user) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/UserNicknameCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/UserNicknameCard.kt index e614a462df..5e666661c5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/UserNicknameCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/UserNicknameCard.kt @@ -73,8 +73,8 @@ fun UserNicknameCard( WatchAndLoadMyEmojiList(accountViewModel) EditNicknameDialog( user = baseUser, - contactCards = accountViewModel.account.contactCards, - onSave = { petName, summary -> accountViewModel.updateContactCardPetName(baseUser, petName, summary) }, + userAssertions = accountViewModel.account.userAssertions, + onSave = { petName, summary -> accountViewModel.updateUserAssertionPetName(baseUser, petName, summary) }, onDismiss = { isEditDialogOpen.value = false }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/UserProfileDropDownMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/UserProfileDropDownMenu.kt index 07a9bc477b..78f5d38f66 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/UserProfileDropDownMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/UserProfileDropDownMenu.kt @@ -71,8 +71,8 @@ fun UserProfileDropDownMenu( WatchAndLoadMyEmojiList(accountViewModel) EditNicknameDialog( user = user, - contactCards = accountViewModel.account.contactCards, - onSave = { petName, summary -> accountViewModel.updateContactCardPetName(user, petName, summary) }, + userAssertions = accountViewModel.account.userAssertions, + onSave = { petName, summary -> accountViewModel.updateUserAssertionPetName(user, petName, summary) }, onDismiss = { isNicknameDialogOpen.value = false }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/mutual/dal/UserProfileMutualFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/mutual/dal/UserProfileMutualFeedFilter.kt index 4a1654ac55..1af33308ff 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/mutual/dal/UserProfileMutualFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/mutual/dal/UserProfileMutualFeedFilter.kt @@ -41,9 +41,9 @@ import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent -import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent +import com.vitorpamplona.quartz.nip54Wiki.WikiArticleEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent @@ -83,8 +83,8 @@ class UserProfileMutualFeedFilter( it.event is BirdexEvent || it.event is BirdDetectionEvent || it.event.isRenderableRepost() || - it.event is LongTextNoteEvent || - it.event is WikiNoteEvent || + it.event is LongFormContentEvent || + it.event is WikiArticleEvent || it.event is NipTextEvent || it.event is ZapPollEvent || it.event is HighlightEvent || diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/TabNotesNewThreads.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/TabNotesNewThreads.kt index abed0924d7..d600bd16fd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/TabNotesNewThreads.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/TabNotesNewThreads.kt @@ -40,12 +40,12 @@ import com.vitorpamplona.amethyst.commons.ui.feeds.FeedEmpty import com.vitorpamplona.amethyst.commons.ui.feeds.FeedError import com.vitorpamplona.amethyst.commons.ui.feeds.LoadingFeed import com.vitorpamplona.amethyst.commons.ui.feeds.RefresheableBox +import com.vitorpamplona.amethyst.commons.ui.feeds.StickToTopOnPrepend +import com.vitorpamplona.amethyst.commons.ui.feeds.WatchScrollToTop import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness import com.vitorpamplona.amethyst.commons.ui.theme.FeedPadding import com.vitorpamplona.amethyst.ui.feeds.PrefetchLoadedFeedMedia -import com.vitorpamplona.amethyst.ui.feeds.StickToTopOnPrepend -import com.vitorpamplona.amethyst.ui.feeds.WatchScrollToTop import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.newthreads.dal.UserProfileNewThreadsFeedViewModel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/dal/UserProfileNewThreadFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/dal/UserProfileNewThreadFeedFilter.kt index f649abb356..561bc45837 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/dal/UserProfileNewThreadFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/dal/UserProfileNewThreadFeedFilter.kt @@ -47,9 +47,9 @@ import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent -import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent +import com.vitorpamplona.quartz.nip54Wiki.WikiArticleEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent @@ -91,8 +91,8 @@ class UserProfileNewThreadFeedFilter( it.event is BirdexEvent || it.event is BirdDetectionEvent || it.event.isRenderableRepost() || - it.event is LongTextNoteEvent || - it.event is WikiNoteEvent || + it.event is LongFormContentEvent || + it.event is WikiArticleEvent || it.event is NipTextEvent || it.event is ZapPollEvent || it.event is PollEvent || diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/payment/SendPaymentContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/payment/SendPaymentContent.kt index c437afb68d..1bd3ac0ebb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/payment/SendPaymentContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/payment/SendPaymentContent.kt @@ -99,7 +99,7 @@ import com.vitorpamplona.amethyst.commons.ui.theme.ButtonBorder import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.note.showAmount -import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -139,7 +139,7 @@ data class PaymentMethodUi( /** One entry of the zap-type selector (Public / Private / Anonymous / Non-Zap). */ @Immutable data class ZapTypeOption( - val type: LnZapEvent.ZapType, + val type: ZapReceiptEvent.ZapType, val label: String, val explainer: String, ) @@ -270,8 +270,8 @@ fun SendPaymentContent( showMessageField: Boolean, messageLabel: String, zapTypes: ImmutableList?, - selectedZapType: LnZapEvent.ZapType, - onZapTypeChange: (LnZapEvent.ZapType) -> Unit, + selectedZapType: ZapReceiptEvent.ZapType, + onZapTypeChange: (ZapReceiptEvent.ZapType) -> Unit, receiptNote: String?, stage: PaymentFlowStage, canSend: Boolean, @@ -630,8 +630,8 @@ private fun AmountSection( @Composable private fun ReceiptSection( zapTypes: ImmutableList?, - selectedZapType: LnZapEvent.ZapType, - onZapTypeChange: (LnZapEvent.ZapType) -> Unit, + selectedZapType: ZapReceiptEvent.ZapType, + onZapTypeChange: (ZapReceiptEvent.ZapType) -> Unit, receiptNote: String?, enabled: Boolean, ) { @@ -771,10 +771,10 @@ private val previewFromSources = private val previewZapTypes = listOf( - ZapTypeOption(LnZapEvent.ZapType.PUBLIC, "Public", "Everybody can see the transaction and message"), - ZapTypeOption(LnZapEvent.ZapType.PRIVATE, "Private", "Sender and receiver can see each other and read the message"), - ZapTypeOption(LnZapEvent.ZapType.ANONYMOUS, "Anonymous", "Receiver does not know who sent the payment"), - ZapTypeOption(LnZapEvent.ZapType.NONZAP, "Non-Zap", "No trace on Nostr, only in Lightning"), + ZapTypeOption(ZapReceiptEvent.ZapType.PUBLIC, "Public", "Everybody can see the transaction and message"), + ZapTypeOption(ZapReceiptEvent.ZapType.PRIVATE, "Private", "Sender and receiver can see each other and read the message"), + ZapTypeOption(ZapReceiptEvent.ZapType.ANONYMOUS, "Anonymous", "Receiver does not know who sent the payment"), + ZapTypeOption(ZapReceiptEvent.ZapType.NONZAP, "Non-Zap", "No trace on Nostr, only in Lightning"), ).toImmutableList() @Composable @@ -822,7 +822,7 @@ private fun SendPaymentLightningPreview() { showMessageField = true, messageLabel = PREVIEW_MESSAGE_LABEL, zapTypes = previewZapTypes, - selectedZapType = LnZapEvent.ZapType.PUBLIC, + selectedZapType = ZapReceiptEvent.ZapType.PUBLIC, onZapTypeChange = {}, receiptNote = null, stage = PaymentFlowStage.Editing, @@ -858,7 +858,7 @@ private fun SendPaymentClinkFixedPreview() { showMessageField = false, messageLabel = "", zapTypes = null, - selectedZapType = LnZapEvent.ZapType.PUBLIC, + selectedZapType = ZapReceiptEvent.ZapType.PUBLIC, onZapTypeChange = {}, receiptNote = "Direct Lightning payment — no zap receipt is published on Nostr.", stage = PaymentFlowStage.Editing, @@ -894,7 +894,7 @@ private fun SendPaymentInProgressPreview() { showMessageField = true, messageLabel = PREVIEW_MESSAGE_LABEL, zapTypes = null, - selectedZapType = LnZapEvent.ZapType.PUBLIC, + selectedZapType = ZapReceiptEvent.ZapType.PUBLIC, onZapTypeChange = {}, receiptNote = "The nutzap event delivers the ecash itself, so it is always published.", stage = PaymentFlowStage.InProgress("Locking and sending ecash…", progress = 0.55f), @@ -930,7 +930,7 @@ private fun SendPaymentSuccessPreview() { showMessageField = true, messageLabel = PREVIEW_MESSAGE_LABEL, zapTypes = previewZapTypes, - selectedZapType = LnZapEvent.ZapType.PUBLIC, + selectedZapType = ZapReceiptEvent.ZapType.PUBLIC, onZapTypeChange = {}, receiptNote = null, stage = PaymentFlowStage.Success("Payment sent!", "1,000 sats to Alice"), @@ -966,7 +966,7 @@ private fun SendPaymentFailurePreview() { showMessageField = true, messageLabel = "Comment", zapTypes = null, - selectedZapType = LnZapEvent.ZapType.PUBLIC, + selectedZapType = ZapReceiptEvent.ZapType.PUBLIC, onZapTypeChange = {}, receiptNote = "An on-chain zap receipt is published on Nostr so the recipient can find the payment.", stage = PaymentFlowStage.Failure("Insufficient funds to cover the amount plus the network fee."), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/payment/SendPaymentScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/payment/SendPaymentScreen.kt index adaec88edf..32bfc5a77a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/payment/SendPaymentScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/payment/SendPaymentScreen.kt @@ -117,7 +117,7 @@ import com.vitorpamplona.quartz.experimental.clink.offers.OfferErrorCode import com.vitorpamplona.quartz.experimental.clink.pointers.ClinkPointerParser import com.vitorpamplona.quartz.experimental.clink.pointers.NOffer import com.vitorpamplona.quartz.experimental.clink.pointers.OfferPriceType -import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.FeeEstimates import com.vitorpamplona.quartz.nipBCOnchainZaps.taproot.SegwitAddress import com.vitorpamplona.quartz.nipBCOnchainZaps.taproot.TaprootAddress @@ -621,9 +621,9 @@ private fun SendPaymentLoaded( val messageLabel = when { selectedMethod == ProfilePaymentMethod.ONCHAIN -> stringRes(Res.string.note_to_receiver) - selectedMethod == ProfilePaymentMethod.LIGHTNING && zapType == LnZapEvent.ZapType.PRIVATE -> + selectedMethod == ProfilePaymentMethod.LIGHTNING && zapType == ZapReceiptEvent.ZapType.PRIVATE -> stringRes(Res.string.custom_zaps_add_a_message_private) - selectedMethod == ProfilePaymentMethod.LIGHTNING && zapType == LnZapEvent.ZapType.NONZAP -> + selectedMethod == ProfilePaymentMethod.LIGHTNING && zapType == ZapReceiptEvent.ZapType.NONZAP -> stringRes(Res.string.custom_zaps_add_a_message_nonzap) else -> stringRes(Res.string.custom_zaps_add_a_message) } @@ -738,10 +738,10 @@ private fun rememberLightningZapTypeOptions(): ImmutableList { return remember { persistentListOf( - ZapTypeOption(LnZapEvent.ZapType.PUBLIC, publicLabel, publicExplainer), - ZapTypeOption(LnZapEvent.ZapType.PRIVATE, privateLabel, privateExplainer), - ZapTypeOption(LnZapEvent.ZapType.ANONYMOUS, anonymousLabel, anonymousExplainer), - ZapTypeOption(LnZapEvent.ZapType.NONZAP, nonzapLabel, nonzapExplainer), + ZapTypeOption(ZapReceiptEvent.ZapType.PUBLIC, publicLabel, publicExplainer), + ZapTypeOption(ZapReceiptEvent.ZapType.PRIVATE, privateLabel, privateExplainer), + ZapTypeOption(ZapReceiptEvent.ZapType.ANONYMOUS, anonymousLabel, anonymousExplainer), + ZapTypeOption(ZapReceiptEvent.ZapType.NONZAP, nonzapLabel, nonzapExplainer), ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelayFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelayFeedViewModel.kt index cb0e4df73a..3c10216804 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelayFeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelayFeedViewModel.kt @@ -30,7 +30,7 @@ import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.amethyst.commons.model.nip01Core.RelayInfo import com.vitorpamplona.amethyst.commons.model.nip01Core.Wrapper import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.nip17Dm.settings.DmRelayListEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.Dispatchers @@ -144,7 +144,7 @@ class RelayFeedViewModel : .flow() .metadata.stateFlow .map { dmNote -> - (dmNote.note.event as? ChatMessageRelayListEvent)?.relays()?.toSet() ?: emptySet() + (dmNote.note.event as? DmRelayListEvent)?.relays()?.toSet() ?: emptySet() }, flow2 = user.relayState().flow(), transform = ::convert, @@ -154,7 +154,7 @@ class RelayFeedViewModel : emit(emptyList()) } }.onStart { - emit(convert((currentUser.value?.nip65RelayListNote?.event as? ChatMessageRelayListEvent)?.relays()?.toSet(), currentUser.value?.relayState()?.data)) + emit(convert((currentUser.value?.nip65RelayListNote?.event as? DmRelayListEvent)?.relays()?.toSet(), currentUser.value?.relayState()?.data)) }.flowOn(Dispatchers.IO) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/ZapNoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/ZapNoteCompose.kt index ed93534498..f3d45d8565 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/ZapNoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/ZapNoteCompose.kt @@ -35,12 +35,12 @@ import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.model.navigation.routeFor +import com.vitorpamplona.amethyst.commons.ui.layouts.listItem.SlimListItem import com.vitorpamplona.amethyst.commons.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.theme.BitcoinOrange import com.vitorpamplona.amethyst.commons.ui.theme.Size55dp import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn -import com.vitorpamplona.amethyst.ui.layouts.listItem.SlimListItem import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.note.UserActionOptions import com.vitorpamplona.amethyst.ui.note.UserPicture diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/dal/UserProfileZapsViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/dal/UserProfileZapsViewModel.kt index 4e718ef67e..db71a85712 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/dal/UserProfileZapsViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/dal/UserProfileZapsViewModel.kt @@ -30,7 +30,7 @@ import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter -import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent import com.vitorpamplona.quartz.utils.BigDecimal @@ -55,14 +55,14 @@ class UserProfileZapsViewModel( ) : ViewModel() { val zapsToUser = Filter( - kinds = listOf(LnZapEvent.KIND, OnchainZapEvent.KIND, Bolt12ZapEvent.KIND), + kinds = listOf(ZapReceiptEvent.KIND, OnchainZapEvent.KIND, Bolt12ZapEvent.KIND), tags = mapOf("p" to listOf(user.pubkeyHex)), ) val sortingModel: Comparator = compareByDescending { it.amount }.thenBy { it.user.pubkeyHex } - suspend fun mapRequest(zapEvent: LnZapEvent): ZapAmount? { + suspend fun mapRequest(zapEvent: ZapReceiptEvent): ZapAmount? { val zapRequest = zapEvent.zapRequest ?: return ZapAmount( LocalCache.getOrCreateUser(zapEvent.pubKey), @@ -122,7 +122,7 @@ class UserProfileZapsViewModel( this.forEach { zapEvent -> val zapAmount = when (zapEvent) { - is LnZapEvent -> mapRequest(zapEvent) + is ZapReceiptEvent -> mapRequest(zapEvent) is OnchainZapEvent -> mapOnchainZap(zapEvent) is Bolt12ZapEvent -> mapBolt12Zap(zapEvent) else -> null diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/AllRelayListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/AllRelayListScreen.kt index 51a2e0107d..42082044f7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/AllRelayListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/AllRelayListScreen.kt @@ -109,7 +109,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.connected.ConnectedR import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.connected.renderConnectedItems import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.dm.DMRelayListViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.dm.renderDMItems -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.feeds.RelayFeedsListViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.feeds.FavoriteRelayListViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.feeds.renderRelayFeedsItems import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.indexer.IndexerRelayListViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.indexer.renderIndexerItems @@ -148,7 +148,7 @@ fun AllRelayListScreen( val broadcastViewModel: BroadcastRelayListViewModel = viewModel() val indexerViewModel: IndexerRelayListViewModel = viewModel() val proxyViewModel: ProxyRelayListViewModel = viewModel() - val relayFeedsViewModel: RelayFeedsListViewModel = viewModel() + val relayFeedsViewModel: FavoriteRelayListViewModel = viewModel() dmViewModel.init(accountViewModel) keyPackageViewModel.init(accountViewModel) @@ -214,7 +214,7 @@ fun MappedAllRelayListView( broadcastViewModel: BroadcastRelayListViewModel, indexerViewModel: IndexerRelayListViewModel, proxyViewModel: ProxyRelayListViewModel, - relayFeedsViewModel: RelayFeedsListViewModel, + relayFeedsViewModel: FavoriteRelayListViewModel, accountViewModel: AccountViewModel, nav: INav, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/KindDisplayName.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/KindDisplayName.kt index 2ea4d40bac..c8b6e75aaf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/KindDisplayName.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/KindDisplayName.kt @@ -206,25 +206,25 @@ import com.vitorpamplona.quartz.kinds.KindNames import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent -import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent -import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip04Dm.messages.EncryptedDmEvent +import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent -import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.nip17Dm.settings.DmRelayListEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelHideMessageEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMuteUserEvent -import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent +import com.vitorpamplona.quartz.nip28PublicChat.list.PublicChatListEvent import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent -import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEvent +import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiListEvent import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestEvent @@ -239,32 +239,32 @@ import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayListEvent -import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent +import com.vitorpamplona.quartz.nip38UserStatus.UserStatusEvent import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent -import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent -import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcRequestEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcResponseEvent import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent import com.vitorpamplona.quartz.nip51Lists.PinListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent -import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkSet.BookmarkSetEvent +import com.vitorpamplona.quartz.nip51Lists.followSet.FollowSetEvent import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent -import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent -import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.LabeledBookmarkListEvent +import com.vitorpamplona.quartz.nip51Lists.interestList.InterestListEvent import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent -import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BroadcastRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.FavoriteRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent -import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relaySets.RelaySetEvent +import com.vitorpamplona.quartz.nip51Lists.starterPack.StarterPackEvent import com.vitorpamplona.quartz.nip51Lists.videoCurationSet.VideoCurationSetEvent import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent -import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent +import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarCollectionEvent import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent @@ -272,16 +272,16 @@ import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEv import com.vitorpamplona.quartz.nip53LiveActivities.nestsServers.NestsServersEvent import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent -import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent +import com.vitorpamplona.quartz.nip54Wiki.WikiArticleEvent import com.vitorpamplona.quartz.nip56Reports.ReportEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import com.vitorpamplona.quartz.nip57Zaps.PrivateZapEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapRequestEvent import com.vitorpamplona.quartz.nip58Badges.accepted.AcceptedBadgeSetEvent import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent import com.vitorpamplona.quartz.nip58Badges.profile.ProfileBadgesEvent -import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent +import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.EphemeralGiftWrapEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent @@ -296,29 +296,29 @@ import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.RelayDiscoveryEvent import com.vitorpamplona.quartz.nip66RelayMonitor.monitor.RelayMonitorEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent -import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent +import com.vitorpamplona.quartz.nip71Video.AddressableNormalVideoEvent +import com.vitorpamplona.quartz.nip71Video.AddressableShortVideoEvent import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent import com.vitorpamplona.quartz.nip71Video.VideoShortEvent -import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent import com.vitorpamplona.quartz.nip71Video.textTrack.TextTrackEvent import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent -import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent +import com.vitorpamplona.quartz.nip75ZapGoals.ZapGoalEvent import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent -import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent +import com.vitorpamplona.quartz.nip85TrustedAssertions.users.UserAssertionEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.AppRecommendationEvent -import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryRequest.NIP90ContentDiscoveryRequestEvent -import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent -import com.vitorpamplona.quartz.nip90Dvms.status.NIP90StatusEvent -import com.vitorpamplona.quartz.nip90Dvms.userDiscoveryRequest.NIP90UserDiscoveryRequestEvent -import com.vitorpamplona.quartz.nip90Dvms.userDiscoveryResponse.NIP90UserDiscoveryResponseEvent -import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent +import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryRequest.DvmContentDiscoveryRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.DvmContentDiscoveryResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.status.DvmStatusEvent +import com.vitorpamplona.quartz.nip90Dvms.userDiscoveryRequest.DvmUserDiscoveryRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.userDiscoveryResponse.DvmUserDiscoveryResponseEvent +import com.vitorpamplona.quartz.nip94FileMetadata.FileMetadataEvent import com.vitorpamplona.quartz.nip96FileStorage.config.FileServersEvent import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent @@ -366,12 +366,12 @@ fun kindDisplayName(kind: Int): StringResource? = BookmarkListEvent.KIND -> Res.string.kind_bookmark_list OldBookmarkListEvent.KIND -> Res.string.kind_old_bookmark_list CalendarDateSlotEvent.KIND -> Res.string.kind_day_appointment - CalendarEvent.KIND -> Res.string.kind_calendar + CalendarCollectionEvent.KIND -> Res.string.kind_calendar CalendarTimeSlotEvent.KIND -> Res.string.kind_appointment CalendarRSVPEvent.KIND -> Res.string.kind_appt_rsvp ChessGameEvent.KIND -> Res.string.kind_chess_games JesterEvent.KIND -> Res.string.kind_chess_auth - RelayFeedsListEvent.KIND -> Res.string.kind_favorite_relays + FavoriteRelayListEvent.KIND -> Res.string.kind_favorite_relays LiveChessGameChallengeEvent.KIND -> Res.string.kind_chess_challenges LiveChessGameAcceptEvent.KIND -> Res.string.kind_chess_game_accept LiveChessMoveEvent.KIND -> Res.string.kind_chess_move @@ -379,32 +379,32 @@ fun kindDisplayName(kind: Int): StringResource? = LiveChessDrawOfferEvent.KIND -> Res.string.kind_chess_draw_offer ChannelCreateEvent.KIND -> Res.string.kind_channel_definition ChannelHideMessageEvent.KIND -> Res.string.kind_channel_hide_msg - ChannelListEvent.KIND -> Res.string.kind_channel_list + PublicChatListEvent.KIND -> Res.string.kind_channel_list ChannelMessageEvent.KIND -> Res.string.kind_channel_message ChannelMetadataEvent.KIND -> Res.string.kind_channel_metadata ChannelMuteUserEvent.KIND -> Res.string.kind_channel_mute_user ChatMessageEncryptedFileHeaderEvent.KIND -> Res.string.kind_dm_file ChatMessageEvent.KIND -> Res.string.kind_dm_message - ChatMessageRelayListEvent.KIND -> Res.string.kind_dm_relays + DmRelayListEvent.KIND -> Res.string.kind_dm_relays ClassifiedsEvent.KIND -> Res.string.kind_classifieds CommentEvent.KIND -> Res.string.kind_comments CommunityDefinitionEvent.KIND -> Res.string.kind_community_def CommunityListEvent.KIND -> Res.string.kind_community_list CommunityPostApprovalEvent.KIND -> Res.string.kind_community_post ContactListEvent.KIND -> Res.string.kind_follow_list - DeletionEvent.KIND -> Res.string.kind_deletions + DeletionRequestEvent.KIND -> Res.string.kind_deletions DraftWrapEvent.KIND -> Res.string.kind_drafts EmojiPackEvent.KIND -> Res.string.kind_emoji_packs - EmojiPackSelectionEvent.KIND -> Res.string.kind_emoji_pack_list + EmojiListEvent.KIND -> Res.string.kind_emoji_pack_list EphemeralChatEvent.KIND -> Res.string.kind_ephemeral_chat EphemeralChatListEvent.KIND -> Res.string.kind_ephemeral_chatrooms - FileHeaderEvent.KIND -> Res.string.kind_file_headers + FileMetadataEvent.KIND -> Res.string.kind_file_headers ProfileGalleryEntryEvent.KIND -> Res.string.kind_profile_gallery FileServersEvent.KIND -> Res.string.kind_file_servers FileStorageEvent.KIND -> Res.string.kind_blob_data FileStorageHeaderEvent.KIND -> Res.string.kind_blob_headers FhirResourceEvent.KIND -> Res.string.kind_medical_data - FollowListEvent.KIND -> Res.string.kind_follow_packs + StarterPackEvent.KIND -> Res.string.kind_follow_packs GenericRepostEvent.KIND -> Res.string.kind_reposts_16 GeohashListEvent.KIND -> Res.string.kind_geohash_follows GiftWrapEvent.KIND -> Res.string.kind_gift_wraps @@ -419,24 +419,24 @@ fun kindDisplayName(kind: Int): StringResource? = GitStatusAppliedEvent.KIND -> Res.string.kind_git_status_applied GitStatusClosedEvent.KIND -> Res.string.kind_git_status_closed GitStatusDraftEvent.KIND -> Res.string.kind_git_status_draft - GoalEvent.KIND -> Res.string.kind_zap_goals - HashtagListEvent.KIND -> Res.string.kind_hashtag_follows + ZapGoalEvent.KIND -> Res.string.kind_zap_goals + InterestListEvent.KIND -> Res.string.kind_hashtag_follows HighlightEvent.KIND -> Res.string.kind_highlights HTTPAuthorizationEvent.KIND -> Res.string.kind_http_auth IndexerRelayListEvent.KIND -> Res.string.kind_index_relay_list InteractiveStoryPrologueEvent.KIND -> Res.string.kind_adventure_prologue InteractiveStorySceneEvent.KIND -> Res.string.kind_adventure_scene InteractiveStoryReadingStateEvent.KIND -> Res.string.kind_adventure_reading - LabeledBookmarkListEvent.KIND -> Res.string.kind_named_bookmarks + BookmarkSetEvent.KIND -> Res.string.kind_named_bookmarks LiveActivitiesChatMessageEvent.KIND -> Res.string.kind_live_chats LiveActivitiesEvent.KIND -> Res.string.kind_live_streams - LnZapEvent.KIND -> Res.string.kind_zaps + ZapReceiptEvent.KIND -> Res.string.kind_zaps Bolt12ZapEvent.KIND -> Res.string.kind_zaps - LnZapPaymentRequestEvent.KIND -> Res.string.kind_nwc_request - LnZapPaymentResponseEvent.KIND -> Res.string.kind_nwc_response - LnZapPrivateEvent.KIND -> Res.string.kind_private_zaps - LnZapRequestEvent.KIND -> Res.string.kind_zap_req - LongTextNoteEvent.KIND -> Res.string.kind_blogs + NwcRequestEvent.KIND -> Res.string.kind_nwc_request + NwcResponseEvent.KIND -> Res.string.kind_nwc_response + PrivateZapEvent.KIND -> Res.string.kind_private_zaps + ZapRequestEvent.KIND -> Res.string.kind_zap_req + LongFormContentEvent.KIND -> Res.string.kind_blogs MeetingRoomEvent.KIND -> Res.string.kind_meeting_room MeetingRoomPresenceEvent.KIND -> Res.string.kind_room_presence MeetingSpaceEvent.KIND -> Res.string.kind_meeting_space @@ -445,14 +445,14 @@ fun kindDisplayName(kind: Int): StringResource? = NNSEvent.KIND -> Res.string.kind_nns NipTextEvent.KIND -> Res.string.kind_nip NostrConnectEvent.KIND -> Res.string.kind_nostr_connect - NIP90StatusEvent.KIND -> Res.string.kind_dvm_status - NIP90ContentDiscoveryRequestEvent.KIND -> Res.string.kind_dvm_content_req - NIP90ContentDiscoveryResponseEvent.KIND -> Res.string.kind_dvm_content_resp - NIP90UserDiscoveryRequestEvent.KIND -> Res.string.kind_dvm_user_req - NIP90UserDiscoveryResponseEvent.KIND -> Res.string.kind_dvm_user_resp + DvmStatusEvent.KIND -> Res.string.kind_dvm_status + DvmContentDiscoveryRequestEvent.KIND -> Res.string.kind_dvm_content_req + DvmContentDiscoveryResponseEvent.KIND -> Res.string.kind_dvm_content_resp + DvmUserDiscoveryRequestEvent.KIND -> Res.string.kind_dvm_user_req + DvmUserDiscoveryResponseEvent.KIND -> Res.string.kind_dvm_user_resp OtsEvent.KIND -> Res.string.kind_ots PaymentTargetsEvent.KIND -> Res.string.kind_pay_to - PeopleListEvent.KIND -> Res.string.kind_people_lists + FollowSetEvent.KIND -> Res.string.kind_people_lists ProfileBadgesEvent.KIND -> Res.string.kind_profile_badges PictureEvent.KIND -> Res.string.kind_pictures WorkoutRecordEvent.KIND -> Res.string.kind_workouts @@ -460,12 +460,12 @@ fun kindDisplayName(kind: Int): StringResource? = ZapPollEvent.KIND -> Res.string.kind_zap_poll PollEvent.KIND -> Res.string.kind_poll PollResponseEvent.KIND -> Res.string.kind_poll_response - PrivateDmEvent.KIND -> Res.string.kind_nip04_dms + EncryptedDmEvent.KIND -> Res.string.kind_nip04_dms PrivateOutboxRelayListEvent.KIND -> Res.string.kind_private_relays ProxyRelayListEvent.KIND -> Res.string.kind_proxy_relays PublicMessageEvent.KIND -> Res.string.kind_public_message ReactionEvent.KIND -> Res.string.kind_reactions - ContactCardEvent.KIND -> Res.string.kind_contact_card + UserAssertionEvent.KIND -> Res.string.kind_contact_card RelayAuthEvent.KIND -> Res.string.kind_relay_auth RelayDiscoveryEvent.KIND -> Res.string.kind_relay_discovery RelayMonitorEvent.KIND -> Res.string.kind_relay_monitor @@ -473,9 +473,9 @@ fun kindDisplayName(kind: Int): StringResource? = ReportEvent.KIND -> Res.string.kind_reports RepostEvent.KIND -> Res.string.kind_reposts RequestToVanishEvent.KIND -> Res.string.kind_user_delete - SealedRumorEvent.KIND -> Res.string.kind_seals + SealEvent.KIND -> Res.string.kind_seals SearchRelayListEvent.KIND -> Res.string.kind_search_relays - StatusEvent.KIND -> Res.string.kind_user_status + UserStatusEvent.KIND -> Res.string.kind_user_status TextNoteEvent.KIND -> Res.string.kind_notes TextNoteModificationEvent.KIND -> Res.string.kind_edits TorrentEvent.KIND -> Res.string.kind_torrents @@ -485,15 +485,15 @@ fun kindDisplayName(kind: Int): StringResource? = VideoCurationSetEvent.KIND -> Res.string.kind_video_list VideoCollaborationEvent.KIND -> Res.string.kind_video_collaboration TextTrackEvent.KIND -> Res.string.kind_video_subtitles - VideoHorizontalEvent.KIND -> Res.string.kind_video_repl - VideoVerticalEvent.KIND -> Res.string.kind_shorts_repl + AddressableNormalVideoEvent.KIND -> Res.string.kind_video_repl + AddressableShortVideoEvent.KIND -> Res.string.kind_shorts_repl VideoNormalEvent.KIND -> Res.string.kind_video VideoShortEvent.KIND -> Res.string.kind_shorts VoiceEvent.KIND -> Res.string.kind_voice_msg VoiceReplyEvent.KIND -> Res.string.kind_voice_reply WakeUpEvent.KIND -> Res.string.kind_wake WebBookmarkEvent.KIND -> Res.string.kind_web_bookmark - WikiNoteEvent.KIND -> Res.string.kind_wiki + WikiArticleEvent.KIND -> Res.string.kind_wiki else -> null } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt index a49752d925..4cfcdbed55 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt @@ -198,7 +198,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl -import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent import com.vitorpamplona.quartz.nip56Reports.ReportEvent @@ -478,7 +478,7 @@ val posts = setOf(0, 1, 6, 7, 16, 30023) val settings = setOf(3, 10002, 10000, 10001, 10003, 10004, 30000) val dms = setOf(4, GiftWrapEvent.KIND, EphemeralGiftWrapEvent.KIND, 10050) val zaps = setOf(9734, 9735, 9041, 17375, 23194, 23195) -val reports = setOf(ReportEvent.KIND, MuteListEvent.KIND, DeletionEvent.KIND, RequestToVanishEvent.KIND) +val reports = setOf(ReportEvent.KIND, MuteListEvent.KIND, DeletionRequestEvent.KIND, RequestToVanishEvent.KIND) @Composable fun KindChip(kind: Int) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoClickableRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoClickableRow.kt index abe244fb9b..a9f424323e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoClickableRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoClickableRow.kt @@ -41,6 +41,7 @@ import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.relays.nip11RelayInfo.Nip11CachedRetriever import com.vitorpamplona.amethyst.commons.relays.ui.RelayCountResult import com.vitorpamplona.amethyst.commons.relays.ui.RelayDragState import com.vitorpamplona.amethyst.commons.relays.ui.RelayEventCountRow @@ -59,7 +60,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.Height25Modifier import com.vitorpamplona.amethyst.commons.ui.theme.LargeRelayIconModifier import com.vitorpamplona.amethyst.commons.ui.theme.ReactionRowHeightChatMaxWidth import com.vitorpamplona.amethyst.commons.ui.theme.Size25dp -import com.vitorpamplona.amethyst.model.nip11RelayInfo.Nip11CachedRetriever import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon import com.vitorpamplona.amethyst.ui.note.UserPicture diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoDialog.kt index d5f1e6e2af..85fe0a2ad8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoDialog.kt @@ -23,11 +23,11 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import com.vitorpamplona.amethyst.commons.model.navigation.Route +import com.vitorpamplona.amethyst.commons.relays.nip11RelayInfo.Nip11CachedRetriever import com.vitorpamplona.amethyst.commons.relays.ui.RelayCountResult import com.vitorpamplona.amethyst.commons.relays.ui.RelayDragState import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.screen.LocalDisplaySettings -import com.vitorpamplona.amethyst.model.nip11RelayInfo.Nip11CachedRetriever import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayUrlEditField.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayUrlEditField.kt index 3a503a1724..c069ff51dd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayUrlEditField.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayUrlEditField.kt @@ -45,6 +45,7 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.commons.relays.nip11RelayInfo.Nip11CachedRetriever import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.add import com.vitorpamplona.amethyst.commons.resources.add_a_relay @@ -58,7 +59,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.PopupUpEffect import com.vitorpamplona.amethyst.commons.ui.theme.StdEndPadding import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText -import com.vitorpamplona.amethyst.model.nip11RelayInfo.Nip11CachedRetriever import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStat diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/ShowRelaySuggestionList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/ShowRelaySuggestionList.kt index 3b91761c41..3c9c6771c1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/ShowRelaySuggestionList.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/ShowRelaySuggestionList.kt @@ -26,11 +26,11 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.relays.nip11RelayInfo.Nip11CachedRetriever import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.screen.LocalDisplaySettings import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness import com.vitorpamplona.amethyst.commons.ui.theme.HalfVertPadding -import com.vitorpamplona.amethyst.model.nip11RelayInfo.Nip11CachedRetriever import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/DMRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/DMRelayListViewModel.kt index 0d2c1399bf..d0f5ac593c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/DMRelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/DMRelayListViewModel.kt @@ -27,7 +27,7 @@ import com.vitorpamplona.amethyst.commons.resources.dms import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip04Dm.messages.EncryptedDmEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.EphemeralGiftWrapEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent @@ -45,7 +45,7 @@ class DMRelayListViewModel : BasicRelaySetupInfoModel() { label = Res.string.dms, filter = Filter( - kinds = listOf(GiftWrapEvent.KIND, EphemeralGiftWrapEvent.KIND, PrivateDmEvent.KIND), + kinds = listOf(GiftWrapEvent.KIND, EphemeralGiftWrapEvent.KIND, EncryptedDmEvent.KIND), tags = mapOf("p" to listOf(account.pubKey)), ), ), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/feeds/RelayFeedsListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/feeds/FavoriteRelayListViewModel.kt similarity index 90% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/feeds/RelayFeedsListViewModel.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/feeds/FavoriteRelayListViewModel.kt index aab6d86fd0..91fd7cf9a6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/feeds/RelayFeedsListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/feeds/FavoriteRelayListViewModel.kt @@ -25,12 +25,12 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySet import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @Stable -class RelayFeedsListViewModel : BasicRelaySetupInfoModel() { +class FavoriteRelayListViewModel : BasicRelaySetupInfoModel() { override fun getRelayList(): List = - account.relayFeedsList.flow.value + account.favoriteRelayList.flow.value .toList() override suspend fun saveRelayList(urlList: List) { - account.saveRelayFeedsList(urlList) + account.saveFavoriteRelayList(urlList) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/feeds/RelayFeedsListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/feeds/RelayFeedsListView.kt index 2c77021427..cf07d2053d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/feeds/RelayFeedsListView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/feeds/RelayFeedsListView.kt @@ -45,8 +45,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayUrlEditF import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder @Composable -fun RelayFeedsList( - postViewModel: RelayFeedsListViewModel, +fun FavoriteRelayList( + postViewModel: FavoriteRelayListViewModel, accountViewModel: AccountViewModel, onClose: () -> Unit, nav: INav, @@ -71,7 +71,7 @@ fun RelayFeedsList( fun LazyListScope.renderRelayFeedsItems( feedState: List, - postViewModel: RelayFeedsListViewModel, + postViewModel: FavoriteRelayListViewModel, accountViewModel: AccountViewModel, nav: INav, dragState: RelayDragState? = null, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip86/RelayManagementScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip86/RelayManagementScreen.kt index fd0e1f5a7c..0498e1996f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip86/RelayManagementScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip86/RelayManagementScreen.kt @@ -122,6 +122,7 @@ import com.vitorpamplona.amethyst.commons.resources.relay_management_tab_pubkeys import com.vitorpamplona.amethyst.commons.resources.relay_management_tab_settings import com.vitorpamplona.amethyst.commons.resources.relay_management_title import com.vitorpamplona.amethyst.commons.resources.search_and_add_a_user +import com.vitorpamplona.amethyst.commons.ui.layouts.listItem.SlimListItem import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.note.ClearTextIcon import com.vitorpamplona.amethyst.commons.ui.stringRes @@ -133,7 +134,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.SmallBorder import com.vitorpamplona.amethyst.commons.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.commons.ui.theme.nip05 import com.vitorpamplona.amethyst.service.relayClient.searchCommand.UserSearchDataSourceSubscription -import com.vitorpamplona.amethyst.ui.layouts.listItem.SlimListItem import com.vitorpamplona.amethyst.ui.note.AboutDisplay import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture import com.vitorpamplona.amethyst.ui.note.ObserveAndRenderNIP05VerifiedSymbol diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/vanish/RequestToVanishScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/vanish/RequestToVanishScreen.kt index 834f109067..abbb321c4c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/vanish/RequestToVanishScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/vanish/RequestToVanishScreen.kt @@ -68,6 +68,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.relays.nip11RelayInfo.Nip11CachedRetriever import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.cancel import com.vitorpamplona.amethyst.commons.resources.confirm @@ -96,7 +97,6 @@ import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness import com.vitorpamplona.amethyst.commons.ui.theme.HorzHalfVertPadding import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn -import com.vitorpamplona.amethyst.model.nip11RelayInfo.Nip11CachedRetriever import com.vitorpamplona.amethyst.ui.note.formatMediumDateTime import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt index f165f0cb52..a5cb13d9f5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt @@ -73,7 +73,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextAlign @@ -112,6 +111,7 @@ import com.vitorpamplona.amethyst.commons.resources.search_source_relays import com.vitorpamplona.amethyst.commons.resources.search_type_to_begin import com.vitorpamplona.amethyst.commons.resources.search_type_to_begin_explainer import com.vitorpamplona.amethyst.commons.resources.search_waiting_on_relays +import com.vitorpamplona.amethyst.commons.search.DataStoreSearchHistoryStorage import com.vitorpamplona.amethyst.commons.search.QuerySerializer import com.vitorpamplona.amethyst.commons.search.SearchScope import com.vitorpamplona.amethyst.commons.search.SearchSortOrder @@ -135,7 +135,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.Size5dp import com.vitorpamplona.amethyst.commons.ui.theme.StdTopPadding import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo -import com.vitorpamplona.amethyst.model.preferences.DataStoreSearchHistoryStorage import com.vitorpamplona.amethyst.service.location.CachedReversedGeoLocations import com.vitorpamplona.amethyst.service.relayClient.searchCommand.TextSearchDataSourceSubscription import com.vitorpamplona.amethyst.ui.components.namecoin.NamecoinResolutionRow @@ -169,7 +168,7 @@ fun SearchScreen( accountViewModel: AccountViewModel, nav: INav, ) { - val historyStorage = LocalContext.current.let { context -> remember(context) { DataStoreSearchHistoryStorage(context) } } + val historyStorage = remember { DataStoreSearchHistoryStorage(Amethyst.instance.appStores.getDataStore(DataStoreSearchHistoryStorage.FILE_NAME)) } val searchBarViewModel: SearchBarViewModel = viewModel( // Keyed on the seed: navigating from one screen's search button to another's has to diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/BottomBarSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/BottomBarSettingsScreen.kt index d2d91c37a6..38b405e133 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/BottomBarSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/BottomBarSettingsScreen.kt @@ -67,6 +67,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon import com.vitorpamplona.amethyst.commons.icons.symbols.Icon @@ -92,7 +93,6 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.commons.ui.theme.Size22Modifier import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonRow -import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarCategories import com.vitorpamplona.amethyst.ui.navigation.bottombars.GroupEntryAvatar import com.vitorpamplona.amethyst.ui.navigation.bottombars.GroupEntryDisplay @@ -498,7 +498,8 @@ private fun PickerChildren( ) { when (item) { NavBarItem.BROWSER -> { - val favorites by FavoriteAppsRegistry.favorites.collectAsStateWithLifecycle() + val favorites by Amethyst.instance.favoriteApps.favorites + .collectAsStateWithLifecycle() if (favorites.isEmpty()) { EmptyChildHint(Res.string.bottom_bar_settings_no_favorites) } else { @@ -817,7 +818,8 @@ private fun rememberPinnedVisual( PinnedVisual.Glyph(def?.icon ?: MaterialSymbols.Apps, def?.let { stringRes(it.labelRes) } ?: "") } is BottomBarEntry.Favorite -> { - val favorites by FavoriteAppsRegistry.favorites.collectAsStateWithLifecycle() + val favorites by Amethyst.instance.favoriteApps.favorites + .collectAsStateWithLifecycle() val app = favorites.firstOrNull { it.id == entry.favoriteId } if (app != null) PinnedVisual.Favorite(app) else PinnedVisual.Glyph(MaterialSymbols.Public, "") } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/DrawerSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/DrawerSettingsScreen.kt index 10e0797d53..51d376c0bc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/DrawerSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/DrawerSettingsScreen.kt @@ -45,6 +45,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.navigation.DrawerSectionId import com.vitorpamplona.amethyst.commons.model.navigation.MandatoryDrawerItems import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.drawer_settings @@ -65,7 +66,6 @@ import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonRow import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarCatalog import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerSection -import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerSectionId import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerSectionVisibility import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerSections import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/MessagesSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/MessagesSettingsScreen.kt index e21ab9fb9b..14cf5b3aeb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/MessagesSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/MessagesSettingsScreen.kt @@ -60,6 +60,8 @@ import com.vitorpamplona.amethyst.commons.napplet.ui.PolicyCard import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.chat_type_concord_desc import com.vitorpamplona.amethyst.commons.resources.chat_type_concord_title +import com.vitorpamplona.amethyst.commons.resources.chat_type_cordn_desc +import com.vitorpamplona.amethyst.commons.resources.chat_type_cordn_title import com.vitorpamplona.amethyst.commons.resources.chat_type_ephemeral_desc import com.vitorpamplona.amethyst.commons.resources.chat_type_ephemeral_title import com.vitorpamplona.amethyst.commons.resources.chat_type_geohash_desc @@ -124,6 +126,7 @@ private val CHAT_FEED_TYPES = ChatFeedTypeUi(ChatFeedType.NIP29, Res.string.chat_type_nip29_title, Res.string.chat_type_nip29_desc, Color(0xFF9E77ED)), ChatFeedTypeUi(ChatFeedType.MARMOT, Res.string.chat_type_marmot_title, Res.string.chat_type_marmot_desc, Color(0xFF5B6AD0)), ChatFeedTypeUi(ChatFeedType.CONCORD, Res.string.chat_type_concord_title, Res.string.chat_type_concord_desc, Color(0xFFEC4899)), + ChatFeedTypeUi(ChatFeedType.CORDN, Res.string.chat_type_cordn_title, Res.string.chat_type_cordn_desc, Color(0xFF14B8A6)), ChatFeedTypeUi(ChatFeedType.GEOHASH, Res.string.chat_type_geohash_title, Res.string.chat_type_geohash_desc, Color(0xFFEF4444)), ChatFeedTypeUi(ChatFeedType.EPHEMERAL, Res.string.chat_type_ephemeral_title, Res.string.chat_type_ephemeral_desc, Color(0xFF06B6D4)), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsScreen.kt index 965b48da38..c20834d797 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsScreen.kt @@ -34,12 +34,12 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.commons.model.preferences.NamecoinSettingsStore import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.namecoin_settings import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.commons.ui.stringRes -import com.vitorpamplona.amethyst.model.preferences.NamecoinSharedPreferences import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumXClient import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinCoreRpcClient import kotlinx.coroutines.launch @@ -58,7 +58,7 @@ fun NamecoinSettingsScreen(nav: INav) { @OptIn(ExperimentalMaterial3Api::class) @Composable fun NamecoinSettingsScreen( - namecoinPrefs: NamecoinSharedPreferences, + namecoinPrefs: NamecoinSettingsStore, electrumXClient: () -> ElectrumXClient, namecoinCoreRpcClient: () -> NamecoinCoreRpcClient, nav: INav, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/OtsSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/OtsSettingsScreen.kt index afcfd317da..3b452871b4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/OtsSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/OtsSettingsScreen.kt @@ -34,14 +34,14 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.commons.model.preferences.OtsSettingsStore import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.ots_explorer_settings +import com.vitorpamplona.amethyst.commons.tor.TorSettingsFlow import com.vitorpamplona.amethyst.commons.tor.TorType import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.commons.ui.stringRes -import com.vitorpamplona.amethyst.model.preferences.OtsSharedPreferences -import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @@ -53,7 +53,7 @@ fun OtsSettingsScreen(nav: INav) { @OptIn(ExperimentalMaterial3Api::class) @Composable fun OtsSettingsScreen( - otsPrefs: OtsSharedPreferences, + otsPrefs: OtsSettingsStore, torSettings: TorSettingsFlow, nav: INav, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt index 99fdcd79ed..a180a43368 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt @@ -44,6 +44,8 @@ import com.vitorpamplona.amethyst.commons.resources.call_settings import com.vitorpamplona.amethyst.commons.resources.call_settings_search_keywords import com.vitorpamplona.amethyst.commons.resources.compose_search_keywords import com.vitorpamplona.amethyst.commons.resources.compose_settings +import com.vitorpamplona.amethyst.commons.resources.cordn_hub_search_keywords +import com.vitorpamplona.amethyst.commons.resources.cordn_hub_title import com.vitorpamplona.amethyst.commons.resources.danger_zone import com.vitorpamplona.amethyst.commons.resources.drawer_search_keywords import com.vitorpamplona.amethyst.commons.resources.drawer_settings @@ -162,6 +164,14 @@ fun buildSettingsCatalog( symEntry(Res.string.napplet_permissions_title, MaterialSymbols.Apps, Res.string.napplet_connected_apps_search_keywords, Route.ConnectedApps), symEntry(Res.string.relay_auth_settings_title, MaterialSymbols.Lock, Res.string.relay_auth_search_keywords, Route.RelayAuthSettings), symEntry(Res.string.call_settings, MaterialSymbols.Phone, Res.string.call_settings_search_keywords, Route.CallSettings), + // One entry, not five. cordn's screens are coordinators, + // key packages, link inspection, backup and migration — + // each a page a user visits rarely and only because they + // are already thinking about cordn. Five flat rows made it + // the largest feature in this list by count and the least + // used by far; the hub keeps them all reachable and + // searchable under one name. + symEntry(Res.string.cordn_hub_title, MaterialSymbols.Dns, Res.string.cordn_hub_search_keywords, Route.CordnHub), ), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/BusyLabel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/BusyLabel.kt new file mode 100644 index 0000000000..c916830d4c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/BusyLabel.kt @@ -0,0 +1,56 @@ +/* + * 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.settings.cordn + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +/** + * A button label that shows work in progress. + * + * Every cordn action screen tracked a `busy` flag and spent it only on + * `enabled = !busy`, so the whole signal that anything was happening was a + * button going grey. Backup and restore derive a key with scrypt — slow by + * design — and migration publishes to relays, so "grey for a few seconds" + * was indistinguishable from "did not register the tap". + */ +@Composable +fun BusyLabel( + busy: Boolean, + label: String, +) { + if (busy) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = LocalContentColor.current, + ) + Spacer(Modifier.width(8.dp)) + } + Text(label) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/CoordinatorIdentity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/CoordinatorIdentity.kt new file mode 100644 index 0000000000..2de55c63fa --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/CoordinatorIdentity.kt @@ -0,0 +1,144 @@ +/* + * 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.settings.cordn + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.model.cache.LocalCache +import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent +import com.vitorpamplona.amethyst.ui.note.UserPicture +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.observeUserNameByHex +import com.vitorpamplona.quartz.contextvm.cep06Announcements.CvmServerAnnouncementEvent +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** + * What to call a coordinator, and the face to put next to it. + * + * A coordinator is a Nostr identity -- ContextVM addresses it with ordinary + * p-tags, and CEP-23 says outright that a server MAY publish NIP-01 profile + * metadata (`CvmKinds.PROFILE_METADATA` is kind 0 for exactly this reason). So + * the app can name one the same way it names anybody else, and showing 64 hex + * characters instead was throwing that away. + * + * ## The order, and why + * + * 1. `CoordinatorConfig.label` -- documented as "what the user calls it. Never + * a claim -- a coordinator cannot prove a name." The only name here that + * means anything, so it wins. + * 2. The CEP-6 announcement's name, when the cache holds one. Before the + * profile because a coordinator added from discovery was *picked* by this + * name, and having the settings screen rename it afterwards would be its own + * small confusion. + * 3. The kind 0's display name (CEP-23). + * 4. The key's first characters. + * + * Both 2 and 3 are the coordinator's own word for itself, and two servers may + * publish the same one, which is why neither outranks a label. + * + * The profile is observed whatever the outcome, so the avatar still loads for a + * coordinator that is named by 1 or 2. + */ +@Composable +fun coordinatorDisplayName( + pubKey: HexKey, + label: String?, + accountViewModel: AccountViewModel, +): String { + // Both unconditional: each one registers the subscription that fetches what + // it reads, so neither may come and go with whether an earlier source in the + // chain happens to have an answer. + val fromProfile = observeUserNameByHex(pubKey, accountViewModel) + val announced = observeAnnouncedServerName(pubKey, accountViewModel) + + return label?.takeIf { it.isNotBlank() } + ?: announced + ?: fromProfile +} + +/** + * The name from the coordinator's CEP-6 announcement, as the cache holds it. + * + * A [CvmServerAnnouncementEvent] is a replaceable event like any other now, so + * reading it here gets the newest one per coordinator, already verified, and + * fetched by the same event-finder data source the rest of the app uses -- + * rather than a hand-rolled fetch, a hand-rolled newest-wins and a second copy + * of the name kept beside the cache. + */ +@Composable +fun observeAnnouncedServerName( + pubKey: HexKey, + accountViewModel: AccountViewModel, +): String? { + val note = + remember(pubKey) { + LocalCache.getOrCreateAddressableNote(Address(CvmServerAnnouncementEvent.KIND, pubKey, "")) + } + val announcement by observeNoteEvent(note, accountViewModel) + + return announcement?.serverName() +} + +/** + * A coordinator rendered as the user it is: avatar, name, and whatever acts on + * it. + * + * The avatar navigates to the profile, because [UserPicture] already does and + * a coordinator's profile is as worth reading as anyone's -- more, given it is + * the party whose metadata exposure the group info screen is about. + * + * [name] is a `RowScope` slot so a caller can weight it, which both of them + * need; before it was, they weighted it anyway and compiled only because + * `ColumnScope.weight` happens to produce the same element. [trailing] is its + * own slot for the same reason: an action smuggled through a slot called `name` + * is a lie about where it lands. + */ +@Composable +fun CoordinatorIdentityRow( + pubKey: HexKey, + label: String?, + accountViewModel: AccountViewModel, + nav: INav, + modifier: Modifier = Modifier, + size: Dp = 28.dp, + trailing: @Composable RowScope.() -> Unit = {}, + name: @Composable RowScope.(String) -> Unit, +) { + Row( + modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + UserPicture(userHex = pubKey, size = size, accountViewModel = accountViewModel, nav = nav) + name(coordinatorDisplayName(pubKey, label, accountViewModel)) + trailing() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/CordnBackupScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/CordnBackupScreen.kt new file mode 100644 index 0000000000..0876d4cf5d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/CordnBackupScreen.kt @@ -0,0 +1,326 @@ +/* + * 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.settings.cordn + +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +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.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.cancel +import com.vitorpamplona.amethyst.commons.resources.cordn_backup_contents_body +import com.vitorpamplona.amethyst.commons.resources.cordn_backup_contents_title +import com.vitorpamplona.amethyst.commons.resources.cordn_backup_explainer +import com.vitorpamplona.amethyst.commons.resources.cordn_backup_export +import com.vitorpamplona.amethyst.commons.resources.cordn_backup_exported +import com.vitorpamplona.amethyst.commons.resources.cordn_backup_failed +import com.vitorpamplona.amethyst.commons.resources.cordn_backup_passphrase +import com.vitorpamplona.amethyst.commons.resources.cordn_backup_restore +import com.vitorpamplona.amethyst.commons.resources.cordn_backup_restore_body +import com.vitorpamplona.amethyst.commons.resources.cordn_backup_restore_confirm_body +import com.vitorpamplona.amethyst.commons.resources.cordn_backup_restore_confirm_title +import com.vitorpamplona.amethyst.commons.resources.cordn_backup_restored +import com.vitorpamplona.amethyst.commons.resources.cordn_backup_section_export +import com.vitorpamplona.amethyst.commons.resources.cordn_backup_section_passphrase +import com.vitorpamplona.amethyst.commons.resources.cordn_backup_section_restore +import com.vitorpamplona.amethyst.commons.resources.cordn_backup_title +import com.vitorpamplona.amethyst.commons.resources.cordn_group_unavailable +import com.vitorpamplona.amethyst.commons.resources.cordn_group_unavailable_detail +import com.vitorpamplona.amethyst.commons.ui.components.EmptyState +import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsSection +import com.vitorpamplona.amethyst.ui.stringRes +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * Exporting and restoring cordn state. + * + * ## Why cordn has this when the rest of Amethyst does not + * + * Amethyst's "backup" is a key backup: your nsec, from which a relay-backed + * account rebuilds itself. A cordn group does not work that way. It is MLS + * state on **one device** plus an ordered stream on a coordinator that cannot + * read a byte of it, so the key alone restores nothing. Lose the device and + * the group is gone — not "until someone re-invites you", because a fresh + * invitation begins at a new epoch and everything before it stays unreadable + * forever. + * + * ## Restoring replaces a device, and the screen says so before the tap + * + * An MLS state export is a cloneable identity. Two devices holding one + * group's state and both committing fork the ratchet tree, and MLS does not + * recover: after the fork, messages silently fail to decrypt for somebody, + * with nothing on screen to explain it. So restoring wipes this device's + * cordn state and takes the file's — it is not a merge and it is not sync, + * and presenting it as either would be selling multi-device support that + * §5.3 deliberately does not exist. + * + * ## The file is as sensitive as the conversations + * + * It carries ratchet trees and KeyPackage private halves. The passphrase is + * the only thing protecting it once it leaves the app, which is why there is + * no "export without a passphrase" and why the warning names what is inside. + */ +@Composable +fun CordnBackupScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + val runtime = accountViewModel.account.cordnRuntime + val context = LocalContext.current + val scope = rememberCoroutineScope() + + var passphrase by remember { mutableStateOf("") } + var busy by remember { mutableStateOf(false) } + var status by remember { mutableStateOf(null) } + var error by remember { mutableStateOf(null) } + var pendingRestore by remember { mutableStateOf(null) } + + val exported = stringRes(Res.string.cordn_backup_exported) + val restored = stringRes(Res.string.cordn_backup_restored) + val failed = stringRes(Res.string.cordn_backup_failed) + + val saver = + rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/octet-stream")) { uri -> + val target = uri ?: return@rememberLauncherForActivityResult + val runtimeNow = runtime ?: return@rememberLauncherForActivityResult + scope.launch { + busy = true + error = null + status = null + try { + val bytes = runtimeNow.exportArchive(passphrase) + withContext(Dispatchers.IO) { + context.contentResolver.openOutputStream(target)?.use { it.write(bytes) } + } + status = exported + passphrase = "" + } catch (e: Exception) { + error = e.message ?: failed + } finally { + busy = false + } + } + } + + // OpenDocument, not GetContent. `GetContent("*/*")` sends ACTION_GET_CONTENT, + // which on this Android version is intercepted by the system photo picker's + // shim (`PhotopickerGetContentActivity`) before it hands off to DocumentsUI — + // and a file chosen through that handoff came back as a cancelled result, so + // Restore opened a picker, took a tap, and quietly did nothing. An archive is + // not media; ACTION_OPEN_DOCUMENT is the right intent for it and reaches SAF + // directly. + val picker = + rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + pendingRestore = uri + } + + val snackbarHostState = remember { SnackbarHostState() } + + // Outcomes go to a snackbar rather than two Text lines at the bottom of a + // scrolling form. They were easy to scroll past, they never left once + // shown, and an export that had just failed sat under a button that looked + // ready to try again. + LaunchedEffect(status) { + status?.let { + snackbarHostState.showSnackbar(it) + status = null + } + } + LaunchedEffect(error) { + error?.let { + snackbarHostState.showSnackbar(it) + error = null + } + } + + Scaffold( + topBar = { TopBarWithBackButton(stringRes(Res.string.cordn_backup_title), nav) }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + ) { padding -> + if (runtime == null) { + // The app's own empty state, centred and titled, rather than a + // sentence stranded in the top-left corner. + EmptyState( + title = stringRes(Res.string.cordn_group_unavailable), + description = stringRes(Res.string.cordn_group_unavailable_detail), + modifier = Modifier.padding(padding), + ) + return@Scaffold + } + + Column( + modifier = + Modifier + .padding(padding) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + Text( + text = stringRes(Res.string.cordn_backup_explainer), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + // The passphrase is its own section because it governs both of the + // two below: the same word exports and restores, and a field + // floating above two unrelated-looking cards did not say so. + SettingsSection(Res.string.cordn_backup_section_passphrase) { + SettingsFormBlock { + OutlinedTextField( + value = passphrase, + onValueChange = { + passphrase = it + error = null + }, + label = { Text(stringRes(Res.string.cordn_backup_passphrase)) }, + visualTransformation = PasswordVisualTransformation(), + // The dots are only half of it. Without the password + // keyboard type the IME treats this as ordinary prose: + // it offers the passphrase in the suggestion strip, in + // the clear, and learns it into the personalised + // dictionary, where it outlives the app. Every other + // secret field in Amethyst already sets this; this one + // was the only one that did not. + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } + } + + SettingsSection(Res.string.cordn_backup_section_export) { + SettingsFormBlock { + Text(stringRes(Res.string.cordn_backup_contents_title), style = MaterialTheme.typography.titleSmall) + Text( + text = stringRes(Res.string.cordn_backup_contents_body), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Button( + onClick = { saver.launch("cordn-backup.bin") }, + // No passphrase, no export. The file carries ratchet + // trees and private key material; there is no version + // of it that is safe to write unprotected. + enabled = !busy && passphrase.isNotBlank(), + modifier = Modifier.fillMaxWidth(), + ) { + // Key derivation here is scrypt, which is slow on + // purpose, so a greyed-out button was the only sign + // anything was happening for several seconds. + BusyLabel(busy, stringRes(Res.string.cordn_backup_export)) + } + } + } + + SettingsSection(Res.string.cordn_backup_section_restore) { + SettingsFormBlock { + Text( + text = stringRes(Res.string.cordn_backup_restore_body), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Button( + onClick = { picker.launch(arrayOf("*/*")) }, + enabled = !busy && passphrase.isNotBlank(), + modifier = Modifier.fillMaxWidth(), + ) { + BusyLabel(busy, stringRes(Res.string.cordn_backup_restore)) + } + } + } + } + } + + pendingRestore?.let { uri -> + AlertDialog( + onDismissRequest = { pendingRestore = null }, + title = { Text(stringRes(Res.string.cordn_backup_restore_confirm_title)) }, + text = { Text(stringRes(Res.string.cordn_backup_restore_confirm_body)) }, + confirmButton = { + TextButton(onClick = { + pendingRestore = null + val runtimeNow = runtime ?: return@TextButton + scope.launch { + busy = true + error = null + status = null + try { + val bytes = withContext(Dispatchers.IO) { context.contentResolver.openInputStream(uri)?.use { it.readBytes() } } + if (bytes == null) { + error = failed + } else { + runtimeNow.importArchive(bytes, passphrase) + status = restored + passphrase = "" + } + } catch (e: Exception) { + error = e.message ?: failed + } finally { + busy = false + } + } + }) { + Text(stringRes(Res.string.cordn_backup_restore), color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(onClick = { pendingRestore = null }) { Text(stringRes(Res.string.cancel)) } + }, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/CordnCoordinatorsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/CordnCoordinatorsScreen.kt new file mode 100644 index 0000000000..3459eec409 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/CordnCoordinatorsScreen.kt @@ -0,0 +1,744 @@ +/* + * 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.settings.cordn + +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.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +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.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +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.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.cordn.CoordinatorConfig +import com.vitorpamplona.amethyst.commons.cordn.CoordinatorHealth +import com.vitorpamplona.amethyst.commons.cordn.CordnCoordinatorDiscovery +import com.vitorpamplona.amethyst.commons.cordn.DiscoveredCoordinator +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.cache.LocalCache +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.cancel +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_add_action +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_add_failed +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_copy_key +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_discover_action +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_discover_add +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_discover_explainer +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_discover_failed +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_discover_none +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_discover_seen +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_discover_unheard +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_explainer +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_health_down +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_health_ok +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_health_retrying +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_health_unknown +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_identify +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_key +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_label +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_label_note +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_more +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_none +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_purge +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_purge_body +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_purge_title +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_relays_label +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_remove +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_rename +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_section_discover +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_section_manual +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_section_yours +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_server +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_server_claim +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_server_silent +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_title +import com.vitorpamplona.amethyst.commons.resources.cordn_create_coordinator_pubkey +import com.vitorpamplona.amethyst.commons.resources.cordn_create_coordinator_relays +import com.vitorpamplona.amethyst.commons.resources.cordn_group_unavailable +import com.vitorpamplona.amethyst.commons.resources.cordn_group_unavailable_detail +import com.vitorpamplona.amethyst.commons.resources.cordn_info_save +import com.vitorpamplona.amethyst.commons.ui.components.CrossfadeIfEnabled +import com.vitorpamplona.amethyst.commons.ui.components.EmptyState +import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.model.cordn.CordnRuntime +import com.vitorpamplona.amethyst.ui.note.UserPicture +import com.vitorpamplona.amethyst.ui.note.timeAgoNoDot +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.cordnGroup.CopyableKeyRow +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsSection +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.cordn.spec00Coordinator.CoordinatorServerInfo +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull +import kotlinx.coroutines.launch + +/** + * The coordinators this account talks to. + * + * ## Why a coordinator is a first-class thing and not a relay setting + * + * Relays are interchangeable and redundant; losing one costs nothing. A + * coordinator is the sole authority for the groups it serves (`spec/00.md` + * §4) — losing it loses the ordering, and a second one does not mirror the + * first. So this screen lists named things a user chose, with what each one + * costs and two very different ways to stop using one. + * + * ## Remove and purge are different decisions + * + * Removing closes the session and leaves the MLS state on disk, so re-adding + * brings the groups back. Purging deletes the ratchet trees, the cursors and + * the KeyPackage private halves, after which those groups can only be + * re-entered by a fresh invitation — MLS state cannot be rebuilt from + * anywhere else. The confirmation says that, because "delete" normally means + * the reversible one. + * + * Neither tells the coordinator anything. It keeps what it already had; this + * screen is about the device, and implying otherwise would be selling a + * deletion nobody can perform. + */ +@Composable +fun CordnCoordinatorsScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + val runtime = accountViewModel.account.cordnRuntime + + Scaffold( + topBar = { TopBarWithBackButton(stringRes(Res.string.cordn_coordinators_title), nav) }, + ) { padding -> + if (runtime == null) { + // The app's own empty state, centred and titled, rather than a + // sentence stranded in the top-left corner. + EmptyState( + title = stringRes(Res.string.cordn_group_unavailable), + description = stringRes(Res.string.cordn_group_unavailable_detail), + modifier = Modifier.padding(padding), + ) + return@Scaffold + } + + val coordinators by runtime.coordinators.collectAsStateWithLifecycle() + + Column( + modifier = + Modifier + .padding(padding) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + Text( + text = stringRes(Res.string.cordn_coordinators_explainer), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + // Three sections rather than one column split by rules: what you + // already use, what is on offer, and the manual escape hatch. The + // dividers said "something else follows" without saying what. + SettingsSection(Res.string.cordn_coordinators_section_yours) { + SettingsFormBlock { + coordinators.forEachIndexed { index, config -> + CoordinatorCard(config, runtime, accountViewModel, nav) + // Between coordinators, never after the last: the + // section's own card edge already ends the list. + if (index != coordinators.lastIndex) HorizontalDivider(thickness = DividerThickness) + } + + if (coordinators.isEmpty()) { + Text( + text = stringRes(Res.string.cordn_coordinators_none), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + SettingsSection(Res.string.cordn_coordinators_section_discover) { + SettingsFormBlock { + DiscoverCoordinators(runtime, accountViewModel, nav, coordinators.map { it.pubKey }.toSet()) + } + } + + // Last, and it reads as the fallback it is now that discovery is + // above it: pasting a 64-character key is what you do when nobody + // announced the one you were told to use. + SettingsSection(Res.string.cordn_coordinators_section_manual) { + SettingsFormBlock { + AddCoordinator(runtime) + } + } + } + } +} + +@Composable +private fun CoordinatorCard( + config: CoordinatorConfig, + runtime: CordnRuntime, + accountViewModel: AccountViewModel, + nav: INav, +) { + val scope = rememberCoroutineScope() + var info by remember(config.pubKey) { mutableStateOf(null) } + var infoChecked by remember(config.pubKey) { mutableStateOf(false) } + var confirmingPurge by remember(config.pubKey) { mutableStateOf(false) } + var renaming by remember(config.pubKey) { mutableStateOf(false) } + var menuOpen by remember(config.pubKey) { mutableStateOf(false) } + + val health = runtime.health(config.pubKey)?.collectAsStateWithLifecycle()?.value + + // No Card here: SettingsSection already IS the card, and a second one + // inside it rendered as a square-cornered slab of a different grey inside + // the section's rounded one -- every other cordn settings section puts its + // content straight into the form block. + Column(Modifier.fillMaxWidth().padding(vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + CoordinatorIdentityRow( + pubKey = config.pubKey, + label = config.label, + accountViewModel = accountViewModel, + nav = nav, + size = 36.dp, + trailing = { + // The label was only ever settable while adding a coordinator + // by hand, so one that arrived from discovery, or came back + // with a restore, could not be named at all. + IconButton(onClick = { renaming = true }) { + Icon( + symbol = MaterialSymbols.Edit, + contentDescription = stringRes(Res.string.cordn_coordinators_rename), + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + ) { name -> + // Name and state together at the top, because "is this one working" + // is the question this screen is opened with. Everything verifiable + // sits below, where it is read once rather than scanned. + Column(Modifier.weight(1f)) { + Text( + text = name, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + health?.let { HealthLine(it) } + } + } + + if (renaming) { + RenameCoordinatorDialog( + current = config.label, + onDismiss = { renaming = false }, + onSave = { + renaming = false + scope.launch { runtime.relabel(config.pubKey, it) } + }, + ) + } + + // An npub, not the 64 hex characters that used to sit here. This is the + // screen where you check that the coordinator you were told to use is + // the one you added -- and what you were told is an npub, which a hex + // string cannot be compared against at all. The full value is one tap + // away on the clipboard, which is how a comparison is actually done. + val coordinator = remember(config.pubKey) { LocalCache.getOrCreateUser(config.pubKey) } + CopyableKeyRow( + label = stringRes(Res.string.cordn_coordinators_key), + shown = coordinator.pubkeyDisplayHex(), + copied = coordinator.pubkeyNpub(), + copyDescription = stringRes(Res.string.cordn_coordinators_copy_key), + ) + + // Every relay, not the first two and a count: this is the management + // screen, and an address the coordinator answers on is the thing you + // came here to check or to rule out. + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = stringRes(Res.string.cordn_coordinators_relays_label), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + ) + config.relays.forEach { relay -> + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Icon( + symbol = MaterialSymbols.Link, + contentDescription = null, + modifier = Modifier.size(13.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = relay.url, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + // Only ever what a call of ours already observed -- nothing here + // polls. The handshake below is a call the user asked for. + if (infoChecked) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = + info?.let { + stringRes( + Res.string.cordn_coordinators_server, + listOfNotNull(it.name, it.version, it.protocolVersion).joinToString(" \u00b7 "), + ) + } ?: stringRes(Res.string.cordn_coordinators_server_silent), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + // Said next to it, every time, because a name on a settings + // screen reads as verified and this one is not: §8.5 makes + // the pubkey the identity and nothing else. + text = stringRes(Res.string.cordn_coordinators_server_claim), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Row( + Modifier.fillMaxWidth().padding(top = 4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedButton(onClick = { + scope.launch { + info = runCatching { runtime.serverInfo(config.pubKey) }.getOrNull() + infoChecked = true + } + }) { + Text(stringRes(Res.string.cordn_coordinators_identify)) + } + + Spacer(Modifier.weight(1f)) + + // Behind a menu rather than beside Identify. Purge deletes this + // coordinator's groups and keys off the device, and it sat one + // tap away from a harmless handshake button, at the same size. + Box { + IconButton(onClick = { menuOpen = true }) { + Icon( + symbol = MaterialSymbols.MoreVert, + contentDescription = stringRes(Res.string.cordn_coordinators_more), + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + DropdownMenuItem( + text = { Text(stringRes(Res.string.cordn_coordinators_remove)) }, + onClick = { + menuOpen = false + scope.launch { runtime.forget(config.pubKey) } + }, + ) + DropdownMenuItem( + text = { + Text( + text = stringRes(Res.string.cordn_coordinators_purge), + color = MaterialTheme.colorScheme.error, + ) + }, + onClick = { + menuOpen = false + confirmingPurge = true + }, + ) + } + } + } + } + + if (confirmingPurge) { + AlertDialog( + onDismissRequest = { confirmingPurge = false }, + title = { Text(stringRes(Res.string.cordn_coordinators_purge_title)) }, + text = { Text(stringRes(Res.string.cordn_coordinators_purge_body)) }, + confirmButton = { + TextButton(onClick = { + confirmingPurge = false + scope.launch { runtime.purge(config.pubKey) } + }) { + Text(stringRes(Res.string.cordn_coordinators_purge), color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(onClick = { confirmingPurge = false }) { + Text(stringRes(Res.string.cancel)) + } + }, + ) + } +} + +/** + * What this coordinator's own traffic has shown, and nothing more. + * + * `CoordinatorHealth` records what the calls the app was making anyway + * observed; it never polls, because a poll is a call and every call is + * metadata (§8). So "unknown" here means nothing has been asked of it yet, + * which is genuinely different from "down" and is shown differently. + */ +@Composable +private fun HealthLine(state: CoordinatorHealth.State) { + val text = + when { + state.isUnknown -> stringRes(Res.string.cordn_coordinators_health_unknown) + state.isDown -> stringRes(Res.string.cordn_coordinators_health_down, state.consecutiveFailures) + state.consecutiveFailures > 0 -> stringRes(Res.string.cordn_coordinators_health_retrying) + else -> stringRes(Res.string.cordn_coordinators_health_ok) + } + + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + color = if (state.isDown) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant, + ) +} + +/** + * Coordinators that announced themselves, offered instead of a hex field. + * + * Adding one used to mean pasting a 64-character public key and a list of + * relay URLs, which is not something anyone can do without being told the + * answer out of band. Coordinators publish CEP-6 announcements; reading them + * is a relay query that touches no coordinator, so nothing here tells anyone + * that this account exists. + * + * Deliberately not automatic on entry. The query is cheap but it is still the + * user's relays being asked a question on their behalf, and a screen that + * reaches out the moment it opens is the kind of thing this feature is + * supposed to be careful about. + */ +@Composable +private fun DiscoverCoordinators( + runtime: CordnRuntime, + accountViewModel: AccountViewModel, + nav: INav, + known: Set, +) { + val scope = rememberCoroutineScope() + var result by remember { mutableStateOf(null) } + var busy by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } + val failed = stringRes(Res.string.cordn_coordinators_discover_failed) + + Text( + text = stringRes(Res.string.cordn_coordinators_discover_explainer), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + OutlinedButton( + onClick = { + busy = true + error = null + scope.launch { + try { + result = runtime.discover(accountViewModel.account.outboxRelays.flow.value) + } catch (e: Exception) { + error = e.message ?: failed + } finally { + busy = false + } + } + }, + enabled = !busy, + ) { + if (busy) { + CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp) + Spacer(Modifier.width(8.dp)) + } + Text(stringRes(Res.string.cordn_coordinators_discover_action)) + } + + error?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error) } + + // The app's own crossfade, so performance mode still turns it off. + CrossfadeIfEnabled( + targetState = result, + label = "cordn-discovery", + ) { found -> + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + if (found == null) return@Column + + // Already-added ones are dropped rather than shown disabled: this + // list is "what you could add", and a row that does nothing is + // just something else to read. + val offers = found.coordinators.filter { it.pubKey !in known } + + if (offers.isEmpty()) { + Text( + text = + if (found.unreachable.isEmpty()) { + stringRes(Res.string.cordn_coordinators_discover_none) + } else { + // "Nobody is announcing" and "we were not told" are + // different answers and only one of them is final. + stringRes(Res.string.cordn_coordinators_discover_unheard, found.unreachable.size) + }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + offers.forEach { offer -> DiscoveredCard(offer, runtime, accountViewModel, nav) } + } + } +} + +/** + * Names a coordinator, or clears the name it was given. + * + * Empty saves as no label rather than an empty one, so the display falls back + * to the profile and then the key instead of rendering a blank line. + */ +@Composable +private fun RenameCoordinatorDialog( + current: String?, + onDismiss: () -> Unit, + onSave: (String?) -> Unit, +) { + var input by remember { mutableStateOf(current.orEmpty()) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringRes(Res.string.cordn_coordinators_rename)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = input, + onValueChange = { input = it }, + label = { Text(stringRes(Res.string.cordn_coordinators_label)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Text( + // The same caution the add form gives, for the same reason. + text = stringRes(Res.string.cordn_coordinators_label_note), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + confirmButton = { + TextButton(onClick = { onSave(input.trim().ifEmpty { null }) }) { + Text(stringRes(Res.string.cordn_info_save)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringRes(Res.string.cancel)) } + }, + ) +} + +@Composable +private fun DiscoveredCard( + offer: DiscoveredCoordinator, + runtime: CordnRuntime, + accountViewModel: AccountViewModel, + nav: INav, +) { + val scope = rememberCoroutineScope() + var busy by remember { mutableStateOf(false) } + + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + // The announcement's name, not the profile's: in a discovery list + // the CEP-6 surface is the thing being offered, and both are the + // server's own word for itself anyway. The avatar is worth having + // regardless -- it is the only part of this card that is hard to + // impersonate at a glance. + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + UserPicture(userHex = offer.pubKey, size = 24.dp, accountViewModel = accountViewModel, nav = nav) + Text( + // Its own word for itself, and said so: a coordinator cannot + // prove a name, which is why this never becomes the label. + // Passed as the label so the announcement still wins, with + // the profile catching an offer that announced no name. + text = coordinatorDisplayName(offer.pubKey, offer.surface.name, accountViewModel), + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.weight(1f), + ) + } + offer.surface.about?.takeIf { it.isNotBlank() }?.let { + Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Text( + text = offer.relays.joinToString { it.url }, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + // Staleness matters more here than anywhere: the last live + // survey found most announcements were months-dead demos. + text = stringRes(Res.string.cordn_coordinators_discover_seen, timeAgoNoDot(offer.announcedAt).trim()), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Button( + onClick = { + busy = true + scope.launch { + try { + runtime.session(offer.toConfig()) + } finally { + busy = false + } + } + }, + enabled = !busy, + modifier = Modifier.padding(top = 4.dp), + ) { + Text(stringRes(Res.string.cordn_coordinators_discover_add)) + } + } + } +} + +@Composable +private fun AddCoordinator(runtime: CordnRuntime) { + val scope = rememberCoroutineScope() + var pubKeyInput by remember { mutableStateOf("") } + var relaysInput by remember { mutableStateOf("") } + var labelInput by remember { mutableStateOf("") } + var error by remember { mutableStateOf(null) } + var busy by remember { mutableStateOf(false) } + val failed = stringRes(Res.string.cordn_coordinators_add_failed) + + val config = + remember(pubKeyInput, relaysInput, labelInput) { + val pubKey = decodePublicKeyAsHexOrNull(pubKeyInput.trim()) + val relays = relaysInput.lines().mapNotNull { RelayUrlNormalizer.normalizeOrNull(it.trim()) } + if (pubKey == null || relays.isEmpty()) { + null + } else { + CoordinatorConfig(pubKey, relays, CoordinatorConfig.Origin.MANUAL, labelInput.trim().ifEmpty { null }) + } + } + + OutlinedTextField( + value = pubKeyInput, + onValueChange = { + pubKeyInput = it + error = null + }, + label = { Text(stringRes(Res.string.cordn_create_coordinator_pubkey)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = relaysInput, + onValueChange = { + relaysInput = it + error = null + }, + label = { Text(stringRes(Res.string.cordn_create_coordinator_relays)) }, + singleLine = false, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = labelInput, + onValueChange = { labelInput = it }, + label = { Text(stringRes(Res.string.cordn_coordinators_label)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + Text( + // A label is ours, never theirs. A coordinator cannot prove a name and + // this one is not asked for one. + text = stringRes(Res.string.cordn_coordinators_label_note), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + error?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error) } + + Button( + onClick = { + val target = config ?: return@Button + busy = true + error = null + scope.launch { + try { + runtime.session(target) + pubKeyInput = "" + relaysInput = "" + labelInput = "" + } catch (e: Exception) { + error = e.message ?: failed + } finally { + busy = false + } + } + }, + enabled = !busy && config != null, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringRes(Res.string.cordn_coordinators_add_action)) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/CordnHubScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/CordnHubScreen.kt new file mode 100644 index 0000000000..776bde38f8 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/CordnHubScreen.kt @@ -0,0 +1,171 @@ +/* + * 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.settings.cordn + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.navigation.Route +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.cordn_hub_backup +import com.vitorpamplona.amethyst.commons.resources.cordn_hub_backup_desc +import com.vitorpamplona.amethyst.commons.resources.cordn_hub_coordinators +import com.vitorpamplona.amethyst.commons.resources.cordn_hub_coordinators_desc +import com.vitorpamplona.amethyst.commons.resources.cordn_hub_explainer +import com.vitorpamplona.amethyst.commons.resources.cordn_hub_keypackages +import com.vitorpamplona.amethyst.commons.resources.cordn_hub_keypackages_desc +import com.vitorpamplona.amethyst.commons.resources.cordn_hub_link +import com.vitorpamplona.amethyst.commons.resources.cordn_hub_link_desc +import com.vitorpamplona.amethyst.commons.resources.cordn_hub_migrate +import com.vitorpamplona.amethyst.commons.resources.cordn_hub_migrate_desc +import com.vitorpamplona.amethyst.commons.resources.cordn_hub_section_device +import com.vitorpamplona.amethyst.commons.resources.cordn_hub_section_service +import com.vitorpamplona.amethyst.commons.resources.cordn_hub_title +import com.vitorpamplona.amethyst.commons.ui.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsControlRow +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsDivider +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsSection +import com.vitorpamplona.amethyst.ui.stringRes +import org.jetbrains.compose.resources.StringResource + +/** + * One door into cordn, instead of five rows in the account settings list. + * + * cordn's pages — coordinators, key packages, link inspection, backup, + * migration — are each visited rarely and only by someone already thinking + * about cordn. As flat entries they made it the biggest feature in that list + * by count and among the least used, pushing everything else down. Grouping + * them costs one tap and keeps every page searchable under a name a user + * would actually look for. + * + * Built from [SettingsSection] and [SettingsItem] like every other settings + * page. The first version hand-rolled its own cards, which made the one screen + * reached FROM the settings list the one screen that did not look like it. + */ +@Composable +fun CordnHubScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + Scaffold( + topBar = { TopBarWithBackButton(stringRes(Res.string.cordn_hub_title), nav) }, + ) { padding -> + Column( + modifier = + Modifier + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + Text( + text = stringRes(Res.string.cordn_hub_explainer), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 4.dp), + ) + + // Split by what the page is about rather than listed flat: the + // first three are about a coordinator, the last two about this + // device's own state. + SettingsSection(Res.string.cordn_hub_section_service) { + HubEntry(MaterialSymbols.Dns, Res.string.cordn_hub_coordinators, Res.string.cordn_hub_coordinators_desc) { + nav.nav(Route.CordnCoordinators) + } + SettingsDivider() + HubEntry(MaterialSymbols.Key, Res.string.cordn_hub_keypackages, Res.string.cordn_hub_keypackages_desc) { + nav.nav(Route.CordnKeyPackages) + } + SettingsDivider() + HubEntry(MaterialSymbols.Link, Res.string.cordn_hub_link, Res.string.cordn_hub_link_desc) { + nav.nav(Route.CordnLink) + } + } + + SettingsSection(Res.string.cordn_hub_section_device) { + HubEntry(MaterialSymbols.Save, Res.string.cordn_hub_backup, Res.string.cordn_hub_backup_desc) { + nav.nav(Route.CordnBackup) + } + SettingsDivider() + HubEntry(MaterialSymbols.SwapHoriz, Res.string.cordn_hub_migrate, Res.string.cordn_hub_migrate_desc) { + nav.nav(Route.CordnMigrate) + } + } + } + } +} + +/** + * A navigation row that keeps its description. + * + * [SettingsItem] is the plain navigation row and has no room for one. Every + * page behind this hub is obscure enough that its title alone does not say + * what it does, so the row with a description — and a chevron supplied as the + * trailing slot, since [SettingsControlRow] is built for inline controls — + * is the honest fit. + */ +@Composable +private fun HubEntry( + icon: MaterialSymbol, + title: StringResource, + description: StringResource, + onClick: () -> Unit, +) { + SettingsControlRow( + icon = icon, + title = stringRes(title), + description = stringRes(description), + onClick = onClick, + ) { + Icon( + symbol = MaterialSymbols.ChevronRight, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Preview +@Composable +fun CordnHubScreenPreview() { + ThemeComparisonColumn { + CordnHubScreen(mockAccountViewModel(), EmptyNav()) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/CordnKeyPackagesScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/CordnKeyPackagesScreen.kt new file mode 100644 index 0000000000..b3ebf82863 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/CordnKeyPackagesScreen.kt @@ -0,0 +1,335 @@ +/* + * 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.settings.cordn + +import androidx.compose.foundation.layout.Arrangement +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 +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.cordn.CoordinatorConfig +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.cordn_coordinators_none +import com.vitorpamplona.amethyst.commons.resources.cordn_group_unavailable +import com.vitorpamplona.amethyst.commons.resources.cordn_group_unavailable_detail +import com.vitorpamplona.amethyst.commons.resources.cordn_keypackages_disclosure +import com.vitorpamplona.amethyst.commons.resources.cordn_keypackages_explainer +import com.vitorpamplona.amethyst.commons.resources.cordn_keypackages_failed +import com.vitorpamplona.amethyst.commons.resources.cordn_keypackages_last_resort_no +import com.vitorpamplona.amethyst.commons.resources.cordn_keypackages_last_resort_yes +import com.vitorpamplona.amethyst.commons.resources.cordn_keypackages_orphans +import com.vitorpamplona.amethyst.commons.resources.cordn_keypackages_orphans_body +import com.vitorpamplona.amethyst.commons.resources.cordn_keypackages_publish +import com.vitorpamplona.amethyst.commons.resources.cordn_keypackages_publish_last_resort +import com.vitorpamplona.amethyst.commons.resources.cordn_keypackages_section +import com.vitorpamplona.amethyst.commons.resources.cordn_keypackages_summary +import com.vitorpamplona.amethyst.commons.resources.cordn_keypackages_title +import com.vitorpamplona.amethyst.commons.resources.cordn_keypackages_topup_note +import com.vitorpamplona.amethyst.commons.resources.cordn_keypackages_withdraw_all +import com.vitorpamplona.amethyst.commons.resources.cordn_keypackages_withdraw_orphans +import com.vitorpamplona.amethyst.commons.ui.components.EmptyState +import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.model.cordn.CordnKeyPackageRow +import com.vitorpamplona.amethyst.model.cordn.CordnRuntime +import com.vitorpamplona.amethyst.ui.pluralStringRes +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsSection +import com.vitorpamplona.amethyst.ui.stringRes +import kotlinx.coroutines.launch + +/** + * The KeyPackages this account has published, per coordinator. + * + * ## Why this screen has to exist at all + * + * `spec/00.md` §4.2 gives cordn **no KeyPackage event kind**. There is no + * relay to fall back on: the coordinator is the only place an inviter can + * find one, so an account with none published simply cannot be added to a + * group, and nothing anywhere would say why. Marmot's equivalent is invisible + * because its packages live on relays and the app republishes them for you; + * here the publish is a call to one named party, which makes it a decision + * rather than a housekeeping detail. + * + * ## Publishing is attributable, and that is the whole cost + * + * §8.4: a KeyPackage is published under the account key, with a signed payload + * the coordinator stores and serves. It tells that coordinator this account + * exists and is invitable — permanently, and whether or not anyone ever + * invites anyone. That is stated above the button, not discovered after it. + * + * ## Two different truths, shown separately + * + * The coordinator's listing says what an inviter can take. The local store + * says whether the Welcome that results can be opened. A package in the first + * without the second belongs to another device of this account — or to an + * install that is gone, in which case anyone who uses it sends a Welcome + * nobody will ever read. Collapsing the two would hide exactly that case. + */ +@Composable +fun CordnKeyPackagesScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + val runtime = accountViewModel.account.cordnRuntime + + Scaffold( + topBar = { TopBarWithBackButton(stringRes(Res.string.cordn_keypackages_title), nav) }, + ) { padding -> + if (runtime == null) { + EmptyState( + title = stringRes(Res.string.cordn_group_unavailable), + description = stringRes(Res.string.cordn_group_unavailable_detail), + modifier = Modifier.padding(padding), + ) + return@Scaffold + } + + val coordinators by runtime.coordinators.collectAsStateWithLifecycle() + + Column( + modifier = + Modifier + .padding(padding) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + Text( + text = stringRes(Res.string.cordn_keypackages_explainer), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + // One section holding every coordinator rather than one each: + // SettingsSection titles come from a StringResource and a + // coordinator's label is runtime text, so a section per + // coordinator is not something this primitive can say. The + // dividers stay, inside, where they now separate peers within a + // box instead of floating in open page. + SettingsSection(Res.string.cordn_keypackages_section) { + SettingsFormBlock { + if (coordinators.isEmpty()) { + Text( + text = stringRes(Res.string.cordn_coordinators_none), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + coordinators.forEachIndexed { index, config -> + // Between, not after: a rule under the last one drew a + // line to nothing. + if (index > 0) HorizontalDivider() + CoordinatorKeyPackages(config, runtime, accountViewModel, nav) + } + } + } + } + } +} + +@Composable +private fun CoordinatorKeyPackages( + config: CoordinatorConfig, + runtime: CordnRuntime, + accountViewModel: AccountViewModel, + nav: INav, +) { + val scope = rememberCoroutineScope() + var rows by remember(config.pubKey) { mutableStateOf?>(null) } + var busy by remember(config.pubKey) { mutableStateOf(false) } + var error by remember(config.pubKey) { mutableStateOf(null) } + val failed = stringRes(Res.string.cordn_keypackages_failed) + + suspend fun reload() { + busy = true + error = null + try { + rows = runtime.keyPackages(config.pubKey) + } catch (e: Exception) { + error = e.message ?: failed + } finally { + busy = false + } + } + + LaunchedEffect(config.pubKey) { reload() } + + CoordinatorIdentityRow( + pubKey = config.pubKey, + label = config.label, + accountViewModel = accountViewModel, + nav = nav, + size = 24.dp, + ) { name -> + Text(text = name, style = MaterialTheme.typography.titleSmall) + } + + if (busy) CircularProgressIndicator() + error?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error) } + + val loaded = rows + if (loaded != null && !busy) { + val single = loaded.count { !it.lastResort } + val hasLastResort = loaded.any { it.lastResort } + + Text( + text = pluralStringRes(Res.plurals.cordn_keypackages_summary, single, single), + style = MaterialTheme.typography.bodyMedium, + ) + Text( + text = + if (hasLastResort) { + stringRes(Res.string.cordn_keypackages_last_resort_yes) + } else { + stringRes(Res.string.cordn_keypackages_last_resort_no) + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + loaded.filterNot { it.openableHere }.takeIf { it.isNotEmpty() }?.let { orphans -> + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + ) { + Column(Modifier.padding(12.dp)) { + Text( + text = pluralStringRes(Res.plurals.cordn_keypackages_orphans, orphans.size, orphans.size), + style = MaterialTheme.typography.titleSmall, + ) + Text( + text = stringRes(Res.string.cordn_keypackages_orphans_body), + style = MaterialTheme.typography.bodySmall, + ) + TextButton(onClick = { + scope.launch { + try { + runtime.withdrawKeyPackages(config.pubKey, orphans.map { it.keyPackageRef }) + } catch (e: Exception) { + error = e.message ?: failed + } + reload() + } + }) { + Text(stringRes(Res.string.cordn_keypackages_withdraw_orphans)) + } + } + } + } + + Text( + // Above the buttons, because it is the part that cannot be taken + // back: §8.4 makes publishing an attributable, permanent record. + text = stringRes(Res.string.cordn_keypackages_disclosure), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Button( + onClick = { + scope.launch { + try { + runtime.publishKeyPackage(config.pubKey) + } catch (e: Exception) { + error = e.message ?: failed + } + reload() + } + }, + enabled = !busy, + ) { + Text(stringRes(Res.string.cordn_keypackages_publish)) + } + OutlinedButton( + onClick = { + scope.launch { + try { + runtime.publishKeyPackage(config.pubKey, lastResort = true) + } catch (e: Exception) { + error = e.message ?: failed + } + reload() + } + }, + enabled = !busy && !hasLastResort, + ) { + Text(stringRes(Res.string.cordn_keypackages_publish_last_resort)) + } + if (loaded.isNotEmpty()) { + TextButton( + onClick = { + scope.launch { + try { + runtime.withdrawKeyPackages(config.pubKey, loaded.map { it.keyPackageRef }) + } catch (e: Exception) { + error = e.message ?: failed + } + reload() + } + }, + enabled = !busy, + ) { + Text(stringRes(Res.string.cordn_keypackages_withdraw_all), color = MaterialTheme.colorScheme.error) + } + } + } + + Text( + // The rule CordnRuntime.maintainKeyPackages implements, said where + // someone can see that it applies to them. + text = stringRes(Res.string.cordn_keypackages_topup_note), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/CordnLinkScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/CordnLinkScreen.kt new file mode 100644 index 0000000000..f4ac911967 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/CordnLinkScreen.kt @@ -0,0 +1,356 @@ +/* + * 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.settings.cordn + +import androidx.compose.foundation.layout.Arrangement +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 +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.cordn.CordnLinkInspection +import com.vitorpamplona.amethyst.commons.cordn.ui.CordnExposureCard +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.cordn_link_clear +import com.vitorpamplona.amethyst.commons.resources.cordn_link_coordinator +import com.vitorpamplona.amethyst.commons.resources.cordn_link_explainer +import com.vitorpamplona.amethyst.commons.resources.cordn_link_field +import com.vitorpamplona.amethyst.commons.resources.cordn_link_group_id +import com.vitorpamplona.amethyst.commons.resources.cordn_link_inspect +import com.vitorpamplona.amethyst.commons.resources.cordn_link_invalid +import com.vitorpamplona.amethyst.commons.resources.cordn_link_no_coordinator +import com.vitorpamplona.amethyst.commons.resources.cordn_link_not_joinable +import com.vitorpamplona.amethyst.commons.resources.cordn_link_paste +import com.vitorpamplona.amethyst.commons.resources.cordn_link_relays +import com.vitorpamplona.amethyst.commons.resources.cordn_link_request +import com.vitorpamplona.amethyst.commons.resources.cordn_link_request_disclosure +import com.vitorpamplona.amethyst.commons.resources.cordn_link_request_failed +import com.vitorpamplona.amethyst.commons.resources.cordn_link_request_sent +import com.vitorpamplona.amethyst.commons.resources.cordn_link_scan +import com.vitorpamplona.amethyst.commons.resources.cordn_link_section_input +import com.vitorpamplona.amethyst.commons.resources.cordn_link_title +import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.SimpleQrCodeScanner +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsSection +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.stringResource + +/** + * "Someone sent me a `cordn1…` link" — what it points at, and what following it + * would cost. + * + * This screen exists because of §8 of `quartz/plans/2026-09-17-cordn-interop.md`: + * a cordn group and a Marmot group look the same and are not the same, and the + * moment the difference can still change a decision is **before** joining. A + * link is where that moment happens, so the disclosure lives here rather than + * in a settings sub-page nobody opens. + * + * It deliberately does not join anything. Joining needs a live coordinator, and + * Tier B of the interop plan is blocked (§7) — so the honest scope is "read the + * link, tell the truth about it" rather than a join button whose other half has + * never been run. + * + * All parsing is [CordnLinkInspection] in `commons`, which has its own tests; + * everything here is drawing. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CordnLinkScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + var input by remember { mutableStateOf("") } + var inspection by remember { mutableStateOf(null) } + var requestState by remember { mutableStateOf(null) } + var requesting by remember { mutableStateOf(false) } + val runtime = accountViewModel.account.cordnRuntime + val scope = rememberCoroutineScope() + val requestFailed = stringRes(Res.string.cordn_link_request_failed) + val asked = stringRes(Res.string.cordn_link_request_sent) + var scanning by remember { mutableStateOf(false) } + val clipboard = LocalClipboardManager.current + + Scaffold( + topBar = { TopBarWithBackButton(stringRes(Res.string.cordn_link_title), nav) }, + ) { insets -> + Column( + modifier = + Modifier + .padding(insets) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + Text( + text = stringResource(Res.string.cordn_link_explainer), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + // The field, its buttons and the scanner are one thing to do; the + // verdict below is another. Boxing the input says where the screen + // asks something of you and where it answers. + SettingsSection(Res.string.cordn_link_section_input) { + SettingsFormBlock { + OutlinedTextField( + value = input, + onValueChange = { + input = it + // Clearing on edit rather than re-parsing per keystroke: a + // half-typed ref is always invalid, and showing that while + // someone is still pasting is noise, not feedback. + inspection = null + requestState = null + }, + label = { Text(stringResource(Res.string.cordn_link_field)) }, + singleLine = false, + modifier = Modifier.fillMaxWidth(), + ) + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button( + onClick = { inspection = CordnLinkInspection.of(input) }, + enabled = input.isNotBlank(), + ) { + Text(stringResource(Res.string.cordn_link_inspect)) + } + OutlinedButton( + onClick = { + clipboard.getText()?.text?.let { + input = it + inspection = CordnLinkInspection.of(it) + } + }, + ) { + Text(stringResource(Res.string.cordn_link_paste)) + } + OutlinedButton(onClick = { scanning = true }) { + Text(stringRes(Res.string.cordn_link_scan)) + } + if (input.isNotEmpty()) { + OutlinedButton( + onClick = { + input = "" + inspection = null + }, + ) { + Text(stringResource(Res.string.cordn_link_clear)) + } + } + } + } + } + + if (scanning) { + // A cordn ref is a long bech32 string nobody types twice. + // Reusing the app's scanner rather than writing one: the + // ref goes through exactly the same parse as a pasted link, + // so a scanned link cannot take a shortcut a typed one + // cannot. + SimpleQrCodeScanner { + scanning = false + if (!it.isNullOrEmpty()) { + input = it + inspection = CordnLinkInspection.of(it) + requestState = null + } + } + } + + when (val result = inspection) { + null -> Unit + + is CordnLinkInspection.Invalid -> + Text( + text = stringResource(Res.string.cordn_link_invalid, result.reason), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + ) + + is CordnLinkInspection.Valid -> { + LabelledValue(stringResource(Res.string.cordn_link_group_id), result.ref.gid) + + result.coordinator?.let { coordinator -> + // The screen exists to answer "should I trust this + // link", and the coordinator is the party being + // trusted -- so it is shown as whoever it is, with a + // face, rather than as 64 characters nobody reads. + LabelledCoordinator( + label = stringResource(Res.string.cordn_link_coordinator), + pubKey = coordinator.pubKey, + coordinatorLabel = coordinator.label, + accountViewModel = accountViewModel, + nav = nav, + ) + LabelledValue( + stringResource(Res.string.cordn_link_relays), + coordinator.relays.joinToString("\n") { it.url }, + ) + } + + result.exposure?.let { CordnExposureCard(it) } + + if (!result.isFollowable) { + Text( + text = stringResource(Res.string.cordn_link_no_coordinator), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Text( + // Still true, and still worth saying: reading a link + // is not joining, and the button below is the only + // thing on this screen that tells anyone anything. + text = stringResource(Res.string.cordn_link_not_joinable), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + val coordinator = result.coordinator + if (coordinator != null && runtime != null) { + Button( + onClick = { + requesting = true + requestState = null + scope.launch { + requestState = + try { + runtime.requestToJoin(coordinator, result.ref.gid) + asked + } catch (e: Exception) { + e.message ?: requestFailed + } + requesting = false + } + }, + enabled = !requesting, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringRes(Res.string.cordn_link_request)) + } + + Text( + // Said before the tap, because it is the part that + // cannot be undone: asking to join publishes a + // KeyPackage under this account's own key and tells + // the coordinator this account wants into this + // group, whether or not anyone ever answers (§8.4). + text = stringRes(Res.string.cordn_link_request_disclosure), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + requestState?.let { + Text( + text = it, + style = MaterialTheme.typography.bodyMedium, + color = + if (it == asked) { + MaterialTheme.colorScheme.onSurfaceVariant + } else { + MaterialTheme.colorScheme.error + }, + ) + } + } + } + } + } + } +} + +/** + * A [LabelledValue] whose value is a coordinator, rendered as its profile. + * + * The pubkey is still reachable -- tapping the avatar opens the profile, which + * carries the npub -- so nothing is hidden by not printing it here. + */ +@Composable +private fun LabelledCoordinator( + label: String, + pubKey: HexKey, + coordinatorLabel: String?, + accountViewModel: AccountViewModel, + nav: INav, +) { + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + CoordinatorIdentityRow( + pubKey = pubKey, + label = coordinatorLabel, + accountViewModel = accountViewModel, + nav = nav, + modifier = Modifier.padding(top = 2.dp), + ) { name -> + Text(text = name, style = MaterialTheme.typography.bodyMedium) + } + } +} + +/** + * A field the user may need to compare against something they were sent, so it + * is selectable and never truncated. + */ +@Composable +private fun LabelledValue( + label: String, + value: String, +) { + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + SelectionContainer { + Text(text = value, style = MaterialTheme.typography.bodySmall) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/CordnMigrateScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/CordnMigrateScreen.kt new file mode 100644 index 0000000000..6c55c2bbb0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/CordnMigrateScreen.kt @@ -0,0 +1,390 @@ +/* + * 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.settings.cordn + +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.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.cordn.CordnMigration +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.cordn_group_unavailable +import com.vitorpamplona.amethyst.commons.resources.cordn_group_unavailable_detail +import com.vitorpamplona.amethyst.commons.resources.cordn_migrate_cancel +import com.vitorpamplona.amethyst.commons.resources.cordn_migrate_cancel_desc +import com.vitorpamplona.amethyst.commons.resources.cordn_migrate_code_note +import com.vitorpamplona.amethyst.commons.resources.cordn_migrate_done +import com.vitorpamplona.amethyst.commons.resources.cordn_migrate_explainer +import com.vitorpamplona.amethyst.commons.resources.cordn_migrate_export +import com.vitorpamplona.amethyst.commons.resources.cordn_migrate_exporting +import com.vitorpamplona.amethyst.commons.resources.cordn_migrate_exposure_body +import com.vitorpamplona.amethyst.commons.resources.cordn_migrate_exposure_title +import com.vitorpamplona.amethyst.commons.resources.cordn_migrate_handed_off +import com.vitorpamplona.amethyst.commons.resources.cordn_migrate_handed_off_desc +import com.vitorpamplona.amethyst.commons.resources.cordn_migrate_import +import com.vitorpamplona.amethyst.commons.resources.cordn_migrate_importing +import com.vitorpamplona.amethyst.commons.resources.cordn_migrate_no_groups +import com.vitorpamplona.amethyst.commons.resources.cordn_migrate_no_server +import com.vitorpamplona.amethyst.commons.resources.cordn_migrate_paste +import com.vitorpamplona.amethyst.commons.resources.cordn_migrate_receive +import com.vitorpamplona.amethyst.commons.resources.cordn_migrate_replaces_warning +import com.vitorpamplona.amethyst.commons.resources.cordn_migrate_scan +import com.vitorpamplona.amethyst.commons.resources.cordn_migrate_scan_this +import com.vitorpamplona.amethyst.commons.resources.cordn_migrate_section_receive +import com.vitorpamplona.amethyst.commons.resources.cordn_migrate_section_send +import com.vitorpamplona.amethyst.commons.resources.cordn_migrate_sign_in_first +import com.vitorpamplona.amethyst.commons.resources.cordn_migrate_title +import com.vitorpamplona.amethyst.commons.ui.components.EmptyState +import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.model.cordn.AndroidCordnBlobStore +import com.vitorpamplona.amethyst.model.cordn.CordnRuntime +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.QrCodeDrawer +import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.SimpleQrCodeScanner +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsSection +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.cordn.appMultiDevice.CordnHandoffCode +import kotlinx.coroutines.launch + +/** + * Moving this account's cordn groups to a phone the user is switching to. + * + * ## Why this is a handoff and not sync + * + * `spec/applications/multi-device.md` §10 leaves one case unresolved: two + * devices of one identity committing inside a single delivery round-trip reach + * the same epoch with different states, and §15 concedes that equal-epoch MLS + * states have no merge function. This screen is buildable because a migration + * has one writer — which is only true if this device stops afterwards, which + * is what the handed-off state below is. + * + * ## What the user is told, and where + * + * Two things are said at the moment they are decided rather than in a help + * page. Before exporting: the encrypted documents leave the device for a + * storage server. Before importing: this replaces whatever cordn groups are + * already here, because MLS state cannot be merged. Both are irreversible in + * the ways that matter, and neither is discoverable afterwards. + */ +@Composable +fun CordnMigrateScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + val runtime = accountViewModel.account.cordnRuntime + + Scaffold( + topBar = { TopBarWithBackButton(stringRes(Res.string.cordn_migrate_title), nav) }, + ) { padding -> + if (runtime == null) { + EmptyState( + title = stringRes(Res.string.cordn_group_unavailable), + description = stringRes(Res.string.cordn_group_unavailable_detail), + modifier = Modifier.padding(padding), + ) + return@Scaffold + } + + val handedOff by runtime.handoff.handedOff.collectAsStateWithLifecycle() + + Column( + modifier = + Modifier + .padding(padding) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + if (handedOff) { + HandedOff(runtime) + return@Column + } + + Text( + text = stringRes(Res.string.cordn_migrate_explainer), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + // Two boxes, because they are two different devices' jobs and + // only one of them is yours today. A rule between them said they + // were separate without saying which was which — and doing the + // wrong one here hands your account to another phone. + SettingsSection(Res.string.cordn_migrate_section_send) { + SettingsFormBlock { + SendSide(runtime, accountViewModel) + } + } + + SettingsSection(Res.string.cordn_migrate_section_receive) { + SettingsFormBlock { + ReceiveSide(runtime, accountViewModel) + } + } + } + } +} + +/** + * The state that makes the rest of this safe. + * + * Deliberately not phrased as an error. Nothing is broken and nothing was + * deleted — the device stood down on purpose, and the groups are still on + * disk so taking it back is possible. + */ +@Composable +private fun HandedOff(runtime: CordnRuntime) { + val scope = rememberCoroutineScope() + var busy by remember { mutableStateOf(false) } + + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(stringRes(Res.string.cordn_migrate_handed_off), style = MaterialTheme.typography.titleSmall) + Text( + text = stringRes(Res.string.cordn_migrate_handed_off_desc), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Text( + text = stringRes(Res.string.cordn_migrate_cancel_desc), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + OutlinedButton( + onClick = { + busy = true + scope.launch { + try { + runtime.cancelHandOff() + } finally { + busy = false + } + } + }, + enabled = !busy, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringRes(Res.string.cordn_migrate_cancel)) + } +} + +@Composable +private fun SendSide( + runtime: CordnRuntime, + accountViewModel: AccountViewModel, +) { + val scope = rememberCoroutineScope() + val context = LocalContext.current + var code by remember { mutableStateOf(null) } + var error by remember { mutableStateOf(null) } + var busy by remember { mutableStateOf(false) } + val noServer = stringRes(Res.string.cordn_migrate_no_server) + val noGroups = stringRes(Res.string.cordn_migrate_no_groups) + + Text( + text = stringRes(Res.string.cordn_migrate_sign_in_first), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + // Said before the button, because this is the moment the decision is made + // and the upload cannot be taken back afterwards. + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(stringRes(Res.string.cordn_migrate_exposure_title), style = MaterialTheme.typography.titleSmall) + Text( + text = stringRes(Res.string.cordn_migrate_exposure_body), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + code?.let { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text(stringRes(Res.string.cordn_migrate_scan_this), style = MaterialTheme.typography.titleSmall) + QrCodeDrawer(it, Modifier.size(260.dp)) + SelectionContainer { Text(it, style = MaterialTheme.typography.labelSmall) } + Text( + text = stringRes(Res.string.cordn_migrate_code_note), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + error?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error) } + + Button( + onClick = { + busy = true + error = null + scope.launch { + try { + val servers = listOfNotNull(accountViewModel.account.settings.defaultFileServer.baseUrl) + if (servers.isEmpty()) { + error = noServer + return@launch + } + if (runtime.migrationSnapshot().groups.isEmpty()) { + error = noGroups + return@launch + } + + val migration = + CordnMigration( + accountViewModel.account.client, + accountViewModel.account.signer, + AndroidCordnBlobStore(servers, context), + ) + code = runtime.handOff(migration, accountViewModel.account.outboxRelays.flow.value).encode() + } catch (e: Exception) { + error = e.message + } finally { + busy = false + } + } + }, + enabled = !busy && code == null, + modifier = Modifier.fillMaxWidth(), + ) { + // The label already says "Exporting"; the spinner says it is still + // going, which a static word cannot. + BusyLabel(busy, stringRes(if (busy) Res.string.cordn_migrate_exporting else Res.string.cordn_migrate_export)) + } +} + +@Composable +private fun ReceiveSide( + runtime: CordnRuntime, + accountViewModel: AccountViewModel, +) { + val scope = rememberCoroutineScope() + val context = LocalContext.current + var typed by remember { mutableStateOf("") } + var scanning by remember { mutableStateOf(false) } + var busy by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } + var done by remember { mutableStateOf(null) } + val badCode = stringRes(Res.string.cordn_migrate_scan) + + Text(stringRes(Res.string.cordn_migrate_receive), style = MaterialTheme.typography.titleSmall) + + // Said before the button, for the same reason as the exposure card: an + // import replaces, and MLS state cannot be merged back afterwards. + Text( + text = stringRes(Res.string.cordn_migrate_replaces_warning), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + if (scanning) { + SimpleQrCodeScanner { + scanning = false + if (it != null) typed = it + } + } + + OutlinedButton(onClick = { scanning = true }, modifier = Modifier.fillMaxWidth()) { + Text(stringRes(Res.string.cordn_migrate_scan)) + } + + OutlinedTextField( + value = typed, + onValueChange = { + typed = it + error = null + }, + label = { Text(stringRes(Res.string.cordn_migrate_paste)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + done?.let { Text(stringRes(Res.string.cordn_migrate_done, it), style = MaterialTheme.typography.bodyMedium) } + error?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error) } + + Button( + onClick = { + val parsed = CordnHandoffCode.decodeOrNull(typed.trim()) + if (parsed == null) { + error = badCode + return@Button + } + busy = true + error = null + scope.launch { + try { + val migration = + CordnMigration( + accountViewModel.account.client, + accountViewModel.account.signer, + AndroidCordnBlobStore(emptyList(), context), + ) + val snapshot = migration.fetch(parsed) + runtime.adoptMigration(snapshot) + done = snapshot.groups.size + typed = "" + } catch (e: Exception) { + error = e.message + } finally { + busy = false + } + } + }, + enabled = !busy && typed.isNotBlank(), + modifier = Modifier.fillMaxWidth(), + ) { + BusyLabel(busy, stringRes(if (busy) Res.string.cordn_migrate_importing else Res.string.cordn_migrate_import)) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/SettingsFormBlock.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/SettingsFormBlock.kt new file mode 100644 index 0000000000..ca8825fec0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/cordn/SettingsFormBlock.kt @@ -0,0 +1,51 @@ +/* + * 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.settings.cordn + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +/** + * Form content inside a `SettingsSection`. + * + * The section's card deliberately has no padding of its own: it is built for + * rows like `SettingsItem`, which carry theirs so a divider can run edge to + * edge between them. cordn's pages are forms rather than row lists — text + * fields, explainers and buttons — and those need the inset the rows would + * otherwise have supplied. + * + * One helper rather than a `Modifier.padding(16.dp)` repeated down five + * screens, so the pages stay identical to each other as they change. + */ +@Composable +fun SettingsFormBlock(content: @Composable ColumnScope.() -> Unit) { + Column( + modifier = Modifier.fillMaxWidth().padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + content = content, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/ShortsTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/ShortsTopBar.kt index 86cb3bf712..0d129f2eec 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/ShortsTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/ShortsTopBar.kt @@ -36,7 +36,7 @@ import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar import com.vitorpamplona.amethyst.ui.screen.FeedDefinition import com.vitorpamplona.amethyst.ui.screen.TopNavFilterState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent +import com.vitorpamplona.quartz.nip71Video.AddressableShortVideoEvent @Composable fun ShortsTopBar( @@ -49,7 +49,7 @@ fun ShortsTopBar( // The feed's own kind window, plus whatever the list spinner narrowed it to — a hashtag // or a geohash says itself as a token; a follow set does not, and seeds nothing. val me = accountViewModel.userProfile().pubkeyHex - val seed = remember(list, me) { SearchSeed.merge(SearchSeed.ofKinds(VideoVerticalEvent.KIND), list.asSearchQuery(me)) } + val seed = remember(list, me) { SearchSeed.merge(SearchSeed.ofKinds(AddressableShortVideoEvent.KIND), list.asSearchQuery(me)) } UserDrawerSearchTopBar(accountViewModel, nav, seed) { ShortsTopNavFilterBar( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/dal/ShortsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/dal/ShortsFeedFilter.kt index 4e5390209a..dfbaf15534 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/dal/ShortsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/dal/ShortsFeedFilter.kt @@ -33,9 +33,9 @@ import com.vitorpamplona.amethyst.ui.dal.FilterByListParams import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.dal.SupportedContent import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent +import com.vitorpamplona.quartz.nip71Video.AddressableShortVideoEvent import com.vitorpamplona.quartz.nip71Video.VideoMeta import com.vitorpamplona.quartz.nip71Video.VideoShortEvent -import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent class ShortsFeedFilter( val account: Account, @@ -60,7 +60,7 @@ class ShortsFeedFilter( LocalCache.notes.filterIntoSet { _, it -> acceptableEvent(it, params) } + - LocalCache.addressables.filterIntoSet(VideoVerticalEvent.KIND) { _, it -> + LocalCache.addressables.filterIntoSet(AddressableShortVideoEvent.KIND) { _, it -> acceptableEvent(it, params) } return sort(notes) @@ -91,7 +91,7 @@ class ShortsFeedFilter( fun acceptableVideoiMetas(iMetas: List): Boolean = iMetas.any { videoFeedSupport.acceptableUrl(it.url, it.mimeType) } - fun acceptanceEvent(noteEvent: VideoVerticalEvent) = acceptableVideoiMetas(noteEvent.imetaTags()) + fun acceptanceEvent(noteEvent: AddressableShortVideoEvent) = acceptableVideoiMetas(noteEvent.imetaTags()) fun acceptanceEvent(noteEvent: VideoShortEvent) = acceptableVideoiMetas(noteEvent.imetaTags()) @@ -106,7 +106,7 @@ class ShortsFeedFilter( } return ( - (noteEvent is VideoVerticalEvent && acceptanceEvent(noteEvent)) || + (noteEvent is AddressableShortVideoEvent && acceptanceEvent(noteEvent)) || (noteEvent is VideoShortEvent && acceptanceEvent(noteEvent)) ) && params.match(noteEvent, note.relays) && diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt index fbd05a0120..75050af4ad 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt @@ -176,17 +176,17 @@ import com.vitorpamplona.amethyst.ui.note.types.DisplayBlockedRelayList import com.vitorpamplona.amethyst.ui.note.types.DisplayBroadcastRelayList import com.vitorpamplona.amethyst.ui.note.types.DisplayContactList import com.vitorpamplona.amethyst.ui.note.types.DisplayDMRelayList -import com.vitorpamplona.amethyst.ui.note.types.DisplayFollowList +import com.vitorpamplona.amethyst.ui.note.types.DisplayFavoriteRelayList import com.vitorpamplona.amethyst.ui.note.types.DisplayIndexerRelayList import com.vitorpamplona.amethyst.ui.note.types.DisplayNIP65RelayList import com.vitorpamplona.amethyst.ui.note.types.DisplayPeopleList import com.vitorpamplona.amethyst.ui.note.types.DisplayProxyRelayList -import com.vitorpamplona.amethyst.ui.note.types.DisplayRelayFeedsList import com.vitorpamplona.amethyst.ui.note.types.DisplayRelaySet import com.vitorpamplona.amethyst.ui.note.types.DisplaySearchRelayList +import com.vitorpamplona.amethyst.ui.note.types.DisplayStarterPack import com.vitorpamplona.amethyst.ui.note.types.DisplayTrustedRelayList import com.vitorpamplona.amethyst.ui.note.types.EditState -import com.vitorpamplona.amethyst.ui.note.types.FileHeaderDisplay +import com.vitorpamplona.amethyst.ui.note.types.FileMetadataDisplay import com.vitorpamplona.amethyst.ui.note.types.FileStorageHeaderDisplay import com.vitorpamplona.amethyst.ui.note.types.MAX_THREAD_SECTIONS import com.vitorpamplona.amethyst.ui.note.types.PictureDisplay @@ -223,7 +223,6 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderHighlight import com.vitorpamplona.amethyst.ui.note.types.RenderInteractiveStory import com.vitorpamplona.amethyst.ui.note.types.RenderLearningResource import com.vitorpamplona.amethyst.ui.note.types.RenderLiveActivityChatMessage -import com.vitorpamplona.amethyst.ui.note.types.RenderLnZap import com.vitorpamplona.amethyst.ui.note.types.RenderMeetingRoomEvent import com.vitorpamplona.amethyst.ui.note.types.RenderMeetingSpaceEvent import com.vitorpamplona.amethyst.ui.note.types.RenderMusicPlaylist @@ -263,6 +262,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderTextModificationEvent import com.vitorpamplona.amethyst.ui.note.types.RenderTorrent import com.vitorpamplona.amethyst.ui.note.types.RenderTorrentComment import com.vitorpamplona.amethyst.ui.note.types.RenderZapPoll +import com.vitorpamplona.amethyst.ui.note.types.RenderZapReceipt import com.vitorpamplona.amethyst.ui.note.types.ReplyRenderType import com.vitorpamplona.amethyst.ui.note.types.VideoDisplay import com.vitorpamplona.amethyst.ui.note.types.VoiceHeader @@ -315,14 +315,14 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.tags.geohash.geoHashOrScope import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent -import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip04Dm.messages.EncryptedDmEvent import com.vitorpamplona.quartz.nip13Pow.strongPoWOrNull import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent -import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.nip17Dm.settings.DmRelayListEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent @@ -343,23 +343,23 @@ import com.vitorpamplona.quartz.nip43RelayMembers.list.RelayMembershipListEvent import com.vitorpamplona.quartz.nip43RelayMembers.removeMember.RelayRemoveMemberEvent import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent import com.vitorpamplona.quartz.nip51Lists.PinListEvent -import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent -import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent +import com.vitorpamplona.quartz.nip51Lists.followSet.FollowSetEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BroadcastRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.FavoriteRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent -import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relaySets.RelaySetEvent import com.vitorpamplona.quartz.nip51Lists.releaseArtifactSet.ReleaseArtifactSetEvent +import com.vitorpamplona.quartz.nip51Lists.starterPack.StarterPackEvent import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent -import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip54Wiki.WikiArticleEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent import com.vitorpamplona.quartz.nip57Zaps.splits.hasZapSplitSetup import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent import com.vitorpamplona.quartz.nip5aStaticWebsites.NamedSiteEvent @@ -372,7 +372,7 @@ import com.vitorpamplona.quartz.nip71Video.VideoEvent import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.nip72ModCommunities.communityAddress import com.vitorpamplona.quartz.nip72ModCommunities.isACommunityPost -import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent +import com.vitorpamplona.quartz.nip75ZapGoals.ZapGoalEvent import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent @@ -382,7 +382,7 @@ import com.vitorpamplona.quartz.nip87Ecash.recommendation.MintRecommendationEven import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.AppRecommendationEvent -import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent +import com.vitorpamplona.quartz.nip94FileMetadata.FileMetadataEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent @@ -737,7 +737,7 @@ private fun FullBleedNoteCompose( // Zap receipts are signed by the recipient's lightning provider; show the // sender from the embedded zap request instead of the service key. val zapSender = - if (noteEvent is LnZapEvent) { + if (noteEvent is ZapReceiptEvent) { observeZapSender(baseNote, accountViewModel).value } else { null @@ -859,9 +859,9 @@ private fun FullBleedNoteCompose( when (noteEvent) { is BadgeDefinitionEvent -> BadgeDisplay(baseNote = baseNote, accountViewModel) - is LongTextNoteEvent -> RenderLongFormHeaderForThread(noteEvent, baseNote, accountViewModel) + is LongFormContentEvent -> RenderLongFormHeaderForThread(noteEvent, baseNote, accountViewModel) is ThreadEvent -> RenderThreadHeaderForThread(noteEvent) - is WikiNoteEvent -> RenderWikiHeaderForThread(noteEvent, accountViewModel, nav) + is WikiArticleEvent -> RenderWikiHeaderForThread(noteEvent, accountViewModel, nav) is ClassifiedsEvent -> RenderClassifiedsReaderForThread(noteEvent, baseNote, accountViewModel, nav) is CodeSnippetEvent -> RenderCodeSnippetHeaderForThread(noteEvent) } @@ -896,14 +896,14 @@ private fun FullBleedNoteCompose( WorkoutDisplay(baseNote, backgroundColor, canPreview = true, quotesLeft = 3, accountViewModel = accountViewModel, nav = nav) } else if (noteEvent is BaseVoiceEvent) { VoiceHeader(noteEvent, baseNote, accountViewModel, nav) - } else if (noteEvent is FileHeaderEvent) { - FileHeaderDisplay(baseNote, roundedCorner = true, ContentScale.FillWidth, accountViewModel = accountViewModel) + } else if (noteEvent is FileMetadataEvent) { + FileMetadataDisplay(baseNote, roundedCorner = true, ContentScale.FillWidth, accountViewModel = accountViewModel) } else if (noteEvent is FileStorageHeaderEvent) { FileStorageHeaderDisplay(baseNote, roundedCorner = true, ContentScale.FillWidth, accountViewModel = accountViewModel) - } else if (noteEvent is PeopleListEvent) { + } else if (noteEvent is FollowSetEvent) { DisplayPeopleList(baseNote, backgroundColor, accountViewModel, nav) - } else if (noteEvent is FollowListEvent) { - DisplayFollowList(baseNote, false, accountViewModel, nav) + } else if (noteEvent is StarterPackEvent) { + DisplayStarterPack(baseNote, false, accountViewModel, nav) } else if (noteEvent is ContactListEvent) { DisplayContactList(baseNote, accountViewModel, nav) } else if (noteEvent is AudioTrackEvent) { @@ -954,7 +954,7 @@ private fun FullBleedNoteCompose( accountViewModel, nav, ) - } else if (noteEvent is ChatMessageRelayListEvent) { + } else if (noteEvent is DmRelayListEvent) { DisplayDMRelayList(baseNote, backgroundColor, accountViewModel, nav) } else if (noteEvent is ChatMessageEncryptedFileHeaderEvent) { RenderChatMessageEncryptedFile( @@ -977,8 +977,8 @@ private fun FullBleedNoteCompose( RenderAttestorProficiency(baseNote, 3, backgroundColor, accountViewModel, nav) } else if (noteEvent is AdvertisedRelayListEvent) { DisplayNIP65RelayList(baseNote, backgroundColor, accountViewModel, nav) - } else if (noteEvent is LnZapEvent) { - RenderLnZap(baseNote, quotesLeft = 3, backgroundColor = backgroundColor, accountViewModel = accountViewModel, nav = nav) + } else if (noteEvent is ZapReceiptEvent) { + RenderZapReceipt(baseNote, quotesLeft = 3, backgroundColor = backgroundColor, accountViewModel = accountViewModel, nav = nav) } else if (noteEvent is NutzapEvent) { RenderNutzap(baseNote, quotesLeft = 3, backgroundColor = backgroundColor, accountViewModel = accountViewModel, nav = nav) } else if (noteEvent is OnchainZapEvent) { @@ -995,8 +995,8 @@ private fun FullBleedNoteCompose( DisplayProxyRelayList(baseNote, backgroundColor, accountViewModel, nav) } else if (noteEvent is TrustedRelayListEvent) { DisplayTrustedRelayList(baseNote, backgroundColor, accountViewModel, nav) - } else if (noteEvent is RelayFeedsListEvent) { - DisplayRelayFeedsList(baseNote, backgroundColor, accountViewModel, nav) + } else if (noteEvent is FavoriteRelayListEvent) { + DisplayFavoriteRelayList(baseNote, backgroundColor, accountViewModel, nav) } else if (noteEvent is IndexerRelayListEvent) { DisplayIndexerRelayList(baseNote, backgroundColor, accountViewModel, nav) } else if (noteEvent is BroadcastRelayListEvent) { @@ -1070,7 +1070,7 @@ private fun FullBleedNoteCompose( RenderCalendarTimeSlotEvent(baseNote, accountViewModel, nav) } else if (noteEvent is CalendarDateSlotEvent) { RenderCalendarDateSlotEvent(baseNote, accountViewModel, nav) - } else if (noteEvent is GoalEvent) { + } else if (noteEvent is ZapGoalEvent) { RenderGoal(baseNote, accountViewModel, nav) } else if (noteEvent is FundraiserEvent) { RenderFundraiser(baseNote, makeItShort = false, accountViewModel, nav) @@ -1158,7 +1158,7 @@ private fun FullBleedNoteCompose( accountViewModel = accountViewModel, nav = nav, ) - } else if (noteEvent is PrivateDmEvent) { + } else if (noteEvent is EncryptedDmEvent) { RenderPrivateMessage( baseNote, false, @@ -1492,7 +1492,7 @@ private fun RenderClassifiedsReaderForThread( @Composable private fun RenderLongFormHeaderForThread( - noteEvent: LongTextNoteEvent, + noteEvent: LongFormContentEvent, note: Note, accountViewModel: AccountViewModel, ) { @@ -1558,7 +1558,7 @@ private fun RenderThreadHeaderForThread(noteEvent: ThreadEvent) { @Preview @Composable private fun RenderWikiHeaderForThreadPreview() { - val event = Event.fromJson("{\"id\":\"277f982a4cd3f67cc47ad9282176acabee1713848f547d6021e0c155572078e1\",\"pubkey\":\"460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c\",\"created_at\":1708695717,\"kind\":30818,\"tags\":[[\"d\",\"amethyst\"],[\"a\",\"30818:f03e7c5262648e0b7823dfb49f8f17309cfec9cb14711413dcabdf3d7fc6369a:amethyst\",\"wss://relay.nostr.band\",\"fork\"],[\"e\",\"ceabc60c8022c472c727aa25ae7691885964366386ce265c47e5a78be6cb00be\",\"wss://relay.nostr.band\",\"fork\"],[\"title\",\"Amethyst\"],[\"published_at\",\"1708707133\"]],\"content\":\"An Android-only app written in Kotlin with support for over 90 event kinds. \\n\\n![](https://play-lh.googleusercontent.com/lvZlAm9dBrpHeOo7sIPKCsiKOLYLhR2b0FiOT4tyiwWO2dvsR2gDS0xk9tOOr9U-6uM=w240-h480-rw)\\n\",\"sig\":\"6748126a909a20dbdb67947a09d64e41d7140a79335a4ad675c6173d7dd5dbcab9c360dec617bd67bbbc20dfad416b15056eda2e20716cd6c425a84301a125a0\"}") as WikiNoteEvent + val event = Event.fromJson("{\"id\":\"277f982a4cd3f67cc47ad9282176acabee1713848f547d6021e0c155572078e1\",\"pubkey\":\"460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c\",\"created_at\":1708695717,\"kind\":30818,\"tags\":[[\"d\",\"amethyst\"],[\"a\",\"30818:f03e7c5262648e0b7823dfb49f8f17309cfec9cb14711413dcabdf3d7fc6369a:amethyst\",\"wss://relay.nostr.band\",\"fork\"],[\"e\",\"ceabc60c8022c472c727aa25ae7691885964366386ce265c47e5a78be6cb00be\",\"wss://relay.nostr.band\",\"fork\"],[\"title\",\"Amethyst\"],[\"published_at\",\"1708707133\"]],\"content\":\"An Android-only app written in Kotlin with support for over 90 event kinds. \\n\\n![](https://play-lh.googleusercontent.com/lvZlAm9dBrpHeOo7sIPKCsiKOLYLhR2b0FiOT4tyiwWO2dvsR2gDS0xk9tOOr9U-6uM=w240-h480-rw)\\n\",\"sig\":\"6748126a909a20dbdb67947a09d64e41d7140a79335a4ad675c6173d7dd5dbcab9c360dec617bd67bbbc20dfad416b15056eda2e20716cd6c425a84301a125a0\"}") as WikiArticleEvent val accountViewModel = mockAccountViewModel() val editState = @@ -1602,7 +1602,7 @@ private fun RenderWikiHeaderForThreadPreview() { @Composable private fun RenderWikiHeaderForThread( - noteEvent: WikiNoteEvent, + noteEvent: WikiArticleEvent, accountViewModel: AccountViewModel, nav: INav, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/FileHeaderCardCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/FileMetadataCardCompose.kt similarity index 91% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/FileHeaderCardCompose.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/FileMetadataCardCompose.kt index 45f3eae01e..4b74c1ddf4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/FileHeaderCardCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/FileMetadataCardCompose.kt @@ -47,20 +47,20 @@ import com.vitorpamplona.amethyst.ui.components.ZoomableContentView import com.vitorpamplona.amethyst.ui.components.collectContentWarningReasons import com.vitorpamplona.amethyst.ui.components.mediaSizingModifier import com.vitorpamplona.amethyst.ui.note.ReactionsRow -import com.vitorpamplona.amethyst.ui.note.types.FileHeaderAttachmentCard +import com.vitorpamplona.amethyst.ui.note.types.FileMetadataAttachmentCard import com.vitorpamplona.amethyst.ui.note.types.toMediaContent import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW -import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent +import com.vitorpamplona.quartz.nip94FileMetadata.FileMetadataEvent @Composable -fun FileHeaderCardCompose( +fun FileMetadataCardCompose( baseNote: Note, accountViewModel: AccountViewModel, nav: INav, ) { - val event = (baseNote.event as? FileHeaderEvent) ?: return + val event = (baseNote.event as? FileMetadataEvent) ?: return val backgroundColor = remember { mutableStateOf(Color.Transparent) } Column( @@ -70,7 +70,7 @@ fun FileHeaderCardCompose( UserCardHeader(baseNote, accountViewModel, nav) // Image content - FileHeaderCardImage(baseNote, event, backgroundColor, accountViewModel) + FileMetadataCardImage(baseNote, event, backgroundColor, accountViewModel) // Reactions row ReactionsRow( @@ -83,14 +83,14 @@ fun FileHeaderCardCompose( ) // Title and content - FileHeaderCardCaption(event) + FileMetadataCardCaption(event) } } @Composable -private fun FileHeaderCardImage( +private fun FileMetadataCardImage( note: Note, - event: FileHeaderEvent, + event: FileMetadataEvent, backgroundColor: MutableState, accountViewModel: AccountViewModel, ) { @@ -108,7 +108,7 @@ private fun FileHeaderCardImage( // Reachable despite VideoFeedFilter admitting only image/video types: the filter accepts on // `urls().any { … }` while this card renders `url()`, the first tag — so a multi-mirror event // whose first URL is unrenderable lands here. The gate wraps it for the same reason it wraps - // the viewer in FileHeaderDisplay: a content warning is about the file, and the card still + // the viewer in FileMetadataDisplay: a content warning is about the file, and the card still // spells out its filename, alt text, MIME and size. Sizing stays on the gate's defaults // (fillMaxWidth, no backdrop) — a link card has no aspect ratio to reserve and no blurhash // to show behind it. @@ -119,7 +119,7 @@ private fun FileHeaderCardImage( preloadUrls = emptyList(), accountViewModel = accountViewModel, ) { - FileHeaderAttachmentCard(event, fullUrl, mimeType) + FileMetadataAttachmentCard(event, fullUrl, mimeType) } return } @@ -145,7 +145,7 @@ private fun FileHeaderCardImage( } @Composable -internal fun FileHeaderCardCaption(videoEvent: FileHeaderEvent) { +internal fun FileMetadataCardCaption(videoEvent: FileMetadataEvent) { val event = (videoEvent as? Event) ?: return val title = videoEvent.summary() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/StoriesTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/StoriesTopBar.kt index 74e418893f..831ee5d806 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/StoriesTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/StoriesTopBar.kt @@ -36,8 +36,8 @@ import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar import com.vitorpamplona.amethyst.ui.screen.FeedDefinition import com.vitorpamplona.amethyst.ui.screen.TopNavFilterState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent -import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent +import com.vitorpamplona.quartz.nip71Video.AddressableNormalVideoEvent +import com.vitorpamplona.quartz.nip71Video.AddressableShortVideoEvent @Composable fun StoriesTopBar( @@ -50,7 +50,7 @@ fun StoriesTopBar( // The feed's own kind window, plus whatever the list spinner narrowed it to — a hashtag // or a geohash says itself as a token; a follow set does not, and seeds nothing. val me = accountViewModel.userProfile().pubkeyHex - val seed = remember(list, me) { SearchSeed.merge(SearchSeed.ofKinds(VideoHorizontalEvent.KIND, VideoVerticalEvent.KIND), list.asSearchQuery(me)) } + val seed = remember(list, me) { SearchSeed.merge(SearchSeed.ofKinds(AddressableNormalVideoEvent.KIND, AddressableShortVideoEvent.KIND), list.asSearchQuery(me)) } UserDrawerSearchTopBar(accountViewModel, nav, seed) { TopNavFilterBar( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/VideoScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/VideoScreen.kt index a4f8fccfcd..7615245745 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/VideoScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/VideoScreen.kt @@ -53,7 +53,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts.VideoCardCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.VideoFilterAssemblerSubscription import com.vitorpamplona.quartz.nip68Picture.PictureEvent import com.vitorpamplona.quartz.nip71Video.VideoEvent -import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent +import com.vitorpamplona.quartz.nip94FileMetadata.FileMetadataEvent @Composable fun VideoScreen( @@ -199,8 +199,8 @@ fun VideoFeedLoaded( Spacer(modifier = Modifier.height(8.dp)) } - item.event is FileHeaderEvent -> { - FileHeaderCardCompose(item, accountViewModel, nav) + item.event is FileMetadataEvent -> { + FileMetadataCardCompose(item, accountViewModel, nav) HorizontalDivider( thickness = DividerThickness, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/dal/VideoFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/dal/VideoFeedFilter.kt index b9b67e2ffc..7fad0d489e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/dal/VideoFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/dal/VideoFeedFilter.kt @@ -34,12 +34,12 @@ import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent import com.vitorpamplona.quartz.nip68Picture.PictureMeta -import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent +import com.vitorpamplona.quartz.nip71Video.AddressableNormalVideoEvent +import com.vitorpamplona.quartz.nip71Video.AddressableShortVideoEvent import com.vitorpamplona.quartz.nip71Video.VideoMeta import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent import com.vitorpamplona.quartz.nip71Video.VideoShortEvent -import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent -import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent +import com.vitorpamplona.quartz.nip94FileMetadata.FileMetadataEvent class VideoFeedFilter( val account: Account, @@ -72,10 +72,10 @@ class VideoFeedFilter( LocalCache.notes.filterIntoSet { _, it -> acceptableEvent(it, params) } + - LocalCache.addressables.filterIntoSet(VideoHorizontalEvent.KIND) { _, it -> + LocalCache.addressables.filterIntoSet(AddressableNormalVideoEvent.KIND) { _, it -> acceptableEvent(it, params) } + - LocalCache.addressables.filterIntoSet(VideoVerticalEvent.KIND) { _, it -> + LocalCache.addressables.filterIntoSet(AddressableShortVideoEvent.KIND) { _, it -> acceptableEvent(it, params) } @@ -101,11 +101,11 @@ class VideoFeedFilter( fun acceptanceEvent(noteEvent: PictureEvent) = acceptablePictureiMetas(noteEvent.imetaTags()) - fun acceptanceEvent(noteEvent: FileHeaderEvent) = acceptableUrls(noteEvent.urls(), noteEvent.mimeType()) + fun acceptanceEvent(noteEvent: FileMetadataEvent) = acceptableUrls(noteEvent.urls(), noteEvent.mimeType()) - fun acceptanceEvent(noteEvent: VideoVerticalEvent) = acceptableVideoiMetas(noteEvent.imetaTags()) + fun acceptanceEvent(noteEvent: AddressableShortVideoEvent) = acceptableVideoiMetas(noteEvent.imetaTags()) - fun acceptanceEvent(noteEvent: VideoHorizontalEvent) = acceptableVideoiMetas(noteEvent.imetaTags()) + fun acceptanceEvent(noteEvent: AddressableNormalVideoEvent) = acceptableVideoiMetas(noteEvent.imetaTags()) fun acceptanceEvent(noteEvent: VideoNormalEvent) = acceptableVideoiMetas(noteEvent.imetaTags()) @@ -122,9 +122,9 @@ class VideoFeedFilter( } return ( - (noteEvent is FileHeaderEvent && acceptanceEvent(noteEvent)) || - (noteEvent is VideoVerticalEvent && acceptanceEvent(noteEvent)) || - (noteEvent is VideoHorizontalEvent && acceptanceEvent(noteEvent)) || + (noteEvent is FileMetadataEvent && acceptanceEvent(noteEvent)) || + (noteEvent is AddressableShortVideoEvent && acceptanceEvent(noteEvent)) || + (noteEvent is AddressableNormalVideoEvent && acceptanceEvent(noteEvent)) || (noteEvent is VideoNormalEvent && acceptanceEvent(noteEvent)) || (noteEvent is VideoShortEvent && acceptanceEvent(noteEvent)) || (noteEvent is PictureEvent && acceptanceEvent(noteEvent)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/TorSettingsSetup.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/TorSettingsSetup.kt index a455d3881d..aab022ad56 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/TorSettingsSetup.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/TorSettingsSetup.kt @@ -34,10 +34,10 @@ import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.connect_via_tor1 import com.vitorpamplona.amethyst.commons.resources.connect_via_tor2 +import com.vitorpamplona.amethyst.commons.tor.TorSettingsFlow import com.vitorpamplona.amethyst.commons.ui.components.appendLink import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.ui.tor.ConnectTorDialog -import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow @Composable fun TorSettingsSetup( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginScreen.kt index a6b5937134..fcc9a24726 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginScreen.kt @@ -79,6 +79,7 @@ import com.vitorpamplona.amethyst.commons.resources.hide_password import com.vitorpamplona.amethyst.commons.resources.ncryptsec_password import com.vitorpamplona.amethyst.commons.resources.show_password import com.vitorpamplona.amethyst.commons.resources.temporary_account +import com.vitorpamplona.amethyst.commons.tor.TorSettingsFlow import com.vitorpamplona.amethyst.commons.ui.insets.imePaddingSafe import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.Size10dp @@ -89,7 +90,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager import com.vitorpamplona.amethyst.ui.screen.loggedOff.TorSettingsSetup import com.vitorpamplona.amethyst.ui.screen.loggedOff.legal.TermsGate -import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow import com.vitorpamplona.quartz.nip55AndroidSigner.client.isExternalSignerInstalled import kotlinx.coroutines.delay import kotlinx.coroutines.launch diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginViewModel.kt index 5b3af89c9a..38e6c7ea99 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginViewModel.kt @@ -36,8 +36,8 @@ import com.vitorpamplona.amethyst.commons.resources.login_bunker_not_supported import com.vitorpamplona.amethyst.commons.resources.login_nostrconnect_not_supported import com.vitorpamplona.amethyst.commons.resources.password_is_required import com.vitorpamplona.amethyst.commons.resources.sign_request_rejected_description +import com.vitorpamplona.amethyst.commons.tor.TorSettingsFlow import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager -import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow @Stable class LoginViewModel : ViewModel() { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/signup/SignUpScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/signup/SignUpScreen.kt index 3987e1dfcc..4425f0a89b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/signup/SignUpScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/signup/SignUpScreen.kt @@ -59,6 +59,7 @@ import com.vitorpamplona.amethyst.commons.resources.app_logo import com.vitorpamplona.amethyst.commons.resources.how_should_we_call_you import com.vitorpamplona.amethyst.commons.resources.my_awesome_name import com.vitorpamplona.amethyst.commons.resources.welcome +import com.vitorpamplona.amethyst.commons.tor.TorSettingsFlow import com.vitorpamplona.amethyst.commons.ui.insets.imePaddingSafe import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.Size10dp @@ -70,7 +71,6 @@ import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager import com.vitorpamplona.amethyst.ui.screen.loggedOff.TorSettingsSetup import com.vitorpamplona.amethyst.ui.screen.loggedOff.legal.TermsGate import com.vitorpamplona.amethyst.ui.screen.loggedOff.login.LoginErrorManager -import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow import kotlinx.coroutines.launch @Preview(device = "spec:width=2160px,height=2340px,dpi=440") diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/signup/SignUpViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/signup/SignUpViewModel.kt index c076422ea3..42307cb17b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/signup/SignUpViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/signup/SignUpViewModel.kt @@ -29,9 +29,9 @@ import androidx.lifecycle.ViewModel import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.name_is_required +import com.vitorpamplona.amethyst.commons.tor.TorSettingsFlow import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager import com.vitorpamplona.amethyst.ui.screen.loggedOff.login.LoginErrorManager -import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow @Stable class SignUpViewModel : ViewModel() { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorManager.kt index fd6449d309..a0b41a4aca 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorManager.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.tor +import com.vitorpamplona.amethyst.commons.tor.TorPreferencesPort import com.vitorpamplona.amethyst.commons.tor.TorType import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineDispatcher diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorSettingsDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorSettingsDialog.kt index 8214177c36..415d54252d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorSettingsDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorSettingsDialog.kt @@ -80,8 +80,10 @@ import com.vitorpamplona.amethyst.commons.resources.use_internal_tor_explainer import com.vitorpamplona.amethyst.commons.tor.TorPresetType import com.vitorpamplona.amethyst.commons.tor.TorSettings import com.vitorpamplona.amethyst.commons.tor.TorType +import com.vitorpamplona.amethyst.commons.tor.explainerId import com.vitorpamplona.amethyst.commons.tor.parseTorPresetType import com.vitorpamplona.amethyst.commons.tor.parseTorType +import com.vitorpamplona.amethyst.commons.tor.resourceId import com.vitorpamplona.amethyst.commons.ui.components.TitleExplainer import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.SavingTopBar import com.vitorpamplona.amethyst.commons.ui.stringRes diff --git a/amethyst/src/main/res/values-ar-rSA/strings.xml b/amethyst/src/main/res/values-ar-rSA/strings.xml index 02c58694b8..53571cc8af 100644 --- a/amethyst/src/main/res/values-ar-rSA/strings.xml +++ b/amethyst/src/main/res/values-ar-rSA/strings.xml @@ -187,6 +187,9 @@ نشر منشور صوتي رد صوتي + + رسالة خاصة Nest — مباشر يبقي الصوت قيد التشغيل أثناء فتح الغرفة. diff --git a/amethyst/src/main/res/values-bn-rBD/strings.xml b/amethyst/src/main/res/values-bn-rBD/strings.xml index 629a0cbe34..9e70af61c4 100644 --- a/amethyst/src/main/res/values-bn-rBD/strings.xml +++ b/amethyst/src/main/res/values-bn-rBD/strings.xml @@ -180,6 +180,9 @@ পেশ করুন ভয়েস পোস্ট ভয়েস রিপ্লাই + + ব্যক্তিগত বার্তা Nest — লাইভ একটি রুম খোলা থাকলে অডিও চালু রাখে। diff --git a/amethyst/src/main/res/values-cs/strings.xml b/amethyst/src/main/res/values-cs/strings.xml index 45fbf4c476..c56adcaead 100644 --- a/amethyst/src/main/res/values-cs/strings.xml +++ b/amethyst/src/main/res/values-cs/strings.xml @@ -265,7 +265,6 @@ - Health Connect a Amethyst @@ -346,6 +345,10 @@ Hlasový příspěvek Hlasová odpověď Nahlášení + + Health Connect a Amethyst + Soukromá zpráva Chatová zpráva Nest — Živě diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index 17f52cb6fc..c46ed3312d 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -253,7 +253,6 @@ - Health Connect und Amethyst @@ -318,6 +317,10 @@ Sprachbeitrag Sprachantwort Meldung + + Health Connect und Amethyst + Private Nachricht Chat-Nachricht Nest — Live diff --git a/amethyst/src/main/res/values-el-rGR/strings.xml b/amethyst/src/main/res/values-el-rGR/strings.xml index 810205da4d..19017ff5e7 100644 --- a/amethyst/src/main/res/values-el-rGR/strings.xml +++ b/amethyst/src/main/res/values-el-rGR/strings.xml @@ -179,6 +179,9 @@ Δημοσίευση Φωνητική ανάρτηση Φωνητική απάντηση + + Ιδιωτικό Μήνυμα Nest — Ζωντανά Διατηρεί τον ήχο σε αναπαραγωγή ενώ το δωμάτιο είναι ανοιχτό. diff --git a/amethyst/src/main/res/values-en-rGB/strings.xml b/amethyst/src/main/res/values-en-rGB/strings.xml index 2609071d48..54b981e3cc 100644 --- a/amethyst/src/main/res/values-en-rGB/strings.xml +++ b/amethyst/src/main/res/values-en-rGB/strings.xml @@ -109,4 +109,7 @@ + + diff --git a/amethyst/src/main/res/values-eo-rUY/strings.xml b/amethyst/src/main/res/values-eo-rUY/strings.xml index baea710fdf..21749d2165 100644 --- a/amethyst/src/main/res/values-eo-rUY/strings.xml +++ b/amethyst/src/main/res/values-eo-rUY/strings.xml @@ -180,6 +180,9 @@ Afiŝi Voĉa Afiŝo Voĉa Respondo + + Privata Mesaĝo Nesto — Viva Nestoj diff --git a/amethyst/src/main/res/values-es-rES/strings.xml b/amethyst/src/main/res/values-es-rES/strings.xml index 8fbe10c36a..b6d1e8fd7a 100644 --- a/amethyst/src/main/res/values-es-rES/strings.xml +++ b/amethyst/src/main/res/values-es-rES/strings.xml @@ -180,6 +180,9 @@ Publicar Publicación de voz Respuesta de voz + + Mensaje privado Nest — En vivo Nests diff --git a/amethyst/src/main/res/values-es-rMX/strings.xml b/amethyst/src/main/res/values-es-rMX/strings.xml index 448ac65dc4..524551b57c 100644 --- a/amethyst/src/main/res/values-es-rMX/strings.xml +++ b/amethyst/src/main/res/values-es-rMX/strings.xml @@ -179,6 +179,9 @@ Publicar Publicación de voz Respuesta de voz + + Mensaje privado Nest — En vivo Nests diff --git a/amethyst/src/main/res/values-es-rUS/strings.xml b/amethyst/src/main/res/values-es-rUS/strings.xml index 448ac65dc4..524551b57c 100644 --- a/amethyst/src/main/res/values-es-rUS/strings.xml +++ b/amethyst/src/main/res/values-es-rUS/strings.xml @@ -179,6 +179,9 @@ Publicar Publicación de voz Respuesta de voz + + Mensaje privado Nest — En vivo Nests diff --git a/amethyst/src/main/res/values-fa-rIR/strings.xml b/amethyst/src/main/res/values-fa-rIR/strings.xml index 1175a3db91..8e193cc2ce 100644 --- a/amethyst/src/main/res/values-fa-rIR/strings.xml +++ b/amethyst/src/main/res/values-fa-rIR/strings.xml @@ -179,6 +179,9 @@ ارسال پست صوتی پاسخ صوتی + + پیام خصوصی Nest — زنده پخش صدا را هنگام باز بودن اتاق ادامه می‌دهد. diff --git a/amethyst/src/main/res/values-fi-rFI/strings.xml b/amethyst/src/main/res/values-fi-rFI/strings.xml index 378fc50c82..8e03cab034 100644 --- a/amethyst/src/main/res/values-fi-rFI/strings.xml +++ b/amethyst/src/main/res/values-fi-rFI/strings.xml @@ -180,6 +180,9 @@ Lähetä Äänijulkaisu Äänivastaus + + Yksityinen viesti Pitää äänen toistossa huoneen ollessa auki. Nest yhdistetty diff --git a/amethyst/src/main/res/values-fr-rCA/strings.xml b/amethyst/src/main/res/values-fr-rCA/strings.xml index 7b498c6cbb..997f345416 100644 --- a/amethyst/src/main/res/values-fr-rCA/strings.xml +++ b/amethyst/src/main/res/values-fr-rCA/strings.xml @@ -177,6 +177,9 @@ Envoyer Message vocal Réponse vocale + + Message privé Nest — En direct Maintient l\'audio actif pendant qu\'une salle est ouverte. diff --git a/amethyst/src/main/res/values-fr-rFR/strings.xml b/amethyst/src/main/res/values-fr-rFR/strings.xml index 13ae11d3fd..002be59eed 100644 --- a/amethyst/src/main/res/values-fr-rFR/strings.xml +++ b/amethyst/src/main/res/values-fr-rFR/strings.xml @@ -204,6 +204,9 @@ Message vocal Réponse vocale Signaler + + Message privé Nest — En direct Maintient l\'audio actif pendant qu\'une salle est ouverte. diff --git a/amethyst/src/main/res/values-hi-rIN/strings.xml b/amethyst/src/main/res/values-hi-rIN/strings.xml index b1b4776a01..8343082ebf 100644 --- a/amethyst/src/main/res/values-hi-rIN/strings.xml +++ b/amethyst/src/main/res/values-hi-rIN/strings.xml @@ -256,7 +256,6 @@ - स्वास्थ्य संयोजन तथा अमेथिस्ट @@ -322,6 +321,10 @@ ध्वनि सन्देश ध्वनि उत्तर वृत्तान्त + + स्वास्थ्य संयोजन तथा अमेथिस्ट + निजी सन्देश चर्चा सन्देश निलय - वर्तमान @@ -350,322 +353,4 @@ ध्वनि सक्रिय। मौन करने के लिए टाँकें चलाएँ विराम - आपने अपना %1$s अन्य क्रमक में परिवर्तित किया - हटाए गए (%1$s) - जोडे गए (%1$s) - परिवर्तित (%1$s) - आपने सभी निजी (रहस्यीकृत) वस्तुओं को हटा दिया। - आपने निजी (रहस्यीकृत) वस्तुओं का परिवर्तन भी किया। उनकी तुलना यहाँ नहीं की जा सकती। - आपने निजी (रहस्यीकृत) वस्तुओं को जोडा। - सुरक्षित संस्करण पुनःस्थापन - इस यन्त्र पर - नया संस्करण - स्थापित नहीं - %1$s हटाए गए - %1$s जोडे गए - %1$s परिवर्तित - - आपने %1$d व्यक्ति का अनुगमन हटाया - आपने %1$d व्यक्तियों का अनुगमन हटाए - - स्वीकार - %1$s अद्यतनकृत - %1$s पारित - अन्य क्रमक में परिवर्तित। %1$s - रखें %1$s - पुनःस्थापन %1$s - पुनःमौन %1$s - पुनःजुडें %1$s - परिचय पुनःस्थापध - पुनःप्रसारक पुनःस्थापन - %1$s रखे गए - %1$s परित्यक्त - %1$s नए - परित्यक्त। %1$s - नए। %1$s - सम्पादित। %1$s - %1$s लोग खोजें - यहाँ कुछ नहीं - %1$s मौनावस्था हटाए गए - ये पुनः आपके सूचनावलियों तक अभिगम्य - %1$s नए मौनकृत - शब्द तथा विषयसूचक - %1$s तथा %2$s अधिक - आपने सभी निजी वस्तुओं को हटा दिया - वे रहस्यीकृत हैं। इसलिए केवल आप देख सकते हैं कि वे क्या थे। पुनःस्थापन से सब लौटके आ जाएँगे। - आपने वस्तुओं को निजी किया - वे अब रहस्यीकृत हैं। जो कि सम्भाव्यतः ऊपर के सार्वजनिक वस्तुओं का गन्तव्य था। - आपने निजी वस्तुओं का परिवर्तन किया - वे रहस्यीकृत हैं। इसलिए उनकी तुलना यहाँ नहीं की जा सकती। - चला गया - नव्य - परित्यक्त - स्थानान्तरित - इकाई - शेष - पुनःनामकृत - निर्गतपेटिका - आप जहाँ प्रकाशित करते हैं - आगतपेटिका - जहाँ अन्य आप तक अभिगमन करते हैं - ग्राहक कुंचिका प्रतिस्थापित - ग्राहक कुंचिका हटाया गया - नटज्साप आगे से इस कुंचिका से बद्ध होंगे। यदि इस यन्त्र में वह नहीं है तो आप सम्भाव्यतः उनको यहाँ नहीं चुका सकते। - शेष - जुड गए - पुनःनामकृत - पूर्वतः %1$s था - पुनःबाधित करें %1$s को - सूचनावलियाँ पुनःस्थापन - विषय पुनःस्थापन - स्थल पुनःस्थापन - प्रदाता पुनःस्थापन - धनकोष पुनःस्थापन - रखे गए - ग्रहण मध्य - सूचीबद्ध - खोज मध्य - अभिलेखन मध्य - विश्वसनीय - अविश्वसनीय - %1$s अबाधित - अमेथिस्ट पुनः उनसे जुडेगा - अब से बाधित नहीं - नव्यतः बाधित - अभी भी बाधित - अनुमत - स्थानान्तरित - सूचनावली - विषय हटाए गए - स्थल हटाए गए - नव्य - विषयसूचक जिनका आप अनुगमन करते हैं - स्थल जिनका आप अनुगमन करते हैं - %2$s में से %1$s सेवाएँ परिवर्तित - वे अब अन्य व्यक्ति से आते है। अथवा किसी से नहीं - सेवा - श्रेणी - अनुचर - विषयसूची - श्रेणीयाँ तथा अनुचर गिनती तथा कचरालेख छालन जो आप देखते हैं इन प्रदाताओं से आते हैं। - - आपको भुगतान करने की %1$d विधि हटाया गया - आपको भुगतान करने की %1$d विधियाँ हटाए गए - - - %1$d प्रस्ताव हटाया गया - %1$d प्रस्ताव हटाए गए - - लोग जो आपको भुगतान करते हैं उसी को देखते हैं जो इस सूची में है। - बोल्ट॰बारह प्रस्ताव - आपने उसे रिक्त किया - आपने उसे बनाया - आपने उसे परिवर्तित किया - आपके धनकोष के टकसाल तथा कुंचिकाएँ आपके लिए रहस्यीकृत हैं। इसलिए उनकी तुलना वस्तु प्रति वस्तु यहाँ नहीं की जा सकती। पुनःस्थापन उन्हीं को लौट डालेगा जिनका अभिलेखन इस यन्त्र ने किया। - आपके कोंकोर्ड समुदाय आपके लिए रहस्यीकृत हैं। इसलिए उनकी तुलना यहाँ नहीं की जा सकती। पुनःस्थापन उन्हीं को लौट डालेगा जिनका अभिलेखन इस यन्त्र ने किया। - - आपने %1$d की मौनावस्था हटाया - आपने %1$d की मौनावस्था हटाए - - - आपने %1$d परिचय प्रपत्रस्थान हटाया - आपने %1$d परिचय प्रपत्रस्थान हटाए - - - आपने %1$d सार्वजनिक पुनःप्रसारक हटाया - आपने %1$d सार्वजनिक पुनःप्रसारकों को हटाए - - - आपने %1$d सीधासन्देश पुनःप्रसारक हटाया - आपने %1$d सीधासन्देश पुनःप्रसारकों को हटाए - - - आपने %1$d समूहचर्चा कुंचिका पुनःप्रसारक हटाया - आपने %1$d समूहचर्चा कुंचिका पुनःप्रसारकों को हटाए - - - आपने %1$d खोज पुनःप्रसारक हटाया - आपने %1$d खोज पुनःप्रसारकों को हटाए - - - आपने %1$d अनुक्रमणीकार पुनःप्रसारक हटाया - आपने %1$d अनुक्रमणीकार पुनःप्रसारकों को हटाए - - - आपने %1$d पुनःप्रसारक सूचनावली हटाया - आपने %1$d पुनःप्रसारक सूचनावलियाँ हटाए - - - आपने %1$d निजी पुनःप्रसारक हटाया - आपने %1$d निजी पुनःप्रसारकों को हटाए - - - आपने %1$d पुनःप्रसारक पर विश्वास करना रोक दिया - आपने %1$d पुनःप्रसारकों पर विश्वास करना रोक दिए - - - आपने %1$d पुनःप्रसारक अबाधित किया - आपने %1$d पुनःप्रसारकों को अबाधित किए - - - आपने %1$d सार्वजनिक चर्चा छोड दिया - आपने %1$d सार्वजनिक चर्चाओं को छोड दिए - - - आपने %1$d समुदाय छोड दिया - आपने %1$d समुदायों को छोड दिए - - - आपने %1$d चर्चाशाला छोड दिया - आपने %1$d चर्चाशालाओं को छोड दिए - - - आपने %1$d समूह छोड दिया - आपने %1$d समूहों को छोड दिए - - - आपने %1$d इष्ट सूचनावली हटाया - आपने %1$d इष्ट सूचनावलियों को हटाए - - - आपने %1$d विषयसूचक का अनुगमन हटाया - आपने %1$d विषयसूचकों का अनुगमन हटाए - - - आपने %1$d स्थल का अनुगमन हटाया - आपने %1$d स्थलों का अनुगमन हटाए - - - आपने %1$d विश्वाससेवा प्रदाता परिवर्तित किया - आपने %1$d विश्वाससेवाओं के प्रदाता को परिवर्तित किया - - - आपने %1$d नटज्साप टकसाल हटाया - आपने %1$d नटज्साप टकसालों को हटाए - - - आपने %1$d भुगतान प्राप्त करने की विधि हटाया - आपने %1$d भुगतान प्राप्त करने के विधियों को हटाए - - - आपने %1$d बोल्ट॰बारह प्रस्ताव हटाया - आपने %1$d बोल्ट॰बारह प्रस्तावों को हटाए - - - %1$s तथा %2$d जोडा - %1$s तथा %2$d जोडे - - - %1$s तथा %2$d का अनुगमन किया - %1$s तथा %2$d का अनुगमन किए - - - %1$s तथा %2$d को मौनकृत किया - %1$s तथा %2$d को मौनकृत किए - - - %1$s तथा %2$d से जुडे - %1$s तथा %2$d से जुडे - - - %1$s तथा %2$d बाधित किया - %1$s तथा %2$d को बाधित किए - - आपके अमेथिस्ट समचरणीकृत क्रमक स्थापना विकल्पों को परिवर्तित किया गया एक अन्य क्रमक में और वह अमेथिस्ट नहीं था। इससे समस्याएँ उत्पन्न हो सकते हैं। - आपने अपनी नटज्साप कुंचिका का प्रतिस्थापन किया - आपने अपनी नटज्साप कुंचिका को हटाया - आपने अपना काशयू धनकोष रिक्त किया - आपने अपने काशयू धनकोष को परिवर्तित किया - आपने अपनी कोंकोर्ड सूची रिक्त की - आपने अपनी कोंकोर्ड सूची का परिवर्तन किया - नया संस्करण का उपयोग करें - पठनेवशक्य - लेखनेवशक्य - पठन तथा लेखन - परिचय - आपका नाम चित्र चरित तथा भुगतान पते। जैसे सब को दिखते हैं वैसे। - अनुचरण सूची - निर्णय करता है किसके पत्र आपकी मुख्य सूचनावली में प्रकट होंगे। - मौन सूची - छिपाता है लोग शब्द विषयसूचक तथा सूत्र उन सब में से जो आप देखते हैं। - सार्वजनिक पुनःप्रसारक सूची - सबको बताता है आपके पत्र कहाँ प्राप्त करें तथा आपको उत्तर तथा उल्लेखों को कहाँ भेजें। - सीधासन्देश पुनःप्रसारक सूची - जहाँ अन्य आपके निजी सन्देश भेजते हैं। - समूहचर्चा कुंचिका पुनःप्रसारक - जहाँ अन्य उन कुंचिकाओं को प्राप्त करते हैं आपको रहस्यीकृत समूहचर्चाओं में जोडने के लिए। - खोज पुनःप्रसारक सूची - उपयुक्त पुनःप्रसारक जब आप खोजते हैं। - अनुक्रमणीकार पुनःप्रसारक सूची - पुनःप्रसारक उपयुक्त अन्य लोगों के परिचय तथा पुनःप्रसारक सूचियों को प्राप्त करने के लिए। - इष्ट पुनःप्रसारक सूचनावलियाँ - पुनःप्रसारक जिनका आप वीक्षण करते हैं सूचनावलियों के रूप में। - बाधित पुनःप्रसारक सूची - पुनःप्रसारक जिनसे यह क्रमक कभी भी संयोजन नहीं करता। - विश्वसनीय पुनःप्रसारक सूची - पुनःप्रसारक जिनपर आप पर्याप्त विश्वास करते हैं बिना पूछे उनसे संयोजन करने के लिए। - निजी पुनःप्रसारक सूची - जहाँ आपके पाण्डुलिपियाँ तथा निजी जानकारी रखे जाते हैं। - समचरणीकृत क्रमक स्थापना विकल्प - अमेथिस्ट स्थापना विकल्प समचरणीकृत आपके यन्त्रों में। वे रहस्यीकृत हैं। इसलिए केवल उनकी उपस्थिति की तुलना की जा सकती है। - सार्वजनिक चर्चा सूची - सार्वजनिक चर्चाएँ जिनसे आप जुडे। - समुदाय सूची - समुदाय जिनसे आप जुडे। - अनुचरित विषयसूचक - विषयसूचक जिससे युक्त पत्र आपकी मुख्य सूचनावली में प्रकट होंगे। - अनुचरित स्थान - स्थान जहाँ से पत्र आपकी मुख्य सूचनावली में प्रकट होंगे। - इष्ट सूचनावली सूची - कलनविध्यात्मक सूचनावलियाँ जिनको आपने अभिलेखित किया। - अस्थायी चर्चा सूची - अस्थायी चर्चा शालाएँ जिनसे आप जुडे। - समूह सूची - पुनःप्रसारक आधारित समूह जिनसे आप जुडे। - कोंकोर्ड समुदाय सूची - कोंकोर्ड समुदाय जिनसे आप जुडे। वे रहस्यीकृत हैं। इसलिए केवल उनकी उपस्थिति की तुलना की जा सकती है। - विश्वाससेवा प्रदाता सूची - सेवाएँ जिनपर आप विश्वास करते है आपके लिए लोगों का सत्यापन तथा श्रेणीकरण करने के लिए। - काशयू धनकोष - आपके ईकाश धनकोष स्थापना विकल्प तथा टकसाल। वे रहस्यीकृत हैं। इसलिए केवल उनकी उपस्थिति की तुलना की जा सकती है। - नटज्साप प्राप्ती स्थापना विकल्प - टकसाल तथा कुंचिका जिनका उपयोग अन्य करते हैं आपको नटज्साप भेजने के लिए। - भुगतान पते - विधियाँ जिनसे अन्य लोग आपको भुगतान कर सके। - बोल्ट॰बारह प्रस्ताव - लैटनिंग प्रस्ताव जिनका उपयोग करके अन्य लोग आपको भुगतान करते हैं। - सूची - इस यन्त्र पर अभिलेखित सूची। - परिचय प्रपत्रस्थान - लोग - विषयसूचक - शब्द - सूत्र - सार्वजनिक चर्चाएँ - समुदाय - सूचनावलियाँ - स्थान - पुनःप्रसारक - समूह - चर्चा शालाएँ - विश्वाससेवा प्रदाता - टकसाल - नटज्साप कुंचिकाएँ - भुगतान पते - प्रस्ताव - सम्बन्धित व्यक्तिगण - अन्य प्रविष्टियाँ - प्रयोक्तानाम - प्रदर्शन नाम - जानकारी - चित्र - ध्वज - जालस्थान - नोस्टर पता - लैटनिंग पता - लैटनिंग जालपता - सर्वनाम - जन्मदिन - क्लिंक॰ प्रस्ताव - क्रमचर चिह्न diff --git a/amethyst/src/main/res/values-hu-rHU/strings.xml b/amethyst/src/main/res/values-hu-rHU/strings.xml index f44b17c264..37bd743202 100644 --- a/amethyst/src/main/res/values-hu-rHU/strings.xml +++ b/amethyst/src/main/res/values-hu-rHU/strings.xml @@ -256,7 +256,6 @@ - Health Connect és Amethyst @@ -322,6 +321,10 @@ Hangos bejegyzés Hangos válasz Jelentés + + Health Connect és Amethyst + Privát üzenet Csevegési üzenet Hangszoba – Élő @@ -350,322 +353,4 @@ Hang bekapcsolva. Koppintson a némításhoz Lejátszás Szüneteltetés - Ön módosította a következőt: %1$s, egy másik alkalmazásban - Eltávolítva (%1$s) - Hozzáadva (%1$s) - Módosítva (%1$s) - Ön eltávolította az összes privát (titkosított) elemet. - Ön privát (titkosított) elemeket is módosított. Ezek itt nem hasonlíthatók össze. - Ön privát (titkosított) elemeket adott hozzá. - Mentett verzió visszaállítása - Ezen az eszközön - Új verzió - Nincs beállítva - %1$s eltávolítva - %1$s hozzáadva - %1$s módosítva - - %1$d személyt nem követ többé - %1$d személyt nem követ többé - - Elfogadás - %1$s frissítve - %1$s törölve - Módosítva egy másik alkalmazásban · %1$s - %1$s megtartása - %1$s visszaállítása - %1$s újbóli némítása - Újbóli csatlakozás ehhez: %1$s - Profil visszaállítása - Átjátszók visszaállítása - %1$s megtartva - %1$s elvetve - %1$s új - Elvetve · %1$s - Új · %1$s - Szerkesztve · %1$s - %1$s személy keresése - Itt nincs semmi - %1$s némítása feloldva - Ezek újra megjelenhetnek a hírfolyamaiban - %1$s újonnan némítva - Szavak és kulcsszavak - %1$s és %2$s további - Ön eltávolította az összes privát elemet - Titkosítva vannak, így csak Ön láthatja, mik voltak. A visszaállítás mindet visszahozza. - Ön priváttá tette az elemeket - Most már titkosítva vannak, valószínűleg ide kerültek a fenti nyilvános elemek. - Ön módosította a privát elemeket - Titkosítva vannak, így itt nem hasonlíthatók össze. - Eltűnt - Új - Elvetve - Áthelyezve - Egységek - Elhagyva - Átnevezve - Kimenő - ahol közzétesz - Bejövő - ahol mások elérik Önt - Fogadási kulcs lecserélve - Fogadási kulcs eltávolítva - A nutzapok ehhez az új kulcshoz lesznek kötve. Ha ez az eszköz nem rendelkezik vele, lehet, hogy itt nem tudja beváltani őket. - elhagyta a csoportot - csatlakozott - átnevezte - „%1$s” volt - %1$s újbóli letiltása - Hírfolyamok visszaállítása - Témák visszaállítása - Helyek visszaállítása - Szolgáltatók visszaállítása - Pénztárca visszaállítása - megtartva - Fogadás - Listázva - Keresés - Tárolás - Megbízható - Nem megbízható - %1$s letiltása feloldva - Az Amethyst újra kapcsolódni fog hozzájuk - Már nincs letiltva - Újonnan letiltva - Továbbra is le van tiltva - Engedélyezve - Áthelyezve - Hírfolyam - eltűnt témák - eltűnt helyek - új - Ön által követett kulcsszavak - Ön által követett helyek - %2$s szolgáltatásból %1$s módosult - Most már valaki mástól vagy senkitől sem származnak - Szolgáltatás - Rang - Követők - Témák - A látott rangok, követőszámok és a kéretlen tartalomszűrés ezektől a szolgáltatóktól származnak. - - %1$d fizetési mód eltávolítva - %1$d fizetési mód eltávolítva - - - %1$d ajánlat eltávolítva - %1$d ajánlat eltávolítva - - A fizető emberek csak azt látják, ami ebben a listában van. - BOLT12 ajánlat - Ön kiürítette - Ön hozta létre - Ön módosította - A saját pénztárca pénzverdéi és kulcsai Önhöz vannak titkosítva, így itt nem hasonlíthatók össze tételesen. A visszaállítás pontosan azt hozza vissza, amit ez az eszköz mentett. - A Concord közösségei Önhöz vannak titkosítva, így itt nem hasonlíthatók össze. A visszaállítás pontosan azt hozza vissza, amit ez az eszköz mentett. - - Ön %1$d dolog némítását oldotta fel - %1$d dolog némítását oldotta fel - - - Ön %1$d profilmezőt távolított el - %1$d profilmezőt távolított el - - - Ön %1$d nyilvános átjátszót távolított el - %1$d nyilvános átjátszót távolított el - - - Ön %1$d privát üzenetátjátszót távolított el - %1$d privát üzenet átjátszót távolított el - - - Ön %1$d csoportos csevegési kulcsátjátszót távolított el - %1$d csoportcsevegés kulcs átjátszót távolított el - - - Ön %1$d kereső átjátszót távolított el - %1$d kereső átjátszót távolított el - - - Ön %1$d indexelő átjátszót távolított el - %1$d indexelő átjátszót távolított el - - - Ön %1$d átjátszó-hírfolyamot távolított el - %1$d átjátszó hírfolyamot távolított el - - - Ön %1$d privát átjátszót távolított el - %1$d privát átjátszót távolított el - - - Ön %1$d átjátszóban nem bízik többé - %1$d átjátszóban nem bízik többé - - - Ön %1$d átjátszó letiltását oldotta fel - %1$d átjátszó blokkolását oldotta fel - - - Ön %1$d nyilvános csevegést hagyott el - %1$d nyilvános csevegést hagyott el - - - Ön %1$d közösséget hagyott el - %1$d közösséget hagyott el - - - Ön %1$d csevegőszobát hagyott el - %1$d csevegőszobát hagyott el - - - Ön %1$d csoportot hagyott el - %1$d csoportot hagyott el - - - Ön %1$d kedvenc hírfolyamot távolított el - %1$d kedvenc hírfolyamot távolított el - - - Ön %1$d kulcsszót nem követ többé - %1$d hashtaget nem követ többé - - - Ön %1$d helyet nem követ többé - %1$d helyet nem követ többé - - - Ön %1$d bizalmi szolgáltatás szolgáltatóját módosította - %1$d bizalmi szolgáltatás szolgáltatóját változtatta meg - - - Ön %1$d nutzap pénzverdét távolított el - %1$d nutzap pénzverdét távolított el - - - Ön %1$d fizetési módot távolított el - %1$d fizetési módot távolított el - - - Ön %1$d BOLT12 ajánlatot távolított el - %1$d BOLT12 ajánlatot távolított el - - - %1$s és %2$d hozzáadva - %1$s és %2$d hozzáadva - - - %1$s és %2$d követve - %1$s és %2$d követve - - - %1$s és %2$d némítva - %1$s és %2$d némítva - - - %1$s és %2$d csatlakozva - %1$s és %2$d csatlakozva - - - %1$s és %2$d letiltva - %1$s és %2$d letiltva - - A szinkronizált Amethyst alkalmazásbeállításai egy másik, nem Amethyst alkalmazásban módosultak. Ez problémákat okozhat. - Ön lecserélte a nutzap kulcsát - Ön eltávolította a nutzap kulcsát - Ön kiürítette a Cashu pénztárcáját - Ön módosította a Cashu pénztárcáját - Ön kiürítette a Concord listáját - Ön módosította a Concord listáját - Új verzió használata - csak olvasható - csak írható - olvasás és írás - profil - Az Ön neve, képe, bemutatkozása és fizetési címei, ahogy mindenki látja őket. - követési lista - Meghatározza, kinek a bejegyzései jelenjenek meg a kezdőlapon. - némítási lista - Elrejti a személyeket, szavakat, kulcsszavakat és beszélgetéseket mindenből, amit lát. - nyilvános átjátszólista - Megmondja mindenkinek, hol találhatók a bejegyzései, és hová küldjék a válaszokat és említéseket. - privát üzenet átjátszólista - Ahová mások kézbesítik a privát üzeneteit. - csoportos csevegési kulcsátjátszók - Ahol mások megtalálják a kulcsokat, hogy titkosított csoportos csevegésekhez adhassák Önt. - kereső átjátszólista - Kereséskor használt átjátszók. - indexelő átjátszólista - Azok az átjátszók, amelyeket mások profiljainak és átjátszólistáinak megtalálásához használnak. - kedvenc átjátszóhírfolyamok - Olyan átjátszók, amelyeket hírfolyamként böngész. - letiltott átjátszólista - Átjátszók, amelyekhez ez az alkalmazás soha nem kapcsolódik. - megbízható átjátszólista - Átjátszók, amelyekhez kérdezés nélkül kapcsolódhat. - privát átjátszólista - Ahol a piszkozatai és privát adatai tárolódnak. - szinkronizált alkalmazásbeállítások - Az eszközök között szinkronizált Amethyst-beállítások. Titkosítva vannak, így csak a jelenlétük hasonlítható össze. - nyilvános csevegési lista - Nyilvános csevegések, amelyekhez csatlakozott. - közösséglista - Közösségek, amelyekhez csatlakozott. - követett kulcsszavak - Kulcsszavak, amelyek bejegyzései megjelennek a hírfolyamban. - követett helyek - Helyek, amelyek bejegyzései megjelennek a hírfolyamaiban. - kedvenc hírfolyamlista - Mentett algoritmus-hírfolyamok. - rövid életű csevegési lista - Rövid életű csevegőszobák, amelyekhez csatlakozott. - csoportlista - Átjátszó-alapú csoportok, amelyekhez csatlakozott. - Concord közösséglista - A Concord közösségek, amelyekhez csatlakozott. Titkosítva vannak, így csak a jelenlétük hasonlítható össze. - bizalmi szolgáltatólista - A szolgáltatások, amelyekben megbízik, hogy rangsorolják és ellenőrizzék Önnek az embereket. - Cashu pénztárca - Az ecash pénztárca beállításai és pénzverdéi. Titkosítva vannak, így csak a jelenlétük hasonlítható össze. - nutzap fogadási beállítások - A pénzverdék és a kulcs, amelyeket mások a nutzapok küldéséhez használnak az Ön számára. - fizetési címek - A módok, ahogy mások fizethetnek Önnek. - BOLT12 ajánlatok - A lightning ajánlatok, amelyeket mások a fizetéshez használnak az Ön számára. - lista - Egy listaelem ezen az eszközön mentve. - Profilmezők - Személyek - Kulcsszavak - Szavak - Beszélgetések - Nyilvános csevegések - Közösségek - Hírfolyamok - Helyek - Átjátszók - Csoportok - Csevegőszobák - Bizalmi szolgáltatók - Pénzverdék - Nutzap kulcsok - Fizetési címek - Ajánlatok - Kapcsolt identitások - Egyéb bejegyzések - Felhasználónév - Megjelenített név - Névjegy - Kép - Borító - Weboldal - Nostr-cím - Lightning-cím - LNURL - Névmások - Születésnap - CLINK-ajánlat - Bot jelölő diff --git a/amethyst/src/main/res/values-in-rID/strings.xml b/amethyst/src/main/res/values-in-rID/strings.xml index d6bb8f10fc..e6e9e6c64c 100644 --- a/amethyst/src/main/res/values-in-rID/strings.xml +++ b/amethyst/src/main/res/values-in-rID/strings.xml @@ -175,6 +175,9 @@ Posting Postingan Suara Balasan Suara + + Pesan Pribadi Menjaga audio tetap berjalan saat ruang terbuka. Nest terhubung diff --git a/amethyst/src/main/res/values-it-rIT/strings.xml b/amethyst/src/main/res/values-it-rIT/strings.xml index 8bb234c00d..f50ac16a6a 100644 --- a/amethyst/src/main/res/values-it-rIT/strings.xml +++ b/amethyst/src/main/res/values-it-rIT/strings.xml @@ -176,6 +176,9 @@ Condividi Post vocale Risposta vocale + + Messaggio privato Mantiene l\'audio attivo mentre una stanza è aperta. Nest connesso diff --git a/amethyst/src/main/res/values-ja-rJP/strings.xml b/amethyst/src/main/res/values-ja-rJP/strings.xml index 85b73bcdcd..fdb541309e 100644 --- a/amethyst/src/main/res/values-ja-rJP/strings.xml +++ b/amethyst/src/main/res/values-ja-rJP/strings.xml @@ -177,6 +177,9 @@ 送信 音声投稿 音声返信 + + プライベートメッセージ Nest — ライブ中 ルームが開いている間オーディオを再生し続けます。 diff --git a/amethyst/src/main/res/values-ko-rKR/strings.xml b/amethyst/src/main/res/values-ko-rKR/strings.xml index dd14c85fa5..9696628f44 100644 --- a/amethyst/src/main/res/values-ko-rKR/strings.xml +++ b/amethyst/src/main/res/values-ko-rKR/strings.xml @@ -178,6 +178,9 @@ 게시 음성 게시물 음성 답글 + + 비공개 메시지 Nest — 라이브 방이 열려 있는 동안 오디오를 계속 재생합니다. diff --git a/amethyst/src/main/res/values-lv-rLV/strings.xml b/amethyst/src/main/res/values-lv-rLV/strings.xml index b202b4af1c..0088d76edd 100644 --- a/amethyst/src/main/res/values-lv-rLV/strings.xml +++ b/amethyst/src/main/res/values-lv-rLV/strings.xml @@ -183,6 +183,9 @@ Ieraksts Balss ziņa Balss atbilde + + Privāta ziņa Nest — Tiešraide Turpina atskaņot audio, kamēr telpa ir atvērta. diff --git a/amethyst/src/main/res/values-nl-rNL/strings.xml b/amethyst/src/main/res/values-nl-rNL/strings.xml index 3ca5a7ea30..274d393290 100644 --- a/amethyst/src/main/res/values-nl-rNL/strings.xml +++ b/amethyst/src/main/res/values-nl-rNL/strings.xml @@ -315,6 +315,9 @@ Spraakbericht Spraakreactie Melding + + Privébericht Chatbericht Nest — Live diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index 27a7baff42..eda8c8fe1a 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -268,7 +268,6 @@ - Health Connect i Amethyst @@ -315,6 +314,7 @@ Dowód pracy w górnictwie… (%1$d posty w kolejce) Wydobywanie dowodu wykonania pracy + Wysyłanie do transmiterów… Anuluj ◆ %1$s pozostało w każdej chwili @@ -349,6 +349,10 @@ Wiadomość głosowa Odpowiedź głosowa Zgłoś + + Health Connect i Amethyst + Wiadomość prywatna Wiadomość Czatu Nest — Na żywo @@ -377,240 +381,4 @@ Dźwięk włączony. Kliknij, aby wyciszyć Odtwórz Pauza - Zmieniłeś %1$s w innej aplikacji - Usunięto (%1$s) - Dodano (%1$s) - Zmieniono (%1$s) - Usunąłeś wszystkie prywatne (zaszyfrowane) elementy. - Zmieniłeś również elementy prywatne (zaszyfrowane). Nie można ich tutaj porównać. - Dodano prywatne (zaszyfrowane) pozycje. - Przywróć zapisaną wersję - Na tym urządzeniu - Nowa wersja - Nie ustawiono - Usunięto %1$s - Dodano %1$s - %1$s zmieniono - - Przestałeś obserwować %1$d osobę - Przestałeś obserwować %1$d osób - Przestałeś obserwować %1$d osób - Przestałeś obserwować %1$d osoby - - Zaakceptuj - %1$s zaktualizowano - %1$s wyczyszczono - Zmieniono w innej aplikacji · %1$s - Zachowaj %1$s - Przywróć %1$s - Ponownie wycisz %1$s - Ponów %1$s - Przywróć profil - Przywróć transmitery - Zachowano %1$s - %1$s odrzuconych - %1$s nowych - Usunięte · %1$s - Nowe · %1$s - Edytowane · %1$s - Szukaj %1$s osób - Nic tu nie ma! - %1$s odblokowanych - Mogą one ponownie pojawić się w Twoich informacjach - %1$s nowo wyciszonych - Wyrazy & hasztagi - %1$s i %2$s więcej - Usunąłeś wszystkie prywatne elementy - Są zaszyfrowane, więc tylko Ty możesz zobaczyć, co to było. Odtworzenie przywróci je wszystkie. - Ustawiłeś te pozycje jako prywatne - Są teraz zaszyfrowane, co prawdopodobnie wyjaśnia, gdzie podziały się powyższe elementy publiczne. - Zmieniłeś prywatne elementy - Są zaszyfrowane, więc nie można ich tu porównać. - Zniknęła - Nowa - Usunięta - Przeniesiona - Jednostek - Zostało - Zmieniono nazwę - Skrzynka nadawcza - gdzie publikujesz - Skrzynka odbiorcza - gdzie inni docierają do Ciebie - Wymieniono klucz odbierający - Usunięto klucz odbierający - Nutzapy zostaną przypisane do tego nowego klucza. Jeśli urządzenie nie posiada tego klucza, może się okazać, że nie będzie można ich tutaj zrealizować. - zostało - dołączył(a) - zmieniono nazwę - było „%1$s” - Zablokuj %1$s ponownie - Przywróć kanały - Przywróć tematy - Przywróć miejsca - Przywróć dostawców - Przywróć portfel - zachowane - Pobieranie - Na Liście - Szukam - Przechowywanie - Zaufane - Niezaufane - %1$s odblokowane - Ametyst ponownie połączy się z nimi - Nie jest już zablokowana - Nowo zablokowana - Nadal zablokowana - Dozwolona - Przeniesiona - Kanał - tematy zniknęły - miejsca zniknęły - nowe - Hashtagi, które obserwujesz - Miejsca które obserwujesz - Zmieniono %1$s z %2$s serwisów - Teraz pochodzą od kogoś innego albo od nikogo - Serwis - Ranking - Obserwujący - Tematy - Rankingi, liczba obserwujących i filtrowanie spamu, które widzisz, pochodzą od tych dostawców. - - %1$d sposób płatności został usunięty - %1$d sposobów płatności zostało usuniętych - %1$d sposobów płatności zostało usuniętych - %1$d sposoby płatności zostały usunięte - - - %1$d oferta usunięta - %1$d ofert usunięto - %1$d ofert usunięto - %1$d oferty usunięte - - Osoby, które Ci płacą, widzą tylko to, co znajduje się na tej liście. - Oferta BOLT12 - Opróżniłeś to - Utworzyłeś to - Zmieniłeś to - Minty i klucze w Twoim portfelu są zaszyfrowane dla Ciebie, więc nie można ich tutaj porównać pozycja po pozycji. Przywrócenie danych powoduje przywrócenie dokładnie tego, co zostało zapisane na tym urządzeniu. - Twoje społeczności w Concord są dla Ciebie zaszyfrowane, więc nie można ich tutaj porównać. Przywrócenie danych powoduje przywrócenie dokładnie tego, co zostało zapisane na tym urządzeniu. - - Włączyłeś dźwięk dla %1$d elementu - Włączyłeś dźwięk dla %1$d elementów - Włączyłeś dźwięk dla %1$d elementów - %1$d elementy mają włączony dźwięk - - - Usunąłeś %1$d pole profilu - Usunąłeś %1$d pól profilowych - Usunąłeś %1$d pól profilowych - Usunąłeś %1$d pola profilowe - - - Usunąłeś %1$d publiczny transmiter - Usunąłeś %1$d publicznych transmiterów - Usunąłeś %1$d publicznych transmiterów - Usunąłeś %1$d publiczne transmitery - - - Usunąłeś %1$d transmiter DM - Usunąłeś %1$d transmiterów DM - Usunąłeś %1$d transmiterów DM - Usunąłeś %1$d transmitery DM - - - Usunięto %1$d transmiter klucza grupowego czatu - Usunięto %1$d transmiterów klucza grupowego czatu - Usunięto %1$d transmiterów klucza grupowego czatu - Usunięto %1$d transmitery klucza grupowego czatu - - - Usunięto %1$d transmiter wyszukiwania - Usunięto %1$d transmiterów wyszukiwania - Usunięto %1$d transmiterów wyszukiwania - Usunięto %1$d transmitery wyszukiwania - - - Usunięto %1$d transmiter indeksujący - Usunięto %1$d transmiterów indeksujących - Usunięto %1$d transmiterów indeksujących - Usunięto %1$d transmitery indeksujące - - - Usunięto %1$d prywatny transmiter - Usunięto %1$d prywatnych transmiterów - Usunięto %1$d prywatnych transmiterów - Usunięto %1$d prywatne transmitery - - Użyj nowej wersji - tylko do odczytu - tylko do zapisu - odczyt i zapis - profil - Twoje imię i nazwisko, zdjęcie, biografia oraz adresy do płatności – tak, jak widzą je wszyscy. - lista obserwowanych - Określa, czyje posty pojawiają się w Twoim kanale głównym. - lista zablokowanych - Ukrywa osoby, słowa, hashtagi i wątki we wszystkich wyświetlanych treściach. - lista publicznych transmiterów - Informuje wszystkich, gdzie mogą znaleźć Twoje posty oraz gdzie mają wysyłać Ci odpowiedzi i wzmianki. - Lista transmiterów DM - Gdzie inni dostarczają Twoje prywatne wiadomości. - transmitery kluczy czatu grupowego - Gdzie inni znajdą klucze do dodawania cię do zaszyfrowanych czatów grupowych. - lista transmiterów wyszukiwania - Transmitery używane podczas wyszukiwania. - lista transmiterów indeksujących - Transmitery służące do wyszukiwania profili innych osób oraz list transmiterów. - ulubione kanały transmitera - Transmitery, które przeglądasz jako kanały informacyjne. - lista zablokowanych przekaźników - obserwowane lokalizacje - Społeczności Concord, do których dołączyłeś. Są one zaszyfrowane, więc można porównać jedynie fakt ich istnienia. - lista zaufanych dostawców - Serwisy, którym ufasz, a które zajmują się klasyfikowaniem i weryfikacją osób w Twoim imieniu. - Portfel Cashu - Ustawienia portfela ecash oraz minty. Są one zaszyfrowane, więc można porównać jedynie sam fakt ich istnienia. - ustawienia odbioru nutzap - Minty i klucz, które inni używają do wysyłania ci „nutzapów”. - adresy płatności - Sposoby, w jakie inni mogą Ci zapłacić. - Oferta BOLT12 - Ta możliwość pozwala innym płacić Ci za pomocą Lightning. - lista - Lista zapisana na tym urządzeniu. - Pola profilu - Osoby - Hashtagi - Słowa - Wątki - Czaty publiczne - Społeczności - Kanały - Lokalizacje - Transmitery - Grupy - Czat pokoje - Zaufani dostawcy - Minty - Klucze Nutzap - Adresy płatności - Oferty - Powiązane tożsamości - Inne wpisy - Nazwa użytkownika - Nazwa wyświetlana - O profilu - Zdjęcie - Baner - Strona www - Adres Nostr - Lightning Adres - LNURL - Zaimki - Data urodzin - Oferta CLINK - Flaga bota diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index 092f8f83fb..53f568feea 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -253,7 +253,6 @@ - Health Connect e Amethyst @@ -318,6 +317,10 @@ Post de voz Resposta em voz Denúncia + + Health Connect e Amethyst + Mensagem Privada Mensagem de chat Nest — Ao vivo diff --git a/amethyst/src/main/res/values-pt-rPT/strings.xml b/amethyst/src/main/res/values-pt-rPT/strings.xml index 71c1d7e8dd..c28aabf159 100644 --- a/amethyst/src/main/res/values-pt-rPT/strings.xml +++ b/amethyst/src/main/res/values-pt-rPT/strings.xml @@ -178,6 +178,9 @@ Salvar Post de voz Resposta em voz + + Mensagem Privada Nest — Ao vivo Salas de áudio diff --git a/amethyst/src/main/res/values-ru-rRU/strings.xml b/amethyst/src/main/res/values-ru-rRU/strings.xml index 462a62d3e3..691c4e379d 100644 --- a/amethyst/src/main/res/values-ru-rRU/strings.xml +++ b/amethyst/src/main/res/values-ru-rRU/strings.xml @@ -184,6 +184,9 @@ Отправить Голосовая публикация Голосовой ответ + + Личное сообщение Nest — в эфире Позволяет воспроизводить звук, пока комната открыта. diff --git a/amethyst/src/main/res/values-ru-rUA/strings.xml b/amethyst/src/main/res/values-ru-rUA/strings.xml index 830c24a913..176e8f540d 100644 --- a/amethyst/src/main/res/values-ru-rUA/strings.xml +++ b/amethyst/src/main/res/values-ru-rUA/strings.xml @@ -184,6 +184,9 @@ Опубликовать Голосовая публикация Голосовой ответ + + Личное сообщение Nest — в эфире Позволяет воспроизводить звук, пока комната открыта. diff --git a/amethyst/src/main/res/values-sl-rSI/strings.xml b/amethyst/src/main/res/values-sl-rSI/strings.xml index 2c7c6a1e10..83a6e963c6 100644 --- a/amethyst/src/main/res/values-sl-rSI/strings.xml +++ b/amethyst/src/main/res/values-sl-rSI/strings.xml @@ -343,6 +343,9 @@ Glasovna objava Zvočni odgovor Prijavi + + Zasebno sporočilo Sporočilo klepeta Gnezdo — V živo diff --git a/amethyst/src/main/res/values-sr-rSP/strings.xml b/amethyst/src/main/res/values-sr-rSP/strings.xml index 9defea95a7..a9d8351884 100644 --- a/amethyst/src/main/res/values-sr-rSP/strings.xml +++ b/amethyst/src/main/res/values-sr-rSP/strings.xml @@ -178,6 +178,9 @@ Objavi Glasovna objava Glasovni odgovor + + Privatna poruka Nest — Uživo Održava zvuk dok je soba otvorena. diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index 4ab4d25f04..310c1657a6 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -253,7 +253,6 @@ - Health Connect och Amethyst @@ -318,6 +317,10 @@ Röstinlägg Röstsvar Rapport + + Health Connect och Amethyst + Privat meddelande Chattmeddelande Nest — Live diff --git a/amethyst/src/main/res/values-sw/strings.xml b/amethyst/src/main/res/values-sw/strings.xml index 8cd7aaab5b..52ffcc2916 100644 --- a/amethyst/src/main/res/values-sw/strings.xml +++ b/amethyst/src/main/res/values-sw/strings.xml @@ -180,6 +180,9 @@ Tuma Chapisho la Sauti Jibu la Sauti + + Ujumbe wa Siri Nest — Moja kwa Moja Inaweka sauti ikicheza wakati chumba kiko wazi. diff --git a/amethyst/src/main/res/values-ta-rIN/strings.xml b/amethyst/src/main/res/values-ta-rIN/strings.xml index bff707a4e7..0e54123858 100644 --- a/amethyst/src/main/res/values-ta-rIN/strings.xml +++ b/amethyst/src/main/res/values-ta-rIN/strings.xml @@ -179,6 +179,9 @@ பதிவு குரல் இடுகை குரல் பதில் + + தனிப்பட்ட செய்தி Nest — நேரலை அறை திறந்திருக்கும்போது ஆடியோவை இயக்கிக் கொண்டே இருக்கும். diff --git a/amethyst/src/main/res/values-th-rTH/strings.xml b/amethyst/src/main/res/values-th-rTH/strings.xml index c1d9e8b17c..2df8b31a6a 100644 --- a/amethyst/src/main/res/values-th-rTH/strings.xml +++ b/amethyst/src/main/res/values-th-rTH/strings.xml @@ -176,6 +176,9 @@ โพสต์ โพสต์เสียง ตอบกลับด้วยเสียง + + ข้อความส่วนตัว เปิดเสียงต่อเนื่องขณะที่ห้องเปิดอยู่ Nest เชื่อมต่อแล้ว diff --git a/amethyst/src/main/res/values-tr-rTR/strings.xml b/amethyst/src/main/res/values-tr-rTR/strings.xml index 7ae35c695c..36fbfb5667 100644 --- a/amethyst/src/main/res/values-tr-rTR/strings.xml +++ b/amethyst/src/main/res/values-tr-rTR/strings.xml @@ -179,6 +179,9 @@ Gönder Sesli Gönderi Sesli Yanıt + + Özel Mesaj Nest — Canlı Oda açıkken ses çalmayı sürdürür. diff --git a/amethyst/src/main/res/values-uk-rUA/strings.xml b/amethyst/src/main/res/values-uk-rUA/strings.xml index c74234bc09..3f0bb69e83 100644 --- a/amethyst/src/main/res/values-uk-rUA/strings.xml +++ b/amethyst/src/main/res/values-uk-rUA/strings.xml @@ -183,6 +183,9 @@ Надіслати Голосова публікація Голосова відповідь + + Приватне повідомлення Підтримує відтворення аудіо, поки кімната відкрита. Nest підключено diff --git a/amethyst/src/main/res/values-uz-rUZ/strings.xml b/amethyst/src/main/res/values-uz-rUZ/strings.xml index 0463975c69..39ace7865c 100644 --- a/amethyst/src/main/res/values-uz-rUZ/strings.xml +++ b/amethyst/src/main/res/values-uz-rUZ/strings.xml @@ -178,6 +178,9 @@ Targʻib qilish Ovozli post Ovozli javob + + Shaxsiy Xabar Nest — Jonli Nestlar diff --git a/amethyst/src/main/res/values-vi-rVN/strings.xml b/amethyst/src/main/res/values-vi-rVN/strings.xml index c1b5db0f18..4a16febf21 100644 --- a/amethyst/src/main/res/values-vi-rVN/strings.xml +++ b/amethyst/src/main/res/values-vi-rVN/strings.xml @@ -175,6 +175,9 @@ Đăng Bài đăng giọng nói Trả lời giọng nói + + Tin nhắn Riêng tư Nest — Trực tiếp Giữ âm thanh phát trong khi phòng đang mở. diff --git a/amethyst/src/main/res/values-zh-rCN/strings.xml b/amethyst/src/main/res/values-zh-rCN/strings.xml index cb08c9f72a..4dd37a0178 100644 --- a/amethyst/src/main/res/values-zh-rCN/strings.xml +++ b/amethyst/src/main/res/values-zh-rCN/strings.xml @@ -268,6 +268,9 @@ 语音帖子 语音回复 举报 + + 私信 聊天消息 语音聊天室 — 在线 diff --git a/amethyst/src/main/res/values-zh-rHK/strings.xml b/amethyst/src/main/res/values-zh-rHK/strings.xml index 0d1af2b360..f93106d584 100644 --- a/amethyst/src/main/res/values-zh-rHK/strings.xml +++ b/amethyst/src/main/res/values-zh-rHK/strings.xml @@ -178,6 +178,9 @@ 發佈 语音帖子 语音回复 + + 私信 语音聊天室 — 在线 语音聊天房 diff --git a/amethyst/src/main/res/values-zh-rSG/strings.xml b/amethyst/src/main/res/values-zh-rSG/strings.xml index 7fbf0a0fab..fe11337977 100644 --- a/amethyst/src/main/res/values-zh-rSG/strings.xml +++ b/amethyst/src/main/res/values-zh-rSG/strings.xml @@ -178,6 +178,9 @@ 帖子 语音帖子 语音回复 + + 私信 语音聊天室 — 在线 语音聊天房 diff --git a/amethyst/src/main/res/values-zh-rTW/strings.xml b/amethyst/src/main/res/values-zh-rTW/strings.xml index a37a58559f..0b5fd82af5 100644 --- a/amethyst/src/main/res/values-zh-rTW/strings.xml +++ b/amethyst/src/main/res/values-zh-rTW/strings.xml @@ -177,6 +177,9 @@ 發佈 語音貼文 語音回覆 + + 私人訊息 Nest — 直播中 在房間開啟期間保持音訊播放。 diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 1219814557..3803e00f8a 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -568,8 +568,6 @@ - Amy Debug - Amy Benchmark @@ -582,7 +580,6 @@ - Health Connect and Amethyst @@ -650,6 +647,14 @@ Voice Post Voice Reply Report + + + Amy Debug + Amy Benchmark + Health Connect and Amethyst + + Private Message Chat message Nest — Live @@ -678,322 +683,4 @@ Sound on. Click to mute Play Pause - You changed your %1$s in another app - Removed (%1$s) - Added (%1$s) - Changed (%1$s) - You removed all private (encrypted) items. - You also changed private (encrypted) items. They can\'t be compared here. - You added private (encrypted) items. - Restore saved version - On this device - New version - Not set - %1$s removed - %1$s added - %1$s changed - - You unfollowed %1$d person - You unfollowed %1$d people - - Accept - %1$s updated - %1$s cleared - Changed in another app · %1$s - Keep %1$s - Restore %1$s - Re-mute %1$s - Rejoin %1$s - Restore profile - Restore relays - %1$s kept - %1$s dropped - %1$s new - Dropped · %1$s - New · %1$s - Edited · %1$s - Search %1$s people - Nothing here - %1$s unmuted - These can reach your feeds again - %1$s newly muted - Words & hashtags - %1$s and %2$s more - You removed all private items - They\'re encrypted, so only you can see what they were. Restoring brings them all back. - You made items private - They\'re encrypted now, which is likely where the public items above went. - You changed private items - They\'re encrypted, so they can\'t be compared here. - Gone - New - Dropped - Moved - Units - Left - Renamed - Outbox - where you publish - Inbox - where others reach you - Receiving key replaced - Receiving key removed - Nutzaps will be locked to this new key. If this device doesn\'t have it, you may not be able to redeem them here. - left - joined - renamed - was “%1$s” - Block %1$s again - Restore feeds - Restore topics - Restore places - Restore providers - Restore wallet - kept - Receiving - Listed - Searching - Storing - Trusted - Untrusted - %1$s unblocked - Amethyst will connect to them again - No longer blocked - Newly blocked - Still blocked - Allowed - Moved - Feed - topics gone - places gone - new - Hashtags you follow - Places you follow - %1$s of %2$s services changed - They now come from someone else, or no one - Service - Rank - Followers - Topics - Ranks, follower counts and spam filtering you see come from these providers. - - %1$d way to pay you removed - %1$d ways to pay you removed - - - %1$d offer removed - %1$d offers removed - - People paying you only see what\'s in this list. - BOLT12 offer - You emptied it - You created it - You changed it - Your wallet\'s mints and keys are encrypted to you, so they can\'t be compared item by item here. Restoring puts back exactly what this device saved. - Your Concord communities are encrypted to you, so they can\'t be compared here. Restoring puts back exactly what this device saved. - - You unmuted %1$d thing - You unmuted %1$d things - - - You removed %1$d profile field - You removed %1$d profile fields - - - You removed %1$d public relay - You removed %1$d public relays - - - You removed %1$d DM relay - You removed %1$d DM relays - - - You removed %1$d group-chat key relay - You removed %1$d group-chat key relays - - - You removed %1$d search relay - You removed %1$d search relays - - - You removed %1$d indexer relay - You removed %1$d indexer relays - - - You removed %1$d relay feed - You removed %1$d relay feeds - - - You removed %1$d private relay - You removed %1$d private relays - - - You stopped trusting %1$d relay - You stopped trusting %1$d relays - - - You unblocked %1$d relay - You unblocked %1$d relays - - - You left %1$d public chat - You left %1$d public chats - - - You left %1$d community - You left %1$d communities - - - You left %1$d chat room - You left %1$d chat rooms - - - You left %1$d group - You left %1$d groups - - - You removed %1$d favorite feed - You removed %1$d favorite feeds - - - You unfollowed %1$d hashtag - You unfollowed %1$d hashtags - - - You unfollowed %1$d place - You unfollowed %1$d places - - - You changed the provider of %1$d trust service - You changed the provider of %1$d trust services - - - You removed %1$d nutzap mint - You removed %1$d nutzap mints - - - You removed %1$d way to get paid - You removed %1$d ways to get paid - - - You removed %1$d BOLT12 offer - You removed %1$d BOLT12 offers - - - %1$s and added %2$d - %1$s and added %2$d - - - %1$s and followed %2$d - %1$s and followed %2$d - - - %1$s and muted %2$d - %1$s and muted %2$d - - - %1$s and joined %2$d - %1$s and joined %2$d - - - %1$s and blocked %2$d - %1$s and blocked %2$d - - Your Amethyst synced app settings changed in another app that is not Amethyst. This can create problems. - You replaced your nutzap key - You removed your nutzap key - You emptied your Cashu wallet - You changed your Cashu wallet - You emptied your Concord list - You changed your Concord list - Use new version - read only - write only - read and write - profile - Your name, picture, bio and payment addresses, as everyone sees them. - follow list - Decides whose posts appear in your home feed. - mute list - Hides people, words, hashtags and threads from everything you see. - public relay list - Tells everyone where to find your posts and where to send you replies and mentions. - DM relay list - Where others deliver your private messages. - group-chat key relays - Where others find the keys to add you to encrypted group chats. - search relay list - The relays used when you search. - indexer relay list - The relays used to find other people\'s profiles and relay lists. - favorite relay feeds - The relays you browse as feeds. - blocked relay list - Relays this app never connects to. - trusted relay list - Relays you trust to connect to without asking. - private relay list - Where your drafts and private data are stored. - synced app settings - Amethyst settings synced across your devices. They are encrypted, so only their presence can be compared. - public chat list - The public chats you joined. - community list - The communities you joined. - followed hashtags - Hashtags whose posts appear in your feeds. - followed locations - Locations whose posts appear in your feeds. - favorite feeds list - The algorithmic feeds you saved. - ephemeral chat list - The ephemeral chat rooms you joined. - group list - The relay-based groups you joined. - Concord community list - The Concord communities you joined. They are encrypted, so only their presence can be compared. - trust provider list - The services you trust to rank and verify people for you. - Cashu wallet - Your ecash wallet settings and mints. They are encrypted, so only their presence can be compared. - nutzap receiving settings - The mints and key others use to send you nutzaps. - payment addresses - The ways other people can pay you. - BOLT12 offers - The lightning offers others use to pay you. - list - A list saved on this device. - Profile fields - People - Hashtags - Words - Threads - Public chats - Communities - Feeds - Locations - Relays - Groups - Chat rooms - Trust providers - Mints - Nutzap keys - Payment addresses - Offers - Linked identities - Other entries - Username - Display name - About - Picture - Banner - Website - Nostr address - Lightning address - LNURL - Pronouns - Birthday - CLINK offer - Bot flag diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt index a0dd8d1bf9..c865137a76 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt @@ -54,14 +54,15 @@ import com.vitorpamplona.quartz.nip17Dm.base.BaseDMGroupEvent import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent import com.vitorpamplona.quartz.nip19Bech32.decodePublicKey import com.vitorpamplona.quartz.nip19Bech32.entities.NPub -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorResponse import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceErrorResponse import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceSuccessResponse import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import com.vitorpamplona.quartz.nip55AndroidSigner.client.NostrSignerExternal -import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapRequestEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent import com.vitorpamplona.quartz.utils.TimeUtils @@ -778,7 +779,7 @@ class AmethystAppFunctions { val filter = Filter( - kinds = listOf(LnZapEvent.KIND, Bolt12ZapEvent.KIND), + kinds = listOf(ZapReceiptEvent.KIND, Bolt12ZapEvent.KIND), tags = mapOf("p" to listOf(myPub)), since = sinceSecs, limit = 500, @@ -789,7 +790,7 @@ class AmethystAppFunctions { idleTimeoutMs = GEMINI_FETCH_TIMEOUT_MS, ) - val receipts = events.mapNotNull { it as? LnZapEvent } + val receipts = events.mapNotNull { it as? ZapReceiptEvent } val zapperIds = mutableSetOf() var totalSats = 0L var unparseable = 0 @@ -936,7 +937,7 @@ class AmethystAppFunctions { val filter = SearchActions.searchNotesFilter( query = query, - kinds = listOf(LongTextNoteEvent.KIND), + kinds = listOf(LongFormContentEvent.KIND), limit = cappedLimit, ) ?: return SearchNotesResult.empty() @@ -953,7 +954,7 @@ class AmethystAppFunctions { val hits = events - .mapNotNull { it as? LongTextNoteEvent } + .mapNotNull { it as? LongFormContentEvent } .take(cappedLimit) .map { (it as com.vitorpamplona.quartz.nip01Core.core.Event).toFeedNoteHit() } @@ -1403,7 +1404,7 @@ class AmethystAppFunctions { amountMillisats = ZapActions.satsToMillisats(sats), inboxRelays = account.nip65RelayList.inboxFlow.value, comment = comment, - zapType = LnZapEvent.ZapType.PUBLIC, + zapType = ZapReceiptEvent.ZapType.PUBLIC, ) val invoice = fetchInvoiceOrThrow(lnAddress, sats, comment, zapRequest) @@ -1614,7 +1615,7 @@ class AmethystAppFunctions { lookupLnAddress = lookupLnAddress, lookupInboxRelays = lookupInboxRelays, comment = trimmedComment, - zapType = LnZapEvent.ZapType.PUBLIC, + zapType = ZapReceiptEvent.ZapType.PUBLIC, ) if (requests.isEmpty()) { throw AppFunctionInvalidArgumentException( @@ -1787,7 +1788,7 @@ class AmethystAppFunctions { lnAddress: String, sats: Long, comment: String, - zapRequest: com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent, + zapRequest: ZapRequestEvent, ): String { // Compute the LNURL-pay endpoint so we can ask the privacy-aware // HttpClient builder for the right OkHttpClient for that host. diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/AccountKeyStoreTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/AccountKeyStoreTest.kt new file mode 100644 index 0000000000..00ccff8987 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/AccountKeyStoreTest.kt @@ -0,0 +1,216 @@ +/* + * 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 + +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The read/write decisions that move account keys off the deprecated store. + * + * Every branch here can cost someone their account: reading a working store as + * empty demotes a signing account to read-only, and clearing a key that was + * only temporarily unreadable destroys it. The AndroidKeyStore itself cannot be + * reached from a unit test, so [PrivateKeyVault] is faked and the logic above + * it is what gets exercised. + */ +class AccountKeyStoreTest { + private val npub = "npub1xxxx" + private val key = "e5e2b1d3f6a94c8d7b0e1f2a3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d" + + private class FakeVault( + var stored: MutableMap = mutableMapOf(), + var failReads: Boolean = false, + var failWrites: Boolean = false, + ) : PrivateKeyVault { + var saves = 0 + var deletes = 0 + + override suspend fun get(npub: String): String? { + if (failReads) throw IllegalStateException("store unreadable") + return stored[npub] + } + + override suspend fun save( + npub: String, + privKeyHex: String, + ) { + saves++ + if (failWrites) throw IllegalStateException("store unwritable") + stored[npub] = privKeyHex + } + + override suspend fun delete(npub: String) { + deletes++ + if (failWrites) throw IllegalStateException("store unwritable") + stored.remove(npub) + } + } + + // ── reads ───────────────────────────────────────────────────────── + + /** First load after the upgrade: the key is only in the legacy store, and moves across. */ + @Test + fun aLegacyOnlyKeyIsReturnedAndMigrated() = + runTest { + val vault = FakeVault() + + val read = AccountKeyStore(vault).read(npub, legacyValue = key) + + assertEquals(key, read) + assertEquals("migrated into the new store", key, vault.stored[npub]) + } + + @Test + fun anAlreadyMigratedKeyIsReadFromTheNewStore() = + runTest { + val vault = FakeVault(mutableMapOf(npub to key)) + + assertEquals(key, AccountKeyStore(vault).read(npub, legacyValue = key)) + assertEquals("already there, so not rewritten", 0, vault.saves) + } + + /** + * The dangerous one. A store that cannot be read must not look like an + * account with no key — that would silently turn a signing account into a + * read-only one. + */ + @Test + fun anUnreadableStoreFallsBackToLegacyRatherThanReportingNoKey() = + runTest { + val vault = FakeVault(failReads = true) + + assertEquals(key, AccountKeyStore(vault).read(npub, legacyValue = key)) + } + + /** And it must not try to migrate into a store that just failed. */ + @Test + fun anUnreadableStoreIsNotWrittenTo() = + runTest { + val vault = FakeVault(failReads = true) + + AccountKeyStore(vault).read(npub, legacyValue = key) + + assertEquals(0, vault.saves) + } + + /** An account genuinely without a key — external signer, or watch-only. */ + @Test + fun noKeyAnywhereReadsAsNull() = + runTest { + val vault = FakeVault() + + assertNull(AccountKeyStore(vault).read(npub, legacyValue = null)) + assertEquals("nothing to migrate", 0, vault.saves) + } + + /** A key added after the upgrade exists only in the new store. */ + @Test + fun aNewStoreOnlyKeyIsReturned() = + runTest { + val vault = FakeVault(mutableMapOf(npub to key)) + + assertEquals(key, AccountKeyStore(vault).read(npub, legacyValue = null)) + } + + /** + * An npub is derived from its key, so the two stores disagreeing means + * corruption. The older, proven store wins. + */ + @Test + fun aMismatchPrefersTheLegacyValue() = + runTest { + val vault = FakeVault(mutableMapOf(npub to "deadbeef")) + + assertEquals(key, AccountKeyStore(vault).read(npub, legacyValue = key)) + } + + /** A failed migration must not fail the account load; the legacy store still has it. */ + @Test + fun aFailedMigrationStillReturnsTheKey() = + runTest { + val vault = FakeVault(failWrites = true) + + assertEquals(key, AccountKeyStore(vault).read(npub, legacyValue = key)) + } + + // ── writes ──────────────────────────────────────────────────────── + + @Test + fun aSaveMirrorsTheKey() = + runTest { + val vault = FakeVault() + + AccountKeyStore(vault).mirrorSave(npub, usesExternalSigner = false, privKeyHex = key) + + assertEquals(key, vault.stored[npub]) + } + + @Test + fun anExternalSignerAccountClearsTheKey() = + runTest { + val vault = FakeVault(mutableMapOf(npub to key)) + + AccountKeyStore(vault).mirrorSave(npub, usesExternalSigner = true, privKeyHex = null) + + assertTrue(vault.stored.isEmpty()) + } + + /** + * The case that is easy to get wrong. With no external signer and no key in + * hand, the legacy store leaves the stored key alone — so this must too. + * Deleting here would drop the key on every save from a session that never + * decrypted it. + */ + @Test + fun aSaveWithoutAKeyInHandLeavesTheStoredKeyAlone() = + runTest { + val vault = FakeVault(mutableMapOf(npub to key)) + + AccountKeyStore(vault).mirrorSave(npub, usesExternalSigner = false, privKeyHex = null) + + assertEquals(key, vault.stored[npub]) + assertEquals(0, vault.deletes) + assertEquals(0, vault.saves) + } + + /** A write failure must never fail the save: the legacy store is still written. */ + @Test + fun aWriteFailureIsSwallowed() = + runTest { + val vault = FakeVault(failWrites = true) + + AccountKeyStore(vault).mirrorSave(npub, usesExternalSigner = false, privKeyHex = key) + } + + @Test + fun deleteRemovesFromTheNewStore() = + runTest { + val vault = FakeVault(mutableMapOf(npub to key)) + + AccountKeyStore(vault).delete(npub) + + assertTrue(vault.stored.isEmpty()) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/AccountRosterTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/AccountRosterTest.kt new file mode 100644 index 0000000000..94f5cb65b5 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/AccountRosterTest.kt @@ -0,0 +1,209 @@ +/* + * 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 + +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The account index. + * + * Every branch here decides whether the app opens to the user's accounts or to + * an empty screen. A read that comes back empty is indistinguishable from a + * store that has not been populated, and only one of those is safe to act on — + * so the legacy file wins every tie. + */ +class AccountRosterTest { + private val accountsJson = """[{"npub":"npub1a","hasPrivKey":true}]""" + + private class FakeStorage( + var migrated: Boolean = false, + var current: String? = null, + var allJson: String? = null, + var failReads: Boolean = false, + var failWrites: Boolean = false, + ) : RosterStorage { + var cleared = 0 + + override suspend fun hasMigrated(): Boolean { + if (failReads) throw IllegalStateException("unreadable") + return migrated + } + + override suspend fun markMigrated() { + if (failWrites) throw IllegalStateException("unwritable") + migrated = true + } + + override suspend fun currentAccount(): String? { + if (failReads) throw IllegalStateException("unreadable") + return current + } + + override suspend fun setCurrentAccount(npub: String?) { + if (failWrites) throw IllegalStateException("unwritable") + current = npub + } + + override suspend fun allAccountInfoJson(): String? { + if (failReads) throw IllegalStateException("unreadable") + return allJson + } + + override suspend fun setAllAccountInfoJson(json: String?) { + if (failWrites) throw IllegalStateException("unwritable") + allJson = json + } + + override suspend fun clear() { + cleared++ + migrated = false + current = null + allJson = null + } + } + + private fun legacy( + current: String? = "npub1a", + all: String? = accountsJson, + ) = Pair<() -> String?, () -> String?>({ current }, { all }) + + // ── first run after the upgrade ─────────────────────────────────── + + @Test + fun theLegacyRosterIsCopiedOnFirstRead() = + runTest { + val store = FakeStorage() + val (c, a) = legacy() + + assertEquals("npub1a", AccountRoster(store).currentAccount(c, a)) + + assertTrue(store.migrated) + assertEquals("npub1a", store.current) + assertEquals(accountsJson, store.allJson) + } + + @Test + fun anAlreadyMigratedRosterIsReadFromTheNewStore() = + runTest { + val store = FakeStorage(migrated = true, current = "npub1z", allJson = """[{"npub":"npub1z"}]""") + val (c, a) = legacy() + + assertEquals("npub1z", AccountRoster(store).currentAccount(c, a)) + assertEquals("""[{"npub":"npub1z"}]""", AccountRoster(store).allAccountInfoJson(c, a)) + } + + // ── the failure modes that empty the account list ───────────────── + + /** An unreadable store must never present as "no accounts". */ + @Test + fun anUnreadableStoreFallsBackToLegacy() = + runTest { + val store = FakeStorage(failReads = true) + val (c, a) = legacy() + + assertEquals("npub1a", AccountRoster(store).currentAccount(c, a)) + assertEquals(accountsJson, AccountRoster(store).allAccountInfoJson(c, a)) + } + + /** Nor must a migration that could not be written. */ + @Test + fun aFailedMigrationFallsBackToLegacy() = + runTest { + val store = FakeStorage(failWrites = true) + val (c, a) = legacy() + + assertEquals("npub1a", AccountRoster(store).currentAccount(c, a)) + assertEquals(accountsJson, AccountRoster(store).allAccountInfoJson(c, a)) + } + + /** + * A migrated-but-empty list is indistinguishable from one that was never + * populated, so it falls through rather than being taken as truth. + */ + @Test + fun anEmptyAccountListFallsBackToLegacy() = + runTest { + val (c, a) = legacy() + + assertEquals(accountsJson, AccountRoster(FakeStorage(migrated = true, allJson = "[]")).allAccountInfoJson(c, a)) + assertEquals(accountsJson, AccountRoster(FakeStorage(migrated = true, allJson = "")).allAccountInfoJson(c, a)) + assertEquals(accountsJson, AccountRoster(FakeStorage(migrated = true, allJson = null)).allAccountInfoJson(c, a)) + } + + @Test + fun anAbsentCurrentAccountFallsBackToLegacy() = + runTest { + val (c, a) = legacy() + + assertEquals("npub1a", AccountRoster(FakeStorage(migrated = true, current = null)).currentAccount(c, a)) + } + + /** A genuinely fresh install has nothing anywhere, and must not invent an account. */ + @Test + fun aFreshInstallReadsAsNothing() = + runTest { + val (c, a) = legacy(current = null, all = null) + + assertNull(AccountRoster(FakeStorage()).currentAccount(c, a)) + assertNull(AccountRoster(FakeStorage()).allAccountInfoJson(c, a)) + } + + // ── writes ──────────────────────────────────────────────────────── + + @Test + fun mirroredWritesReachTheStore() = + runTest { + val store = FakeStorage() + val subject = AccountRoster(store) + + subject.mirrorCurrentAccount("npub1b") + subject.mirrorAllAccountInfoJson(accountsJson) + + assertEquals("npub1b", store.current) + assertEquals(accountsJson, store.allJson) + } + + @Test + fun clearingWipesTheRoster() = + runTest { + val store = FakeStorage(migrated = true, current = "npub1a", allJson = accountsJson) + + AccountRoster(store).clear() + + assertEquals(1, store.cleared) + assertNull(store.current) + assertNull(store.allJson) + } + + /** A write failure must never fail the save: the legacy file is still written. */ + @Test + fun aWriteFailureIsSwallowed() = + runTest { + val subject = AccountRoster(FakeStorage(failWrites = true)) + + subject.mirrorCurrentAccount("npub1b") + subject.mirrorAllAccountInfoJson(accountsJson) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/CordnCoordinatorNameTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/CordnCoordinatorNameTest.kt new file mode 100644 index 0000000000..339fa80822 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/CordnCoordinatorNameTest.kt @@ -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 + +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.cordnGroup.disambiguate +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Telling two coordinators apart on the discovery list. + * + * An announced name is the coordinator's own word for itself and collides + * constantly: the reference server ships as "My coordinator", so a run against + * a public relay comes back with a dozen rows carrying that name and nothing + * else to separate them. + */ +class CordnCoordinatorNameTest { + @Test + fun `a name nobody else uses is left alone`() { + val names = listOf("cordn-net", "My coordinator") + + assertEquals("cordn-net", disambiguate("cordn-net", "aabbccdd11223344", names)) + } + + @Test + fun `a colliding name gains its key`() { + val names = listOf("My coordinator", "My coordinator", "cordn-net") + + assertEquals("My coordinator · aabbccdd", disambiguate("My coordinator", "aabbccdd11223344", names)) + } + + @Test + fun `two that collide get different suffixes`() { + val names = listOf("My coordinator", "My coordinator") + + val first = disambiguate("My coordinator", "aaaaaaaa11112222", names) + val second = disambiguate("My coordinator", "bbbbbbbb33334444", names) + + assertEquals("My coordinator · aaaaaaaa", first) + assertEquals("My coordinator · bbbbbbbb", second) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/LegacyKeyCoverageTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/LegacyKeyCoverageTest.kt new file mode 100644 index 0000000000..e218c8be32 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/LegacyKeyCoverageTest.kt @@ -0,0 +1,117 @@ +/* + * 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 + +import com.vitorpamplona.amethyst.commons.model.preferences.LegacyAccountSecretNames +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.lang.reflect.Modifier + +/** + * Every key the app has ever written to a `secret_keeper` file has to be + * accounted for somewhere, and this is what says so. + * + * [LegacyPreferenceCleanup] refuses to delete a file holding a key it does not + * recognise, which is the right runtime behaviour but a slow way to find out. + * A key added to `PrefKeys` and to neither a migration table nor the accepted + * list fails here instead — at the commit that adds it, naming it. + */ +class LegacyKeyCoverageTest { + /** Read off the object rather than restated, so the test cannot go stale. */ + private val allPrefKeys: Set = + PrefKeys::class.java.declaredFields + .filter { Modifier.isStatic(it.modifiers) && it.type == String::class.java } + .map { + it.isAccessible = true + it.get(null) as String + }.toSet() + + /** + * Keys of the *global* `secret_keeper` file, which has no per-account + * counterpart and is not what the cleanup deletes. + * `notification_service_enabled` is not even in it — it lives in a plain + * file, deliberately, because it is read synchronously in fresh processes. + */ + private val globalFileKeys = + setOf( + PrefKeys.CURRENT_ACCOUNT, + PrefKeys.SAVED_ACCOUNTS, + PrefKeys.ALL_ACCOUNT_INFO, + PrefKeys.SHARED_SETTINGS, + PrefKeys.NOTIFICATION_SERVICE_ENABLED, + ) + + @Test + fun thePrefKeysListWasActuallyRead() { + assertTrue(allPrefKeys.size.toString(), allPrefKeys.size > 100) + assertTrue(PrefKeys.NOSTR_PUBKEY in allPrefKeys) + } + + @Test + fun everyLegacyKeyIsEitherMigratedOrDeliberatelyDropped() { + val migrated = LegacyAccountKeys.tables.flatMapTo(mutableSetOf()) { it.legacyNames } + val classified = migrated + LegacyAccountSecretNames.all + LegacyAccountKeys.accepted + globalFileKeys + + assertEquals( + "Unclassified legacy keys. Add each to a migration table, or to LegacyAccountKeys.accepted if losing it is deliberate.", + emptySet(), + allPrefKeys - classified, + ) + } + + /** + * The reverse direction: a table claiming a key `PrefKeys` no longer has + * means the copy is reading a name nothing writes. + */ + @Test + fun noTableClaimsAKeyThatNoLongerExists() { + val migrated = LegacyAccountKeys.tables.flatMapTo(mutableSetOf()) { it.legacyNames } + + assertEquals(emptySet(), migrated - allPrefKeys) + assertEquals(emptySet(), LegacyAccountSecretNames.all - allPrefKeys) + assertEquals(emptySet(), LegacyAccountKeys.accepted - allPrefKeys) + } + + /** + * The seven that were still read only from the legacy file. Named + * individually because `nostr_pubkey` is the one whose loss empties the + * app: without it the loader returns null and the account disappears, with + * its private key sitting safe and unreachable in the key store. + */ + @Test + fun theLastSevenKeysAreMigrated() { + val migrated = LegacyAccountKeys.tables.flatMapTo(mutableSetOf()) { it.legacyNames } + + listOf( + PrefKeys.NOSTR_PUBKEY, + PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, + PrefKeys.SIGNER_PACKAGE_NAME, + PrefKeys.HAS_BACKED_UP_KEYS, + PrefKeys.LOCAL_RELAY_SERVERS, + PrefKeys.OPEN_BACKUP_CONFLICTS, + ).forEach { assertTrue(it, it in migrated) } + + // The seventh is global, and moved into the UI settings DataStore + // rather than a per-account one. + assertTrue(PrefKeys.SHARED_SETTINGS in globalFileKeys) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/LegacyPreferenceCleanupTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/LegacyPreferenceCleanupTest.kt new file mode 100644 index 0000000000..ddb6ca0aa0 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/LegacyPreferenceCleanupTest.kt @@ -0,0 +1,431 @@ +/* + * 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 + +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.mutablePreferencesOf +import androidx.datastore.preferences.core.stringPreferencesKey +import com.vitorpamplona.amethyst.commons.model.preferences.AccountSecrets +import com.vitorpamplona.amethyst.commons.model.preferences.GeohashIdentitySecrets +import com.vitorpamplona.amethyst.commons.model.preferences.LegacyBooleanKey +import com.vitorpamplona.amethyst.commons.model.preferences.LegacyKeyTable +import com.vitorpamplona.amethyst.commons.model.preferences.LegacyPreferenceSource +import com.vitorpamplona.amethyst.commons.model.preferences.LegacyStringKey +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +private const val NPUB = "npub1test" + +private class MapSource( + private val values: Map, +) : LegacyPreferenceSource { + override fun keys() = values.keys + + override fun getBoolean(name: String) = values[name] as Boolean? + + override fun getString(name: String) = values[name] as String? + + @Suppress("UNCHECKED_CAST") + override fun getStringSet(name: String) = values[name] as Set? +} + +private class FakeFiles( + private val values: Map, + private val geohashValues: Map = emptyMap(), +) : LegacyAccountFiles { + var deleted = false + private set + + /** The `secret_keeper_` file goes with the account's own. */ + var deletedGeohash = false + private set + + var present = true + + /** + * Tracked apart from [present]: the two are different files, and the gate + * has to stay reachable while only one of them is left. + */ + var geohashPresent = true + + override fun source(npub: String) = MapSource(values) + + override fun geohashSource(npub: String) = MapSource(geohashValues) + + override fun exists(npub: String) = present || geohashPresent + + override suspend fun delete(npub: String): Boolean { + deleted = true + deletedGeohash = true + present = false + geohashPresent = false + return true + } +} + +private class FakeSecrets( + private val stored: AccountSecrets? = AccountSecrets(), + private val key: String? = null, + private val throws: Boolean = false, + private val geohash: GeohashIdentitySecrets? = GeohashIdentitySecrets(), +) : MigratedSecrets { + override suspend fun secrets(npub: String): AccountSecrets? { + if (throws) throw IllegalStateException("keystore unavailable") + return stored + } + + override suspend fun privateKey(npub: String): String? { + if (throws) throw IllegalStateException("keystore unavailable") + return key + } + + override suspend fun geohashIdentity(npub: String): GeohashIdentitySecrets? { + if (throws) throw IllegalStateException("keystore unavailable") + return geohash + } +} + +/** + * The gate in front of deleting an account's `secret_keeper_` file. + * + * Every test here is a way the deletion could destroy something, so the + * assertions are mostly that it did *not* happen. + */ +class LegacyPreferenceCleanupTest { + private val flag = booleanPreferencesKey("flag") + private val text = stringPreferencesKey("text") + + private val table = + LegacyKeyTable( + "migrated.group", + listOf(LegacyBooleanKey("legacy_flag", flag), LegacyStringKey("legacy_text", text)), + ) + + private val migrated = mutablePreferencesOf().also { it[booleanPreferencesKey("migrated.group")] = true } + + private fun cleanup( + values: Map, + current: Preferences = migrated, + secrets: MigratedSecrets = FakeSecrets(), + files: FakeFiles = FakeFiles(values), + retired: Boolean = true, + accepted: Set = setOf("pending_attestations"), + ) = files to + LegacyPreferenceCleanup( + tables = listOf(table), + accepted = accepted, + files = files, + currentStore = { current }, + secrets = secrets, + legacyWritesRetired = retired, + ) + + @Test + fun deletesOnceEverythingIsAccountedFor() = + runTest { + val (files, subject) = cleanup(mapOf("legacy_flag" to true, "pending_attestations" to "[]")) + + assertEquals(emptyList(), subject.verify(NPUB)) + assertEquals(LegacyCleanupResult.Deleted, subject.deleteIfVerified(NPUB)) + assertTrue(files.deleted) + } + + /** + * The hole a hand-maintained checklist leaves: a key added later that no + * migration carries. The check runs from the file's own keys so that it + * cannot be missed. + */ + @Test + fun anUnrecognisedKeyStopsTheDeletion() = + runTest { + val (files, subject) = cleanup(mapOf("legacy_flag" to true, "something_new" to "value")) + + val result = subject.deleteIfVerified(NPUB) + + assertEquals(LegacyCleanupResult.Kept(listOf("no migration claims 'something_new'")), result) + assertTrue(!files.deleted) + } + + @Test + fun aCopyThatHasNotRunStopsTheDeletion() = + runTest { + val (files, subject) = cleanup(mapOf("legacy_flag" to true), current = emptyPreferences()) + + val result = subject.deleteIfVerified(NPUB) + + assertEquals(LegacyCleanupResult.Kept(listOf("the 'migrated.group' copy has not run")), result) + assertTrue(!files.deleted) + } + + /** + * A file that never held a group's keys has nothing for that copy to prove, + * so an account predating a setting is not held back by it forever. + */ + @Test + fun aGroupTheFileNeverHeldDoesNotBlock() = + runTest { + val (_, subject) = cleanup(mapOf("pending_attestations" to "[]"), current = emptyPreferences()) + + assertEquals(emptyList(), subject.verify(NPUB)) + } + + @Test + fun secretsThatHaveNotBeenCopiedStopTheDeletion() = + runTest { + val (files, subject) = cleanup(mapOf("legacy_flag" to true), secrets = FakeSecrets(stored = null)) + + val result = subject.deleteIfVerified(NPUB) + + assertEquals(LegacyCleanupResult.Kept(listOf("the secrets have not been copied across")), result) + assertTrue(!files.deleted) + } + + /** + * A migrated secrets group that has since moved on from the legacy file + * must not block deletion. + * + * This check only ever runs in the release that stopped writing the legacy + * file, so from then on that copy is frozen while the live one keeps + * changing. Comparing the two would mean any account that re-pairs a bunker + * or adds a wallet after upgrading never gets its file deleted. The gate is + * the migration marker, which is what a non-null read reports. + */ + @Test + fun secretsThatHaveMovedOnSinceTheCopyDoNotBlock() = + runTest { + val (files, subject) = + cleanup( + mapOf("legacy_flag" to true, "nip46BunkerSecret" to "what-the-file-still-says"), + secrets = FakeSecrets(stored = AccountSecrets(nip46BunkerSecret = "re-paired since")), + ) + + assertEquals(emptyList(), subject.verify(NPUB)) + assertEquals(LegacyCleanupResult.Deleted, subject.deleteIfVerified(NPUB)) + assertTrue(files.deleted) + } + + @Test + fun aPrivateKeyThatHasNotBeenCopiedStopsTheDeletion() = + runTest { + val (files, subject) = + cleanup( + mapOf("nostr_privkey" to "abc123"), + secrets = FakeSecrets(key = null), + ) + + val result = subject.deleteIfVerified(NPUB) + + assertEquals(LegacyCleanupResult.Kept(listOf("the private key has not been copied across")), result) + assertTrue(!files.deleted) + } + + @Test + fun aPrivateKeyThatDisagreesStopsTheDeletion() = + runTest { + val (_, subject) = cleanup(mapOf("nostr_privkey" to "abc123"), secrets = FakeSecrets(key = "def456")) + + assertEquals(listOf("the stored private key differs from the legacy file"), subject.verify(NPUB)) + } + + @Test + fun aMatchingPrivateKeyPasses() = + runTest { + val (_, subject) = cleanup(mapOf("nostr_privkey" to "abc123"), secrets = FakeSecrets(key = "abc123")) + + assertEquals(emptyList(), subject.verify(NPUB)) + } + + /** + * An external-signer account has no private key in either store, and must + * not be held back for the one it never had. + */ + @Test + fun anAccountWithNoPrivateKeyIsNotHeldBack() = + runTest { + val (_, subject) = cleanup(mapOf("legacy_flag" to true), secrets = FakeSecrets(key = null)) + + assertEquals(emptyList(), subject.verify(NPUB)) + } + + /** "The check itself failed" is not "the check passed". */ + @Test + fun aStoreThatCannotBeReadStopsTheDeletion() = + runTest { + val (files, subject) = cleanup(mapOf("nostr_privkey" to "abc123"), secrets = FakeSecrets(throws = true)) + + val result = subject.deleteIfVerified(NPUB) + + assertEquals( + LegacyCleanupResult.Kept(listOf("the secrets store could not be read", "the key store could not be read")), + result, + ) + assertTrue(!files.deleted) + } + + /** + * While the app still mirrors into this file, deleting it achieves nothing + * — the next save recreates it — and would look like it had worked. + */ + @Test + fun nothingIsDeletedWhileTheLegacyFileIsStillWritten() = + runTest { + val (files, subject) = cleanup(mapOf("legacy_flag" to true), retired = false) + + val result = subject.deleteIfVerified(NPUB) + + assertEquals(LegacyCleanupResult.Kept(listOf(LegacyPreferenceCleanup.STILL_WRITTEN)), result) + assertTrue(!files.deleted) + } + + /** Neither file — the account's own nor the location-chat one. */ + @Test + fun anAccountWithNoLegacyFileIsAlreadyDone() = + runTest { + val files = + FakeFiles(emptyMap()).also { + it.present = false + it.geohashPresent = false + } + val (_, subject) = cleanup(emptyMap(), files = files) + + assertEquals(LegacyCleanupResult.NothingToDelete, subject.deleteIfVerified(NPUB)) + } + + /** Every reason is reported, so one fix does not merely reveal the next. */ + @Test + fun everyReasonIsReportedAtOnce() = + runTest { + val (_, subject) = + cleanup( + mapOf("legacy_flag" to true, "mystery" to "x", "nostr_privkey" to "abc123"), + current = emptyPreferences(), + secrets = FakeSecrets(stored = null, key = null), + ) + + assertEquals( + listOf( + "no migration claims 'mystery'", + "the 'migrated.group' copy has not run", + "the secrets have not been copied across", + "the private key has not been copied across", + ), + subject.verify(NPUB), + ) + } + // ── the location-chat identity's own legacy file ────────────────── + + /** + * The seed is in `secret_keeper_`, and [delete] removes that + * file too. So the gate has to refuse while it holds something the current + * store does not — otherwise every geohash identity the account has would + * change on the next launch. + */ + @Test + fun anUncopiedLocationChatIdentityBlocksDeletion() = + runTest { + val (files, subject) = + cleanup( + values = emptyMap(), + files = FakeFiles(emptyMap(), mapOf("geohash_chat_device_seed" to "a".repeat(64))), + secrets = FakeSecrets(geohash = null), + ) + + val result = subject.deleteIfVerified(NPUB) + + assertTrue(result is LegacyCleanupResult.Kept) + assertTrue( + "was ${(result as LegacyCleanupResult.Kept).reasons}", + result.reasons.any { it.contains("location-chat identity") }, + ) + assertTrue(!files.deleted) + } + + /** Copied across: nothing to lose, so it must not block. */ + @Test + fun aCopiedLocationChatIdentityDoesNotBlockDeletion() = + runTest { + val (files, subject) = + cleanup( + values = emptyMap(), + files = FakeFiles(emptyMap(), mapOf("geohash_chat_device_seed" to "a".repeat(64))), + secrets = FakeSecrets(geohash = GeohashIdentitySecrets(deviceSeed = "a".repeat(64))), + ) + + assertEquals(LegacyCleanupResult.Deleted, subject.deleteIfVerified(NPUB)) + assertTrue(files.deleted) + } + + /** + * An account that never opened a location chat holds neither key. That is a + * real answer, not "not migrated", and must not hold the file hostage. + */ + @Test + fun anAccountWithNoLocationChatIdentityIsNotBlocked() = + runTest { + val (files, subject) = + cleanup( + values = emptyMap(), + files = FakeFiles(emptyMap(), emptyMap()), + secrets = FakeSecrets(geohash = null), + ) + + assertEquals(LegacyCleanupResult.Deleted, subject.deleteIfVerified(NPUB)) + assertTrue(files.deleted) + } + + /** + * Both files go, or the hex one is an orphan nothing will ever remove — + * the whole reason it is wired into this gate. + */ + @Test + fun deletingTheAccountFileAlsoRemovesTheLocationChatFile() = + runTest { + val (files, subject) = cleanup(values = emptyMap()) + + assertEquals(LegacyCleanupResult.Deleted, subject.deleteIfVerified(NPUB)) + assertTrue("the hex-keyed file must be deleted with the account's own", files.deletedGeohash) + } + + /** + * Once the npub file is gone, the hex-keyed one is all that is left — and + * it still has to be removable. Gating on the account file alone returned + * NothingToDelete and stranded it forever. + */ + @Test + fun theGateStillRunsWhenOnlyTheLocationChatFileIsLeft() = + runTest { + val files = FakeFiles(emptyMap(), mapOf("geohash_chat_nickname" to "vitor")) + files.present = false + val (_, subject) = + cleanup( + values = emptyMap(), + files = files, + secrets = FakeSecrets(geohash = GeohashIdentitySecrets(nickname = "vitor")), + ) + + assertEquals(LegacyCleanupResult.Deleted, subject.deleteIfVerified(NPUB)) + assertTrue(files.deletedGeohash) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/VoiceWaveformDownsampleTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/VoiceWaveformDownsampleTest.kt new file mode 100644 index 0000000000..bafa34d841 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/VoiceWaveformDownsampleTest.kt @@ -0,0 +1,77 @@ +/* + * 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 + +import com.vitorpamplona.amethyst.ui.actions.uploads.downsampled +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The bars a recording reports. + * + * Sampling used to run once a second, so a five-second note produced five + * numbers and the drawn waveform could not track speech at all. It now samples + * ten times a second and reduces here, which is only worth doing if the + * reduction keeps the shape and bounds the size. + */ +class VoiceWaveformDownsampleTest { + @Test + fun `a short recording is left exactly as it is`() { + val short = listOf(1f, 2f, 3f) + + assertEquals(short, short.downsampled(100)) + } + + @Test + fun `a long recording is bounded`() { + // Ten minutes at ten samples a second. These travel inside events and + // imeta tags, so the size has to depend on the detail wanted rather + // than on how long somebody spoke. + val long = List(6000) { it.toFloat() } + + assertEquals(100, long.downsampled(100).size) + } + + @Test + fun `reducing averages rather than dropping samples`() { + // Taking every Nth would let one loud frame stand for a whole bucket + // and make the bars flicker with the sampling phase. The first bucket + // here averages 0..9, whose mean is 4.5. + val ramp = List(1000) { it.toFloat() } + + val reduced = ramp.downsampled(100) + + assertEquals(4.5f, reduced.first(), 0.001f) + assertEquals(994.5f, reduced.last(), 0.001f) + } + + @Test + fun `a loud burst survives the reduction`() { + // Averaging must not flatten real signal away: a quiet recording with + // one loud moment should still show that moment. + val quietWithBurst = MutableList(1000) { 100f }.also { for (i in 500..509) it[i] = 30000f } + + val reduced = quietWithBurst.downsampled(100) + + assertTrue("the burst was averaged away: ${reduced.max()}", reduced.max() > 10000f) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/CalendarReminderPrefsTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/CalendarReminderPrefsTest.kt deleted file mode 100644 index 56006280bd..0000000000 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/CalendarReminderPrefsTest.kt +++ /dev/null @@ -1,237 +0,0 @@ -/* - * 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.calendar - -import android.content.Context -import android.content.SharedPreferences -import com.vitorpamplona.amethyst.service.calendar.CalendarReminderPrefs -import com.vitorpamplona.amethyst.service.calendar.CalendarReminderStore -import io.mockk.every -import io.mockk.mockk -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Test - -/** - * Unit tests for the device-level reminder preferences and the per-event "already notified" - * store. Backed by an in-memory fake [SharedPreferences] so the test runs on the JVM without - * needing Robolectric. - */ -class CalendarReminderPrefsTest { - private lateinit var fakePrefs: FakeSharedPreferences - private lateinit var ctx: Context - - @Before - fun setUp() { - fakePrefs = FakeSharedPreferences() - ctx = mockk() - every { ctx.getSharedPreferences(any(), any()) } returns fakePrefs - } - - @Test - fun prefs_defaultsMatchPublicConstants() { - val prefs = CalendarReminderPrefs(ctx) - // Defaults are the contract callers in AppModules rely on — flipping these without an - // explicit migration would silently re-enable reminders for users who had turned them - // off (or vice versa). - assertEquals(CalendarReminderPrefs.DEFAULT_ENABLED, prefs.isEnabled()) - assertEquals(CalendarReminderPrefs.DEFAULT_LEAD_MINUTES, prefs.leadMinutes()) - } - - @Test - fun prefs_setEnabled_roundTrips() { - val prefs = CalendarReminderPrefs(ctx) - prefs.setEnabled(false) - assertFalse(prefs.isEnabled()) - prefs.setEnabled(true) - assertTrue(prefs.isEnabled()) - } - - @Test - fun prefs_setLeadMinutes_roundTrips() { - val prefs = CalendarReminderPrefs(ctx) - prefs.setLeadMinutes(30) - assertEquals(30, prefs.leadMinutes()) - } - - @Test - fun store_wasNotified_isFalseByDefault() { - val store = CalendarReminderStore(ctx) - assertFalse(store.wasNotified("event-a", 1_000_000L)) - } - - @Test - fun store_markNotified_makesWasNotifiedTrueForSameStart() { - val store = CalendarReminderStore(ctx) - store.markNotified("event-a", 1_000_000L) - assertTrue(store.wasNotified("event-a", 1_000_000L)) - } - - @Test - fun store_wasNotified_isFalseWhenStartChanges() { - // Regression test for the "moved meeting" case: if the author updates the appointment - // with a new start, the store should not silently swallow the new reminder. - val store = CalendarReminderStore(ctx) - store.markNotified("event-a", 1_000_000L) - assertFalse(store.wasNotified("event-a", 2_000_000L)) - } - - @Test - fun store_forgetBefore_dropsOldEntries() { - val store = CalendarReminderStore(ctx) - store.markNotified("old", 1_000_000L) - store.markNotified("recent", 5_000_000L) - store.forgetBefore(3_000_000L) - assertFalse(store.wasNotified("old", 1_000_000L)) - assertTrue(store.wasNotified("recent", 5_000_000L)) - } -} - -/** - * Bare-bones in-memory implementation of [SharedPreferences] sufficient for the prefs/store - * round-trip tests. apply() is synchronous here — fine because the production code never relies - * on apply()'s async semantics. - */ -private class FakeSharedPreferences : SharedPreferences { - private val data = mutableMapOf() - - override fun getAll(): MutableMap = data - - override fun getString( - key: String, - defValue: String?, - ): String? = data[key] as? String ?: defValue - - override fun getStringSet( - key: String, - defValues: MutableSet?, - ): MutableSet? { - @Suppress("UNCHECKED_CAST") - return data[key] as? MutableSet ?: defValues - } - - override fun getInt( - key: String, - defValue: Int, - ): Int = (data[key] as? Int) ?: defValue - - override fun getLong( - key: String, - defValue: Long, - ): Long = (data[key] as? Long) ?: defValue - - override fun getFloat( - key: String, - defValue: Float, - ): Float = (data[key] as? Float) ?: defValue - - override fun getBoolean( - key: String, - defValue: Boolean, - ): Boolean = (data[key] as? Boolean) ?: defValue - - override fun contains(key: String): Boolean = data.containsKey(key) - - override fun edit(): SharedPreferences.Editor = FakeEditor(data) - - override fun registerOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener?) = Unit - - override fun unregisterOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener?) = Unit -} - -private class FakeEditor( - private val data: MutableMap, -) : SharedPreferences.Editor { - private val pending = mutableMapOf() - private val removed = mutableSetOf() - private var clearAll = false - - override fun putString( - key: String, - value: String?, - ): SharedPreferences.Editor { - pending[key] = value - return this - } - - override fun putStringSet( - key: String, - values: MutableSet?, - ): SharedPreferences.Editor { - pending[key] = values - return this - } - - override fun putInt( - key: String, - value: Int, - ): SharedPreferences.Editor { - pending[key] = value - return this - } - - override fun putLong( - key: String, - value: Long, - ): SharedPreferences.Editor { - pending[key] = value - return this - } - - override fun putFloat( - key: String, - value: Float, - ): SharedPreferences.Editor { - pending[key] = value - return this - } - - override fun putBoolean( - key: String, - value: Boolean, - ): SharedPreferences.Editor { - pending[key] = value - return this - } - - override fun remove(key: String): SharedPreferences.Editor { - removed.add(key) - return this - } - - override fun clear(): SharedPreferences.Editor { - clearAll = true - return this - } - - override fun commit(): Boolean { - apply() - return true - } - - override fun apply() { - if (clearAll) data.clear() - removed.forEach { data.remove(it) } - data.putAll(pending) - } -} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/CalendarsViewModelFlowTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/CalendarsViewModelFlowTest.kt index e504817753..aaf1c26d9b 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/CalendarsViewModelFlowTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/CalendarsViewModelFlowTest.kt @@ -30,7 +30,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarsViewMode import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.CalendarAppointmentsFeedFilter import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent -import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent +import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarCollectionEvent import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -90,7 +90,7 @@ class CalendarsViewModelFlowTest { dTag: String?, title: String, members: List
= emptyList(), - ) = CalendarEvent( + ) = CalendarCollectionEvent( id = id, pubKey = pubKey, createdAt = 1_700_000_000L, @@ -121,7 +121,7 @@ class CalendarsViewModelFlowTest { * for the length of the test: `LocalCache.addressables` is a weak cache, and with nothing * holding a reference a calendar can be collected out from under the assertions. */ - private fun consume(vararg calendars: CalendarEvent): List { + private fun consume(vararg calendars: CalendarCollectionEvent): List { calendars.forEach { LocalCache.justConsumeMyOwnEvent(it) } return calendars.map { LocalCache.getAddressableNoteIfExists(it.address()) } } diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/cordn/CordnMessageGroupingTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/cordn/CordnMessageGroupingTest.kt new file mode 100644 index 0000000000..8a6066509a --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/cordn/CordnMessageGroupingTest.kt @@ -0,0 +1,132 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cordn + +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.cordnGroup.cordnGroupPositionFor +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.cordnGroup.sameDayAs +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.ChatGroupPosition +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnDeliveredMessage +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnEnvelope +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.time.LocalDate +import java.time.ZoneId + +/** + * Where a cordn bubble sits in a run of its sender's messages. + * + * The feed is reverse-laid-out, so the newer/older argument order is easy to invert and + * impossible to notice from the code alone — an inverted version still produces + * plausible-looking bubbles, just with the tail on the wrong end of every burst. These + * assert the ends of a run by name. + */ +class CordnMessageGroupingTest { + private val alice: HexKey = "aa".repeat(32) + private val bob: HexKey = "bb".repeat(32) + + /** Local noon today, so a ±10 minute window never crosses midnight by accident. */ + private val noon = + LocalDate + .now(ZoneId.systemDefault()) + .atStartOfDay(ZoneId.systemDefault()) + .plusHours(12) + .toEpochSecond() + + private var cursor = 0L + + private fun msg( + author: HexKey, + at: Long, + ) = CordnDeliveredMessage( + envelope = CordnEnvelope.build(author, at, 9, emptyArray(), "hi"), + cursor = ++cursor, + ) + + @Test + fun `a lone message is SINGLE`() { + assertEquals(ChatGroupPosition.SINGLE, cordnGroupPositionFor(null, msg(alice, noon), null)) + } + + @Test + fun `a run of three from one sender is TOP MIDDLE BOTTOM oldest first`() { + val oldest = msg(alice, noon) + val middle = msg(alice, noon + 60) + val newest = msg(alice, noon + 120) + + // The oldest of a run renders at the visual top of the burst and carries the + // author line; the newest carries the bubble tail. + assertEquals(ChatGroupPosition.TOP, cordnGroupPositionFor(middle, oldest, null)) + assertEquals(ChatGroupPosition.MIDDLE, cordnGroupPositionFor(newest, middle, oldest)) + assertEquals(ChatGroupPosition.BOTTOM, cordnGroupPositionFor(null, newest, middle)) + } + + @Test + fun `a different sender between them breaks the run`() { + val mine = msg(alice, noon) + val theirs = msg(bob, noon + 60) + val mineAgain = msg(alice, noon + 120) + + assertEquals(ChatGroupPosition.SINGLE, cordnGroupPositionFor(theirs, mine, null)) + assertEquals(ChatGroupPosition.SINGLE, cordnGroupPositionFor(mineAgain, theirs, mine)) + assertEquals(ChatGroupPosition.SINGLE, cordnGroupPositionFor(null, mineAgain, theirs)) + } + + @Test + fun `the shared ten-minute window is what decides, not a cordn-local one`() { + val first = msg(alice, noon) + + // Inside the window the run holds... + val nine = msg(alice, noon + 9 * 60) + assertEquals(ChatGroupPosition.BOTTOM, cordnGroupPositionFor(null, nine, first)) + + // ...and just past it, it breaks. A cordn-local 5-minute window (what this + // screen used before adopting the shared one) would already have broken at 9. + val eleven = msg(alice, noon + 11 * 60) + assertEquals(ChatGroupPosition.SINGLE, cordnGroupPositionFor(null, eleven, first)) + } + + @Test + fun `a run does not cross local midnight even inside the time window`() { + val midnight = + LocalDate + .now(ZoneId.systemDefault()) + .atStartOfDay(ZoneId.systemDefault()) + .toEpochSecond() + + val lastNight = msg(alice, midnight - 120) + val thisMorning = msg(alice, midnight + 120) + + // Four minutes apart, same sender — but a day separator lands between them, so + // joining them into one burst would draw the separator inside a sealed bubble run. + assertFalse(thisMorning.sameDayAs(lastNight)) + assertEquals(ChatGroupPosition.SINGLE, cordnGroupPositionFor(null, thisMorning, lastNight)) + } + + @Test + fun `sameDayAs treats a missing neighbour as a new day`() { + // Drives the day separator above the very first message in the list. + assertFalse(msg(alice, noon).sameDayAs(null)) + assertTrue(msg(alice, noon).sameDayAs(msg(bob, noon + 3600))) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/cordn/CordnRuntimeTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/cordn/CordnRuntimeTest.kt new file mode 100644 index 0000000000..8adf6ce8db --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/cordn/CordnRuntimeTest.kt @@ -0,0 +1,743 @@ +/* + * 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.cordn + +import com.vitorpamplona.amethyst.commons.cordn.CoordinatorConfig +import com.vitorpamplona.amethyst.commons.cordn.CordnBlobCipher +import com.vitorpamplona.amethyst.commons.cordn.CordnCoordinatorLink +import com.vitorpamplona.amethyst.commons.cordn.CordnCoordinatorLinkFactory +import com.vitorpamplona.amethyst.commons.cordn.CordnHandedOffException +import com.vitorpamplona.amethyst.commons.cordn.CordnStorageLayout +import com.vitorpamplona.amethyst.model.cordn.CordnRuntime +import com.vitorpamplona.quartz.contextvm.core.CvmKinds +import com.vitorpamplona.quartz.cordn.spec00Coordinator.AvailableKeyPackage +import com.vitorpamplona.quartz.cordn.spec00Coordinator.ConsumedJoinRequestRef +import com.vitorpamplona.quartz.cordn.spec00Coordinator.ConsumedWelcomeRef +import com.vitorpamplona.quartz.cordn.spec00Coordinator.GroupMessage +import com.vitorpamplona.quartz.cordn.spec00Coordinator.ICoordinator +import com.vitorpamplona.quartz.cordn.spec00Coordinator.JoinRequest +import com.vitorpamplona.quartz.cordn.spec00Coordinator.PendingWelcome +import com.vitorpamplona.quartz.cordn.spec00Coordinator.PostedMessage +import com.vitorpamplona.quartz.cordn.spec00Coordinator.PublishedKeyPackage +import com.vitorpamplona.quartz.cordn.spec00Coordinator.TakenKeyPackage +import com.vitorpamplona.quartz.cordn.spec01GroupMetadata.CordnGroupMetadata +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeoutOrNull +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +/** + * The rules `CordnRuntime` holds, made executable. + * + * It is the assembly point for everything cordn: the KeyPackage pool policy, + * purge, restore, the invitation fan-out. Every one of those was a sentence in + * a commit message and none of them was a test, which is the combination that + * makes a rule quietly stop being true. + * + * Two seams make this possible without a device or a relay. `cipher` was + * always one, because the Android KeyStore cannot run in a JVM unit test. + * `links` is the second: production opens a real ContextVM transport per + * coordinator, so without it nothing in this class could be reached at all. + * Both default to the real thing. + */ +class CordnRuntimeTest { + /** Reversible and unmistakably not the input — the same trick the store tests use. */ + private class XorCipher : CordnBlobCipher { + override fun encrypt(bytes: ByteArray) = ByteArray(bytes.size) { (bytes[it].toInt() xor 0x5A).toByte() } + + override fun decrypt(bytes: ByteArray) = encrypt(bytes) + } + + private val signer = NostrSignerInternal(KeyPair()) + private val account: HexKey get() = signer.pubKey + + private val coordinatorA = StubCoordinator() + private val coordinatorB = StubCoordinator() + private val keyA = "a".repeat(64) + private val keyB = "b".repeat(64) + + private val root = + File.createTempFile("cordn-runtime", "").also { + it.delete() + it.mkdirs() + } + + private val scopes = mutableListOf() + + /** + * Anything a runtime coroutine threw and nobody caught. + * + * A bare `CoroutineScope(Dispatchers.Default + Job())` has no handler, so + * an exception escaping a `launch` goes to the THREAD's uncaught handler — + * out of this test entirely and into the JVM the whole module shares. The + * next test to call `runTest` then fails with + * `UncaughtExceptionsBeforeTest`, blaming a test that did nothing wrong. + * That is a miserable failure to chase: it is a race between a coroutine + * outliving its test and the next one starting, so it moves between + * classes and build variants and vanishes when run in isolation. + * + * Collecting them here keeps them inside this suite, and [cleanup] fails + * this test rather than a stranger's. + */ + private val leaked = java.util.concurrent.CopyOnWriteArrayList() + + private fun configFor(pubKey: HexKey) = + CoordinatorConfig( + pubKey = pubKey, + relays = listOf(RelayUrlNormalizer.normalizeOrNull("wss://relay.example.com")!!), + ) + + /** + * Every connection opened, newest last. + * + * One per `connect`, so a coordinator whose relays were corrected appears + * twice -- which is the only way to tell whether the sync loop followed the + * reopen or stayed on the connection that was replaced. The stub behind + * them is shared per pubkey, so key packages and published state survive a + * reopen exactly as the real stores do. + */ + private val connections = java.util.concurrent.CopyOnWriteArrayList() + + /** Hands each coordinator pubkey its own stub, with no transport in between. */ + private val links = + CordnCoordinatorLinkFactory { _, config -> + val counting = + CountingCoordinator(if (config.pubKey == keyA) coordinatorA else coordinatorB) + .also { connections += it } + + object : CordnCoordinatorLink { + override val coordinator = counting + + override suspend fun close() = Unit + } + } + + /** + * A scope for the runtime, on real threads. + * + * Real rather than a `TestScope` dispatcher, and that is forced rather + * than chosen: `maintainKeyPackages` reads the KeyPackage store, which + * hops to `Dispatchers.IO`. Virtual time cannot reach across that, so no + * amount of `advanceUntilIdle()` makes the upkeep deterministic — an + * earlier version of this suite looked green while the work had simply not + * run yet, which is the failure mode a test is supposed to prevent. + * + * Its own `Job`, so nothing waits on `CordnSyncLoop`, whose design is + * never to return; cancelled in [cleanup]. + */ + private fun runtimeScope() = + CoroutineScope( + Dispatchers.Default + Job() + + CoroutineExceptionHandler { _, throwable -> leaked += throwable }, + ).also { scopes += it } + + private fun runtime( + scope: CoroutineScope, + client: INostrClient = EmptyNostrClient(), + ) = CordnRuntime( + accountSigner = signer, + client = client, + filesDir = root, + scope = scope, + cipher = XorCipher(), + links = links, + ) + + /** + * Waits for [condition], or fails by name. + * + * A bounded poll rather than a barrier, for the reason above: the work is + * on another dispatcher. The budget is orders of magnitude more than it + * needs, so a timeout here is a real failure and not a slow machine. + */ + private suspend fun awaitUntil( + what: String, + condition: () -> Boolean, + ) { + withTimeoutOrNull(AWAIT_BUDGET_MS) { + while (!condition()) delay(POLL_MS) + } ?: error("timed out waiting for $what") + } + + /** + * Gives the runtime's detached upkeep room to run, then returns. + * + * Only used before asserting something did NOT happen. Weaker than a + * barrier by nature — the honest way to assert a negative about another + * dispatcher is to give it far more time than it needs and look again. + */ + private suspend fun settle() = delay(SETTLE_MS) + + @After + fun cleanup() { + scopes.forEach { it.cancel() } + root.deleteRecursively() + + // Cancellation is not a leak; anything else is, and it would otherwise + // have surfaced as an unrelated test failing somewhere else entirely. + val real = leaked.filterNot { it is CancellationException } + check(real.isEmpty()) { "a runtime coroutine leaked: ${real.joinToString { it.toString() }}" } + } + + /** + * Records every REQ, so a test can say which relays were asked what. + * + * [EmptyNostrClient] answers nothing, which is all this needs: the + * assertion is about the filter that goes out, not the events that come + * back. + */ + private class RecordingClient( + delegate: INostrClient = EmptyNostrClient(), + ) : INostrClient by delegate { + val reqs = java.util.concurrent.CopyOnWriteArrayList>>() + + override fun subscribe( + subId: String, + filters: Map>, + listener: SubscriptionListener?, + ) { + reqs += filters + } + } + + @Test + fun `opening a coordinator asks its own relays who it is`() = + runBlocking { + // The screens render a coordinator as the Nostr user it is, which + // puts it in LocalCache and sets the user-metadata machinery looking + // for its kind 0. Left alone, that lookup has no outbox to go on and + // falls through to THIS ACCOUNT's index and home relays -- telling + // them the account is interested in a pubkey that CEP-6 + // announcements publicly identify as a coordinator. Fetching the + // kind 0 and the kind 10002 here, from the coordinator's own relays, + // is what stops that: with a relay list cached the outbox finder + // issues no discovery filter at all. + val client = RecordingClient() + val config = configFor(keyA) + + runtime(runtimeScope(), client).session(config) + + awaitUntil("the coordinator's profile to be requested") { client.reqs.isNotEmpty() } + + val profileReqs = + client.reqs.filter { req -> + req.values.any { filters -> + filters.any { it.authors == listOf(keyA) } + } + } + + assertTrue("no request carried the coordinator as its author", profileReqs.isNotEmpty()) + + profileReqs.forEach { req -> + assertEquals( + "asked the wrong relays -- this must not reach the account's own relays", + config.relays.toSet(), + req.keys, + ) + req.values.flatten().forEach { + assertEquals( + "expected the profile, the relay list and the announcement (CEP-23, CEP-17, CEP-6)", + listOf(MetadataEvent.KIND, AdvertisedRelayListEvent.KIND, CvmKinds.SERVER_ANNOUNCEMENT), + it.kinds, + ) + } + } + } + + @Test + fun `correcting a coordinator's relays moves the sync loop onto the new connection`() = + runBlocking { + // The registry cannot carry a transport across a relay change, so it + // opens a new one and builds a new manager over it. CordnSyncLoop + // captures its source for life, so a loop kept across that swap goes + // on polling the connection that was just closed -- the coordinator + // then retries forever and its rooms never update again, with a + // restart the only way out. + val runtime = runtime(runtimeScope()) + // A group first: the loop has nothing to subscribe to until the + // coordinator holds one, so a bare session would never subscribe and + // the assertion below would pass for the wrong reason. + runtime.createGroup(configFor(keyA), CordnGroupMetadata(name = "A"), gid = "moved-gid") + + awaitUntil("the first connection to be subscribed") { + connections.firstOrNull()?.subscriptions?.let { it > 0 } == true + } + + runtime.session( + configFor(keyA).copy( + relays = listOf(RelayUrlNormalizer.normalizeOrNull("wss://moved.example.com")!!), + ), + ) + + assertEquals("the relay change did not open a second connection", 2, connections.size) + + awaitUntil("the loop to follow the reopen onto the new connection") { + connections[1].subscriptions > 0 + } + } + + @Test + fun `renaming a coordinator keeps the connection it already had`() = + runBlocking { + // The registry reopens the transport for a config that differs, + // because the transport is bound to the relays -- and that builds a + // NEW manager while CordnSyncLoop goes on holding the old one, whose + // scope has just been closed. A label is not part of the address, so + // renaming must not take that path. + val runtime = runtime(runtimeScope()) + val config = configFor(keyA) + runtime.session(config) + + val before = runtime.sessionOrNull(keyA)?.manager + assertNotNull("no session to rename", before) + + runtime.relabel(keyA, " My coordinator ") + + assertSame( + "renaming reopened the transport, which orphans the running sync loop", + before, + runtime.sessionOrNull(keyA)?.manager, + ) + assertEquals( + "the label is not trimmed to what the user meant", + "My coordinator", + runtime.coordinators.value + .first { it.pubKey == keyA } + .label, + ) + } + + @Test + fun `clearing a coordinator's name stores no name rather than an empty one`() = + runBlocking { + // An empty label would render as a blank line where a name goes, and + // the display chain could never fall through to the profile or the + // key behind it. + val runtime = runtime(runtimeScope()) + runtime.session(configFor(keyA)) + runtime.relabel(keyA, "named") + + runtime.relabel(keyA, " ") + + assertNull( + runtime.coordinators.value + .first { it.pubKey == keyA } + .label, + ) + } + + @Test + fun `opening a coordinator with nothing published does not publish a key package`() = + runBlocking { + // §8.4: publishing tells a coordinator this account exists and is + // invitable, permanently. Doing that on someone's behalf at login + // is the decision this rule exists to refuse. + val runtime = runtime(runtimeScope()) + runtime.session(configFor(keyA)) + settle() + + assertFalse("published without being asked", coordinatorA.published.isNotEmpty()) + } + + @Test + fun `a forgotten coordinator stays forgotten across a relaunch`() = + runBlocking { + // `remember()` merges the open sessions over what is on disk rather + // than replacing it, because a coordinator whose relay was down + // this launch is missing from the open set and saving that set + // verbatim would erase it. A forgotten coordinator is missing for + // the other reason and looks identical, so the merge protected it + // too and wrote it straight back: Remove worked until the next + // launch and then undid itself. + val runtime = runtime(runtimeScope()) + runtime.session(configFor(keyA)) + runtime.session(configFor(keyB)) + settle() + + runtime.forget(keyA) + settle() + + // A second runtime over the same files is the relaunch: it reads + // the coordinator list back off disk, which is where the removal + // either stuck or did not. + val reopened = runtime(runtimeScope()) + reopened.restore() + settle() + + val keys = reopened.coordinators.value.map { it.pubKey } + assertFalse("the forgotten coordinator came back", keys.contains(keyA)) + assertTrue("the other coordinator was lost with it", keys.contains(keyB)) + } + + @Test + fun `opening a coordinator that already has one tops the pool back up`() = + runBlocking { + // The other half of the rule. Once a package is published there the + // coordinator already knows, and a drained pool fails the next + // invitation for a reason the inviter sees and the invitee never + // does. + val runtime = runtime(runtimeScope()) + runtime.session(configFor(keyA)) + runtime.publishKeyPackage(keyA) + val afterFirst = coordinatorA.published.size + + // A second runtime over the same files: a relaunch, with one + // package already published and its private half on disk. + runtime(runtimeScope()).session(configFor(keyA)) + + awaitUntil("the pool to be topped up after a relaunch") { coordinatorA.published.size > afterFirst } + } + + @Test + fun `purging one coordinator leaves the other's groups on disk`() = + runBlocking { + val runtime = runtime(runtimeScope()) + runtime.createGroup(configFor(keyA), CordnGroupMetadata(name = "A"), gid = "shared-gid") + runtime.createGroup(configFor(keyB), CordnGroupMetadata(name = "B"), gid = "shared-gid") + + runtime.purge(keyA) + + // A gid is unique only within one coordinator, so both hold + // "shared-gid" as unrelated groups. Deleting a level too high takes + // both, and the only visible symptom is a room that vanished. + assertFalse(CordnStorageLayout.directoryFor(root, account, keyA).exists()) + assertTrue(CordnStorageLayout.directoryFor(root, account, keyB).exists()) + assertEquals( + listOf(keyB), + runtime.groups.all.value + .map { it.coordinatorPubKey }, + ) + } + + @Test + fun `an archive from another account is refused`() = + runBlocking { + // Restoring one account's groups under another's key gives a device + // MLS state whose credentials name somebody else; every Commit it + // made would be rejected by the rest of the group. + val runtime = runtime(runtimeScope()) + runtime.createGroup(configFor(keyA), CordnGroupMetadata(name = "Mine"), gid = "mine") + val archive = runtime.exportArchive("pw") + + val stranger = + CordnRuntime( + accountSigner = NostrSignerInternal(KeyPair()), + client = EmptyNostrClient(), + filesDir = root, + scope = runtimeScope(), + cipher = XorCipher(), + links = links, + ) + + val failure = runCatching { stranger.importArchive(archive, "pw") }.exceptionOrNull() + assertTrue("expected a refusal, got $failure", failure is IllegalArgumentException) + } + + @Test + fun `a snapshot carries the groups, their cursors and their key packages`() = + runBlocking { + val runtime = runtime(runtimeScope()) + runtime.createGroup(configFor(keyA), CordnGroupMetadata(name = "Mine"), gid = "mine") + + val snapshot = runtime.migrationSnapshot() + + assertEquals(listOf("mine"), snapshot.groups.map { it.gid }) + assertEquals(keyA, snapshot.groups[0].coordinatorPubKey) + assertTrue(snapshot.groups[0].coordinatorRelays.isNotEmpty()) + assertTrue(snapshot.groups[0].clientStateBase64.isNotEmpty()) + } + + @Test + fun `adopting a snapshot replaces this device's groups rather than merging them`() = + runBlocking { + // Same rule as restoring a backup, and for the same reason: a merge + // is what produces two devices holding one group's state. + val runtime = runtime(runtimeScope()) + runtime.createGroup(configFor(keyA), CordnGroupMetadata(name = "In the snapshot"), gid = "kept") + val snapshot = runtime.migrationSnapshot() + + runtime.createGroup(configFor(keyA), CordnGroupMetadata(name = "Added after"), gid = "dropped") + + runtime.adoptMigration(snapshot) + + assertEquals( + setOf("kept"), + runtime.groups.all.value + .map { it.gid } + .toSet(), + ) + } + + @Test + fun `a snapshot from another account is refused`() = + runBlocking { + val runtime = runtime(runtimeScope()) + val snapshot = runtime.migrationSnapshot().copy(accountPubKey = "ff".repeat(32)) + + val failure = runCatching { runtime.adoptMigration(snapshot) }.exceptionOrNull() + + assertTrue("expected a refusal, got $failure", failure is IllegalArgumentException) + } + + @Test + fun `a handed-off device refuses to open a session`() = + runBlocking { + // The fork guard. Without it both phones hold one leaf and both + // commit, and MLS does not recover from that. + val runtime = runtime(runtimeScope()) + runtime.createGroup(configFor(keyA), CordnGroupMetadata(name = "Mine"), gid = "mine") + runtime.handoff.markHandedOff() + + val failure = runCatching { runtime.session(configFor(keyA)) }.exceptionOrNull() + + assertTrue("expected a refusal, got $failure", failure is CordnHandedOffException) + } + + @Test + fun `cancelling a handoff lets the device work again`() = + runBlocking { + val runtime = runtime(runtimeScope()) + runtime.createGroup(configFor(keyA), CordnGroupMetadata(name = "Mine"), gid = "mine") + runtime.handoff.markHandedOff() + + runtime.cancelHandOff() + + assertNotNull(runtime.session(configFor(keyA))) + } + + @Test + fun `adopting a snapshot clears a handoff, because this device now holds the newest copy`() = + runBlocking { + val runtime = runtime(runtimeScope()) + runtime.createGroup(configFor(keyA), CordnGroupMetadata(name = "Mine"), gid = "mine") + val snapshot = runtime.migrationSnapshot() + runtime.handoff.markHandedOff() + + runtime.adoptMigration(snapshot) + + assertFalse(runtime.handoff.handedOff.value) + } + + @Test + fun `restoring replaces this device's groups rather than merging them`() = + runBlocking { + // The rule the confirmation dialog states. A merge is what produces + // two devices holding one group's state, and MLS does not recover + // from the fork that follows. + val runtime = runtime(runtimeScope()) + runtime.createGroup(configFor(keyA), CordnGroupMetadata(name = "In the backup"), gid = "kept") + val archive = runtime.exportArchive("pw") + + runtime.createGroup(configFor(keyA), CordnGroupMetadata(name = "Added after"), gid = "dropped") + assertEquals( + setOf("kept", "dropped"), + runtime.groups.all.value + .map { it.gid } + .toSet(), + ) + + runtime.importArchive(archive, "pw") + + assertEquals( + setOf("kept"), + runtime.groups.all.value + .map { it.gid } + .toSet(), + ) + } + + @Test + fun `a coordinator that will not answer is reported, not counted as empty`() = + runBlocking { + // "We could not ask" and "there is nothing for you" look identical + // on screen and mean opposite things. + val runtime = runtime(runtimeScope()) + runtime.session(configFor(keyA)) + coordinatorA.failEverything = true + + val invitations = runtime.invitations() + + assertTrue(invitations.pending.isEmpty()) + assertEquals(listOf(keyA), invitations.unreachable.map { it.coordinator.pubKey }) + assertFalse("an unreachable coordinator is not an empty inbox", invitations.isEmpty) + } + + /** + * One opened connection, counting what was asked of it. + * + * Only the subscribe matters: it is what `CordnSyncLoop` does forever, so a + * connection that is never subscribed is one no loop is pointing at. + */ + private class CountingCoordinator( + private val delegate: ICoordinator, + ) : ICoordinator by delegate { + @Volatile + var subscriptions = 0 + private set + + override suspend fun subscribeMessages( + cursors: Map, + timeoutMs: Long, + onMessage: (GroupMessage) -> Unit, + ) { + subscriptions++ + delegate.subscribeMessages(cursors, timeoutMs, onMessage) + } + } + + /** + * Just enough coordinator to let the runtime run. + * + * Not a fidelity fixture — `CordnFixtureCoordinator` is that, and it sits + * behind a transport because it answers `CvmRequest`s. What these tests + * need is the `ICoordinator` surface the runtime talks to, with a way to + * make it stop answering. + */ + private class StubCoordinator : ICoordinator { + val published = mutableMapOf() + var failEverything = false + + private var clock = 1L + + private fun guard() { + if (failEverything) throw IllegalStateException("coordinator is down") + } + + override suspend fun publishKeyPackage( + keyPackageRef: String, + keyPackageBase64: String, + ): PublishedKeyPackage { + guard() + published[keyPackageRef] = keyPackageBase64 + return PublishedKeyPackage(keyPackageRef, false, clock++) + } + + override suspend fun removeKeyPackages(keyPackageRefs: List): List { + guard() + return keyPackageRefs.filter { published.remove(it) != null } + } + + override suspend fun listKeyPackages(): List { + guard() + return published.keys.map { AvailableKeyPackage(OWNER, it, false, clock) } + } + + override suspend fun takeKeyPackage(id: String): TakenKeyPackage? { + guard() + return null + } + + override suspend fun storeWelcome( + targetPubKey: HexKey, + keyPackageRef: String, + welcomeBase64: String, + after: Long?, + ): Long { + guard() + return clock++ + } + + override suspend fun takeWelcomes(consumed: List): List { + guard() + return emptyList() + } + + override suspend fun storeJoinRequest( + gid: String, + keyPackageRef: String, + ): Long { + guard() + return clock++ + } + + override suspend fun takeJoinRequests( + gids: List, + consumed: List, + ): List { + guard() + return emptyList() + } + + override suspend fun postMessage( + gid: String, + sealedBase64: String, + ): PostedMessage { + guard() + return PostedMessage(gid, clock++, clock) + } + + override suspend fun fetchMessages(cursors: Map): List { + guard() + return emptyList() + } + + override suspend fun subscribeMessages( + cursors: Map, + timeoutMs: Long, + onMessage: (GroupMessage) -> Unit, + ) { + guard() + // Suspends for the whole window, like a real subscription: the + // coordinator holds the call open and returns when it closes. + // Returning immediately turns `CordnSyncLoop` into a spin, which + // on a virtual-time dispatcher is an unbounded one. + delay(timeoutMs) + } + + private companion object { + /** Whose packages these are. The runtime filters `kp_list` by account. */ + const val OWNER = "owner" + } + } + + private companion object { + const val AWAIT_BUDGET_MS = 10_000L + const val POLL_MS = 10L + + /** Long enough that upkeep which was going to happen already has. */ + const val SETTLE_MS = 1_000L + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/cordn/CordnScreenIndependenceTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/cordn/CordnScreenIndependenceTest.kt new file mode 100644 index 0000000000..e5a28cf819 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/cordn/CordnScreenIndependenceTest.kt @@ -0,0 +1,135 @@ +/* + * 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.cordn + +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +/** + * The screen half of the Marmot/cordn boundary. + * + * `CordnIndependenceTest` in `commons` guards the models and the protocol + * code; this guards the Android screens, which is where the coupling would + * actually be convenient. A cordn chat screen that imported a Marmot composable + * would work — and would then make Marmot's UI, which is frozen, unable to + * change without breaking cordn. + * + * What cordn may share is what any feature shares: the design system, the + * theme, navigation, and generic components. The line is drawn at + * `marmotGroup`-named code, because that is where a real coupling would land. + * + * Third of the three guards §3.1 of `amethyst/plans/2026-09-19-cordn-ui.md` + * asks for, added with the first screen rather than after the fifth. + */ +class CordnScreenIndependenceTest { + private val screensRoot: File by lazy { + // Resolved, not assumed: a wrong root makes an architecture test pass + // for the wrong reason, which is worse than not having one. + generateSequence(File(".").absoluteFile) { it.parentFile } + .map { File(it, "amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats") } + .firstOrNull { it.isDirectory } + ?: error("cannot locate the chats screen package from ${File(".").absolutePath}") + } + + private fun kotlinFilesIn(pkg: String): List = + File(screensRoot, pkg) + .walkTopDown() + .filter { it.isFile && it.extension == "kt" } + .toList() + + private fun importsMatching( + pkg: String, + forbidden: Regex, + ): List = + kotlinFilesIn(pkg).flatMap { file -> + file.readLines().withIndex().mapNotNull { (i, line) -> + val trimmed = line.trimStart() + if (trimmed.startsWith("import ") && forbidden.containsMatchIn(trimmed)) { + "${file.name}:${i + 1} $trimmed" + } else { + null + } + } + } + + /** + * Plain substring, NOT `\bmarmot\b`. + * + * The word-boundary form cannot match `…marmotGroups.MarmotGroupChatroom` + * — a word character follows `marmot` both times, so `\b` fails and the + * guard passes exactly the import it exists to stop. Only `import` lines + * are scanned, so a substring is both safe and the correct test. + */ + private val marmotImport = Regex("marmot", RegexOption.IGNORE_CASE) + + private val cordnImport = Regex("cordn", RegexOption.IGNORE_CASE) + + @Test + fun `the matcher catches the import it exists to catch`() { + // A guard that can never fire is indistinguishable from a codebase + // that never violates the rule. This tells them apart. + assertTrue( + "the Marmot matcher would not flag a real Marmot import — the guard cannot fire", + marmotImport.containsMatchIn("import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom"), + ) + assertTrue( + "the cordn matcher cannot fire either", + cordnImport.containsMatchIn("import com.vitorpamplona.amethyst.commons.model.cordnGroups.CordnGroupChatroom"), + ) + } + + @Test + fun `the cordn screens do not reach into Marmot's`() { + val offences = importsMatching("cordnGroup", marmotImport) + assertTrue( + "cordn screens must not import Marmot — Marmot's UI is frozen and cordn's is not\n " + + offences.joinToString("\n "), + offences.isEmpty(), + ) + } + + @Test + fun `Marmot's screens do not reach into cordn's`() { + val offences = importsMatching("marmotGroup", cordnImport) + assertTrue( + "Marmot screens must not import cordn — cordn moves and Marmot is frozen\n " + + offences.joinToString("\n "), + offences.isEmpty(), + ) + } + + @Test + fun `the scan sees real files and real imports`() { + // Without this the two tests above pass just as happily when the + // scanner is pointed at nothing at all. + listOf("cordnGroup", "marmotGroup").forEach { + assertTrue( + "found ${kotlinFilesIn(it).size} files under $it — the scan is broken, not the code", + kotlinFilesIn(it).size >= 2, + ) + } + assertTrue( + "the scanner found no amethyst imports in cordnGroup, so it would not find a marmot one either", + importsMatching("cordnGroup", Regex("amethyst", RegexOption.IGNORE_CASE)).isNotEmpty(), + ) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/AddressedAuthorRoutingTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/AddressedAuthorRoutingTest.kt index 815b6e933b..3033cec228 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/AddressedAuthorRoutingTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/AddressedAuthorRoutingTest.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.model -import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent +import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarCollectionEvent import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue @@ -49,7 +49,7 @@ class AddressedAuthorRoutingTest { ) private fun calendar(vararg tags: Array) = - CalendarEvent( + CalendarCollectionEvent( id = "22".repeat(32), pubKey = host, createdAt = 1700000000, diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/BuzzWorkspaceChannelTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/BuzzWorkspaceChannelTest.kt index 30766d0f2c..97512fd09b 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/BuzzWorkspaceChannelTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/BuzzWorkspaceChannelTest.kt @@ -31,7 +31,7 @@ import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal -import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent import com.vitorpamplona.quartz.nip29RelayGroups.GroupId import io.mockk.every import io.mockk.mockk @@ -225,7 +225,7 @@ class BuzzWorkspaceChannelTest { // The author deletes their own edit (NIP-09). It must stop overlaying the message and // be unlinked from Note.edits, not linger as a stale overlay. - val deletion = signer.sign(DeletionEvent.build(listOf(edit))) + val deletion = signer.sign(DeletionRequestEvent.build(listOf(edit))) LocalCache.checkDeletionAndConsume(deletion, buzzRelay, false) assertNull("a deleted edit must no longer overlay its message", target.latestBuzzEdit()) diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/HomeFeedTypeTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/HomeFeedTypeTest.kt index e9580d0ac5..7b96f5a90d 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/HomeFeedTypeTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/HomeFeedTypeTest.kt @@ -24,10 +24,10 @@ import com.vitorpamplona.amethyst.commons.model.HomeFeedType import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent -import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent +import com.vitorpamplona.quartz.nip71Video.AddressableNormalVideoEvent +import com.vitorpamplona.quartz.nip71Video.AddressableShortVideoEvent import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent import com.vitorpamplona.quartz.nip71Video.VideoShortEvent -import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull @@ -76,8 +76,8 @@ class HomeFeedTypeTest { fun picturesVideosShortsAndTorrentsOwnTheirKinds() { assertEquals(listOf(PictureEvent.KIND), HomeFeedType.PICTURES.kinds) // Long-form videos are the horizontal/normal kinds; vertical/short kinds belong to Shorts. - assertEquals(listOf(VideoNormalEvent.KIND, VideoHorizontalEvent.KIND), HomeFeedType.VIDEOS.kinds) - assertEquals(listOf(VideoShortEvent.KIND, VideoVerticalEvent.KIND), HomeFeedType.SHORTS.kinds) + assertEquals(listOf(VideoNormalEvent.KIND, AddressableNormalVideoEvent.KIND), HomeFeedType.VIDEOS.kinds) + assertEquals(listOf(VideoShortEvent.KIND, AddressableShortVideoEvent.KIND), HomeFeedType.SHORTS.kinds) assertEquals(listOf(TorrentEvent.KIND), HomeFeedType.TORRENTS.kinds) } diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavFilterTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavFilterTest.kt index a8872f4ad3..3d06dfb3d5 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavFilterTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavFilterTest.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds import com.vitorpamplona.amethyst.commons.model.topNavFeeds.favoriteAlgoFeeds.FavoriteAlgoFeedTopNavFilter import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test @@ -34,7 +34,7 @@ class FavoriteAlgoFeedTopNavFilterTest { private fun longFormNote( pubkey: String, dTag: String, - ) = LongTextNoteEvent( + ) = LongFormContentEvent( id = "0".repeat(64), pubKey = pubkey, createdAt = 1, diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/NappletProtocolJsonTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/NappletProtocolJsonTest.kt index 1f2422de4b..93399e85a2 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/NappletProtocolJsonTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/NappletProtocolJsonTest.kt @@ -184,6 +184,13 @@ class NappletProtocolJsonTest { assertEquals("Hi", up.bytes.decodeToString()) } + @Test + fun uploadAcceptsUnpaddedBase64() { + // java.util.Base64 decoded "SGk" (no '=') as "Hi"; the commonMain decoder must too. + val up = NappletProtocolJson.decodeRequest("""{"type":"upload.upload","request":{"dataBase64":"SGk"}}""") as NappletRequest.UploadBlob + assertEquals("Hi", up.bytes.decodeToString()) + } + @Test fun unknownTypeDecodesToNull() { assertNull(NappletProtocolJson.decodeRequest("""{"type":"inc.emit","id":"1"}""")) diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/navigation/DrawerItemVisibilityTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/navigation/DrawerItemVisibilityTest.kt index 693f3c0d26..11ecb7f0f8 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/navigation/DrawerItemVisibilityTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/navigation/DrawerItemVisibilityTest.kt @@ -21,8 +21,8 @@ package com.vitorpamplona.amethyst.navigation import com.vitorpamplona.amethyst.commons.model.navigation.DrawerItemVisibility +import com.vitorpamplona.amethyst.commons.model.navigation.DrawerSectionId import com.vitorpamplona.amethyst.commons.model.navigation.NavBarItem -import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerSectionId import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerSectionVisibility import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerSections import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.DrawerSettingsState diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/navigation/DrawerSectionsTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/navigation/DrawerSectionsTest.kt index b61e2d5e1f..49b0ed57b0 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/navigation/DrawerSectionsTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/navigation/DrawerSectionsTest.kt @@ -20,13 +20,13 @@ */ package com.vitorpamplona.amethyst.navigation +import com.vitorpamplona.amethyst.commons.model.navigation.DrawerSectionId import com.vitorpamplona.amethyst.commons.model.navigation.MandatoryDrawerItems +import com.vitorpamplona.amethyst.commons.model.navigation.drawerSectionIdsFromNames +import com.vitorpamplona.amethyst.commons.model.navigation.toNames import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarCatalog -import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerSectionId import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerSections import com.vitorpamplona.amethyst.ui.navigation.drawer.SdkGatedDrawerItems -import com.vitorpamplona.amethyst.ui.navigation.drawer.drawerSectionIdsFromNames -import com.vitorpamplona.amethyst.ui.navigation.drawer.toNames import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/DelayingTestSigner.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/DelayingTestSigner.kt index ff18cabafc..9cb566f031 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/DelayingTestSigner.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/DelayingTestSigner.kt @@ -25,7 +25,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal -import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapRequestEvent import kotlinx.coroutines.delay /** @@ -74,7 +74,7 @@ class DelayingTestSigner( fromPublicKey: HexKey, ) = unsupported() - override suspend fun decryptZapEvent(event: LnZapRequestEvent) = unsupported() + override suspend fun decryptZapEvent(event: ZapRequestEvent) = unsupported() override suspend fun deriveKey(nonce: HexKey) = unsupported() diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/pow/PowAndUsageFileFormatTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/pow/PowAndUsageFileFormatTest.kt index dbea72be3d..91583920c1 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/pow/PowAndUsageFileFormatTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/pow/PowAndUsageFileFormatTest.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.service.pow import com.vitorpamplona.amethyst.commons.service.pow.PersistedPoWJob +import com.vitorpamplona.amethyst.commons.service.pow.PoWJobsFile import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageStore import kotlinx.serialization.json.Json import org.junit.Assert.assertEquals @@ -43,7 +44,7 @@ class PowAndUsageFileFormatTest { } private val powSample = - PowJobsFile( + PoWJobsFile( version = 1, jobs = listOf( @@ -79,7 +80,7 @@ class PowAndUsageFileFormatTest { @Test fun powJobsFromTheJacksonBuildStillLoad() { - val loaded = json.decodeFromString(POW_JACKSON_OUTPUT) + val loaded = json.decodeFromString(POW_JACKSON_OUTPUT) assertEquals(1, loaded.jobs.size) val job = loaded.jobs.first() @@ -113,7 +114,7 @@ class PowAndUsageFileFormatTest { assertEquals( "j1", json - .decodeFromString(pow) + .decodeFromString(pow) .jobs .first() .id, diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterGroupNotificationsToPubkeyTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterGroupNotificationsToPubkeyTest.kt index 475a99e29b..d87a15fded 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterGroupNotificationsToPubkeyTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterGroupNotificationsToPubkeyTest.kt @@ -58,7 +58,7 @@ class FilterGroupNotificationsToPubkeyTest { assertTrue(GroupNotificationKinds.contains(9)) // ChatEvent assertTrue(GroupNotificationKinds.contains(1111)) // CommentEvent assertTrue(GroupNotificationKinds.contains(7)) // ReactionEvent - assertTrue(GroupNotificationKinds.contains(9735)) // LnZapEvent + assertTrue(GroupNotificationKinds.contains(9735)) // ZapReceiptEvent } @Test diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt index 9eca6b4480..6f0365319d 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt @@ -43,8 +43,8 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import com.vitorpamplona.quartz.nip57Zaps.PrivateZapEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapRequestEvent import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.CoroutineScope @@ -650,7 +650,7 @@ class MeteringNostrSignerTest { fromPublicKey: HexKey, ) = "dec44" - override suspend fun decryptZapEvent(event: LnZapRequestEvent): LnZapPrivateEvent = throw UnsupportedOperationException("fake") + override suspend fun decryptZapEvent(event: ZapRequestEvent): PrivateZapEvent = throw UnsupportedOperationException("fake") override suspend fun deriveKey(nonce: HexKey): HexKey = "bb".repeat(32) diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorkGateTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorkGateTest.kt index e346274630..d3028575ea 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorkGateTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorkGateTest.kt @@ -57,7 +57,7 @@ class ScheduledPostWorkGateTest { decisions.clear() } - private fun newStore() = ScheduledPostStore(file) + private fun newStore() = ScheduledPostStore(file.path) private fun TestScope.startGate(store: ScheduledPostStore) = ScheduledPostWorkGate( diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsVideoEventBuilderTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsVideoEventBuilderTest.kt index 1d7223b49c..114660dbb0 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsVideoEventBuilderTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsVideoEventBuilderTest.kt @@ -25,8 +25,8 @@ import com.davotoula.lightcompressor.hls.HlsRenditionSummary import com.davotoula.lightcompressor.hls.HlsUploaded import com.davotoula.lightcompressor.hls.Rendition import com.vitorpamplona.amethyst.service.uploads.MediaUploadResult -import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent -import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent +import com.vitorpamplona.quartz.nip71Video.AddressableNormalVideoEvent +import com.vitorpamplona.quartz.nip71Video.AddressableShortVideoEvent import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull @@ -122,7 +122,7 @@ class HlsVideoEventBuilderTest { assertTrue("expected Horizontal template", result.template is HlsVideoEventTemplate.Horizontal) val template = (result.template as HlsVideoEventTemplate.Horizontal).template - assertEquals(VideoHorizontalEvent.KIND, template.kind) + assertEquals(AddressableNormalVideoEvent.KIND, template.kind) assertEquals("A cool video", template.content) } @@ -132,7 +132,7 @@ class HlsVideoEventBuilderTest { assertTrue("expected Vertical template", result.template is HlsVideoEventTemplate.Vertical) val template = (result.template as HlsVideoEventTemplate.Vertical).template - assertEquals(VideoVerticalEvent.KIND, template.kind) + assertEquals(AddressableShortVideoEvent.KIND, template.kind) } @Test diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/RouteForPointerTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/RouteForPointerTest.kt index 92337c0f2e..19964c6862 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/RouteForPointerTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/RouteForPointerTest.kt @@ -31,12 +31,12 @@ import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent -import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent -import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent +import com.vitorpamplona.quartz.nip71Video.AddressableNormalVideoEvent +import com.vitorpamplona.quartz.nip71Video.AddressableShortVideoEvent import com.vitorpamplona.quartz.utils.EventFactory import io.mockk.mockk import org.junit.Assert.assertEquals @@ -73,7 +73,7 @@ class RouteForPointerTest { // the wrap has to be opened before it can say anything assertNull(routeForPointer(GiftWrapEvent.KIND, id)) // addressables are cited by `a` tag, not by id - assertNull(routeForPointer(LongTextNoteEvent.KIND, id)) + assertNull(routeForPointer(LongFormContentEvent.KIND, id)) // a kind:0 IS the person — Route.Profile, not a note assertNull(routeForPointer(MetadataEvent.KIND, id)) } @@ -111,7 +111,7 @@ class RouteForPointerTest { */ @Test fun addressableVideoKindsDoNotTakeTheShortcut() { - assertNull(routeForPointer(VideoHorizontalEvent.KIND, id)) - assertNull(routeForPointer(VideoVerticalEvent.KIND, id)) + assertNull(routeForPointer(AddressableNormalVideoEvent.KIND, id)) + assertNull(routeForPointer(AddressableShortVideoEvent.KIND, id)) } } diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/note/share/QrPayloadTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/note/share/QrPayloadTest.kt index 72a8c43723..e00804aded 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/note/share/QrPayloadTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/note/share/QrPayloadTest.kt @@ -26,7 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue @@ -79,9 +79,9 @@ class QrPayloadTest { runTest { // build(description, title, summary, image, publishedAt, dTag, createdAt, init) // — `title` is a required positional; `dTag` must be named. - // LongTextNoteEvent.kt:148-157. + // LongFormContentEvent.kt:148-157. val event = - LongTextNoteEvent + LongFormContentEvent .build("body", "My Article", dTag = "my-article") {} .let { aliceSigner.sign(it) } val note = AddressableNote(event.address()) diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/note/share/SharedNoteCardTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/note/share/SharedNoteCardTest.kt index 0e70df1ace..7d76a5852f 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/note/share/SharedNoteCardTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/note/share/SharedNoteCardTest.kt @@ -25,7 +25,7 @@ import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder import com.vitorpamplona.quartz.nip92IMeta.imetas import kotlinx.coroutines.test.runTest @@ -129,7 +129,7 @@ class SharedNoteCardTest { fun article_prefersItsTitle() = runTest { val note = - LongTextNoteEvent + LongFormContentEvent .build("the body", "My Article", dTag = "a1") {} .let { aliceSigner.sign(it) } diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupPresentationLabelsTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupPresentationLabelsTest.kt new file mode 100644 index 0000000000..bd28de71ee --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/backups/BackupPresentationLabelsTest.kt @@ -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.ui.screen.loggedIn.backups + +import com.vitorpamplona.quartz.nip01Core.diff.EventDiff +import com.vitorpamplona.quartz.nip01Core.diff.ListDiff +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataDiff +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListDiff +import org.jetbrains.compose.resources.StringResource +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * rememberPresentation resolves labels in composition and hands presentationOf a lookup + * over exactly [labelsWrittenBy]; a label presentationOf asks for that is not in that list + * crashes the review screen. presentationOf asks for every label of a diff type + * regardless of which fields changed, so an empty diff of each type exercises them all. + */ +class BackupPresentationLabelsTest { + private fun empty() = ListDiff(emptyList(), emptyList(), emptyList()) + + private fun asked(diff: EventDiff): Set { + val asked = mutableSetOf() + presentationOf(diff) { + asked.add(it) + "" + } + return asked + } + + @Test + fun metadataLabelsAreAllProvided() { + val diff = MetadataDiff(null, null, null, null, null, null, null, null, null, null, null, null, null, empty(), empty()) + assertEquals(labelsWrittenBy(diff).toSet(), asked(diff)) + } + + @Test + fun relayListLabelsAreAllProvided() { + val diff = AdvertisedRelayListDiff(empty()) + assertEquals(labelsWrittenBy(diff).toSet(), asked(diff)) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationTagsAnEventByUserTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationTagsAnEventByUserTest.kt index 786c4eda28..f57bf851e3 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationTagsAnEventByUserTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationTagsAnEventByUserTest.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.dal import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.quartz.nip22Comments.CommentEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test @@ -81,10 +81,10 @@ class NotificationTagsAnEventByUserTest { 1000, arrayOf( arrayOf("E", zapId, "", wallet), - arrayOf("K", LnZapEvent.KIND.toString()), + arrayOf("K", ZapReceiptEvent.KIND.toString()), arrayOf("P", wallet), arrayOf("e", zapId, "", wallet), - arrayOf("k", LnZapEvent.KIND.toString()), + arrayOf("k", ZapReceiptEvent.KIND.toString()), arrayOf("p", wallet), arrayOf("p", me), ), @@ -109,10 +109,10 @@ class NotificationTagsAnEventByUserTest { 1000, arrayOf( arrayOf("E", zapId, "", wallet), - arrayOf("K", LnZapEvent.KIND.toString()), + arrayOf("K", ZapReceiptEvent.KIND.toString()), arrayOf("P", wallet), arrayOf("e", zapId, "", wallet), - arrayOf("k", LnZapEvent.KIND.toString()), + arrayOf("k", ZapReceiptEvent.KIND.toString()), arrayOf("p", wallet), ), "nice zap", diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/tor/TorManagerTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/tor/TorManagerTest.kt index a6ea38384c..5b472c33fe 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/tor/TorManagerTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/tor/TorManagerTest.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.tor +import com.vitorpamplona.amethyst.commons.tor.TorPreferencesPort import com.vitorpamplona.amethyst.commons.tor.TorType import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.awaitCancellation diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/tor/TorSettingsTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/tor/TorSettingsTest.kt deleted file mode 100644 index d7361f9602..0000000000 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/tor/TorSettingsTest.kt +++ /dev/null @@ -1,305 +0,0 @@ -/* - * 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.tor - -import com.vitorpamplona.amethyst.commons.tor.TorPresetType -import com.vitorpamplona.amethyst.commons.tor.TorSettings -import com.vitorpamplona.amethyst.commons.tor.TorType -import com.vitorpamplona.amethyst.commons.tor.isPreset -import com.vitorpamplona.amethyst.commons.tor.parseTorPresetType -import com.vitorpamplona.amethyst.commons.tor.parseTorType -import com.vitorpamplona.amethyst.commons.tor.torDefaultPreset -import com.vitorpamplona.amethyst.commons.tor.torFullyPrivate -import com.vitorpamplona.amethyst.commons.tor.torOnlyWhenNeededPreset -import com.vitorpamplona.amethyst.commons.tor.torSmallPayloadsPreset -import com.vitorpamplona.amethyst.commons.tor.whichPreset -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNotEquals -import org.junit.Assert.assertTrue -import org.junit.Test - -class TorSettingsTest { - // --- parseTorType --- - - @Test - fun parseTorType_code0_returnsOff() { - assertEquals(TorType.OFF, parseTorType(0)) - } - - @Test - fun parseTorType_code1_returnsInternal() { - assertEquals(TorType.INTERNAL, parseTorType(1)) - } - - @Test - fun parseTorType_code2_returnsExternal() { - assertEquals(TorType.EXTERNAL, parseTorType(2)) - } - - @Test - fun parseTorType_null_defaultsToInternal() { - assertEquals(TorType.INTERNAL, parseTorType(null)) - } - - @Test - fun parseTorType_unknownCode_defaultsToInternal() { - assertEquals(TorType.INTERNAL, parseTorType(99)) - } - - @Test - fun parseTorType_negativeCode_defaultsToInternal() { - assertEquals(TorType.INTERNAL, parseTorType(-1)) - } - - // --- TorType screenCode consistency --- - - @Test - fun torType_screenCodes_areUnique() { - val codes = TorType.entries.map { it.screenCode } - assertEquals(codes.size, codes.toSet().size) - } - - @Test - fun torType_allValues_roundTripViaParse() { - TorType.entries.forEach { type -> - assertEquals(type, parseTorType(type.screenCode)) - } - } - - // --- parseTorPresetType --- - - @Test - fun parseTorPresetType_code0_returnsOnlyWhenNeeded() { - assertEquals(TorPresetType.ONLY_WHEN_NEEDED, parseTorPresetType(0)) - } - - @Test - fun parseTorPresetType_code1_returnsDefault() { - assertEquals(TorPresetType.DEFAULT, parseTorPresetType(1)) - } - - @Test - fun parseTorPresetType_code2_returnsSmallPayloads() { - assertEquals(TorPresetType.SMALL_PAYLOADS, parseTorPresetType(2)) - } - - @Test - fun parseTorPresetType_code3_returnsFullPrivacy() { - assertEquals(TorPresetType.FULL_PRIVACY, parseTorPresetType(3)) - } - - @Test - fun parseTorPresetType_unknownCode_defaultsToCustom() { - assertEquals(TorPresetType.CUSTOM, parseTorPresetType(99)) - } - - @Test - fun parseTorPresetType_null_defaultsToCustom() { - assertEquals(TorPresetType.CUSTOM, parseTorPresetType(null)) - } - - @Test - fun torPresetType_screenCodes_areUnique() { - val codes = TorPresetType.entries.map { it.screenCode } - assertEquals(codes.size, codes.toSet().size) - } - - // --- Preset definitions --- - - @Test - fun onlyWhenNeededPreset_onlyOnionEnabled() { - assertTrue(torOnlyWhenNeededPreset.onionRelaysViaTor) - assertFalse(torOnlyWhenNeededPreset.dmRelaysViaTor) - assertFalse(torOnlyWhenNeededPreset.newRelaysViaTor) - assertFalse(torOnlyWhenNeededPreset.trustedRelaysViaTor) - assertFalse(torOnlyWhenNeededPreset.urlPreviewsViaTor) - assertFalse(torOnlyWhenNeededPreset.profilePicsViaTor) - assertFalse(torOnlyWhenNeededPreset.imagesViaTor) - assertFalse(torOnlyWhenNeededPreset.videosViaTor) - assertFalse(torOnlyWhenNeededPreset.moneyOperationsViaTor) - assertFalse(torOnlyWhenNeededPreset.nip05VerificationsViaTor) - assertFalse(torOnlyWhenNeededPreset.mediaUploadsViaTor) - } - - @Test - fun defaultPreset_onionDmNewEnabled() { - assertTrue(torDefaultPreset.onionRelaysViaTor) - assertTrue(torDefaultPreset.dmRelaysViaTor) - assertTrue(torDefaultPreset.newRelaysViaTor) - assertFalse(torDefaultPreset.trustedRelaysViaTor) - assertFalse(torDefaultPreset.urlPreviewsViaTor) - assertFalse(torDefaultPreset.imagesViaTor) - assertFalse(torDefaultPreset.videosViaTor) - assertFalse(torDefaultPreset.moneyOperationsViaTor) - assertFalse(torDefaultPreset.nip05VerificationsViaTor) - assertFalse(torDefaultPreset.mediaUploadsViaTor) - } - - @Test - fun smallPayloadsPreset_addsPreviewsNip05Money() { - assertTrue(torSmallPayloadsPreset.onionRelaysViaTor) - assertTrue(torSmallPayloadsPreset.dmRelaysViaTor) - assertTrue(torSmallPayloadsPreset.newRelaysViaTor) - assertTrue(torSmallPayloadsPreset.trustedRelaysViaTor) - assertTrue(torSmallPayloadsPreset.urlPreviewsViaTor) - assertTrue(torSmallPayloadsPreset.profilePicsViaTor) - assertFalse(torSmallPayloadsPreset.imagesViaTor) - assertFalse(torSmallPayloadsPreset.videosViaTor) - assertTrue(torSmallPayloadsPreset.moneyOperationsViaTor) - assertTrue(torSmallPayloadsPreset.nip05VerificationsViaTor) - assertFalse(torSmallPayloadsPreset.mediaUploadsViaTor) - } - - @Test - fun fullPrivacyPreset_allEnabled() { - assertTrue(torFullyPrivate.onionRelaysViaTor) - assertTrue(torFullyPrivate.dmRelaysViaTor) - assertTrue(torFullyPrivate.newRelaysViaTor) - assertTrue(torFullyPrivate.trustedRelaysViaTor) - assertTrue(torFullyPrivate.urlPreviewsViaTor) - assertTrue(torFullyPrivate.profilePicsViaTor) - assertTrue(torFullyPrivate.imagesViaTor) - assertTrue(torFullyPrivate.videosViaTor) - assertTrue(torFullyPrivate.moneyOperationsViaTor) - assertTrue(torFullyPrivate.nip05VerificationsViaTor) - assertTrue(torFullyPrivate.mediaUploadsViaTor) - } - - // --- Preset hierarchy: each level is a superset of the previous --- - - @Test - fun presets_areIncreasing_defaultSupersetOfOnlyWhenNeeded() { - // Default enables DM + new relays on top of onlyWhenNeeded - assertTrue(torDefaultPreset.dmRelaysViaTor) - assertTrue(torDefaultPreset.newRelaysViaTor) - assertFalse(torOnlyWhenNeededPreset.dmRelaysViaTor) - assertFalse(torOnlyWhenNeededPreset.newRelaysViaTor) - } - - @Test - fun presets_areIncreasing_fullPrivacySupersetOfSmallPayloads() { - // Full privacy adds images, videos, media uploads - assertTrue(torFullyPrivate.imagesViaTor) - assertTrue(torFullyPrivate.videosViaTor) - assertTrue(torFullyPrivate.mediaUploadsViaTor) - assertFalse(torSmallPayloadsPreset.imagesViaTor) - assertFalse(torSmallPayloadsPreset.videosViaTor) - assertFalse(torSmallPayloadsPreset.mediaUploadsViaTor) - } - - // --- whichPreset --- - - @Test - fun whichPreset_matchesOnlyWhenNeeded() { - assertEquals(TorPresetType.ONLY_WHEN_NEEDED, whichPreset(torOnlyWhenNeededPreset)) - } - - @Test - fun whichPreset_matchesDefault() { - assertEquals(TorPresetType.DEFAULT, whichPreset(torDefaultPreset)) - } - - @Test - fun whichPreset_matchesSmallPayloads() { - assertEquals(TorPresetType.SMALL_PAYLOADS, whichPreset(torSmallPayloadsPreset)) - } - - @Test - fun whichPreset_matchesFullPrivacy() { - assertEquals(TorPresetType.FULL_PRIVACY, whichPreset(torFullyPrivate)) - } - - @Test - fun whichPreset_returnsCustomForMixedSettings() { - val mixed = - TorSettings( - onionRelaysViaTor = true, - dmRelaysViaTor = true, - newRelaysViaTor = false, // differs from DEFAULT - trustedRelaysViaTor = true, // differs from DEFAULT - ) - assertEquals(TorPresetType.CUSTOM, whichPreset(mixed)) - } - - @Test - fun whichPreset_ignoresProfilePicsInComparison() { - // profilePicsViaTor is commented out in isPreset() - val withProfilePics = torDefaultPreset.copy(profilePicsViaTor = true) - assertEquals(TorPresetType.DEFAULT, whichPreset(withProfilePics)) - } - - @Test - fun whichPreset_ignoresTorTypeAndPort() { - // whichPreset only compares boolean flags, not torType/port - val withExternal = torDefaultPreset.copy(torType = TorType.EXTERNAL, externalSocksPort = 1234) - assertEquals(TorPresetType.DEFAULT, whichPreset(withExternal)) - } - - // --- isPreset --- - - @Test - fun isPreset_exactMatch_returnsTrue() { - assertTrue(isPreset(torFullyPrivate, torFullyPrivate)) - } - - @Test - fun isPreset_differentFlag_returnsFalse() { - val modified = torFullyPrivate.copy(imagesViaTor = false) - assertFalse(isPreset(modified, torFullyPrivate)) - } - - @Test - fun isPreset_torTypeDifference_ignored() { - val withOff = torDefaultPreset.copy(torType = TorType.OFF) - assertTrue(isPreset(withOff, torDefaultPreset)) - } - - // --- TorSettings data class --- - - @Test - fun torSettings_defaultValues() { - val defaults = TorSettings() - assertEquals(TorType.INTERNAL, defaults.torType) - assertEquals(9050, defaults.externalSocksPort) - assertTrue(defaults.onionRelaysViaTor) - assertTrue(defaults.dmRelaysViaTor) - assertTrue(defaults.newRelaysViaTor) - assertFalse(defaults.trustedRelaysViaTor) - } - - @Test - fun torSettings_equality_worksForDistinctUntilChanged() { - val a = TorSettings(torType = TorType.INTERNAL, externalSocksPort = 9050) - val b = TorSettings(torType = TorType.INTERNAL, externalSocksPort = 9050) - assertEquals(a, b) - assertEquals(a.hashCode(), b.hashCode()) - } - - @Test - fun torSettings_copy_changesOneField() { - val original = TorSettings() - val modified = original.copy(torType = TorType.OFF) - assertEquals(TorType.OFF, modified.torType) - assertEquals(original.externalSocksPort, modified.externalSocksPort) - assertNotEquals(original, modified) - } -} diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/GiftWrapBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/GiftWrapBenchmark.kt index 34431d9e3c..60e6b13cd7 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/GiftWrapBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/GiftWrapBenchmark.kt @@ -30,7 +30,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip17Dm.NIP17Factory import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent -import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent +import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import kotlinx.coroutines.runBlocking import org.junit.Assert @@ -78,7 +78,7 @@ class GiftWrapBenchmark { val event = it.unwrapThrowing(keyToUse) event.checkSignature() - if (event is SealedRumorEvent) { + if (event is SealEvent) { val innerData = event.unsealThrowing(keyToUse) Assert.assertEquals(message, innerData.content) } else { @@ -116,7 +116,7 @@ class GiftWrapBenchmark { val seal = wrap.unwrapThrowing(keyToUse) seal.checkSignature() - if (seal is SealedRumorEvent) { + if (seal is SealEvent) { val innerData = seal.unsealThrowing(keyToUse) Assert.assertEquals(message, innerData.content) } else { diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/GiftWrapReceivingBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/GiftWrapReceivingBenchmark.kt index 62c3e676af..2501bb7845 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/GiftWrapReceivingBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/GiftWrapReceivingBenchmark.kt @@ -37,7 +37,7 @@ import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning import com.vitorpamplona.quartz.nip44Encryption.Nip44 import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiser import com.vitorpamplona.quartz.nip59Giftwrap.rumors.Rumor -import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent +import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import junit.framework.TestCase.assertNotNull import kotlinx.coroutines.runBlocking @@ -62,7 +62,7 @@ class GiftWrapReceivingBenchmark { runBlocking { GiftWrapEvent.create( event = - SealedRumorEvent.create( + SealEvent.create( event = sender.sign( ChatMessageEvent.build( @@ -84,7 +84,7 @@ class GiftWrapReceivingBenchmark { fun createSeal( sender: NostrSigner, receiver: NostrSigner, - ): SealedRumorEvent = + ): SealEvent = runBlocking { val msg = sender.sign( @@ -101,7 +101,7 @@ class GiftWrapReceivingBenchmark { }, ) - SealedRumorEvent.create( + SealEvent.create( event = msg, encryptTo = receiver.pubKey, signer = sender, diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/GiftWrapSigningBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/GiftWrapSigningBenchmark.kt index 1395ab7202..796f2c309e 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/GiftWrapSigningBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/GiftWrapSigningBenchmark.kt @@ -30,7 +30,7 @@ import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent import com.vitorpamplona.quartz.nip17Dm.messages.changeSubject import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiser -import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent +import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import kotlinx.coroutines.runBlocking import org.junit.Rule @@ -95,7 +95,7 @@ class GiftWrapSigningBenchmark { benchmarkRule.measureRepeated { runBlocking { - SealedRumorEvent.create( + SealEvent.create( event = msg, encryptTo = receiver.pubKey, signer = sender, @@ -128,7 +128,7 @@ class GiftWrapSigningBenchmark { val seal = runBlocking { - SealedRumorEvent.create( + SealEvent.create( event = msg, encryptTo = receiver.pubKey, signer = sender, @@ -169,7 +169,7 @@ class GiftWrapSigningBenchmark { val seal = runBlocking { - SealedRumorEvent.create( + SealEvent.create( event = msg, encryptTo = receiver.pubKey, signer = sender, diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/LargeDBInsertBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/LargeDBInsertBenchmark.kt index 228f247b89..5882a26a03 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/LargeDBInsertBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/LargeDBInsertBenchmark.kt @@ -28,7 +28,7 @@ import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore -import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent import com.vitorpamplona.quartz.nip40Expiration.isExpired import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.flattenToSet @@ -72,7 +72,7 @@ class LargeDBInsertBenchmark : BaseLargeCacheBenchmark() { @Test fun bench40DeletionRequestsEvents() { val context = ApplicationProvider.getApplicationContext() - val deletions = allEvents.filterIsInstance() + val deletions = allEvents.filterIsInstance() val deletionIds = deletions.map { it.deleteEventIds() }.flattenToSet() val deletionAddresses = deletions.map { it.deleteAddressIds() }.flattenToSet() diff --git a/cli/README.md b/cli/README.md index d031074438..359e2c5787 100644 --- a/cli/README.md +++ b/cli/README.md @@ -680,6 +680,65 @@ also carried on-relay as an encrypted kind:13302. | `amy concord grant COMMUNITY USER ROLE-ID` | Grant a role to a member. | | `amy concord ban COMMUNITY USER` / `unban COMMUNITY USER` | Ban / unban a member. | +### cordn (MLS over an MCP coordinator) + +MLS group chat where delivery is one **coordinator** — an MCP server reached +over ContextVM — instead of relays. State lives under +`~/.amy//cordn/`, encrypted with a key at +`~/.amy//cordn/blob.key`; the coordinator list is encrypted beside it. + +Two things shape every verb: + +- **A `gid` is unique only within one coordinator** (`spec/00.md` §4), so + `--coordinator` is part of a group's address. Every live verb takes + `[--coordinator PK] [--relay URL[,URL…]]`, and you can leave them off only + while exactly one coordinator is remembered. A coordinator has no address + besides its pubkey (§8.5), which is why its relays have to be remembered + rather than looked up. +- **Delivery is pulled, not pushed.** A CLI run is a process and cannot hold a + subscription, so `amy cordn fetch` drains what the cursor has not seen and + exits. Nothing arrives while amy is not running; the coordinator holds the + ordered stream until asked. + +| Command | What it does | +|---|---| +| `amy cordn coordinator add --coordinator PK --relay URL[,URL…] [--label L]` | Remember a coordinator. Local only — nothing is announced to it. | +| `amy cordn coordinator list` | Coordinators this account knows, with their relays. | +| `amy cordn coordinator info` | The MCP `initialize` handshake. Every field is a claim the coordinator signed with the key that was already answering (§8.5) — the pubkey is the identity, the name is what an operator typed. | +| `amy cordn coordinator forget --coordinator PK` | Drop it from the list. Local: the group state on disk is kept, and so is anything the coordinator holds. | +| `amy cordn keypackage publish [--last-resort] [--count N]` | Publish KeyPackages. **Attributable** (§8.4): a signed, re-servable record that this npub uses cordn here. | +| `amy cordn keypackage list` | Ours on the coordinator, and whether this device holds the private half that could open the Welcome one produces. | +| `amy cordn keypackage withdraw --kp-ref REF[,REF…] \| --all` | Remove them. Does not undo the exposure — that already happened. | +| `amy cordn group create --name N [--about A] [--gid GID] [--admin PK[,PK…]] [--icon I] [--image URL]` | Create a group. The `gid` is ours to choose and the coordinator never interprets it (§4); random unless given. No `--admin` means egalitarian **permanently** (`spec/01.md` §5.3). | +| `amy cordn group list` | Groups on this coordinator, with epoch and member count. | +| `amy cordn group info [--gid GID]` | Metadata, members, the shareable `cordn1…` ref, and what this coordinator learns about the group. | +| `amy cordn invite --pubkey PK [--gid GID] [--kp-ref REF]` | Take their KeyPackage, verify the publication payload binds it to that npub, commit, and leave a Welcome. | +| `amy cordn request --gid GID \| --ref cordn1…` | Ask to join. Publishes a KeyPackage first if none exists. Attributable (§8.1), and asking is not joining. | +| `amy cordn requests list` | Who is asking to join a group we hold. | +| `amy cordn requests accept --pubkey PK \| --all` / `decline` | Answer them. Any member may — `admin_pubkeys` is presentation metadata with nothing enforcing it (§5.3). | +| `amy cordn welcomes` | Open every pending invitation **without joining**: a Welcome is opaque until processed, so the gid, name and members can only be shown after opening it. | +| `amy cordn join --gid GID \| --all` / `decline` | Accept or refuse one. | +| `amy cordn send --text "…" [--gid GID]` | A kind-9 chat message. | +| `amy cordn send [--reply-to ID \| --react-to ID \| --edit ID \| --delete ID \| --pin ID \| --unpin ID] --to-author PK [--to-kind N]` | Annotate a message. `--to-author` is required because an annotation's tags name the target's author and kind, not just its id, and amy keeps no message store. | +| `amy cordn fetch` | Drain the stream and print it: messages, epoch changes, echoes, and anything undecryptable (reported, not hidden — it is a gap in a conversation). | +| `amy cordn ref encode --gid GID [--coordinator PK] [--relay URL[,URL…]]` | Build a `cordn1…` group reference. | +| `amy cordn ref decode REF` | Read one back. | +| `amy cordn exposure --coordinator PK [--groups N] [--published]` | What a coordinator would learn, before joining anything (§8). | + +> A group ref is a **locator, not an invitation**: holding one lets you ask to +> join, it does not make you a member, and nothing obliges anyone to answer. + +Two live harnesses, neither wired into any build — read +[`tests/cordn/stack.sh`](tests/cordn/stack.sh) first, it boots an unlicensed +reference coordinator: + +- [`tests/cordn/tier-b.sh`](tests/cordn/tier-b.sh) — amy against amy through + the reference coordinator. Proves the transport and the coordinator client. +- [`tests/cordn/interop-client.sh`](tests/cordn/interop-client.sh) — amy and + the reference client (`@cordn/cli`, MIT) in one group. Proves the MLS layer + against a second implementation, in both directions, including a + public-framed Commit of ours that their engine has to apply. + ### Geochat (Bitchat geohash channels) Bitchat-interoperable public location chat: ephemeral kind:20000 events diff --git a/cli/ROADMAP.md b/cli/ROADMAP.md index 09b80c2072..41f46bbc9c 100644 --- a/cli/ROADMAP.md +++ b/cli/ROADMAP.md @@ -71,7 +71,7 @@ Status legend: ✅ shipped · 📦 logic lives in `commons/`, needs a command · | NIP-5A nsites + NIP-5D napplets | ✅ | `NsiteCommands` + `NappletCommands` — fetch/publish/serve/list with sha256 + aggregate-hash verification and `requires` capability reporting. | | Podcasting 2.0 / podstr (`amy podcast20`) | ✅ | `Podcast20Commands` — kind:30078 metadata, 30054 episodes, 30055 trailers, list. | | Follows-of-follows (`amy fof get/list/sync`) | ✅ | `FofCommand` — single-hop social proof from the local store (`wot` kept as deprecation alias). | -| NIP-85 GrapeRank web-of-trust (`amy graperank`) | ✅ | `GrapeRankCommand` — outbox-model crawl + scoring engine in `commons/wot/` (`GrapeRank`, `TrustGraph`, `TrustGraphBuilder`); every score run persists kind:30382 `ContactCardEvent` cards to the local store (diffed against prior ranks, kind:5 retractions), `publish` mirrors that set to the operator relays via NIP-77 up-sync, `rank` reads cards back, plus `register` / `unregister` / `providers` for the kind:10040 `TrustProviderListEvent` discovery layer. | +| NIP-85 GrapeRank web-of-trust (`amy graperank`) | ✅ | `GrapeRankCommand` — outbox-model crawl + scoring engine in `commons/wot/` (`GrapeRank`, `TrustGraph`, `TrustGraphBuilder`); every score run persists kind:30382 `UserAssertionEvent` cards to the local store (diffed against prior ranks, kind:5 retractions), `publish` mirrors that set to the operator relays via NIP-77 up-sync, `rank` reads cards back, plus `register` / `unregister` / `providers` for the kind:10040 `TrustProviderListEvent` discovery layer. | | NIP-72 communities | 🆕 | | | NIP-78 app-specific data (settings sync) | 🆕 | | | Long-form (NIP-23) publish / read | 🆕 | | diff --git a/cli/plans/archive/2026-04-23-nip17-dm.md b/cli/plans/archive/2026-04-23-nip17-dm.md index 28918e37e2..7a46443a45 100644 --- a/cli/plans/archive/2026-04-23-nip17-dm.md +++ b/cli/plans/archive/2026-04-23-nip17-dm.md @@ -42,7 +42,7 @@ DMs); on `list`/`await` it filters by counter-party. | Build `kind:14` chat message template | `ChatMessageEvent.build(msg, to, …)` | `quartz/.../nip17Dm/messages/ChatMessageEvent.kt:51` | | Sign + seal + wrap (one wrap per recipient incl. self) | `NIP17Factory().createMessageNIP17(template, signer)` | `quartz/.../nip17Dm/NIP17Factory.kt:37` | | `GiftWrapEvent.create / unwrapOrNull / recipientPubKey` | `GiftWrapEvent` (kind 1059) | `quartz/.../nip59Giftwrap/wraps/GiftWrapEvent.kt` | -| Inner seal layer (kind 13) | `SealedRumorEvent` | `quartz/.../nip59Giftwrap/seals/SealedRumorEvent.kt` | +| Inner seal layer (kind 13) | `SealEvent` | `quartz/.../nip59Giftwrap/seals/SealEvent.kt` | | NIP-44 v2 encrypt/decrypt (called from wrap/seal) | `Nip44` singleton | `quartz/.../nip44Encryption/Nip44.kt` | | Resolve recipient relay lists (10050 / 10051 / 10002) | `RecipientRelayFetcher.fetchRelayLists` | `quartz/.../marmot/RecipientRelayFetcher.kt:80` | | Publish + per-relay ACK | `Context.publish(event, relays)` | `cli/.../Context.kt:154` | @@ -58,7 +58,7 @@ Everything above is already on the JVM classpath of the `cli` module. | Piece | Status | Current | Target | Notes | |---|---|---|---|---| -| `NIP17Factory`, `GiftWrapEvent`, `SealedRumorEvent`, `Nip44`, `ChatMessageEvent` | ✅ Reuse | `quartz/` | — | Pure protocol; CLI calls directly. | +| `NIP17Factory`, `GiftWrapEvent`, `SealEvent`, `Nip44`, `ChatMessageEvent` | ✅ Reuse | `quartz/` | — | Pure protocol; CLI calls directly. | | `RecipientRelayFetcher.fetchRelayLists` | ✅ Reuse | `quartz/.../marmot/` | — | Already used by `AwaitCommands.awaitKeyPackage`; same call shape. | | `filterGiftWrapsToPubkey` | 📦 Extract | `amethyst/.../service/relayClient/reqCommand/account/nip59GiftWraps/FilterGiftWrapsToPubkey.kt:31` | `commons/.../relayClient/nip17Dm/FilterGiftWrapsToPubkey.kt` | Pure function, no Android deps. Update Android caller (`AccountGiftWrapsEoseManager.kt:55`) in the same commit. | | Per-recipient relay routing (kind:10050 → 10002 read → bootstrap) | 🆕 New (in CLI) | inline inside `Account.computeRelayListToBroadcast` (`amethyst/.../model/Account.kt:908`) | `cli/.../commands/DmCommands.kt` (private helper) | Tiny — three-line fallback. Promoting it to commons can wait until a second consumer appears. | @@ -133,7 +133,7 @@ raw = ctx.drain(filters, timeoutMs = --timeout * 1000 ?: 8_000) ↓ messages = raw.mapNotNull { (relay, event) -> if (event !is GiftWrapEvent) return@mapNotNull null - val sealed = event.unwrapOrNull(ctx.signer) as? SealedRumorEvent ?: return@mapNotNull null + val sealed = event.unwrapOrNull(ctx.signer) as? SealEvent ?: return@mapNotNull null val inner = sealed.unsealOrNull(ctx.signer) as? ChatMessageEvent ?: return@mapNotNull null DecryptedMsg(inner, relay, event.id) }.dedupBy { it.inner.id } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt index 2cd961752f..efc4c20f40 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt @@ -239,6 +239,26 @@ class DataDir( /** Inbound events this account has terminally decided about. */ val ingestDedupFile = File(marmotDir, "ingested.ids") + /** + * cordn state. `CordnStorageLayout` lays out `//` + * underneath, the same shape the Android app writes, so a directory can be + * read by either without a migration. + */ + val cordnDir = File(root, "cordn") + + /** + * The key `KeyedCordnBlobCipher` encrypts cordn blobs with. + * + * A random 32 bytes at 0600, generated on first use. Be clear about what + * that buys: the blobs are genuinely encrypted, and the key sits beside + * them, so this protects a copied directory or a stale backup and not a + * process running as this user — which is already the threat model of + * every other file under `~/.amy/`. Android's equivalent key lives in the + * KeyStore and does protect against a reader of the device's storage; a + * CLI has nowhere comparable to put one. + */ + val cordnBlobKeyFile = File(cordnDir, "blob.key") + /** * SQLite event-store DB file, a sibling of [eventsDir] under * `/shared/`. Used when the store backend is SQLite (the @@ -272,6 +292,7 @@ class DataDir( SecureFileIO.tighten(identityFile) SecureFileIO.tighten(stateFile) SecureFileIO.tighten(marmotDir) + SecureFileIO.tighten(cordnDir) SecureFileIO.tighten(keyPackageBundleFile) } } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt index be88e3861b..1db3013b82 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt @@ -66,7 +66,7 @@ import com.vitorpamplona.quartz.nip01Core.store.verifyAndInsert import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip05DnsIdentifiers.resolveUserHexOrNull import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation -import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.nip17Dm.settings.DmRelayListEvent import com.vitorpamplona.quartz.nip46RemoteSigner.signer.NostrSignerRemote import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip66RelayMonitor.reachability.RelayObserver @@ -377,6 +377,13 @@ class Context( */ val cashu: CashuContext by lazy { CashuContext(this) } + /** + * cordn wiring (coordinator registry, MLS groups, key packages) — see + * [CordnContext]. Lazy for the same reason: a run that never says `cordn` + * opens no ContextVM transport and writes no blob key. + */ + val cordn: CordnContext by lazy { CordnContext(this) } + /** See [CashuContext.ops]. */ fun cashuOps(): CashuWalletOps = cashu.ops() @@ -753,11 +760,11 @@ class Context( * for [pubKey], or `null` if Amy has never observed one. Used by * `dm send` to resolve where to deliver a wrap. */ - suspend fun dmInboxOf(pubKey: HexKey): ChatMessageRelayListEvent? = + suspend fun dmInboxOf(pubKey: HexKey): DmRelayListEvent? = store .query( - Filter(authors = listOf(pubKey), kinds = listOf(ChatMessageRelayListEvent.KIND), limit = 1), - ).firstOrNull() as? ChatMessageRelayListEvent + Filter(authors = listOf(pubKey), kinds = listOf(DmRelayListEvent.KIND), limit = 1), + ).firstOrNull() as? DmRelayListEvent /** * Latest known kind:10051 KeyPackage relay list (MIP-00) for diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/CordnContext.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/CordnContext.kt new file mode 100644 index 0000000000..5e299d73ad --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/CordnContext.kt @@ -0,0 +1,203 @@ +/* + * 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.cli + +import com.vitorpamplona.amethyst.commons.cordn.CoordinatorConfig +import com.vitorpamplona.amethyst.commons.cordn.CordnBlobCipher +import com.vitorpamplona.amethyst.commons.cordn.CordnCoordinatorRegistry +import com.vitorpamplona.amethyst.commons.cordn.CordnLinks +import com.vitorpamplona.amethyst.commons.cordn.CordnSession +import com.vitorpamplona.amethyst.commons.cordn.CordnStorageLayout +import com.vitorpamplona.amethyst.commons.cordn.FileBackedCordnScopeFactory +import com.vitorpamplona.amethyst.commons.cordn.FileCordnCoordinatorStore +import com.vitorpamplona.amethyst.commons.cordn.KeyedCordnBlobCipher +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import java.io.File + +/** + * cordn wiring for the CLI, split out of [Context] the way [CashuContext] is + * and built lazily, so a run that never says `cordn` opens no transport and + * generates no blob key. + * + * Everything below this class is shared: the registry, the manager, the + * stores, and `CordnLinks` are the same objects the Android app runs. What is + * here is the three things a CLI has to answer differently. + * + * **Where the blob key comes from.** Android hides it in the KeyStore; a CLI + * has nowhere comparable, so it is a 0600 file beside the blobs. See + * [DataDir.cordnBlobKeyFile] for what that does and does not protect. + * + * **There is no sync loop.** Each invocation is a process: it opens a + * session, does one thing, and exits. `CordnSyncLoop` is for a front end that + * stays running, and a CLI that started one would hang. So a `fetch` verb + * drains explicitly and the cursor on disk is what carries continuity between + * runs — which is exactly the primitive `spec/02.md` §7 says a cursor is. + * + * **A coordinator has to be named.** The app has a screen listing them; here + * `--coordinator` selects one, and is optional only while exactly one is + * remembered. Guessing would be worse than asking: a `gid` means nothing + * outside the coordinator that issued it (`spec/00.md` §4), so the wrong + * coordinator is not a slower answer, it is an answer about a different + * group. + */ +class CordnContext( + private val ctx: Context, +) { + private val accountPubKey: HexKey get() = ctx.identity.pubKeyHex + + private val cipher: CordnBlobCipher by lazy { KeyedCordnBlobCipher(blobKey()) } + + private val registry: CordnCoordinatorRegistry by lazy { + CordnCoordinatorRegistry( + accountPubKey = accountPubKey, + scopes = + FileBackedCordnScopeFactory( + root = ctx.dataDir.root, + cipher = cipher, + links = CordnLinks.over(ctx.signer, ctx.client), + ), + ) + } + + private val coordinatorStore by lazy { + FileCordnCoordinatorStore( + CordnStorageLayout.accountDirectoryFor(ctx.dataDir.root, accountPubKey), + cipher, + ) + } + + /** Every coordinator this account has been told about, in the order added. */ + suspend fun coordinators(): List = coordinatorStore.load() + + /** + * Adds [config], replacing any entry for the same pubkey. + * + * Keyed by pubkey because that IS the coordinator's identity (§8.5) — a + * second entry for the same key with different relays is a corrected + * address, not a second coordinator. + */ + suspend fun remember(config: CoordinatorConfig) { + val kept = coordinators().filterNot { it.pubKey == config.pubKey } + coordinatorStore.save(kept + config) + } + + suspend fun forget(coordinatorPubKey: HexKey): Boolean { + val before = coordinators() + val after = before.filterNot { it.pubKey == coordinatorPubKey } + if (after.size == before.size) return false + coordinatorStore.save(after) + registry.forget(coordinatorPubKey) + return true + } + + /** The live session for [config], restored from disk. */ + suspend fun session(config: CoordinatorConfig): CordnSession = + registry.session(config).also { + it.manager.restore() + it.keyPackages.restore() + } + + /** Where the cordn tree lives, for the migration verbs. */ + val migrationRoot: File get() = ctx.dataDir.root + + /** The at-rest cipher, so a migration can read and write the same blobs. */ + val blobCipher: CordnBlobCipher get() = cipher + + /** Replaces the remembered coordinator list, after adopting a migration. */ + suspend fun replaceCoordinators(configs: List) = coordinatorStore.save(configs) + + /** + * Picks the coordinator a command should act on. + * + * `--coordinator` wins. With no flag and exactly one remembered, that one + * is used; with none or several, the caller is asked rather than guessed + * at. `--relay` is accepted alongside a pubkey so a first call can name a + * coordinator that is not remembered yet — that is how `coordinator add` + * and a one-shot `--coordinator … --relay …` are the same code path. + */ + suspend fun resolve(args: Args): CoordinatorConfig { + val requested = args.flag("coordinator") + val relays = relayFlag(args) + val known = coordinators() + + if (requested == null) { + if (relays.isNotEmpty()) throw CordnArgException("--relay needs --coordinator to belong to") + return when (known.size) { + 0 -> throw CordnArgException("no coordinator known yet: amy cordn coordinator add --coordinator PK --relay URL") + 1 -> known.single() + else -> + throw CordnArgException( + "several coordinators known, name one with --coordinator: " + + known.joinToString(", ") { it.pubKey.take(8) }, + ) + } + } + + val remembered = known.firstOrNull { it.pubKey == requested } + if (relays.isEmpty()) { + return remembered + ?: throw CordnArgException("coordinator $requested is not known here; add --relay to reach it") + } + // A coordinator named with relays is reachable whether or not it was + // remembered, and the relays given now are the current ones. + return (remembered ?: CoordinatorConfig(pubKey = requested, relays = relays)).copy(relays = relays) + } + + /** `--relay URL[,URL…]`; the flag map collapses repeats, so they arrive comma-separated. */ + fun relayFlag(args: Args): List = + args + .flag("relay") + ?.split(",") + ?.map { it.trim() } + ?.filter { it.isNotEmpty() } + .orEmpty() + .map { url -> + RelayUrlNormalizer.normalizeOrNull(url) ?: throw CordnArgException("not a relay URL: $url") + } + + suspend fun close() = registry.close() + + /** + * Reads the blob key, generating one on first use. + * + * Not derived from the account key on purpose. A derivation would mean a + * bunker-backed identity — which has no local private key at all — could + * not use cordn, and it would tie every blob to a key the user may rotate. + */ + private fun blobKey(): ByteArray { + val file = ctx.dataDir.cordnBlobKeyFile + if (file.exists()) { + val bytes = file.readBytes() + require(bytes.size == KeyedCordnBlobCipher.KEY_LENGTH) { + "$file is ${bytes.size} bytes, expected ${KeyedCordnBlobCipher.KEY_LENGTH}" + } + return bytes + } + return KeyedCordnBlobCipher.newKey().also { SecureFileIO.writeBytesAtomic(file, it) } + } +} + +/** A bad-args failure a command turns into `Output.error("bad_args", …)`. */ +class CordnArgException( + message: String, +) : IllegalArgumentException(message) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt index 6d7207d0f9..89f9929c45 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.cli.commands.Bolt12Commands import com.vitorpamplona.amethyst.cli.commands.BunkerCommand import com.vitorpamplona.amethyst.cli.commands.BuzzCommands import com.vitorpamplona.amethyst.cli.commands.ConcordCommands +import com.vitorpamplona.amethyst.cli.commands.CordnCommands import com.vitorpamplona.amethyst.cli.commands.CountCommand import com.vitorpamplona.amethyst.cli.commands.CreateCommand import com.vitorpamplona.amethyst.cli.commands.CyberspaceCommands @@ -337,6 +338,7 @@ private suspend fun dispatch(argv: Array): Int { FofCommand.dispatch(dataDir, tail) } "concord" -> ConcordCommands.dispatch(dataDir, tail) + "cordn" -> CordnCommands.dispatch(dataDir, tail) else -> { Output.error("bad_args", "unknown subcommand: $head") printVerbList() @@ -359,7 +361,7 @@ private fun printVerbList() { | primitives: decode encode verify key filter nip kind pow namecoin | events: event publish fetch subscribe count sync encrypt decrypt gift | social: notes profile follow unfollow search zap dm outbox - | groups: marmot relaygroup concord geochat + | groups: marmot relaygroup concord cordn geochat | relays: relay admin serve store | trust: graperank fof | media/sites: blossom nsite napplet podcast podcast20 git @@ -885,6 +887,27 @@ private fun printUsage() { | concord revoke COMMUNITY TOKEN|URL retire a link you minted (vsk=9 tombstone) | concord join URL redeem an invite link and save the community | + | cordn coordinator add --coordinator PK --relay URL[,URL] + | remember one (it has no other address) + | cordn coordinator list|info|forget what we know; MCP initialize; drop it + | cordn keypackage publish [--last-resort] [--count N] + | attributable: names this npub (§8.4) + | cordn keypackage list|withdraw ours on the coordinator + | cordn group create --name N [--gid GID] [--admin PK[,PK]] + | cordn group list|info [--gid GID] metadata, members, exposure + | cordn invite --pubkey PK [--gid GID] take their KeyPackage, commit, leave a Welcome + | cordn request --gid GID | --ref cordn1… ask to join (§8.1) + | cordn requests list|accept|decline answer askers (any member may, §5.3) + | cordn welcomes open invitations without joining + | cordn join|decline --gid GID | --all accept or refuse one + | cordn send --text "…" [--gid GID] a kind-9 chat message + | cordn fetch drain the stream and print it + | cordn ref encode --gid GID [--coordinator PK] [--relay URL[,URL]] + | build a cordn1… group reference + | cordn ref decode REF read one back + | cordn exposure --coordinator PK [--groups N] [--published] + | what that coordinator would learn (spec/00.md §8) + | |Local event store (shared, under `/shared/`): | Backend selected by AMY_STORE: sqlite (default; `shared/events.db`) | or fs (`AMY_STORE=fs`; the `shared/events-store/` tree). SQLite is diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CordnCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CordnCommands.kt new file mode 100644 index 0000000000..b3492b9156 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CordnCommands.kt @@ -0,0 +1,261 @@ +/* + * 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.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.amethyst.commons.cordn.CoordinatorConfig +import com.vitorpamplona.amethyst.commons.cordn.ExposureNote +import com.vitorpamplona.amethyst.commons.cordn.GroupExposure +import com.vitorpamplona.quartz.cordn.appGroupRef.CordnGroupRef +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer + +/** + * `amy cordn …` — the cordn (MLS-over-an-MCP-coordinator) surface. + * + * Two halves. The offline half needs no coordinator: the `cordn1…` group-ref + * codec, and the §8 metadata-exposure model. A ref is the one cordn artifact a + * human copies by hand, and `exposure` is how a script or a person checks what + * a coordinator would learn *before* joining anything. + * + * The rest drives a live coordinator — `coordinator`, `keypackage`, `group`, + * `invite`, `request`, `requests`, `welcomes`, `join`, `decline`, `send`, + * `fetch` — and lives in [CordnCoordinatorCommands] and + * [CordnGroupCommands]. They exist so the binding can be run end to end + * against a real counterparty from a shell script, which is the only kind of + * interop test that proves anything. + * + * Two shapes to know before reading the verbs: + * + * - **A `gid` is unique only within one coordinator** (`spec/00.md` §4), so + * `--coordinator` is part of a group's address and not a convenience. + * Omitting it works only while exactly one coordinator is remembered. + * - **Delivery is pulled, not pushed.** A CLI invocation is a process and + * cannot hold a subscription, so `fetch` drains what the cursor has not seen + * and exits. The cursor on disk is the whole continuity mechanism between + * runs. + */ +object CordnCommands { + val USAGE: String = + """ + |cordn (MLS group chat over an MCP coordinator): + | offline — no coordinator needed: + | cordn ref encode --gid GID [--coordinator PK] build a cordn1… group reference + | [--relay URL[,URL…]] + | cordn ref decode REF read one back + | cordn exposure --coordinator PK what that coordinator would learn + | [--groups N] [--published] (spec/00.md §8) + | + | live — every verb below takes [--coordinator PK] [--relay URL[,URL…]], + | optional while exactly one coordinator is remembered: + | cordn coordinator add --coordinator PK --relay URL[,URL…] [--label L] + | cordn coordinator list what this account knows + | cordn coordinator info MCP initialize; every field a claim + | cordn coordinator forget --coordinator PK local only; state is kept + | cordn keypackage publish [--last-resort] attributable (§8.4) + | [--count N] + | cordn keypackage list ours on the coordinator + | cordn keypackage withdraw --kp-ref REF[,REF…] | --all + | cordn group create --name N [--about A] gid is ours to choose (§4) + | [--gid GID] [--admin PK[,PK…]] + | [--icon I] [--image URL] + | cordn group list groups on this coordinator + | cordn group info [--gid GID] metadata, members, exposure + | cordn invite --pubkey PK [--gid GID] [--kp-ref REF] + | cordn request --gid GID | --ref cordn1… ask to join (§8.1) + | cordn requests list who is asking + | cordn requests accept --pubkey PK | --all any member may answer (§5.3) + | cordn requests decline --pubkey PK | --all + | cordn welcomes open invitations, joining none + | cordn join --gid GID | --all accept one + | cordn decline --gid GID | --all refuse and retire it + | cordn send --text "…" [--gid GID] a kind-9 chat message + | [--reply-to ID] [--react-to ID] + | cordn fetch drain the stream and print it + | cordn watch [--timeout MS] hold a live subscription + | + |A group ref is a locator, not an invitation: holding one lets you ASK to + |join, it does not make you a member. Relays say where to reach the + |coordinator and are meaningless without --coordinator. + """.trimMargin() + + suspend fun dispatch( + dataDir: DataDir, + tail: Array, + ): Int = + route( + "cordn", + tail, + "cordn ", + help = USAGE, + routes = + mapOf( + "ref" to { rest -> ref(rest) }, + "exposure" to { rest -> exposure(rest) }, + "coordinator" to { rest -> CordnCoordinatorCommands.coordinator(dataDir, rest) }, + "keypackage" to { rest -> CordnCoordinatorCommands.keyPackage(dataDir, rest) }, + "migrate" to { rest -> CordnMigrateCommands.migrate(dataDir, rest) }, + "group" to { rest -> CordnGroupCommands.group(dataDir, rest) }, + "invite" to { rest -> CordnGroupCommands.invite(dataDir, rest) }, + "request" to { rest -> CordnGroupCommands.request(dataDir, rest) }, + "requests" to { rest -> CordnGroupCommands.requests(dataDir, rest) }, + "welcomes" to { rest -> CordnGroupCommands.welcomes(dataDir, rest) }, + "join" to { rest -> CordnGroupCommands.join(dataDir, rest) }, + "decline" to { rest -> CordnGroupCommands.decline(dataDir, rest) }, + "send" to { rest -> CordnGroupCommands.send(dataDir, rest) }, + "fetch" to { rest -> CordnGroupCommands.fetch(dataDir, rest) }, + "watch" to { rest -> CordnGroupCommands.watch(dataDir, rest) }, + ), + ) + + private suspend fun ref(tail: Array): Int = + route( + "cordn ref", + tail, + "cordn ref ", + help = USAGE, + routes = + mapOf( + "encode" to { rest -> encode(rest) }, + "decode" to { rest -> decode(rest) }, + ), + ) + + private fun encode(tail: Array): Int { + val args = Args(tail) + val gid = args.flag("gid") ?: return Output.error("bad_args", "cordn ref encode needs --gid") + val coordinator = args.flag("coordinator") + // The flag map collapses repeats, so several relays arrive comma-separated. + val relays = + args + .flag("relay") + ?.split(",") + ?.map { it.trim() } + ?.filter { it.isNotEmpty() } + .orEmpty() + args.rejectUnknown() + + val normalized = + relays.map { url -> + RelayUrlNormalizer.normalizeOrNull(url) + ?: return Output.error("bad_args", "not a relay URL: $url") + } + + val ref = + try { + CordnGroupRef(gid, coordinator, normalized.map { it.url }) + } catch (e: IllegalArgumentException) { + return Output.error("bad_args", e.message ?: "invalid group reference") + } + + Output.emit( + mapOf( + "ref" to ref.encode(), + "gid" to ref.gid, + "coordinator" to ref.coordinatorPubKey, + "relays" to ref.relays, + ), + ) + return 0 + } + + private fun decode(tail: Array): Int { + val args = Args(tail) + val encoded = args.positional.firstOrNull() ?: return Output.error("bad_args", "cordn ref decode needs a cordn1… reference") + args.rejectUnknown() + + val ref = + try { + CordnGroupRef.decode(encoded) + } catch (e: IllegalArgumentException) { + return Output.error("bad_ref", e.message ?: "not a cordn group reference") + } + + Output.emit( + mapOf( + "gid" to ref.gid, + "coordinator" to ref.coordinatorPubKey, + "relays" to ref.relays, + // A ref carrying no coordinator is legal (spec §2) and means the + // recipient must already know who serves this group. + "reachable" to (ref.coordinatorPubKey != null && ref.relays.isNotEmpty()), + ), + ) + return 0 + } + + private fun exposure(tail: Array): Int { + val args = Args(tail) + val coordinator = args.flag("coordinator") ?: return Output.error("bad_args", "cordn exposure needs --coordinator") + val groups = args.flag("groups")?.toIntOrNull() ?: 1 + val published = "published" in args.booleans + val fromLink = "from-link" in args.booleans + args.rejectUnknown("published", "from-link") + + if (groups < 1) return Output.error("bad_args", "--groups must be at least 1") + + val exposure = + GroupExposure( + coordinator = coordinator, + linkedGroupCount = groups, + joinedFromShareLink = fromLink, + publishedKeyPackage = published, + // Our transport pins CEP-4 encryption to REQUIRED and fails + // closed (§8.6), so this is a property of the client, not a + // setting a coordinator can talk us out of. + encryptionPinned = true, + ) + + Output.emit( + mapOf( + "coordinator" to coordinator, + "content" to exposure.content.name, + "membership" to exposure.membership.name, + "messaging" to exposure.messaging.name, + "linked_groups" to exposure.linkedGroupCount, + "differs_from_marmot" to exposure.differsFromMarmot(), + "notes" to exposure.notes().map { it.name to describe(it) }.toMap(), + ), + ) + return 0 + } + + /** One line per note, for the human-readable half of the dual output. */ + private fun describe(note: ExposureNote): String = + when (note) { + ExposureNote.MEMBERSHIP_IS_IDENTIFIED -> + "admission names real npubs on both ends, so the coordinator sees who is in this group" + ExposureNote.GROUPS_LINKED_BY_SESSION -> + "one throwaway key posts and fetches for every group you have here, linking them to each other" + ExposureNote.SINGLE_OPERATOR_HOLDS_HISTORY -> + "one operator holds the complete ordered history of every group it serves" + ExposureNote.PUBLICATION_IS_A_SIGNED_RECORD -> + "your published KeyPackage is a signed, re-servable record that this account uses cordn" + ExposureNote.MESSAGE_SIZES_UNPADDED -> + "sealed payloads are not padded, so message sizes are visible" + ExposureNote.ENCRYPTION_NOT_PINNED -> + "BUG: this client is not pinning transport encryption; report it" + } + + /** The coordinator a ref points at, for callers wiring one up. */ + fun coordinatorOf(ref: CordnGroupRef): CoordinatorConfig? = CoordinatorConfig.from(ref) +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CordnCoordinatorCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CordnCoordinatorCommands.kt new file mode 100644 index 0000000000..0bc3b78383 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CordnCoordinatorCommands.kt @@ -0,0 +1,308 @@ +/* + * 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.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.amethyst.commons.cordn.CoordinatorConfig + +/** + * `amy cordn coordinator …` and `amy cordn keypackage …`. + * + * ## Why a coordinator has to be remembered at all + * + * A coordinator has no address beyond its pubkey (`spec/00.md` §8.5), so + * "which relays reach it" is knowledge the client holds and nothing on the + * network can supply. Losing it does not degrade to a slow lookup — it + * degrades to a coordinator you cannot reach and groups you cannot open. + * That is why `coordinator add` exists as its own verb and why the list is + * encrypted at rest alongside the group state. + * + * ## Why publishing a KeyPackage is a separate, explicit verb + * + * It is the one attributable thing a client does before anyone has invited it + * anywhere (§8.4): a signed, re-servable record that this npub uses cordn on + * this coordinator. Doing it implicitly — at `coordinator add`, say — would + * make being told about a coordinator indistinguishable from announcing + * yourself to it. + */ +internal object CordnCoordinatorCommands { + suspend fun coordinator( + dataDir: DataDir, + tail: Array, + ): Int = + route( + "cordn coordinator", + tail, + "cordn coordinator ", + help = CordnCommands.USAGE, + routes = + mapOf( + "add" to { rest -> add(dataDir, rest) }, + "list" to { rest -> list(dataDir, rest) }, + "info" to { rest -> info(dataDir, rest) }, + "forget" to { rest -> forget(dataDir, rest) }, + ), + ) + + private suspend fun add( + dataDir: DataDir, + tail: Array, + ): Int { + val args = Args(tail) + val pubKey = args.flag("coordinator") ?: return Output.error("bad_args", "cordn coordinator add needs --coordinator") + val label = args.flag("label") + args.rejectUnknown("relay") + + return Context.open(dataDir).use { ctx -> + try { + val relays = ctx.cordn.relayFlag(args) + if (relays.isEmpty()) return@use Output.error("bad_args", "cordn coordinator add needs --relay URL[,URL…]") + + val config = CoordinatorConfig(pubKey = pubKey, relays = relays, label = label) + ctx.cordn.remember(config) + + Output.emit( + mapOf( + "coordinator" to config.pubKey, + "relays" to config.relays.map { it.url }, + "label" to config.label, + "known" to ctx.cordn.coordinators().size, + // Said here because this is where a person decides. The + // list is local knowledge; nothing has been told to the + // coordinator by adding it. + "announced" to false, + ), + ) + 0 + } catch (e: IllegalArgumentException) { + Output.error("bad_args", e.message ?: "bad coordinator") + } + } + } + + private suspend fun list( + dataDir: DataDir, + tail: Array, + ): Int { + val args = Args(tail) + args.rejectUnknown() + + return Context.open(dataDir).use { ctx -> + Output.emit( + mapOf( + "coordinators" to + ctx.cordn.coordinators().map { + mapOf( + "coordinator" to it.pubKey, + "relays" to it.relays.map { relay -> relay.url }, + "label" to it.label, + "origin" to it.origin.name, + ) + }, + ), + ) + 0 + } + } + + /** + * `cordn coordinator info` — the MCP `initialize` handshake. + * + * Every field it returns is a claim signed with nothing but the key that + * was already signing the response (§8.5). Reported with the pubkey beside + * it so the two are never confused: the pubkey is the identity, the name is + * what the operator typed. + */ + private suspend fun info( + dataDir: DataDir, + tail: Array, + ): Int { + val args = Args(tail) + args.rejectUnknown("coordinator", "relay") + + return CordnRun.withSession(dataDir, args) { _, scope -> + val info = scope.session.serverInfo() + Output.emit( + mapOf( + "coordinator" to scope.config.pubKey, + "reachable" to (info != null), + "name" to info?.name, + "version" to info?.version, + "protocol_version" to info?.protocolVersion, + "capabilities" to info?.capabilities?.keys?.sorted(), + // The one field worth branching on, and the reason the rest + // are not: a coordinator answering a protocol this client + // does not implement is one whose later answers may not + // mean what they appear to. + "claims_are_unverified" to true, + ), + ) + if (info == null) 1 else 0 + } + } + + private suspend fun forget( + dataDir: DataDir, + tail: Array, + ): Int { + val args = Args(tail) + val pubKey = args.flag("coordinator") ?: return Output.error("bad_args", "cordn coordinator forget needs --coordinator") + args.rejectUnknown() + + return Context.open(dataDir).use { ctx -> + val forgotten = ctx.cordn.forget(pubKey) + Output.emit( + mapOf( + "coordinator" to pubKey, + "forgotten" to forgotten, + // Forgetting is local. The coordinator still holds every + // message of every group it served, and still holds any + // KeyPackage published to it — `keypackage withdraw` is + // the verb for that, and it is a different act. + "state_on_disk_kept" to true, + ), + ) + if (forgotten) 0 else 1 + } + } + + suspend fun keyPackage( + dataDir: DataDir, + tail: Array, + ): Int = + route( + "cordn keypackage", + tail, + "cordn keypackage ", + help = CordnCommands.USAGE, + routes = + mapOf( + "publish" to { rest -> publish(dataDir, rest) }, + "list" to { rest -> keyPackageList(dataDir, rest) }, + "withdraw" to { rest -> withdraw(dataDir, rest) }, + ), + ) + + private suspend fun publish( + dataDir: DataDir, + tail: Array, + ): Int { + val args = Args(tail) + val lastResort = "last-resort" in args.booleans + val count = args.intFlag("count", 1) + args.rejectUnknown("coordinator", "relay", "last-resort") + + if (count < 1) return Output.error("bad_args", "--count must be at least 1") + + return CordnRun.withSession(dataDir, args) { _, scope -> + val published = (1..count).map { scope.keyPackages.publishNew(lastResort = lastResort) } + Output.emit( + mapOf( + "coordinator" to scope.config.pubKey, + "published" to + published.map { + mapOf("kp_ref" to it.keyPackageRef, "last_resort" to it.lastResort, "at" to it.at) + }, + // Worth printing every time, not once in a doc: this is the + // call that tells the coordinator this npub exists here. + "attributable" to true, + ), + ) + 0 + } + } + + private suspend fun keyPackageList( + dataDir: DataDir, + tail: Array, + ): Int { + val args = Args(tail) + args.rejectUnknown("coordinator", "relay") + + return CordnRun.withSession(dataDir, args) { _, scope -> + val onCoordinator = scope.keyPackages.listPublished() + val held = scope.keyPackages.published.value + + Output.emit( + mapOf( + "coordinator" to scope.config.pubKey, + "on_coordinator" to + onCoordinator.map { + mapOf( + "kp_ref" to it.keyPackageRef, + "last_resort" to it.lastResort, + "at" to it.at, + // A package the coordinator will serve but we + // have no private half for cannot open the + // Welcome it produces. It belongs to another + // device of this account, or to a wiped one. + "openable_here" to (it.keyPackageRef in held), + ) + }, + "held_locally" to held.size, + ), + ) + 0 + } + } + + private suspend fun withdraw( + dataDir: DataDir, + tail: Array, + ): Int { + val args = Args(tail) + val refs = + args + .flag("kp-ref") + ?.split(",") + ?.map { it.trim() } + ?.filter { it.isNotEmpty() } + args.rejectUnknown("coordinator", "relay", "all") + + return CordnRun.withSession(dataDir, args) { _, scope -> + val target = + refs + ?: if ("all" in args.booleans) { + scope.keyPackages.published.value + .toList() + } else { + return@withSession Output.error("bad_args", "cordn keypackage withdraw needs --kp-ref REF[,REF…] or --all") + } + + val removed = scope.keyPackages.withdraw(target) + Output.emit( + mapOf( + "coordinator" to scope.config.pubKey, + "requested" to target, + "removed" to removed, + // Withdrawing removes the package, not the record that it + // was once published: §8.4's exposure is a past event and + // no call undoes it. + "exposure_is_not_undone" to true, + ), + ) + 0 + } + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CordnGroupCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CordnGroupCommands.kt new file mode 100644 index 0000000000..7c44488126 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CordnGroupCommands.kt @@ -0,0 +1,694 @@ +/* + * 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.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.amethyst.commons.cordn.CordnGroupManager +import com.vitorpamplona.quartz.cordn.appGroupRef.CordnGroupRef +import com.vitorpamplona.quartz.cordn.groups.CordnCredential +import com.vitorpamplona.quartz.cordn.spec00Coordinator.JoinRequest +import com.vitorpamplona.quartz.cordn.spec01GroupMetadata.CordnGroupMetadata +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnMessageReferences +import com.vitorpamplona.quartz.utils.RandomInstance + +/** + * `amy cordn group …`, `invite`, `request`, `welcomes`, `join`, `send`, `fetch` + * — the group lifecycle over a live coordinator. + * + * ## What this is for + * + * A second implementation is the only thing that tests a protocol, and a + * non-interactive client is the only way to drive one from a script. These + * verbs exist so the cordn binding can be run end to end against the + * reference coordinator without a phone, and so an interop scenario can be a + * shell script rather than a description of one. + * + * ## A fetch is explicit here, and that is a real difference + * + * The Android app runs `CordnSyncLoop`, which catches up and then holds a + * live subscription. A CLI invocation is a process: it cannot hold anything. + * So `fetch` drains what the cursor has not seen and exits, and the cursor on + * disk is the entire continuity mechanism between runs. `spec/02.md` §7 calls + * a cursor a delivery primitive rather than message identity — here it is + * also the only state that makes two separate invocations one conversation. + * + * A consequence worth stating: nothing arrives while amy is not running. A + * message posted between two `fetch` calls is not lost — the coordinator holds + * the ordered stream — but it is not seen until asked for. That is the correct + * model for a script and the wrong one for a chat app, which is why both exist. + */ +internal object CordnGroupCommands { + suspend fun group( + dataDir: DataDir, + tail: Array, + ): Int = + route( + "cordn group", + tail, + "cordn group ", + help = CordnCommands.USAGE, + routes = + mapOf( + "create" to { rest -> create(dataDir, rest) }, + "list" to { rest -> list(dataDir, rest) }, + "info" to { rest -> info(dataDir, rest) }, + ), + ) + + /** + * `cordn group create --name NAME` + * + * The `gid` is ours to choose and the coordinator never interprets it + * (`spec/00.md` §4). A random 128-bit hex value by default; `--gid` is + * accepted because an interop script needs to name the group it is about + * to assert things about. It must not be derived from the MLS `group_id`, + * which is secret — here the relationship runs the other way, which is + * what lets a joiner read the delivery id out of the Welcome alone. + */ + private suspend fun create( + dataDir: DataDir, + tail: Array, + ): Int { + val args = Args(tail) + val name = args.flag("name") ?: return Output.error("bad_args", "cordn group create needs --name") + val about = args.flag("about") ?: "" + val icon = args.flag("icon") ?: "" + val imageUrl = args.flag("image") ?: "" + val gid = args.flag("gid") ?: RandomInstance.bytes(16).joinToString("") { "%02x".format(it) } + val admins = + args + .flag("admin") + ?.split(",") + ?.map { it.trim() } + ?.filter { it.isNotEmpty() } + .orEmpty() + args.rejectUnknown("coordinator", "relay") + + return CordnRun.withSession(dataDir, args) { _, scope -> + val metadata = + try { + CordnGroupMetadata(name = name, description = about, adminPubkeys = admins, icon = icon, imageUrl = imageUrl) + } catch (e: IllegalArgumentException) { + return@withSession Output.error("bad_args", e.message ?: "bad group metadata") + } + + val group = scope.manager.createGroup(gid, metadata) + val ref = scope.manager.shareRef(gid) + + Output.emit( + mapOf( + "coordinator" to scope.config.pubKey, + "gid" to gid, + "epoch" to group.epoch, + "ref" to ref.encode(), + "name" to name, + // Empty is not "no admins yet" — spec/01.md §5.3 makes it + // egalitarian, permanently, because there is no way to add + // an admin later to a field that has no enforcement behind + // it anyway. + "egalitarian" to admins.isEmpty(), + // Nothing has been posted: creating a group is local until + // the first message or the first invite. + "posted" to false, + ), + ) + 0 + } + } + + private suspend fun list( + dataDir: DataDir, + tail: Array, + ): Int { + val args = Args(tail) + args.rejectUnknown("coordinator", "relay") + + return CordnRun.withSession(dataDir, args) { _, scope -> + Output.emit( + mapOf( + "coordinator" to scope.config.pubKey, + "groups" to + scope.manager.gids.value.sorted().map { gid -> + val group = scope.manager.group(gid) + mapOf( + "gid" to gid, + "epoch" to group?.epoch, + "name" to group?.let { CordnGroupMetadata.fromExtensions(it.extensions)?.name }, + "members" to group?.let { CordnCredential.memberIdentities(it).size }, + ) + }, + ), + ) + 0 + } + } + + private suspend fun info( + dataDir: DataDir, + tail: Array, + ): Int { + val args = Args(tail) + args.rejectUnknown("coordinator", "relay", "gid") + + return CordnRun.withSession(dataDir, args) { _, scope -> + val gid = CordnRun.gid(args, scope) + val group = scope.manager.group(gid) ?: return@withSession Output.error("not_found", "no group $gid here") + val metadata = CordnGroupMetadata.fromExtensions(group.extensions) + val exposure = scope.session.exposure(gid) + + Output.emit( + mapOf( + "coordinator" to scope.config.pubKey, + "gid" to gid, + "epoch" to group.epoch, + "name" to metadata?.name, + "about" to metadata?.description, + "members" to CordnCredential.memberIdentities(group).toList(), + "admins" to metadata?.adminPubkeys.orEmpty(), + "egalitarian" to metadata?.adminPubkeys.orEmpty().isEmpty(), + "ref" to scope.manager.shareRef(gid).encode(), + "exposure" to + mapOf( + "content" to exposure.content.name, + "membership" to exposure.membership.name, + "messaging" to exposure.messaging.name, + "notes" to exposure.notes().map { it.name }, + ), + ), + ) + 0 + } + } + + /** + * `cordn invite --gid GID --pubkey PK` + * + * Takes their KeyPackage from the coordinator, verifies the publication + * payload binds it to that npub, commits, and leaves a Welcome. The + * verification is not belt-and-braces: §9/§10 require the client to check + * even though the coordinator does, because a coordinator that skipped it + * could hand any account's name over any account's key material and we + * would add the wrong person. + */ + suspend fun invite( + dataDir: DataDir, + tail: Array, + ): Int { + val args = Args(tail) + val target = args.flag("pubkey") ?: return Output.error("bad_args", "cordn invite needs --pubkey") + val keyPackageRef = args.flag("kp-ref") + args.rejectUnknown("coordinator", "relay", "gid") + + return CordnRun.withSession(dataDir, args) { _, scope -> + val gid = CordnRun.gid(args, scope) + val result = scope.manager.invite(gid, target, keyPackageRef) + Output.emit( + mapOf( + "gid" to result.gid, + "invited" to result.invited, + // Which of their one-time packages this spent. Their + // client needs it to find the Welcome; nobody else can + // use it again. + "kp_ref" to result.keyPackageRef, + "commit_cursor" to result.commitCursor, + "welcome_at" to result.welcomeAt, + "epoch" to scope.manager.group(gid)?.epoch, + ), + ) + 0 + } + } + + /** + * `cordn request --gid GID` + * + * Asks to join. Needs a published KeyPackage, because the request names + * the exact one the inviter should use — a request with nothing to add is + * a request nobody can accept. The asking itself is attributable (§8.1): + * the coordinator learns this npub wants into this group whether or not + * anyone ever answers, and that is unavoidable rather than an oversight. + */ + suspend fun request( + dataDir: DataDir, + tail: Array, + ): Int { + val args = Args(tail) + val gid = args.flag("gid") + val ref = args.flag("ref") + val keyPackageRef = args.flag("kp-ref") + args.rejectUnknown("coordinator", "relay") + + return CordnRun.withSession(dataDir, args) { _, scope -> + val target = + gid + ?: ref?.let { CordnGroupRef.decode(it).gid } + ?: return@withSession Output.error("bad_args", "cordn request needs --gid or --ref cordn1…") + + val using = + keyPackageRef + ?: scope.keyPackages.published.value + .firstOrNull() + ?: scope.keyPackages.publishNew().keyPackageRef + + val at = scope.manager.requestToJoin(target, using) + Output.emit( + mapOf( + "coordinator" to scope.config.pubKey, + "gid" to target, + "kp_ref" to using, + "at" to at, + // Holding a ref lets you ask. It does not make you a member, + // and nothing obliges anyone to answer. + "member" to false, + "attributable" to true, + ), + ) + 0 + } + } + + /** + * `cordn requests` — everyone asking to join a group we hold. + * + * Any member can answer these, not only an admin: `spec/01.md` §5.3 makes + * `admin_pubkeys` presentation metadata and nothing enforces it, so a + * check here would be inventing a boundary cordn does not have. + */ + suspend fun requests( + dataDir: DataDir, + tail: Array, + ): Int = + route( + "cordn requests", + tail, + "cordn requests ", + help = CordnCommands.USAGE, + routes = + mapOf( + "list" to { rest -> requestList(dataDir, rest) }, + "accept" to { rest -> answerRequest(dataDir, rest, accept = true) }, + "decline" to { rest -> answerRequest(dataDir, rest, accept = false) }, + ), + ) + + private suspend fun requestList( + dataDir: DataDir, + tail: Array, + ): Int { + val args = Args(tail) + args.rejectUnknown("coordinator", "relay") + + return CordnRun.withSession(dataDir, args) { _, scope -> + Output.emit( + mapOf( + "coordinator" to scope.config.pubKey, + "requests" to scope.manager.pendingJoinRequests().map { it.toMap() }, + ), + ) + 0 + } + } + + private suspend fun answerRequest( + dataDir: DataDir, + tail: Array, + accept: Boolean, + ): Int { + val args = Args(tail) + val who = args.flag("pubkey") + args.rejectUnknown("coordinator", "relay", "gid", "all") + + return CordnRun.withSession(dataDir, args) { _, scope -> + val pending = scope.manager.pendingJoinRequests() + val gidFilter = args.flag("gid") + val chosen = + pending.filter { (who == null || it.pubKey == who) && (gidFilter == null || it.gid == gidFilter) } + + if (chosen.isEmpty()) { + Output.emit(mapOf("answered" to emptyList(), "pending" to pending.size)) + return@withSession 1 + } + if (who == null && "all" !in args.booleans && chosen.size > 1) { + return@withSession Output.error( + "bad_args", + "${chosen.size} requests pending; name one with --pubkey or pass --all", + ) + } + + // Taken, so the loop cannot hand the same request to two calls. + val answered = + chosen.map { req -> + if (accept) { + val result = scope.manager.acceptJoinRequest(req) + mapOf("pubkey" to req.pubKey, "gid" to req.gid, "welcome_at" to result.welcomeAt) + } else { + scope.manager.declineJoinRequest(req) + mapOf("pubkey" to req.pubKey, "gid" to req.gid, "declined" to true) + } + } + + Output.emit(mapOf("accepted" to accept, "answered" to answered)) + 0 + } + } + + /** + * `cordn welcomes` — open every pending Welcome without joining anything. + * + * Opening and accepting are separate acts on purpose. A Welcome is opaque + * until processed: the `gid`, the group's name and who is already in it all + * live inside it, so a person cannot be asked about an invitation that has + * not been opened. Printing them without joining is that split, in a shell. + */ + suspend fun welcomes( + dataDir: DataDir, + tail: Array, + ): Int { + val args = Args(tail) + args.rejectUnknown("coordinator", "relay") + + return CordnRun.withSession(dataDir, args) { _, scope -> + val inbox = scope.manager.pendingWelcomes(scope.keyPackages::bundleFor) + Output.emit( + mapOf( + "coordinator" to scope.config.pubKey, + "pending" to + inbox.pending.map { + mapOf( + "gid" to it.gid, + "kp_ref" to it.keyPackageRef, + "at" to it.at, + "epoch" to it.epoch, + "name" to it.metadata?.name, + "members" to it.members.toList(), + ) + }, + // Not errors. A Welcome for a KeyPackage this device never + // held belongs to another device of the same account, and + // draining it here would destroy it. + "skipped" to inbox.skipped.map { mapOf("kp_ref" to it.keyPackageRef, "reason" to it.reason) }, + ), + ) + 0 + } + } + + /** `cordn join [--gid GID | --all]` — accept a pending Welcome. */ + suspend fun join( + dataDir: DataDir, + tail: Array, + ): Int = acceptOrDecline(dataDir, tail, accept = true) + + /** `cordn decline [--gid GID | --all]` — refuse one, and retire it. */ + suspend fun decline( + dataDir: DataDir, + tail: Array, + ): Int = acceptOrDecline(dataDir, tail, accept = false) + + private suspend fun acceptOrDecline( + dataDir: DataDir, + tail: Array, + accept: Boolean, + ): Int { + val args = Args(tail) + val gid = args.flag("gid") + val all = "all" in args.booleans + args.rejectUnknown("coordinator", "relay", "all") + + return CordnRun.withSession(dataDir, args) { _, scope -> + val inbox = scope.manager.pendingWelcomes(scope.keyPackages::bundleFor) + val chosen = inbox.pending.filter { gid == null || it.gid == gid } + + if (chosen.isEmpty()) { + Output.emit(mapOf("joined" to emptyList(), "skipped" to inbox.skipped.size)) + return@withSession 1 + } + if (gid == null && !all && chosen.size > 1) { + return@withSession Output.error( + "bad_args", + "${chosen.size} invitations pending; name one with --gid or pass --all", + ) + } + + val done = + chosen.map { + if (accept) { + scope.manager.accept(it) + } else { + scope.manager.decline(it) + it.gid + } + } + + Output.emit( + mapOf( + "coordinator" to scope.config.pubKey, + (if (accept) "joined" else "declined") to done, + "epochs" to done.associateWith { scope.manager.group(it)?.epoch }, + "skipped" to inbox.skipped.map { mapOf("kp_ref" to it.keyPackageRef, "reason" to it.reason) }, + ), + ) + 0 + } + } + + /** + * `cordn send --text "…"` — a chat message, or an annotation of one. + * + * `--reply-to` / `--react-to` / `--edit` / `--delete` / `--pin` / `--unpin` + * take a message id and need `--to-author` beside it, and that is not an + * awkward flag — it is the shape of the data. An annotation's tags name the + * target's author and kind as well as its id, and the CLI keeps no message + * store: cursors and MLS state persist between runs, decrypted messages + * deliberately do not. So the fields a reference needs have to come from + * the caller, who has them from the `fetch` that printed the message. + * + * One limitation worth naming rather than discovering. Threading reads the + * *target's* tags to find the thread root, and those are not passed here, + * so replying to a reply roots the new message at the message it answers + * instead of at the original root. Correct for a one-level reply, which is + * what a script tends to send; a client with a message store does better. + */ + suspend fun send( + dataDir: DataDir, + tail: Array, + ): Int { + val args = Args(tail) + val text = args.flag("text") ?: "" + val author = args.flag("to-author") + val targetKind = args.intFlag("to-kind", CordnGroupManager.CHAT_KIND) + val replyTo = args.flag("reply-to") + val reactionTo = args.flag("react-to") + val editTo = args.flag("edit") + val deleteTo = args.flag("delete") + val pinTo = args.flag("pin") + val unpinTo = args.flag("unpin") + args.rejectUnknown("coordinator", "relay", "gid") + + val referenced = listOfNotNull(replyTo, reactionTo, editTo, deleteTo, pinTo, unpinTo) + if (referenced.size > 1) { + return Output.error("bad_args", "cordn send takes at most one of --reply-to/--react-to/--edit/--delete/--pin/--unpin") + } + val targetId = referenced.firstOrNull() + if (targetId != null && author == null) { + return Output.error("bad_args", "a reference needs --to-author PK: its tags name the target's author, not only its id") + } + if (targetId == null && text.isEmpty()) { + return Output.error("bad_args", "cordn send needs --text, or a reference to annotate") + } + + val target = targetId?.let { CordnMessageReferences.Target(id = it, pubKey = author!!, kind = targetKind) } + + return CordnRun.withSession(dataDir, args) { _, scope -> + val gid = CordnRun.gid(args, scope) + val envelope = + try { + scope.manager.post( + gid = gid, + content = text, + replyTo = target.takeIf { replyTo != null }, + reactionTo = target.takeIf { reactionTo != null }, + editTo = target.takeIf { editTo != null }, + deleteTo = target.takeIf { deleteTo != null }, + pinTo = target.takeIf { pinTo != null || unpinTo != null }, + pinOp = if (unpinTo != null) CordnMessageReferences.PinOp.REMOVE else CordnMessageReferences.PinOp.ADD, + ) + } catch (e: IllegalArgumentException) { + // The manager refuses an edit or delete of somebody else's + // message. That is a rule, not a failure to handle here. + return@withSession Output.error("refused", e.message ?: "refused") + } + + Output.emit( + mapOf( + "gid" to gid, + // The envelope id is the message's identity (spec/02.md §7) + // — the cursor a fetch reports is not, so a script that + // needs to refer to this message later must use this. + "id" to envelope.envelope.id, + "kind" to envelope.envelope.kind, + "created_at" to envelope.envelope.createdAt, + // The coordinator's cursor for this post, so a script can + // see where in the stream its own message landed. + "cursor" to envelope.cursor, + "epoch" to scope.manager.group(gid)?.epoch, + ), + ) + 0 + } + } + + /** + * `cordn fetch` — drain everything the cursor has not seen, and print it. + * + * Every outcome is reported, including the ones that are not messages. An + * `undecryptable` payload especially: the cursor moves past it, because the + * alternative is a stream that never advances, and a client that printed + * nothing would be hiding a gap in a conversation rather than reporting one. + */ + suspend fun fetch( + dataDir: DataDir, + tail: Array, + ): Int { + val args = Args(tail) + args.rejectUnknown("coordinator", "relay") + + return CordnRun.withSession(dataDir, args) { _, scope -> + val messages = mutableListOf>() + val epochs = mutableListOf>() + val echoes = mutableListOf>() + val undecryptable = mutableListOf>() + + val drained = scope.manager.catchUp { it.collectInto(messages, epochs, echoes, undecryptable) } + + Output.emit( + mapOf( + "coordinator" to scope.config.pubKey, + "drained" to drained, + "messages" to messages, + "epoch_changes" to epochs, + "echoes" to echoes, + "undecryptable" to undecryptable, + // A fetch is the response most likely to outgrow one relay + // event, so this is where CEP-22 shows up if it shows up at + // all. Zero is the normal answer and not a warning. + "oversized_transfers" to scope.session.oversizedTransfers, + ), + ) + 0 + } + } + + /** + * `cordn watch` - holds a live subscription and prints what arrives. + * + * The sibling of `fetch`, and deliberately not a superset of it: this + * calls `msg_sub_many` and nothing else, so what it prints arrived over + * an open CEP-41 stream rather than a poll. That distinction is the + * point - it is the only way to exercise the one coordinator tool a + * request/response client never reaches. + * + * `--timeout` is a budget, not a failure: the call returns when the + * coordinator closes the stream or the budget runs out, whichever comes + * first, and either way what was delivered has been ingested and saved. + */ + suspend fun watch( + dataDir: DataDir, + tail: Array, + ): Int { + val args = Args(tail) + val timeoutMs = args.longFlag("timeout", DEFAULT_WATCH_MS) + args.rejectUnknown("coordinator", "relay", "timeout") + + if (timeoutMs < 1) return Output.error("bad_args", "--timeout must be positive") + + return CordnRun.withSession(dataDir, args) { _, scope -> + val messages = mutableListOf>() + val epochs = mutableListOf>() + val echoes = mutableListOf>() + val undecryptable = mutableListOf>() + + scope.manager.subscribe(timeoutMs) { delivery -> delivery.collectInto(messages, epochs, echoes, undecryptable) } + + Output.emit( + mapOf( + "coordinator" to scope.config.pubKey, + "watched_ms" to timeoutMs, + "messages" to messages, + "epoch_changes" to epochs, + "echoes" to echoes, + "undecryptable" to undecryptable, + // Everything above came over the subscription, so this is + // a fact about the run rather than a label. + "via" to "msg_sub_many", + ), + ) + 0 + } + } + + /** Files one delivery under the list its kind belongs in. */ + private fun CordnGroupManager.Delivery.collectInto( + messages: MutableList>, + epochs: MutableList>, + echoes: MutableList>, + undecryptable: MutableList>, + ) { + when (this) { + is CordnGroupManager.Delivery.Message -> + messages += + mapOf( + "gid" to gid, + "cursor" to cursor, + "id" to received.envelope.id, + // What MLS authenticated, not what the envelope + // claims: the envelope is unsigned (spec/02.md), so + // its pubKey field is a claim and this is the fact. + "sender" to received.sender, + "epoch" to received.epoch, + "kind" to received.envelope.kind, + "created_at" to received.envelope.createdAt, + "content" to received.envelope.content, + ) + + is CordnGroupManager.Delivery.EpochAdvanced -> + epochs += mapOf("gid" to gid, "cursor" to cursor, "epoch" to epoch) + + is CordnGroupManager.Delivery.Echo -> echoes += mapOf("gid" to gid, "cursor" to cursor) + + is CordnGroupManager.Delivery.Undecryptable -> + undecryptable += mapOf("gid" to gid, "cursor" to cursor, "reason" to reason) + } + } + + /** Long enough for a round trip, short enough to be a foreground command. */ + private const val DEFAULT_WATCH_MS = 15_000L + + private fun JoinRequest.toMap() = + mapOf( + "gid" to gid, + "pubkey" to pubKey, + "kp_ref" to keyPackageRef, + "at" to at, + ) +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CordnMigrateCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CordnMigrateCommands.kt new file mode 100644 index 0000000000..47458d9aae --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CordnMigrateCommands.kt @@ -0,0 +1,178 @@ +/* + * 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.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.amethyst.cli.stores.HttpCordnBlobStore +import com.vitorpamplona.amethyst.commons.cordn.CordnMigration +import com.vitorpamplona.amethyst.commons.cordn.CordnMigrationException +import com.vitorpamplona.amethyst.commons.cordn.CordnMigrationStores +import com.vitorpamplona.quartz.cordn.appMultiDevice.CordnHandoffCode + +/** + * `amy cordn migrate …` — the device handoff, drivable without a phone. + * + * Here because the whole point of the CLI is that a protocol flow can be + * exercised end to end before any UI exists. A migration is two devices and a + * scan; two `amy` homes and a string is the same thing with the camera taken + * out. + * + * ## Why `export` does not stand the CLI down + * + * On a phone, exporting is a handoff: the device stops, because two devices + * committing from one MLS leaf fork the ratchet tree irrecoverably. `amy` has + * no sync loop to stop — each invocation is a process — so there is no + * equivalent state to set, and `--and-stop` would be a flag that did nothing. + * The check that matters lives on the runtime that actually keeps loops + * running. Exporting from an `amy` home and then continuing to send from it is + * the same mistake, and this verb says so rather than pretending to prevent it. + */ +internal object CordnMigrateCommands { + suspend fun migrate( + dataDir: DataDir, + tail: Array, + ): Int = + route( + "cordn migrate", + tail, + "cordn migrate ", + help = CordnCommands.USAGE, + routes = + mapOf( + "export" to { rest -> export(dataDir, rest) }, + "import" to { rest -> import(dataDir, rest) }, + ), + ) + + private suspend fun export( + dataDir: DataDir, + tail: Array, + ): Int { + val args = Args(tail) + val servers = + args + .flag("server") + ?.split(",") + ?.map { it.trim() } + ?.filter { it.isNotEmpty() } + .orEmpty() + args.rejectUnknown("relay", "server") + + if (servers.isEmpty()) { + return Output.error("bad_args", "cordn migrate export needs --server URL[,URL…] to store the documents on") + } + + return Context.open(dataDir).use { ctx -> + val relays = ctx.cordn.relayFlag(args) + if (relays.isEmpty()) { + return@use Output.error("bad_args", "cordn migrate export needs --relay URL[,URL…] to publish the tip on") + } + + try { + val snapshot = + CordnMigrationStores.read( + ctx.cordn.migrationRoot, + ctx.identity.pubKeyHex, + ctx.cordn.blobCipher, + ctx.cordn.coordinators(), + ) + + val code = + CordnMigration(ctx.client, ctx.signer, HttpCordnBlobStore(servers)) + .publish(snapshot, relays.toSet()) + + Output.emit( + mapOf( + "code" to code.encode(), + "groups" to snapshot.groups.size, + "key_packages" to snapshot.keyPackages.size, + "relays" to relays.map { it.url }, + "servers" to servers, + // Said here because this is where a person decides. The + // sealed state is on someone else's disk now; a reader + // learns size and timing but not content. + "uploaded" to true, + // amy keeps no sync loop, so nothing was stood down — + // see the object KDoc. + "this_device_stopped" to false, + ), + ) + 0 + } catch (e: CordnMigrationException) { + Output.error("migration_failed", e.message ?: "the handoff could not be published") + } catch (e: IllegalArgumentException) { + Output.error("bad_args", e.message ?: "bad migration") + } + } + } + + private suspend fun import( + dataDir: DataDir, + tail: Array, + ): Int { + val args = Args(tail) + val raw = args.flag("code") ?: args.positionalOrNull(0) + args.rejectUnknown("code") + + if (raw == null) return Output.error("bad_args", "cordn migrate import needs a cordndev1… code") + + val code = + CordnHandoffCode.decodeOrNull(raw) + ?: return Output.error("bad_args", "that is not a cordndev1… handoff code") + + return Context.open(dataDir).use { ctx -> + try { + val snapshot = + CordnMigration(ctx.client, ctx.signer, HttpCordnBlobStore(emptyList())) + .fetch(code) + + // Replaces, never merges: two devices holding one group's state + // and both committing fork the ratchet tree, and MLS does not + // recover from that. + val configs = + CordnMigrationStores.write( + ctx.cordn.migrationRoot, + ctx.identity.pubKeyHex, + ctx.cordn.blobCipher, + snapshot, + ) + ctx.cordn.replaceCoordinators(configs) + + Output.emit( + mapOf( + "groups" to snapshot.groups.size, + "key_packages" to snapshot.keyPackages.size, + "coordinators" to configs.map { it.pubKey }, + "replaced_local_state" to true, + ), + ) + 0 + } catch (e: CordnMigrationException) { + Output.error("migration_failed", e.message ?: "the handoff could not be read") + } catch (e: IllegalArgumentException) { + Output.error("bad_args", e.message ?: "bad migration") + } + } + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CordnRun.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CordnRun.kt new file mode 100644 index 0000000000..a364373f7f --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CordnRun.kt @@ -0,0 +1,101 @@ +/* + * 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.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.CordnArgException +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.amethyst.commons.cordn.CoordinatorConfig +import com.vitorpamplona.amethyst.commons.cordn.CordnSession + +/** + * The plumbing every `amy cordn` verb that touches a coordinator shares. + * + * Two things live here rather than in each command. The first is opening and + * closing a session around a block, because a CLI run that forgets to close + * leaves a relay subscription behind. The second is turning the three ways a + * cordn call fails into the CLI's three error codes, which matters more than + * it looks: `bad_args` means the caller can fix it, `coordinator` means the + * other end did something, and anything else is ours. A script that cannot + * tell those apart has to treat every failure as fatal. + */ +internal object CordnRun { + /** + * Opens a session on the coordinator [args] names, runs [block], closes. + * + * The session is restored from disk first, so a `gid` from a previous run + * is already known and a cursor already positioned. That is the whole + * continuity story for a process-per-command client: `spec/02.md` §7 calls + * a cursor a delivery primitive, and here it is also the only thing + * carried between invocations. + */ + suspend fun withSession( + dataDir: DataDir, + args: Args, + block: suspend (Context, CordnSessionScope) -> Int, + ): Int = + Context.open(dataDir).use { ctx -> + ctx.prepare() + try { + val config = ctx.cordn.resolve(args) + val session = ctx.cordn.session(config) + try { + block(ctx, CordnSessionScope(config, session)) + } finally { + ctx.cordn.close() + } + } catch (e: CordnArgException) { + Output.error("bad_args", e.message ?: "bad coordinator selection") + } + } + + /** Config plus session, so a command does not have to carry both. */ + class CordnSessionScope( + val config: CoordinatorConfig, + val session: CordnSession, + ) { + val manager get() = session.manager + val keyPackages get() = session.keyPackages + } + + /** + * The `gid` a command was given, or the only one we are in. + * + * Same rule as `--coordinator`: convenient when there is one, refuses to + * guess when there are several. A `gid` is unique only within a + * coordinator (`spec/00.md` §4), so a wrong guess is not a near miss. + */ + fun gid( + args: Args, + scope: CordnSessionScope, + ): String { + val requested = args.flag("gid") + if (requested != null) return requested + val known = scope.manager.gids.value + return when (known.size) { + 0 -> throw CordnArgException("not in any group on this coordinator yet") + 1 -> known.single() + else -> throw CordnArgException("several groups here, name one with --gid: ${known.sorted().joinToString(", ")}") + } + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GiftCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GiftCommands.kt index 4df0d26172..73db57cb9d 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GiftCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GiftCommands.kt @@ -25,7 +25,7 @@ import com.vitorpamplona.amethyst.cli.Context import com.vitorpamplona.amethyst.cli.DataDir import com.vitorpamplona.amethyst.cli.Output import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent +import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent /** @@ -41,7 +41,7 @@ import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent * Inner/wrap JSON comes from the positional argument or stdin (`-`). * * Thin assembly only: seal/wrap/unwrap all live in quartz - * (`SealedRumorEvent`, `GiftWrapEvent`). + * (`SealEvent`, `GiftWrapEvent`). */ object GiftCommands { val USAGE: String = @@ -87,7 +87,7 @@ object GiftCommands { Context.open(dataDir).use { ctx -> ctx.prepare() val peer = ctx.requireUserHex(to) - val seal = SealedRumorEvent.create(event = inner, encryptTo = peer, signer = ctx.signer) + val seal = SealEvent.create(event = inner, encryptTo = peer, signer = ctx.signer) val giftWrap = GiftWrapEvent.create(event = seal, recipientPubKey = peer) val wrapNode = Output.mapper.readTree(giftWrap.toJson()) @@ -134,7 +134,7 @@ object GiftCommands { return Output.error("decrypt_failed", "could not unwrap (is this gift addressed to the active account?): ${e.message}") } // The wrap holds a seal (kind:13); unseal it to recover the rumor. - val inner = if (unwrapped is SealedRumorEvent) unwrapped.unsealThrowing(ctx.signer) else unwrapped + val inner = if (unwrapped is SealEvent) unwrapped.unsealThrowing(ctx.signer) else unwrapped Output.emit(mapOf("event" to Output.mapper.readTree(inner.toJson()))) return 0 } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GrapeRankCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GrapeRankCommand.kt index 0d4ae9c3b8..3beb8aacc0 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GrapeRankCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GrapeRankCommand.kt @@ -51,7 +51,7 @@ import com.vitorpamplona.amethyst.cli.commands.graperank.GrapeRankScore * Answers "do I need to crawl again?" with no network and no signing. * - `amy graperank score [OBSERVER]` — local only: build the graph from the store, * score (same as bare `--offline`), and ALWAYS reconcile the result into the - * store as kind:30382 ContactCardEvent cards signed by the observer's + * store as kind:30382 UserAssertionEvent cards signed by the observer's * per-observer service key (`rank = round(score*100)`, cutoff `--min-rank`): * changed ranks are re-signed, unchanged ones skipped, dropped targets * retracted with a kind:5. That persisted card set is what `publish` and diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayCommands.kt index 09f31e2e29..3d5a3746ec 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayCommands.kt @@ -32,14 +32,14 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrlOrNull import com.vitorpamplona.quartz.nip01Core.relay.normalizer.toHttp import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation -import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.nip17Dm.settings.DmRelayListEvent import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayListEvent import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BroadcastRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.FavoriteRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent -import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayFacet import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent @@ -144,14 +144,14 @@ object RelayCommands { Flat( "dm", "dm", - ChatMessageRelayListEvent.KIND, + DmRelayListEvent.KIND, setOf("chat", "inbox-dm"), // allRelays(), not relays(): every Flat here is read for SELF, // and the filtered accessor drops local entries meant for // attacker-supplied lists — making a deliberately configured // local relay report as no configuration at all. read = { c, pk -> c.dmInboxOf(pk)?.allRelays().orEmpty() }, - build = { c, r -> ChatMessageRelayListEvent.create(r, c.signer) }, + build = { c, r -> DmRelayListEvent.create(r, c.signer) }, ), Flat( "key-package", @@ -220,10 +220,10 @@ object RelayCommands { Flat( "feeds", "feeds", - RelayFeedsListEvent.KIND, + FavoriteRelayListEvent.KIND, setOf("favorites", "relay_feeds"), - read = { c, pk -> (c.latestReplaceable(pk, RelayFeedsListEvent.KIND) as? RelayFeedsListEvent)?.decryptRelays(c.signer).orEmpty() }, - build = { c, r -> RelayFeedsListEvent.create(r, c.signer) }, + read = { c, pk -> (c.latestReplaceable(pk, FavoriteRelayListEvent.KIND) as? FavoriteRelayListEvent)?.decryptRelays(c.signer).orEmpty() }, + build = { c, r -> FavoriteRelayListEvent.create(r, c.signer) }, ), ) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayGroupCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayGroupCommands.kt index 2f7e08bfdf..0bf79a57d1 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayGroupCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayGroupCommands.kt @@ -33,9 +33,9 @@ import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip29RelayGroups.hTag import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateGroupEvent -import com.vitorpamplona.quartz.nip29RelayGroups.moderation.EditMetadataEvent -import com.vitorpamplona.quartz.nip29RelayGroups.request.JoinRequestEvent -import com.vitorpamplona.quartz.nip29RelayGroups.request.LeaveRequestEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.GroupEditMetadataEvent +import com.vitorpamplona.quartz.nip29RelayGroups.request.GroupJoinRequestEvent +import com.vitorpamplona.quartz.nip29RelayGroups.request.GroupLeaveRequestEvent import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.GroupTag import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.SimpleGroupListEvent import com.vitorpamplona.quartz.nipC7Chats.ChatEvent @@ -116,7 +116,7 @@ object RelayGroupCommands { val createAck = ctx.publish(ctx.signer.sign(CreateGroupEvent.build(groupId)), target) val status = groupStatus(isPrivate, isClosed) - val edit = EditMetadataEvent.build(groupId, name = name, about = about, status = status) + val edit = GroupEditMetadataEvent.build(groupId, name = name, about = about, status = status) val editAck = ctx.publish(ctx.signer.sign(edit), target) // Track it in our own kind:10009 so `relaygroup list` shows it, matching // the Android create flow (Account.createRelayGroup → follow). @@ -154,7 +154,7 @@ object RelayGroupCommands { Context.open(dataDir).use { ctx -> ctx.prepare() - val join = JoinRequestEvent.build(groupId, reason = args.flag("reason") ?: "", inviteCode = args.flag("code")) + val join = GroupJoinRequestEvent.build(groupId, reason = args.flag("reason") ?: "", inviteCode = args.flag("code")) args.rejectUnknown() val signed = ctx.signer.sign(join) val ack = ctx.publish(signed, setOf(relay)) @@ -184,7 +184,7 @@ object RelayGroupCommands { Context.open(dataDir).use { ctx -> ctx.prepare() - val signed = ctx.signer.sign(LeaveRequestEvent.build(groupId)) + val signed = ctx.signer.sign(GroupLeaveRequestEvent.build(groupId)) val ack = ctx.publish(signed, setOf(relay)) RawEventSupport.publishGuard(ack, signed.id)?.let { return it } val listed = updateGroupList(ctx, relay, groupId, add = false) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayGroupModerationCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayGroupModerationCommands.kt index 12bde96071..937b14ed19 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayGroupModerationCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayGroupModerationCommands.kt @@ -26,10 +26,10 @@ import com.vitorpamplona.amethyst.cli.DataDir import com.vitorpamplona.amethyst.cli.Output import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent -import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateInviteEvent -import com.vitorpamplona.quartz.nip29RelayGroups.moderation.EditMetadataEvent -import com.vitorpamplona.quartz.nip29RelayGroups.moderation.PutUserEvent -import com.vitorpamplona.quartz.nip29RelayGroups.moderation.RemoveUserEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.GroupCreateInviteEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.GroupEditMetadataEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.GroupPutUserEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.GroupRemoveUserEvent /** * Moderator/admin write verbs for a relay group (the relay is the final authority @@ -94,7 +94,7 @@ object RelayGroupModerationCommands { // A kind-9002 re-asserts the whole metadata, so omitted fields must be carried over // from the current 39000 — otherwise editing only a flag would wipe name/about/tags. val template = - EditMetadataEvent.build( + GroupEditMetadataEvent.build( groupId, name = args.flag("name") ?: meta?.name(), about = args.flag("about") ?: meta?.about(), @@ -127,7 +127,7 @@ object RelayGroupModerationCommands { ): Int { val code = Args(rest).flag("code") ?: return Output.error("bad_args", "relaygroup invite RELAY GROUP_ID --code CODE") return publishScoped(dataDir, rest, "relaygroup invite RELAY GROUP_ID --code CODE", allowedFlags = arrayOf("code")) { _, groupId, _ -> - CreateInviteEvent.build(groupId, code) + GroupCreateInviteEvent.build(groupId, code) } } @@ -153,7 +153,7 @@ object RelayGroupModerationCommands { Context.open(dataDir).use { ctx -> ctx.prepare() val pubkey = ctx.requireUserHex(user) - val signed = ctx.signer.sign(PutUserEvent.build(groupId, listOf(pubkey to roles))) + val signed = ctx.signer.sign(GroupPutUserEvent.build(groupId, listOf(pubkey to roles))) val ack = ctx.publish(signed, setOf(relay)) RawEventSupport.publishGuard(ack, signed.id)?.let { return it } Output.emit( @@ -186,7 +186,7 @@ object RelayGroupModerationCommands { Context.open(dataDir).use { ctx -> ctx.prepare() val pubkey = ctx.requireUserHex(user) - val signed = ctx.signer.sign(RemoveUserEvent.build(groupId, listOf(pubkey))) + val signed = ctx.signer.sign(GroupRemoveUserEvent.build(groupId, listOf(pubkey))) val ack = ctx.publish(signed, setOf(relay)) RawEventSupport.publishGuard(ack, signed.id)?.let { return it } Output.emit( diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StatusReport.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StatusReport.kt index 5109d78728..91e692140d 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StatusReport.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StatusReport.kt @@ -34,7 +34,7 @@ import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.store.IEventStore import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent -import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.nip17Dm.settings.DmRelayListEvent import com.vitorpamplona.quartz.nip19Bech32.entities.NPub import com.vitorpamplona.quartz.nip60Cashu.wallet.CashuWalletEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent @@ -317,7 +317,7 @@ internal object StatusReport { var profile: MetadataEvent? = null var contacts: ContactListEvent? = null var relayList: AdvertisedRelayListEvent? = null - var dmInbox: ChatMessageRelayListEvent? = null + var dmInbox: DmRelayListEvent? = null var wallet = false // Newest-first with limit 1 gives the last-activity stamp. @@ -330,7 +330,7 @@ internal object StatusReport { MetadataEvent.KIND, ContactListEvent.KIND, AdvertisedRelayListEvent.KIND, - ChatMessageRelayListEvent.KIND, + DmRelayListEvent.KIND, CashuWalletEvent.KIND, ) store.query(Filter(authors = authors, kinds = describes)) { event -> @@ -340,7 +340,7 @@ internal object StatusReport { is MetadataEvent -> if (isNewer(event, profile)) profile = event is ContactListEvent -> if (isNewer(event, contacts)) contacts = event is AdvertisedRelayListEvent -> if (isNewer(event, relayList)) relayList = event - is ChatMessageRelayListEvent -> if (isNewer(event, dmInbox)) dmInbox = event + is DmRelayListEvent -> if (isNewer(event, dmInbox)) dmInbox = event is CashuWalletEvent -> wallet = true else -> Unit } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StreamCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StreamCommands.kt index 245373936d..2e945bb1a3 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StreamCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StreamCommands.kt @@ -32,7 +32,7 @@ import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.AgentTextSt import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.InMemoryAgentTextStreamSequenceStore import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.PreviewStatus import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.RecordOutcome -import com.vitorpamplona.quartz.marmot.mls.crypto.MlsCryptoProvider +import com.vitorpamplona.quartz.mls.crypto.MlsCryptoProvider import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ZapCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ZapCommand.kt index 7f0300c989..df39413b02 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ZapCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ZapCommand.kt @@ -33,8 +33,8 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapRequestEvent import okhttp3.OkHttpClient /** @@ -222,8 +222,8 @@ object ZapCommand { sats: Long, lnAddress: String, comment: String, - request: LnZapRequestEvent, - zapType: LnZapEvent.ZapType, + request: ZapRequestEvent, + zapType: ZapReceiptEvent.ZapType, timeoutMs: Long, settleWith: NDebit?, zappedEventId: HexKey? = null, @@ -300,7 +300,7 @@ object ZapCommand { sats: Long, comment: String, zappedEventId: HexKey, - zapType: LnZapEvent.ZapType, + zapType: ZapReceiptEvent.ZapType, requests: List, timeoutMs: Long, settleWith: NDebit?, @@ -350,11 +350,11 @@ object ZapCommand { ) } - private fun parseZapType(args: Args): LnZapEvent.ZapType = + private fun parseZapType(args: Args): ZapReceiptEvent.ZapType = when { - args.bool("anon") -> LnZapEvent.ZapType.ANONYMOUS - args.bool("private") -> LnZapEvent.ZapType.PRIVATE - else -> LnZapEvent.ZapType.PUBLIC + args.bool("anon") -> ZapReceiptEvent.ZapType.ANONYMOUS + args.bool("private") -> ZapReceiptEvent.ZapType.PRIVATE + else -> ZapReceiptEvent.ZapType.PUBLIC } private suspend fun fetchLatestMetadata( diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/graperank/GrapeRankOperator.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/graperank/GrapeRankOperator.kt index c380072c06..057768d857 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/graperank/GrapeRankOperator.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/graperank/GrapeRankOperator.kt @@ -34,7 +34,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent -import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent import com.vitorpamplona.quartz.nip56Reports.ReportEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent @@ -45,7 +45,7 @@ import com.vitorpamplona.quartz.nip85TrustedAssertions.list.serviceProviders import com.vitorpamplona.quartz.nip85TrustedAssertions.list.tags.ProviderTypes import com.vitorpamplona.quartz.nip85TrustedAssertions.list.tags.ServiceProviderTag import com.vitorpamplona.quartz.nip85TrustedAssertions.list.tags.ServiceType -import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent +import com.vitorpamplona.quartz.nip85TrustedAssertions.users.UserAssertionEvent import com.vitorpamplona.quartz.utils.TimeUtils /** @@ -90,8 +90,8 @@ object GrapeRankOperator { linkedMapOf( "observer" to observer, "provider_pubkey" to rec.providerPubKey, - "cards" to ctx.store.count(Filter(kinds = listOf(ContactCardEvent.KIND), authors = listOf(rec.providerPubKey))), - "retractions" to ctx.store.count(Filter(kinds = listOf(DeletionEvent.KIND), authors = listOf(rec.providerPubKey))), + "cards" to ctx.store.count(Filter(kinds = listOf(UserAssertionEvent.KIND), authors = listOf(rec.providerPubKey))), + "retractions" to ctx.store.count(Filter(kinds = listOf(DeletionRequestEvent.KIND), authors = listOf(rec.providerPubKey))), ) } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/graperank/GrapeRankPublish.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/graperank/GrapeRankPublish.kt index f57b4eb3ce..dff06fcc6f 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/graperank/GrapeRankPublish.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/graperank/GrapeRankPublish.kt @@ -36,7 +36,7 @@ import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEve import com.vitorpamplona.quartz.nip85TrustedAssertions.list.serviceProviders import com.vitorpamplona.quartz.nip85TrustedAssertions.list.tags.ProviderTypes import com.vitorpamplona.quartz.nip85TrustedAssertions.list.tags.ServiceProviderTag -import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent +import com.vitorpamplona.quartz.nip85TrustedAssertions.users.UserAssertionEvent /** * The transport + consumption sides of NIP-85: `graperank publish` pushes the @@ -169,12 +169,12 @@ object GrapeRankPublish { val provider = providerArg?.let { ctx.requireUserHex(it) } val cardFilter = Filter( - kinds = listOf(ContactCardEvent.KIND), + kinds = listOf(UserAssertionEvent.KIND), tags = mapOf("d" to listOf(user)), authors = provider?.let { listOf(it) }, ) - suspend fun localCards(): List = ctx.store.query(cardFilter).filterIsInstance() + suspend fun localCards(): List = ctx.store.query(cardFilter).filterIsInstance() var cards = localCards() if (refresh || cards.isEmpty()) { @@ -190,7 +190,7 @@ object GrapeRankPublish { cards .groupBy { it.pubKey } .mapNotNull { (_, list) -> list.maxByOrNull { it.createdAt } } - .sortedWith(compareByDescending { it.rank() ?: -1 }.thenByDescending { it.createdAt }) + .sortedWith(compareByDescending { it.rank() ?: -1 }.thenByDescending { it.createdAt }) // A provider key this machine's operator master derived maps back to // the observer whose subjective view the rank expresses. @@ -236,7 +236,7 @@ object GrapeRankPublish { if (!ctx.anonymous) { providerListOf(ctx, ctx.identity.pubKeyHex) ?.serviceProviders() - ?.filter { it.service.kind == ContactCardEvent.KIND && (provider == null || it.pubkey == provider) } + ?.filter { it.service.kind == UserAssertionEvent.KIND && (provider == null || it.pubkey == provider) } ?.map { it.relayUrl } .orEmpty() } else { diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/graperank/GrapeRankScore.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/graperank/GrapeRankScore.kt index 34efcf4f86..e23b4a3b06 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/graperank/GrapeRankScore.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/graperank/GrapeRankScore.kt @@ -34,8 +34,8 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent -import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent import com.vitorpamplona.quartz.nip09Deletions.DeletionIndex +import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent import com.vitorpamplona.quartz.nip56Reports.ReportEvent import kotlin.math.roundToInt @@ -293,8 +293,8 @@ object GrapeRankScore { // Everything in the store already passed verifyAndStore, so mark the // deletions as verified and skip the redundant signature check. val deletions = DeletionIndex() - for (ev in ctx.store.query(Filter(kinds = listOf(DeletionEvent.KIND)))) { - if (ev is DeletionEvent) deletions.add(ev, wasVerified = true) + for (ev in ctx.store.query(Filter(kinds = listOf(DeletionRequestEvent.KIND)))) { + if (ev is DeletionRequestEvent) deletions.add(ev, wasVerified = true) } var dropped = 0 diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/FileCashuKeysetCounterStore.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/FileCashuKeysetCounterStore.kt index c561cf2b10..02b3317824 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/FileCashuKeysetCounterStore.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/FileCashuKeysetCounterStore.kt @@ -54,9 +54,9 @@ class FileCashuKeysetCounterStore( Persisted() } - override fun peek(keysetId: String): Long = synchronized(lock) { load().keyset_counters[keysetId] ?: 0L } + override suspend fun peek(keysetId: String): Long = synchronized(lock) { load().keyset_counters[keysetId] ?: 0L } - override fun reserve( + override suspend fun reserve( keysetId: String, count: Int, ): Long = diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/FileStores.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/FileStores.kt index 3457784ec7..5290c1f1e6 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/FileStores.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/FileStores.kt @@ -23,9 +23,9 @@ package com.vitorpamplona.amethyst.cli.stores import com.vitorpamplona.amethyst.cli.SecureFileIO import com.vitorpamplona.amethyst.commons.util.deleteOrWarn import com.vitorpamplona.quartz.marmot.MarmotIngestDedupStore +import com.vitorpamplona.quartz.marmot.groups.MarmotMessageStore +import com.vitorpamplona.quartz.marmot.groups.MlsGroupStateStore import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore -import com.vitorpamplona.quartz.marmot.mls.group.MarmotMessageStore -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore import com.vitorpamplona.quartz.marmot.protocolCore.MarmotPublishObligationStore import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/HttpCordnBlobStore.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/HttpCordnBlobStore.kt new file mode 100644 index 0000000000..0c89ff3890 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/HttpCordnBlobStore.kt @@ -0,0 +1,118 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli.stores + +import com.vitorpamplona.amethyst.commons.cordn.CordnBlobStore +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent +import com.vitorpamplona.quartz.utils.sha256.sha256 +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import java.util.Base64 +import java.util.concurrent.TimeUnit + +/** + * A Blossom store for `amy`, over plain OkHttp. + * + * Exists because the app's `BlossomUploader` needs an Android `Context` for a + * MIME lookup that a migration blob does not need — every document is opaque + * bytes. Keeping a JVM implementation means the whole handoff is drivable and + * testable without a phone, which is how the interop scripts exercise it. + * + * ## The ephemeral signer is the point, not a detail + * + * §12 requires the BUD-01 authorization event to be signed by an ephemeral key + * and never the owner. Signing as the owner would publish, in the clear to the + * storage server, that this account is uploading right now — the linkage the + * opaque tip exists to prevent. [signer] is minted per instance and is not + * derived from the account. + */ +class HttpCordnBlobStore( + private val servers: List, + private val http: OkHttpClient = + OkHttpClient + .Builder() + .callTimeout(CALL_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .build(), +) : CordnBlobStore { + private val signer = NostrSignerInternal(KeyPair()) + + override suspend fun put(blob: ByteArray): List = + withContext(Dispatchers.IO) { + val hash = sha256(blob).toHexKey() + servers.filter { server -> runCatching { upload(server, blob, hash) }.getOrDefault(false) } + } + + override suspend fun get( + address: String, + servers: List, + ): ByteArray? = + withContext(Dispatchers.IO) { + // Ordered: §6 says the reader tries the tip's servers in the order + // it listed them, most reliable first. + servers.firstNotNullOfOrNull { server -> + runCatching { + http + .newCall( + Request + .Builder() + .url("${server.trimEnd('/')}/$address") + .get() + .build(), + ).execute() + .use { if (it.isSuccessful) it.body?.bytes() else null } + }.getOrNull() + } + } + + private suspend fun upload( + server: String, + blob: ByteArray, + hash: String, + ): Boolean { + val auth = + BlossomAuthorizationEvent.createUploadAuth( + hash = hash, + size = blob.size.toLong(), + alt = "", + signer = signer, + ) + + val request = + Request + .Builder() + .url("${server.trimEnd('/')}/upload") + .header("Authorization", "Nostr " + Base64.getEncoder().encodeToString(auth.toJson().encodeToByteArray())) + .put(blob.toRequestBody(null)) + .build() + + return http.newCall(request).execute().use { it.isSuccessful } + } + + companion object { + private const val CALL_TIMEOUT_SECONDS = 60L + } +} diff --git a/cli/tests/cordn/interop-client.sh b/cli/tests/cordn/interop-client.sh new file mode 100755 index 0000000000..89ee63ab2a --- /dev/null +++ b/cli/tests/cordn/interop-client.sh @@ -0,0 +1,245 @@ +#!/usr/bin/env bash +# +# interop-client.sh — amy and the REFERENCE CLIENT in one group. +# +# The claim Tier B does not test. `tier-b.sh` runs amy against amy through the +# reference *coordinator*: it proves our transport and our coordinator client, +# but both MLS endpoints are ours, so the ratchet tree, the Welcome and the +# Commit are only ever agreeing with themselves. This script puts +# **`@cordn/cli` (ts-mls)** on one end and **amy (quartz)** on the other, which +# is the actual interop claim: two independent RFC 9420 implementations in one +# group, over a live wire. +# +# `@cordn/cli` is **MIT** and comes from npm, so this half carries no licensing +# problem. The coordinator underneath it still does — see stack.sh, and read it +# before running this. +# +# What it covers, and why each direction is its own test: +# +# 1. Their group, our joiner — our engine opens a ts-mls Welcome, reads +# the group metadata extension and the +# roster out of it, and decrypts their +# application messages. +# 2. Our group, their joiner — their engine opens OUR Welcome. This is +# the direction that tests our output, and +# it is the one a fixture can never check, +# because a fixture we wrote accepts what we +# emit by construction. +# 3. Our later Commit — the sharpest one. Our engine emits +# **public-framed** handshake messages +# (`MlsMessage(PublicMessage)`, wireformat +# 2) while cordn's client emits +# private-framed. `CordnGroupManager.invite` +# asserts in its KDoc that their +# `processMessageBase64` admits both — a +# claim read off their source and never +# executed. Here they must process our +# Commit to stay in the group at all: if +# they cannot, their epoch stalls and every +# later message fails to open. +# +# Prereqs: see stack.sh, plus network access to npm for `@cordn/cli`. +# +# Usage: +# ./cli/tests/cordn/interop-client.sh +# KEEP=1 ./cli/tests/cordn/interop-client.sh # leave the stack up +# +# Exit 0 only if every step passed. + +set -uo pipefail + +WORK="${WORK:-$(mktemp -d)}" +PORT="${PORT:-7452}" +CONTAINER="cordn-interop-client" +# shellcheck source=stack.sh +. "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/stack.sh" + +export AMY_PASSPHRASE="${AMY_PASSPHRASE:-interop}" + +fail=0 +step() { echo; echo "── $*"; } +ok() { echo " ok: $*"; } +bad() { echo " FAIL: $*"; fail=1; } +check() { if [ "$1" = "$2" ]; then ok "$3"; else bad "$3 (expected '$2', got '$1')"; fi; } + +trap stack_down EXIT + +stack_require +command -v npm >/dev/null 2>&1 || { echo "npm is needed to install @cordn/cli"; exit 2; } + +# A stuck reference client should fail the run rather than hang it, but macOS +# ships no `timeout` (it is GNU coreutils; `gtimeout` if Homebrew installed +# them). Without this the whole harness died at the first `ref` call with +# nothing but "could not read the reference client's pubkey", because ref() +# folds stderr into the output it parses and "command not found" matches no +# field. Running untimed is better than not running. +if command -v timeout >/dev/null 2>&1; then + REF_TIMEOUT="timeout 120" +elif command -v gtimeout >/dev/null 2>&1; then + REF_TIMEOUT="gtimeout 120" +else + REF_TIMEOUT="" + echo "note: no timeout(1) — the reference client runs untimed" +fi + +step "boot geode on $RELAY and the reference coordinator" +stack_up +ok "coordinator $COORD" + +step "install @cordn/cli (MIT) from npm" +mkdir -p "$WORK/ref" +npm install --silent --prefix "$WORK/ref" @cordn/cli >"$WORK/npm.log" 2>&1 || { + echo "npm install failed; see $WORK/npm.log" + exit 1 +} +CORDN="$WORK/ref/node_modules/.bin/cordn" +[ -x "$CORDN" ] || { echo "no cordn binary at $CORDN"; exit 1; } +ok "$("$CORDN" --version 2>/dev/null || echo unknown)" + +# ---- the two clients ------------------------------------------------------ +# amy is process-per-command by design; the reference client is driven the +# same way with --command, so neither side gets to hold state in RAM that the +# other cannot see. Whatever agreement they reach went over the wire. +amy() { HOME="$WORK/amy" "$AMY" --account a --secret-backend ncryptsec "$@" 2>/dev/null; } +amy2() { HOME="$WORK/amy2" "$AMY" --account b --secret-backend ncryptsec "$@" 2>/dev/null; } +ref() { + # shellcheck disable=SC2086 # REF_TIMEOUT is a command prefix, or empty + $REF_TIMEOUT "$CORDN" \ + --private-key-file "$WORK/ref.key" \ + --server-pubkey "$COORD" \ + --relay "$RELAY" \ + --state-file "$WORK/ref-state.json" \ + --command "$1" 2>&1 +} +field() { python3 -c "import json,sys; d=json.load(sys.stdin); v=d$1; print(v if isinstance(v,str) else json.dumps(v))"; } +# Their output comes in two shapes and it is worth having both readers rather +# than one clever one: `group-info` and `available-kps` print `key=value` +# tokens, `status` prints `key: value`. +reffield() { grep -oE "$1=[^ ]+" | head -1 | cut -d= -f2-; } +refcolon() { grep -oE "^$1: .*" | head -1 | cut -d' ' -f2-; } + +step "identities" +mkdir -p "$WORK/amy" "$WORK/amy2" +openssl rand -hex 32 >"$WORK/ref.key" +amy create --json >/dev/null +amy2 create --json >/dev/null +AMY_PK=$(amy whoami --json | field "['hex']") +AMY2_PK=$(amy2 whoami --json | field "['hex']") +REF_PK=$(ref "status" | refcolon "stablePubkey") +[ -n "$REF_PK" ] || { echo "could not read the reference client's pubkey"; exit 1; } +ok "amy $AMY_PK" +ok "amy(2nd) $AMY2_PK" +ok "ts-mls $REF_PK" + +for a in amy amy2; do + $a cordn coordinator add --coordinator "$COORD" --relay "$RELAY" --json >/dev/null +done + +step "both sides publish a KeyPackage" +amy cordn keypackage publish --json >/dev/null +amy2 cordn keypackage publish --json >/dev/null +ref "gen-kp k1" >/dev/null +# Each client has to be able to READ the other's publication off the +# coordinator, which means our KeyPackage has to parse under their zod schema +# and their capability flags have to survive our encoder. +KPS=$(ref "available-kps") +echo "$KPS" | grep -q "$AMY_PK" && ok "ts-mls can read our published KeyPackage" || bad "our KeyPackage is invisible to them" +echo "$KPS" | grep -q "groupMetadataSupport=yes" && ok "and reads our metadata capability" || bad "our capability flags did not survive" + +# --------------------------------------------------------------------------- +step "DIRECTION 1 — their group, our joiner" +# --------------------------------------------------------------------------- +ref "create-group g1 --name TheirGroup" >/dev/null +THEIR_GID=$(ref "group-info g1" | reffield "groupId") +ok "ts-mls created $THEIR_GID" + +ref "add-member g1 $AMY_PK" >/dev/null +PENDING=$(amy cordn welcomes --json) +check "$(echo "$PENDING" | field "['pending'][0]['gid']")" "$THEIR_GID" "our engine opened a ts-mls Welcome" +# Read out of the Welcome itself, so these assert that their GroupContext +# extensions and their credentials decode under our parser. +check "$(echo "$PENDING" | field "['pending'][0]['name']")" "TheirGroup" "and read their metadata extension" +echo "$PENDING" | grep -q "$REF_PK" && ok "and their credential in the roster" || bad "their credential did not decode" + +amy cordn join --all --json >/dev/null +ref "send-to g1 hello from ts-mls" >/dev/null +GOT=$(amy cordn fetch --json) +check "$(echo "$GOT" | field "['messages'][0]['content']")" "hello from ts-mls" "we decrypt their application message" +check "$(echo "$GOT" | field "['messages'][0]['sender']")" "$REF_PK" "and MLS authenticates them as the sender" + +amy cordn send --gid "$THEIR_GID" --text "hello from quartz" --json >/dev/null +ref "sync g1" >/dev/null +ref "messages g1" | grep -q "hello from quartz" && ok "they decrypt ours" || bad "they could not read our message" + +# --------------------------------------------------------------------------- +step "DIRECTION 2 — our group, their joiner" +# --------------------------------------------------------------------------- +# The direction a fixture cannot test: a fixture we wrote accepts what we emit +# by construction, so only a foreign implementation can say our Welcome is +# well formed. +OUR_GID="quartz-side-group" +amy cordn group create --gid "$OUR_GID" --name "OurGroup" --json >/dev/null +ref "gen-kp k2" >/dev/null +INVITE=$(amy cordn invite --gid "$OUR_GID" --pubkey "$REF_PK" --json) +SPENT=$(echo "$INVITE" | field "['kp_ref']") +check "$(echo "$INVITE" | field "['epoch']")" "1" "our commit advanced us to epoch 1" + +ref "fetch-welcomes" >/dev/null +ACCEPTED=$(ref "accept-welcome $SPENT g2") +echo "$ACCEPTED" | grep -q "name=OurGroup" && ok "ts-mls opened OUR Welcome and read our metadata" || bad "ts-mls could not open our Welcome: $ACCEPTED" +check "$(ref "group-info g2" | reffield "groupId")" "$OUR_GID" "and agrees on the gid" + +amy cordn send --gid "$OUR_GID" --text "quartz made this group" --json >/dev/null +ref "sync g2" >/dev/null +ref "messages g2" | grep -q "quartz made this group" && ok "they read ours at epoch 1" || bad "they could not read ours" + +ref "send-to g2 ts-mls replying in a quartz group" >/dev/null +amy cordn fetch --json | grep -q "ts-mls replying in a quartz group" && ok "we read theirs" || bad "we could not read theirs" + +# --------------------------------------------------------------------------- +step "DIRECTION 3 — our LATER commit, which they must process" +# --------------------------------------------------------------------------- +# Until now their epoch came from a Welcome, which carries the group state +# ready-made. This is the first time they have to apply one of our handshake +# messages, and ours are public-framed (wireformat 2) where theirs are +# private-framed. If they cannot parse it their epoch stalls at 1 and the +# message they send afterwards is sealed under a key we do not have. +amy2 cordn keypackage publish --json >/dev/null +COMMIT=$(amy cordn invite --gid "$OUR_GID" --pubkey "$AMY2_PK" --json) +check "$(echo "$COMMIT" | field "['epoch']")" "2" "our second commit advanced us to epoch 2" + +ref "sync g2" >/dev/null +ref "send-to g2 after the quartz commit" >/dev/null +AFTER=$(amy cordn fetch --json) +# The real assertion: a message they sealed at epoch 2 only opens if they +# applied our Commit. A stalled peer would have sealed at epoch 1, and this +# would come back undecryptable instead. +check "$(echo "$AFTER" | field "['messages'][0]['content']")" "after the quartz commit" "ts-mls applied our public-framed Commit" +check "$(echo "$AFTER" | field "['messages'][0]['epoch']")" "2" "and sealed at the new epoch" + +step "the third member joins a group two implementations built" +amy2 cordn join --all --json >/dev/null +THIRD=$(amy2 cordn fetch --json) +echo "$THIRD" | grep -q "after the quartz commit" && ok "reads the ts-mls message it was welcomed into" || bad "third member could not read history at its join epoch" + +step "all three agree" +A_EPOCH=$(amy cordn group info --gid "$OUR_GID" --json | field "['epoch']") +B_EPOCH=$(amy2 cordn group info --gid "$OUR_GID" --json | field "['epoch']") +R_CURSOR=$(ref "group-info g2" | reffield "cursor") +check "$A_EPOCH" "2" "quartz (inviter) at epoch 2" +check "$B_EPOCH" "2" "quartz (invitee) at epoch 2" +[ -n "$R_CURSOR" ] && ok "ts-mls advanced to cursor $R_CURSOR" || bad "ts-mls reported no cursor" + +MEMBERS=$(amy cordn group info --gid "$OUR_GID" --json | field "['members']") +for pk in "$AMY_PK" "$AMY2_PK" "$REF_PK"; do + echo "$MEMBERS" | grep -q "$pk" || bad "roster is missing $pk" +done +ok "roster holds all three credentials" + +echo +if [ "$fail" = "0" ]; then + echo "CLIENT INTEROP PASSED" +else + echo "CLIENT INTEROP FAILED" +fi +exit "$fail" diff --git a/cli/tests/cordn/migrate.sh b/cli/tests/cordn/migrate.sh new file mode 100755 index 0000000000..1a7bec33b5 --- /dev/null +++ b/cli/tests/cordn/migrate.sh @@ -0,0 +1,178 @@ +#!/usr/bin/env bash +# +# migrate.sh — one account, two devices, a handoff between them. +# +# The claim tier-b.sh does not test. There, two accounts talk to each other. +# Here ONE account moves from the phone it is on to a new one, which is the +# case `spec/applications/multi-device.md` calls device addition (§11) and we +# implement as a one-shot handoff rather than continuous sync. +# +# What this proves that the unit tests cannot: +# +# * the sealed documents survive a real HTTP round trip to a blob server, +# * the tip survives a real relay as a real replaceable event, +# * the new device, starting from an empty home and a scanned string alone, +# ends up holding the same groups. +# +# The blob server is a throwaway python stand-in for Blossom: PUT /upload +# stores by sha256, GET / serves it back. Enough to exercise the real +# HttpCordnBlobStore, and deliberately not a Blossom implementation. +# +# Usage: cli/tests/cordn/migrate.sh +set -uo pipefail + +WORK="${WORK:-$(mktemp -d)}" +CONTAINER="cordn-migrate" +# shellcheck source=stack.sh +. "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/stack.sh" + +export AMY_PASSPHRASE="${AMY_PASSPHRASE:-migrate}" + +# Above 1024: binding below it needs root, and this test has no business +# asking for that. 877 was the default and bound for nobody. +BLOB_PORT="${BLOB_PORT:-8877}" +BLOB="http://127.0.0.1:$BLOB_PORT" +BLOB_DIR="$WORK/blobs" +BLOB_PID="" + +fail=0 +step() { echo; echo "── $*"; } +ok() { echo " ok: $*"; } +bad() { echo " FAIL: $*"; fail=1; } + +# Two HOMEs, one identity: the same nsec on an old phone and a new one. That is +# what a migration is, and it is why the new device needs no Welcome — it +# adopts the shared leaf rather than joining as a member (§9). +old() { HOME="$WORK/old" "$AMY" --account me --secret-backend ncryptsec "$@" 2>/dev/null; } +new() { HOME="$WORK/new" "$AMY" --account me --secret-backend ncryptsec "$@" 2>/dev/null; } +field() { python3 -c "import json,sys; d=json.load(sys.stdin); print(json.dumps(d$1) if not isinstance(d$1,str) else d$1)"; } + +blob_up() { + mkdir -p "$BLOB_DIR" + python3 - "$BLOB_PORT" "$BLOB_DIR" >"$WORK/blob.log" 2>&1 & + BLOB_PID=$! + # Wait for the port, and say so here if it never opens. Letting a dead blob + # server through costs three misleading failures later — export reports "no + # server accepted the document", and the group/blob assertions all fall over + # behind it — none of which name the thing that is actually wrong. + for _ in $(seq 20); do + if python3 -c "import socket,sys; s=socket.socket(); s.settimeout(0.3); sys.exit(0 if s.connect_ex(('127.0.0.1',$BLOB_PORT))==0 else 1)"; then + return 0 + fi + kill -0 "$BLOB_PID" 2>/dev/null || break + sleep 0.5 + done + echo "the blob server never came up on $BLOB — see $WORK/blob.log" + tail -3 "$WORK/blob.log" 2>/dev/null + exit 2 +} <<'PYEOF' +import hashlib, os, sys +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +port, root = int(sys.argv[1]), sys.argv[2] + +class H(BaseHTTPRequestHandler): + def do_PUT(self): + body = self.rfile.read(int(self.headers.get("Content-Length", 0))) + digest = hashlib.sha256(body).hexdigest() + open(os.path.join(root, digest), "wb").write(body) + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(('{"sha256":"%s","size":%d}' % (digest, len(body))).encode()) + + def do_GET(self): + path = os.path.join(root, self.path.lstrip("/")) + if not os.path.isfile(path): + self.send_response(404); self.end_headers(); return + body = open(path, "rb").read() + self.send_response(200) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *a): pass + +ThreadingHTTPServer(("127.0.0.1", port), H).serve_forever() +PYEOF + +cleanup() { + [ -n "$BLOB_PID" ] && kill "$BLOB_PID" 2>/dev/null + stack_down +} +trap cleanup EXIT + +stack_require + +step "boot geode on $RELAY, the reference coordinator, and a blob server on $BLOB" +stack_up +blob_up +ok "coordinator $COORD" + +step "one account, on its old phone" +mkdir -p "$WORK/old" "$WORK/new" +old create --json >/dev/null +PK=$(old whoami --json | field "['hex']") +ok "npub $PK" + +# The new phone is the SAME identity. Copy the key across the way a user would +# by signing in again; the migration carries group state, never identity (§4.3). +NSEC=$(old key export --json 2>/dev/null | field "['nsec']" 2>/dev/null) +if [ -z "$NSEC" ]; then + cp -r "$WORK/old/.amy" "$WORK/new/.amy" + ok "new phone signed in (identity copied)" +else + new import --nsec "$NSEC" --json >/dev/null + ok "new phone signed in" +fi + +step "the old phone has a group" +old cordn coordinator add --coordinator "$COORD" --relay "$RELAY" --label migrate --json >/dev/null +GROUP=$(old cordn group create --name "Moves with me" --about "handoff" --json) +GID=$(echo "$GROUP" | field "['gid']") +[ -n "$GID" ] && ok "gid $GID" || bad "no group to migrate" + +step "the old phone exports a handoff" +EXPORT=$(old cordn migrate export --relay "$RELAY" --server "$BLOB" --json) +echo " $EXPORT" +CODE=$(echo "$EXPORT" | field "['code']") +if [ -n "$CODE" ] && [ "${CODE:0:9}" = "cordndev1" ]; then ok "code minted"; else bad "no handoff code"; fi +[ "$(echo "$EXPORT" | field "['groups']")" = "1" ] && ok "one group in the snapshot" || bad "wrong group count" +[ "$(echo "$EXPORT" | field "['uploaded']")" = "true" ] && ok "documents stored" || bad "nothing stored" + +step "blobs really landed on the server" +COUNT=$(ls "$BLOB_DIR" 2>/dev/null | wc -l | tr -d ' ') +# One group document plus the meta document. +[ "$COUNT" -ge 2 ] && ok "$COUNT blobs" || bad "expected >=2 blobs, found $COUNT" + +step "the new phone has nothing yet" +BEFORE=$(new cordn group list --json 2>/dev/null | field "['groups']" 2>/dev/null || echo "[]") +[ "$BEFORE" = "[]" ] && ok "empty" || echo " (starting from: $BEFORE)" + +step "the new phone imports, from the code alone" +IMPORT=$(new cordn migrate import --code "$CODE" --json) +echo " $IMPORT" +[ "$(echo "$IMPORT" | field "['groups']")" = "1" ] && ok "one group adopted" || bad "group did not arrive" +[ "$(echo "$IMPORT" | field "['replaced_local_state']")" = "true" ] && ok "replaced, not merged" || bad "did not replace" + +step "the group is really there, with the same gid" +AFTER=$(new cordn group list --json 2>/dev/null) +echo " $AFTER" +echo "$AFTER" | grep -q "$GID" && ok "gid $GID on the new phone" || bad "gid missing after import" + +step "a group ref is not a handoff code" +# Called directly rather than through new(): the --json error contract writes +# to stderr, which the helper sends to /dev/null. The first version of this +# check compared an empty string and passed for the wrong reason. +STRANGER=$(HOME="$WORK/new" "$AMY" --account me --secret-backend ncryptsec \ + cordn migrate import --code "cordn1qqqqqq" --json 2>&1 || true) +echo " $STRANGER" +echo "$STRANGER" | grep -q "bad_args" && ok "refused" || bad "accepted a non-handoff code" + +echo +if [ "$fail" -eq 0 ]; then + echo "── migrate: PASS" +else + echo "── migrate: FAIL" +fi +exit "$fail" diff --git a/cli/tests/cordn/stack.sh b/cli/tests/cordn/stack.sh new file mode 100644 index 0000000000..ca8005b95a --- /dev/null +++ b/cli/tests/cordn/stack.sh @@ -0,0 +1,117 @@ +# shellcheck shell=bash +# +# stack.sh — boot the cordn test stack: a geode relay plus the REFERENCE +# coordinator in Docker. Sourced by tier-b.sh and interop-client.sh. +# +# ───────────────────────────────────────────────────────────────────────────── +# The reference coordinator (`ghcr.io/cordn-msg/cordn`, and the +# `packages/coordinator` / `packages/server` sources it is built from) ships +# with NO LICENSE — default copyright, all rights reserved. See §7 of +# quartz/plans/2026-09-17-cordn-interop.md. +# +# Nothing here is wired into a build: no Gradle task, no CI job, and nothing +# pulls the image for you. You pull it by hand having decided that is +# something you want to do. Do not add these scripts to a build file. +# ───────────────────────────────────────────────────────────────────────────── +# +# The caller sets WORK (a scratch directory) and may set PORT. After +# `stack_up`, these are exported: +# +# RELAY ws://127.0.0.1:$PORT +# COORD the coordinator's pubkey, read from its own startup log +# +# `stack_down` is registered by the caller's EXIT trap; KEEP=1 skips it. + +ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../../.." && pwd)" +AMY="$ROOT/cli/build/install/amy/bin/amy" +GEODE="$ROOT/geode/build/install/geode/bin/geode" +IMAGE="ghcr.io/cordn-msg/cordn:latest" + +PORT="${PORT:-7447}" +RELAY="ws://127.0.0.1:$PORT" +CONTAINER="${CONTAINER:-cordn-test}" +GEODE_PID="" + +# Prereqs, each with its own message. A stopped daemon and an unpulled image +# both fail `docker image inspect`, and telling someone to pull an image they +# cannot pull sends them the wrong way. +stack_require() { + for f in "$AMY" "$GEODE"; do + [ -x "$f" ] || { + echo "missing $f — run ./gradlew :cli:installDist :geode:installDist" + exit 2 + } + done + docker info >/dev/null 2>&1 || { + echo "the docker daemon is not reachable — start it (e.g. 'sudo dockerd &' or 'systemctl start docker') and retry" + exit 2 + } + docker image inspect "$IMAGE" >/dev/null 2>&1 || { + echo "missing $IMAGE — pull it by hand, and read the licence note at the top of this file first" + exit 2 + } +} + +stack_up() { + "$GEODE" --port "$PORT" >"$WORK/geode.log" 2>&1 & + GEODE_PID=$! + for _ in $(seq 30); do + curl -sS --noproxy '*' -H 'Accept: application/nostr+json' "http://127.0.0.1:$PORT/" >/dev/null 2>&1 && break + sleep 1 + done + + # The container has to reach geode, which is on the host's loopback. + # + # On Linux `--network host` puts it there. On Docker Desktop (macOS, + # Windows) the daemon is inside a VM, so `--network host` is the *VM's* + # loopback and 127.0.0.1:$PORT is nothing at all: the coordinator retries + # "Relay connection error" until stack_up gives up on a pubkey that was + # never going to arrive. There the host is reachable by name instead, over + # the default bridge. + # + # A stable key so the coordinator pubkey survives a re-run against the + # same WORK directory. + if [ "$(uname -s)" = "Linux" ]; then + COORD_NET="--network host" + COORD_RELAY="$RELAY" + else + COORD_NET="--add-host=host.docker.internal:host-gateway" + COORD_RELAY="ws://host.docker.internal:$PORT" + fi + + [ -f "$WORK/coordinator.key" ] || openssl rand -hex 32 >"$WORK/coordinator.key" + docker rm -f "$CONTAINER" >/dev/null 2>&1 + # shellcheck disable=SC2086 # COORD_NET is two words on purpose + docker run -d --name "$CONTAINER" $COORD_NET \ + -e CORDN_STORAGE_BACKEND=memory \ + -e CORDN_ANNOUNCED=false \ + -e CORDN_RELAY_URLS="$COORD_RELAY" \ + -e CORDN_SERVER_PRIVATE_KEY="$(cat "$WORK/coordinator.key")" \ + -e CORDN_SERVER_NAME="cordn-test" \ + "$IMAGE" >/dev/null || { echo "could not start $CONTAINER"; exit 1; } + + # Read the pubkey out of its own startup log rather than deriving it: the + # coordinator is the authority on its identity, and a key we derived + # wrongly would fail later as an unreachable coordinator. + COORD="" + for _ in $(seq 60); do + COORD=$(docker logs "$CONTAINER" 2>&1 | grep -oE 'serverPubkey":"[0-9a-f]{64}' | head -1 | cut -d'"' -f3) + [ -n "$COORD" ] && break + sleep 1 + done + [ -n "$COORD" ] || { + echo "coordinator never announced its pubkey" + docker logs "$CONTAINER" | tail -20 + exit 1 + } +} + +stack_down() { + if [ "${KEEP:-0}" != "1" ]; then + docker rm -f "$CONTAINER" >/dev/null 2>&1 + [ -n "$GEODE_PID" ] && kill "$GEODE_PID" 2>/dev/null + else + echo + echo "KEEP=1: relay on $RELAY, coordinator $CONTAINER ($COORD), state in $WORK" + fi +} diff --git a/cli/tests/cordn/tier-b.sh b/cli/tests/cordn/tier-b.sh new file mode 100755 index 0000000000..ce85b43bd0 --- /dev/null +++ b/cli/tests/cordn/tier-b.sh @@ -0,0 +1,247 @@ +#!/usr/bin/env bash +# +# tier-b.sh — the cordn binding, end to end against the REFERENCE coordinator. +# +# Tier B of quartz/plans/2026-09-17-cordn-interop.md §6.4: a live +# counterparty, not a fixture. Three amy accounts, one local relay (geode), +# one reference coordinator, and the whole lifecycle — publish a KeyPackage, +# create a group, invite, open the Welcome without joining, join, talk in both +# directions, and check both sides agree on epoch and membership. +# +# Then the parts that lifecycle never reaches, each driven directly, so that +# all eleven coordinator tools of `spec/00.md` are exercised against a real +# coordinator: a third account who ASKS to join rather than being invited +# (join_request_store, join_request_take_many), a withdrawn KeyPackage +# (kp_remove), a backlog big enough to be chunked (CEP-22), and a message +# pushed down an open stream (msg_sub_many). +# +# The reference coordinator it runs against is UNLICENSED — read the header of +# stack.sh, which boots it, before running this. Nothing here is wired into a +# build, and it must not become so. +# +# Sibling: interop-client.sh puts amy and the reference CLIENT in one group, +# which is the test this one does not do — here both MLS endpoints are ours. +# +# Prereqs: see stack.sh. +# +# Usage: +# ./cli/tests/cordn/tier-b.sh # boot everything, run, tear down +# KEEP=1 ./cli/tests/cordn/tier-b.sh # leave the relay + coordinator up +# +# Exit 0 only if every step passed. + +set -uo pipefail + +WORK="${WORK:-$(mktemp -d)}" +PORT="${PORT:-7447}" +CONTAINER="cordn-tier-b" +# shellcheck source=stack.sh +. "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/stack.sh" + +export AMY_PASSPHRASE="${AMY_PASSPHRASE:-tier-b}" + +fail=0 +step() { echo; echo "── $*"; } +ok() { echo " ok: $*"; } +bad() { echo " FAIL: $*"; fail=1; } + +# Every amy run is its own process, which is the point: the cursor on disk is +# the only thing carrying continuity between them. +alice() { HOME="$WORK/alice" "$AMY" --account alice --secret-backend ncryptsec "$@" 2>/dev/null; } +bob() { HOME="$WORK/bob" "$AMY" --account bob --secret-backend ncryptsec "$@" 2>/dev/null; } +carol() { HOME="$WORK/carol" "$AMY" --account carol --secret-backend ncryptsec "$@" 2>/dev/null; } +field() { python3 -c "import json,sys; d=json.load(sys.stdin); print(json.dumps(d$1) if not isinstance(d$1,str) else d$1)"; } + +trap stack_down EXIT + +stack_require + +step "boot geode on $RELAY, and the reference coordinator" +stack_up +ok "coordinator $COORD" + +step "three accounts" +# Carol is here for the door nobody knocks on in the two-party flow: bob is +# invited, so he never sends a join request. +mkdir -p "$WORK/alice" "$WORK/bob" "$WORK/carol" +alice create --json >/dev/null +bob create --json >/dev/null +carol create --json >/dev/null +ALICE_PK=$(alice whoami --json | field "['hex']") +BOB_PK=$(bob whoami --json | field "['hex']") +CAROL_PK=$(carol whoami --json | field "['hex']") +ok "alice $ALICE_PK" +ok "bob $BOB_PK" +ok "carol $CAROL_PK" + +step "all three remember the coordinator" +alice cordn coordinator add --coordinator "$COORD" --relay "$RELAY" --label tier-b --json >/dev/null +bob cordn coordinator add --coordinator "$COORD" --relay "$RELAY" --label tier-b --json >/dev/null +carol cordn coordinator add --coordinator "$COORD" --relay "$RELAY" --label tier-b --json >/dev/null + +step "the MCP handshake" +# The first thing to break if the transport regresses, and the cheapest to +# check. A timeout here means the coordinator never saw the request at all — +# which is exactly how §7.1's finding 1 presented. +INFO=$(alice cordn coordinator info --json) +echo " $INFO" +[ "$(echo "$INFO" | field "['reachable']")" = "true" ] && ok "reachable" || bad "no initialize response" + +step "bob publishes a KeyPackage" +KP=$(bob cordn keypackage publish --json) +echo " $KP" +[ -n "$(echo "$KP" | field "['published'][0]['kp_ref']")" ] && ok "published" || bad "no kp_ref" + +step "alice creates a group" +GROUP=$(alice cordn group create --name "Tier B" --about "live" --json) +echo " $GROUP" +GID=$(echo "$GROUP" | field "['gid']") +[ -n "$GID" ] && ok "gid $GID" || bad "no gid" + +step "alice invites bob" +INVITE=$(alice cordn invite --pubkey "$BOB_PK" --json) +echo " $INVITE" +[ "$(echo "$INVITE" | field "['epoch']")" = "1" ] && ok "epoch 1 after the commit" || bad "epoch did not advance" + +step "bob opens the invitation without joining" +PENDING=$(bob cordn welcomes --json) +echo " $PENDING" +[ "$(echo "$PENDING" | field "['pending'][0]['gid']")" = "$GID" ] && ok "sees $GID" || bad "no pending welcome" +[ "$(echo "$PENDING" | field "['pending'][0]['name']")" = "Tier B" ] && ok "reads the name out of the Welcome" || bad "no metadata in the Welcome" + +step "bob joins" +JOINED=$(bob cordn join --all --json) +echo " $JOINED" +[ "$(echo "$JOINED" | field "['joined']")" = "[\"$GID\"]" ] && ok "joined" || bad "join failed" + +step "alice sends, bob reads" +SENT=$(alice cordn send --text "hello from amy" --json) +MSG_ID=$(echo "$SENT" | field "['id']") +GOT=$(bob cordn fetch --json) +echo " $GOT" +[ "$(echo "$GOT" | field "['messages'][0]['id']")" = "$MSG_ID" ] && ok "same envelope id" || bad "bob did not read it" +[ "$(echo "$GOT" | field "['messages'][0]['sender']")" = "$ALICE_PK" ] && ok "sender is what MLS authenticated" || bad "wrong sender" + +step "bob replies, alice reads" +BACK=$(bob cordn send --text "and hello back" --json) +BACK_ID=$(echo "$BACK" | field "['id']") +GOT2=$(alice cordn fetch --json) +echo " $GOT2" +echo "$GOT2" | grep -q "$BACK_ID" && ok "alice read the reply" || bad "alice did not read the reply" +# §7.1 finding 2: a sender must not report its own traffic as a gap in its own +# conversation. Everything alice posted came back at a cursor she re-reads, +# and recognising it needs bookkeeping that survives the process exit. +[ "$(echo "$GOT2" | field "['undecryptable']")" = "[]" ] && ok "no self-inflicted gaps" || bad "own traffic came back undecryptable: $(echo "$GOT2" | field "['undecryptable']")" + +# ── The three tools the two-party flow never reaches, and CEP-22 ──────────── +# Every other step above exercises a tool as a side effect of the lifecycle. +# These four do not happen on that path, so they are driven directly. + +step "carol asks to join — join_request_store" +REQ=$(carol cordn request --gid "$GID" --json) +echo " $REQ" +[ "$(echo "$REQ" | field "['gid']")" = "$GID" ] && ok "request stored" || bad "join request not stored" +# A ref is an invitation to ask, not membership (spec/01.md §5.3). +[ "$(echo "$REQ" | field "['member']")" = "false" ] && ok "asking is not joining" || bad "request reported membership" + +step "alice reads it — join_request_take_many" +PEND=$(alice cordn requests list --json) +echo " $PEND" +[ "$(echo "$PEND" | field "['requests'][0]['pubkey']")" = "$CAROL_PK" ] && ok "sees carol" || bad "no pending request" +[ "$(echo "$PEND" | field "['requests'][0]['gid']")" = "$GID" ] && ok "for $GID" || bad "wrong gid on the request" +# Listing must NOT consume. A request is retired by answering it, not by +# reading it, and the ack rides the next call — so a reader that lost the +# process between list and accept has to still find it. Two separate amy +# runs is exactly that case. +AGAIN=$(alice cordn requests list --json) +[ "$(echo "$AGAIN" | field "['requests'][0]['pubkey']")" = "$CAROL_PK" ] && ok "still there for a second reader" || bad "reading a join request consumed it" + +step "alice accepts, carol joins" +ACC=$(alice cordn requests accept --pubkey "$CAROL_PK" --json) +echo " $ACC" +[ "$(echo "$ACC" | field "['answered'][0]['pubkey']")" = "$CAROL_PK" ] && ok "accepted" || bad "accept failed" +CJOIN=$(carol cordn join --all --json) +[ "$(echo "$CJOIN" | field "['joined']")" = "[\"$GID\"]" ] && ok "carol joined" || bad "carol did not join" +# Now it is retired — and the ack for it rode a later call, so this also +# proves the retirement survived the process that issued it. +GONE=$(alice cordn requests list --json) +[ "$(echo "$GONE" | field "['requests']")" = "[]" ] && ok "answering retired it" || bad "an answered request is still pending: $(echo "$GONE" | field "['requests']")" + +step "bob withdraws a KeyPackage — kp_remove" +# Two, so there is something left to prove the removal was targeted rather +# than a wipe. +PUB=$(bob cordn keypackage publish --count 2 --json) +KEEP=$(echo "$PUB" | field "['published'][0]['kp_ref']") +DROP=$(echo "$PUB" | field "['published'][1]['kp_ref']") +WD=$(bob cordn keypackage withdraw --kp-ref "$DROP" --json) +echo " $WD" +[ "$(echo "$WD" | field "['removed']")" = "[\"$DROP\"]" ] && ok "coordinator confirms the removal" || bad "kp_remove did not confirm $DROP" +LIST=$(bob cordn keypackage list --json) +echo "$LIST" | grep -q "$DROP" && bad "the withdrawn package is still served" || ok "gone from kp_list" +echo "$LIST" | grep -q "$KEEP" && ok "the other one survived" || bad "kp_remove took more than it was asked for" + +step "a response too big for one event — CEP-22" +# The reference coordinator switches to oversized transfer at a 48000-byte +# published envelope. One message does not reach it; a backlog of them does, +# and a fetch is where a backlog is delivered. Nothing below reads the +# messages differently — a reassembled response is meant to be invisible — +# so the count is the only thing that can tell us the profile ran. +BIG=$(python3 -c "print('x' * 6000)") +for i in $(seq 12); do + alice cordn send --text "chunk-$i $BIG" --json >/dev/null +done +FETCH=$(bob cordn fetch --json) +COUNT=$(echo "$FETCH" | python3 -c "import json,sys; print(len(json.load(sys.stdin)['messages']))") +CHUNKED=$(echo "$FETCH" | field "['oversized_transfers']") +echo " $COUNT messages, oversized_transfers=$CHUNKED" +[ "$COUNT" = "12" ] && ok "all 12 arrived" || bad "expected 12 messages, got $COUNT" +[ "${CHUNKED:-0}" -ge 1 ] && ok "reassembled over CEP-22" || bad "the response was never chunked — CEP-22 went unexercised" +# Carol was added at a later epoch, so she must read them too: an oversized +# response is still one response, not a per-member special case. +CGOT=$(carol cordn fetch --json) +CCOUNT=$(echo "$CGOT" | python3 -c "import json,sys; print(len(json.load(sys.stdin)['messages']))") +[ "$CCOUNT" = "12" ] && ok "carol read the same 12" || bad "carol got $CCOUNT of 12" + +step "a message delivered over an open stream — msg_sub_many" +# The eleventh tool, and the only one a request/response client never +# reaches: `cordn watch` calls msg_sub_many and nothing else, so anything +# it prints arrived over an open CEP-41 stream rather than a poll. +# +# Ordering is the whole test. The watcher goes up FIRST and we wait for it +# to be listening; only then does alice send. A message sent beforehand +# would be backlog the subscription replays, which proves a stream opened +# but not that anything was pushed down it. +WATCH_OUT="$WORK/watch.json" +bob cordn watch --timeout 25000 --json >"$WATCH_OUT" 2>/dev/null & +WATCH_PID=$! +sleep 8 +LIVE="live-$$-$(date +%s)" +alice cordn send --text "$LIVE" --json >/dev/null +wait "$WATCH_PID" +WATCHED=$(cat "$WATCH_OUT") +echo " $(echo "$WATCHED" | head -c 400)" +[ "$(echo "$WATCHED" | field "['via']")" = "msg_sub_many" ] && ok "the stream was the source" || bad "cordn watch did not report a subscription" +echo "$WATCHED" | grep -q "$LIVE" && ok "pushed live, not polled for" || bad "the subscription never delivered $LIVE" +[ "$(echo "$WATCHED" | field "['messages'][0]['sender']")" = "$ALICE_PK" ] && ok "MLS authenticated the sender over the stream too" || bad "wrong sender on the streamed message" + +step "the watcher saved what it ingested" +# Decrypting a streamed message advances the ratchet and the cursor. A +# subscription that ended without writing would leave that only in memory, +# and the next process would re-read its own progress as a gap. +AFTER=$(bob cordn fetch --json) +echo "$AFTER" | grep -q "$LIVE" && bad "the streamed message came back on the next fetch" || ok "the cursor survived the watching process" +[ "$(echo "$AFTER" | field "['undecryptable']")" = "[]" ] && ok "no gaps after the stream closed" || bad "gaps after the stream: $(echo "$AFTER" | field "['undecryptable']")" + +step "both sides agree" +A=$(alice cordn group info --json) +B=$(bob cordn group info --json) +[ "$(echo "$A" | field "['epoch']")" = "$(echo "$B" | field "['epoch']")" ] && ok "same epoch" || bad "epochs differ" +[ "$(echo "$A" | field "['members']")" = "$(echo "$B" | field "['members']")" ] && ok "same members" || bad "membership differs" + +echo +if [ "$fail" = "0" ]; then + echo "TIER B PASSED" +else + echo "TIER B FAILED" +fi +exit "$fail" diff --git a/commons/build.gradle.kts b/commons/build.gradle.kts index b952fe67af..86c5c10c33 100644 --- a/commons/build.gradle.kts +++ b/commons/build.gradle.kts @@ -90,6 +90,14 @@ kotlin { // OkHttp), so declare the dependency the file actually has. implementation(libs.okio) + // DataStore (KMP, Apache-2.0) — the preference storage layer. + // Publishes android/jvm/ios/linux/macos variants plus common + // metadata, so the stores under model/preferences/ are shared + // rather than duplicated per front end. Uses the okio-based + // `createWithPath` factory in common; the `java.io.File` + // overloads are jvmAndroid-only. + implementation(libs.androidx.datastore.preferences) + // Immutable collections api(libs.kotlinx.collections.immutable) @@ -159,9 +167,8 @@ kotlin { // Compose UI artifacts before the :commonsUI split. implementation(libs.androidx.core.ktx) - // Secure key storage via Android Keystore - implementation(libs.androidx.security.crypto.ktx) - implementation(libs.androidx.datastore.preferences) + // Secure key storage talks to the AndroidKeyStore directly through + // SecretEncryption; androidx.security.crypto is gone from this module. } } diff --git a/commons/plans/2026-05-04-custom-feeds-plan.md b/commons/plans/2026-05-04-custom-feeds-plan.md index 1a54116a6a..f572a0d9cf 100644 --- a/commons/plans/2026-05-04-custom-feeds-plan.md +++ b/commons/plans/2026-05-04-custom-feeds-plan.md @@ -463,7 +463,7 @@ class FeedBuilderState(initial: FeedDefinition?) { - DVM request goes to DVM's advertised relays (from kind 31990 `relay` tags) - Response subscription listens on both user's relays AND DVM's relays - Use `MetadataPreloader` for bulk-fetching author metadata of returned notes -- Reuse existing `NIP90ContentDiscoveryRequestEvent.build()` pattern from Quartz +- Reuse existing `DvmContentDiscoveryRequestEvent.build()` pattern from Quartz ### Phase 7: Publish/Import (kind 31890) @@ -532,7 +532,7 @@ commons/src/commonTest/kotlin/.../feeds/custom/ | `TopFilter` | `amethyst/.../AccountSettings.kt` | Reference; `FeedSource.toTopFilter()` for bridge | | `FavoriteAlgoFeedsOrchestrator` | `amethyst/.../algoFeeds/` | Extract to commons for DVM reuse | | `FavoriteAlgoFeedsListEvent` (kind 10090) | `quartz/.../nip51Lists/` | Pinned feed sync | -| `NIP90ContentDiscoveryRequestEvent` | `quartz/.../nip90Dvms/` | DVM request building | +| `DvmContentDiscoveryRequestEvent` | `quartz/.../nip90Dvms/` | DVM request building | | `AppDefinitionEvent` (kind 31990) | `quartz/.../nip89AppHandlers/` | DVM marketplace discovery | | `NAddress` | `quartz/.../nip19Bech32/entities/` | naddr encode/decode | | `Nip19Parser` | `quartz/.../nip19Bech32/` | Detect pasted naddr | @@ -542,7 +542,7 @@ commons/src/commonTest/kotlin/.../feeds/custom/ | `PinnedNavBarState` | `desktopApp/.../deck/PinnedNavBarState.kt` | Reference pattern for pin state | | `HomeFeed` rendering | `desktopApp/.../home/` | Phase 2 content reuse | | `BaseAddressableEvent` | `quartz/.../nip01Core/core/` | Base for kind 31890 | -| `PeopleListEvent` / `InterestSetEvent` | `quartz/.../nip51Lists/` | Resolve ATag -> members | +| `FollowSetEvent` / `InterestSetEvent` | `quartz/.../nip51Lists/` | Resolve ATag -> members | | `MetadataPreloader` | `commons/.../relayClient/` | Bulk metadata fetch for feed results | | `FeedMetadataCoordinator` | `commons/.../relayClient/` | Coordinate metadata for visible notes | | `ComposeSubscriptionManager` | `commons/.../relayClient/` | Subscription lifecycle | diff --git a/commons/plans/2026-08-30-commons-migration-sweep.md b/commons/plans/2026-08-30-commons-migration-sweep.md index 20982ddae2..dc121ad209 100644 --- a/commons/plans/2026-08-30-commons-migration-sweep.md +++ b/commons/plans/2026-08-30-commons-migration-sweep.md @@ -771,11 +771,11 @@ Options, for the maintainer to pick: ### Next work, in recommended order (needs maintainer go-ahead per item) -- **Tier 4 unlocks** (small, mechanical): `NappletProtocolJson` +- **Tier 4 unlocks** — **done 2026-09-26**, see the last section. (Was: small, mechanical: `NappletProtocolJson` `java.util.Base64` → `kotlin.io.encoding.Base64` (frees `NappletIdentityWatch`); `ScheduledPostStore` Jackson+`java.io.File` → kotlinx-serialization+okio (frees `ScheduledPostWorkGate`). - `LargeSoftCache` stays parked (needs a WeakReference expect/actual). + `LargeSoftCache` stays parked (needs a WeakReference expect/actual).) - **Wave 2: LocalCache move-group** — **part A landed (2026-09-19)**, see the section below. Part B (repoint `desktopApp`, delete `DesktopLocalCache.kt`) is the remaining half. @@ -934,7 +934,7 @@ measured picture. are handled; the 9 without a dedicated `EventCache` overload (`TextNoteEvent`, `ContactListEvent`, `CommentEvent`, `AdvertisedRelayListEvent`, `BlossomServersEvent`, `BookmarkListEvent`, - `OldBookmarkListEvent`, `ChatMessageRelayListEvent`, `FollowListEvent`) fall + `OldBookmarkListEvent`, `DmRelayListEvent`, `StarterPackEvent`) fall into the generic replaceable/addressable group. - **13 of 13 core read methods match** by name and signature. - **Feed retention is already aligned** — both platforms hold feed content @@ -982,7 +982,7 @@ a deletion: ~500 lines of genuinely Desktop-specific state survive, and the concentrated in five places (metadata, contact list, follow pack, live activity, `clear`) and re-derive cleanly from the event after consume. 4. **Two Desktop-shaped `consume` overloads do not generalize** — the NIP-47 - `LnZapPaymentRequestEvent` one takes a `zappedNote` and an `onResponse` + `NwcRequestEvent` one takes a `zappedNote` and an `onResponse` callback, and the response one drives `paymentTracker` + `appScope`. Android reaches the same tracker through account state. Keep them Desktop-side. 5. **`clear()`** has only 2 production call sites (`Main.kt`, logout/account @@ -1302,3 +1302,133 @@ through an `ingest` helper that pins what the cache built for the lifetime of the test instance, the way a screen holds the notes it is showing in the app, and keeps the forced GC in the test that failed so the contract is asserted rather than left to the heap. + +## 2026-09-26 — Tier 4 unlocks, the strings drift, and the UI stragglers + +### Tier 4: two jvmAndroid groups promoted to `commonMain` + +- **Napplet wire protocol.** `NappletProtocolJson`, `NappletIdentityWatch` and + `NappletRequestRouter` moved to `commons/commonMain/…/napplet`. The only + pins were `java.util.Base64` and one `System.currentTimeMillis()`. Base64 is + now `kotlin.io.encoding.Base64`, decoding with `PaddingOption.PRESENT_OPTIONAL` + because `java.util.Base64.getDecoder()` accepted unpadded input and applets + may send it. The clock is `TimeUtils.now()`. +- **Scheduled posts.** `ScheduledPostStore` had already dropped Jackson for + kotlinx-serialization; what pinned it was `java.io.File` and a POSIX + `chmod`. It now takes an okio `Path` plus a `FileSystem` (default + `platformFileSystem`), and a `String` constructor so callers need no okio, + the same shape as `FileSystemNip95BlobStore`. The owner-only permission is a + new `expect fun restrictFileToOwner(path, tag)`: + `Files.setPosixFilePermissions` on jvmAndroid, POSIX `chmod(0600)` on iOS. + The temp-file swap is `FileSystem.atomicMove`, which replaces an existing + target on every platform, so the old "rename, else delete and rename again" + fallback went with it. `ScheduledPostWorkGate`, `ScheduledPostPublisher` and + `ScheduledPostNotifier` came along; none had a JVM pin of its own. + +### The strings drift, fixed + +Between the 2026-09-22 move and today, the cordn UI and the backup-conflict +review added **447 keys** to `amethyst/src/main/res` (`cordn_*`, `backup_*`), +taking it from 190 keys back to 597. All 447 moved with +`tools/strings-migrate/migrate.py` (1,131 elements over 57 locales) and the 18 +files that read them were repointed. The non-mechanical sites: + +- `stringRes(context, …)` inside `suspend` senders (`sendAttachment`, + `sendVoiceNote`) → `loadStringRes(…)`. `adminFailureText` became `suspend`; + its only caller is already in `scope.launch`. +- The backup review kept its labels as `@StringRes Int` (`DiffGroup.label`, + `ReviewRow.*Res`, `eventTypeName()`, the `countTiles`/`moneyHero`/`laneItems` + params) → `StringResource` / `PluralStringResource`. +- `rememberPresentation` resolved labels inside `remember { }` through + `LocalResources`. Compose resources have no non-suspend accessor, so the 16 + labels `presentationOf` writes into item text are resolved in composition into + a map, and the map is the `remember` key. +- `androidx.compose.ui.res.pluralStringResource` → the compose-resources one. + +Six moved keys have no reader today (`cordn_backup_restore_title`, +`cordn_coordinators_relays`, `cordn_create_admin_only_me`, +`cordn_create_coordinator`, `cordn_send`, `cordn_voice_play`). They moved +rather than being deleted; dropping them is the feature owner's call. +`pow_notification_sending` stays in Android `res/`: the PoW foreground service +reads it synchronously. + +The rule is now written down in `.claude/CLAUDE.md` ("Strings"), in +`commonsUI/ARCHITECTURE.md`, and in the `android-expert` and +`find-missing-translations` skills: new strings go in `commonsUI` +composeResources, even for Android-only screens. + +### UI stragglers moved to `commonsUI` + +A survey against `main` found that most of "Batch 4" had already landed in +other PRs (`dde228d2`, `1a563cf2`, `8dd409cb`, `1f1b6dab`): the theme +constants (in `commonsUI/…/theme/Shape.kt`, not the `Sizes.kt` this plan +named), the feed shell, the top bars, `M3ActionDialog`, `NoteComposeLayout`, +`RepostLayout` and the toast queue. This round moved what was left that had no +real blocker: + +| Moved | To | Note | +|---|---|---| +| `NewItemsBubble` (from `ChatroomHeaderCompose.kt`) | `commonsUI/…/ui/components` | | +| `ChatHeaderLayout`, `LeftPictureLayout` | `commonsUI/…/ui/layouts` | Their `@Preview`s use app drawables, `TimeAgo` and `TextCount`, so the previews stay app-side in `*Preview.kt` files | +| `listItem/SlimListItemLayout` | `commonsUI/…/ui/layouts/listItem` | Same preview split; `@VisibleForTesting` dropped (no androidx.annotation in commonMain) | +| `WatchScrollToTop` (list/grid/pager overloads) | `commonsUI/…/ui/feeds` | The `CardFeedContentState` overload stays in the app | +| app `StickToTopOnPrepend` | deleted | It was a copy of the commons one. The commons copy now collects with `collectAsStateWithLifecycle`, as the app copy did | +| `ChannelFeedState`, `ChannelFeedContentState` | `commons/commonMain/…/feeds` | `checkNotInMainThread()` → `LocalCache.appHost.assertNotMainThread()`, which Android wires to the same check | +| `ClickableBuzzInviteLink`, `ClickableConcordInviteLink`, `ClickableRelayGroupLink`, `ClickableRelayUrl`, `OutlinedThinPaddingTextField` | `commonsUI/…/ui/components` | | +| app `ClickableEmail`, `ClickablePhone` | deleted | Android now uses the commons ones (`mailto:` / `tel:` through `LocalUriHandler`) instead of `ACTION_SEND` / `ACTION_DIAL` intents. The commons `ClickablePhone` is new. Desktop still renders phone numbers as plain text, on purpose | + +**Still app-side, with the reason:** + +- `AmethystClickableIcon`: 9 lines around an Android debug action; moving it + only pushes `LocalContext` into its two callers. +- `ScreenLayout`: needs `LocalConfiguration` → `LocalWindowInfo`, and the + window-size-class breakpoints inlined. +- `SlidingCarousel`: needs `animatedViewerChromeInset` hoisted out of the + viewer-chrome file. +- `AudioWaveformReadOnly`: needs two enums from the Android-only waveform + library replaced. +- `FileAttachmentCard`: needs `extractFilename` hoisted. +- `PdfFetcher`: needs the disk cache injected. +- `ClickablePhone` / `ClickableEmail` on Desktop: deliberately unchanged. +- **`AmethystTheme` split** (the pure scheme/typography half to commonsUI; the + `Amethyst.instance` overload and the system-bar `SideEffect` stay). +- **`TimeAgo` split**: the labels, `NowProvider` and the style enum are pure; + the absolute and short formatters need expect/actual over + `android.text.format`. +- **Palette unification**: Desktop's `PlatformColorScheme` and the app palette + differ in primary, secondary, surfaces and outlines, and every shared token + (`placeholderText`, `chatBubbleThem`, …) is frozen from the app palette. This + is a design decision, not a move. + +### Audit of the 2026-09-26 round + +The four commits were reviewed file by file against the originals. They had no +functional bugs. The follow-ups: + +- **Compose resources keep XML whitespace; Android didn't.** This is older than this + round (it dates back to the first string moves), and it is the one real + regression. aapt collapses every whitespace run in an unquoted value and trims + the ends; Compose draws the XML text verbatim. So the values wrapped over + indented lines (`account_backup_tips2_md`, `push_server_install_app_description`, + `couldnt_find_nwc_wallets_description`, the chat explainers, and 349 translated + values) rendered with a leading line break and eight spaces. In the `_md` strings + CommonMark turns that into a code block. Crowdin also exports a translator's stray + edge space, which aapt used to drop, for example ` miejsca zniknęły` and Hindi's + ` दि॰` time suffix. `fix_escapes.py` now applies aapt's rule to runs holding a line + break or tab. In a translation it also trims an edge space its source string lacks, + which keeps deliberate ones like `" and "`. The Crowdin workflow already runs it + after every sync, and `compose_escaping_check.py` now fails on raw line breaks, so + a sync can't bring it back. 598 values across 54 files were repaired. +- `rememberPresentation` read all 16 backup labels on every recomposition, even for + diff types that write none. It now reads only the ones its diff type uses, and + `BackupPresentationLabelsTest` fails if `presentationOf` asks for a label that list + lacks. +- `ScheduledPostStore`: a failed stat now counts as "no file" rather than throwing + (okio's posix metadata throws on EACCES where `File.exists()` returned false). + Symlinked store files still count as present. The chmod is skipped for an injected + non-system `FileSystem`, and the cleanup log keeps its throwable. +- `ClickableEmail` percent-encodes `%` in the `mailto:` URI (RFC 6068). +- Left as-is, by design: Kotlin's Base64 rejects non-zero pad bits (`"SGl="`) that + Java ignored, which no browser encoder produces. `%1$d` arguments now render ASCII + digits in every locale, the same as every other migrated string. + `ChannelFeedContentState` reaches `LocalCache.appHost` for its main-thread check. diff --git a/commons/plans/2026-09-24-cordn-message-store.md b/commons/plans/2026-09-24-cordn-message-store.md new file mode 100644 index 0000000000..07f326a7b7 --- /dev/null +++ b/commons/plans/2026-09-24-cordn-message-store.md @@ -0,0 +1,192 @@ +# cordn message store + +*2026-09-24* + +## The problem + +A cordn room's messages exist in exactly one place on the device: a +`LinkedHashMap` inside `CordnGroupChatroom`. `CordnGroupStore` persists the MLS +group state, the `GroupCursor`, echo state, joined-via-request and +`CordnRoomState(draft, lastReadCursor)`. It does not persist a single message. + +That is worse than an uncached history, because of how the cursor works. +`GroupCursor.fetchCursor` advances past every message the stream delivers and is +saved on the way out of `catchUp`/`subscribe`. On the next launch, +`msg_fetch_many(after: fetchCursor)` therefore returns nothing — the client has +already consumed everything. The room opens empty and **never refills**. + +Rewinding the cursor would not rescue it either. A cordn payload is sealed twice: +the MLS application message, then `SealedPayload` under +`MLS-Exporter("cordn", "group-payload", 32)`. Both keys are epoch-derived, so +once the group ratchets forward the ciphertext the coordinator still holds is +unreadable to us. **Ingestion is the only moment the message is in the clear.** + +Three visible symptoms, one cause: + +- rooms are empty after every app restart +- the Messages inbox says "No messages yet" for every cordn room, because + `CordnGroupChatroom._newest` is only ever set by `add`/`addAll` +- leaving and re-opening a room mid-session is fine; killing the app is not + +## What Marmot already does + +`MarmotMessageStore` / `AndroidMarmotMessageStore` is the same subsystem, built: +an `EncryptedAppendLog` at `/mls_groups//messages`, storing the +**decrypted inner event JSON**, with idempotent appends. Its own KDoc states the +principle we need: *"the ratchet moved past the ciphertext long ago, so this +store is the only copy."* + +Two differences shape our design rather than letting us copy it outright: + +1. **Marmot has a second recovery path; cordn has none.** Marmot's kind-445 + events sit on relays and it retains epoch secrets, so a replay can + re-decrypt — which is *why* it needs dedup. cordn has no replay, which makes + the store more load-bearing here, not less. +2. **Marmot messages are `Note`s in `LocalCache`; cordn's must never be.** The + isolation rule means our read path hydrates `CordnGroupChatroom` directly. + +cordn also has **no message expiry**, so there is no prune-before-read step. + +## Design + +### What a stored entry is + +`CordnDeliveredMessage` = `CordnEnvelope` + `cursor`. The envelope already has +`toJson()`/`encode()`/`decode()`. The cursor has to ride along: the room orders +on it and the unread divider compares against it, and it is not derivable from +the envelope. + +Entry format — one JSON object per log entry, versioned: + +```json +{"v":1,"c":,"e":{…envelope…}} +``` + +A new `CordnDeliveredMessageCodec` beside `CordnEnvelope` in quartz owns it, so +the format is tested where the envelope's own round-trip test already lives. + +### Where it is written + +`CordnGroupManager.ingest` produces `Delivery.Message(gid, cursor, …)` but is +not `suspend`. The append belongs in the `suspend` caller — the `onDelivery` +path inside `catchUp`/`subscribe` — so that "the room has it" and "disk has it" +happen together rather than on separate beats. + +**Ordering is the correctness property.** Append the message, *then* advance and +persist the cursor. Crash between the two and the message is on disk while the +cursor still points before it: the next `catchUp` re-delivers it and dedup drops +it. Do it in the other order and the message is gone forever, with no way back. +This is the one invariant a reviewer should check first. + +### Dedup + +Marmot dedups on whole-entry string equality via `EncryptedAppendLog.contains`. +That is not enough here: the same message re-delivered after a crash carries the +same envelope but the entry string is only equal if the cursor matches too, and +`Ingestion` can legitimately hand back a different cursor. Dedup on +`envelope.id`, using an in-memory `Set` per group built at load — cheap, +and the log is already read in full on open. + +### Where it is read + +`CordnRuntime.restoreRoomState` loads draft + read position when a screen opens +a room, deliberately: its KDoc notes that doing it at login would read state for +rooms nobody opens. + +The inbox preview breaks that symmetry — it needs `_newest` for every room +*before* any room is opened. So two reads, not one: + +- **per-group summary** (newest envelope + count), its own small whole-blob key, + read at login. Fixes "No messages yet" without touching the logs. +- **full log**, read on room open, hydrating via `addAll` so the existing + `recompute`/annotation fold runs exactly as it does for live delivery. + +The summary is written on the same beat as the append. + +### `EncryptedAppendLog` placement — needs a decision + +`CordnIndependenceTest` forbids cordn importing anything whose import line +contains "marmot". The log currently lives in +`commons/.../commons/marmot/EncryptedAppendLog.kt`, so cordn cannot use it where +it is. + +It is, however, entirely protocol-neutral: it takes `encrypt`/`decrypt` lambdas +and stores opaque strings. Nothing in it knows what Marmot is. + +- **A — move it** to a neutral package (`commons/.../store/`), update Marmot's + import. One implementation, one file format, one migration path. Touches a + Marmot file (import line only, no behaviour change) and *improves* + independent-deletability: neither feature owns the primitive any more. +- **B — duplicate it** into a cordn package. ~400 lines of subtle framing, + folding and legacy-format migration, copied, free to drift. +- **C — whole-blob rewrites** with cordn's existing `atomicWrite` pattern. O(n) + bytes per send. This is the cost the append log exists to avoid. + +**Recommend A.** The guard's own KDoc draws the line at "marmot-named code" — +the coupling it exists to prevent — and a shared neutral primitive is not that. +Worth Vitor's sign-off before it happens, since it edits a Marmot file. + +### Backup and migration — needs a decision + +`CordnBackup.Archive.Group` carries coordinator, gid, state, cursor and +joined-via-request. No messages. + +- **Device migration** (`CordnMigrationStores`) is "this device becomes that + device". Arriving with no history would be the surprising outcome. **Include.** + *Done* — as `amethystMessages` on the group document, alongside the other + additive `amethyst*` fields, written before the cursor on import for the same + reason the live path writes it that way. Bounded per group by + `MESSAGE_BUDGET_BYTES`, newest first: these blobs go to hosts whose limits we + do not know, and a handoff that fails because one group is chatty is worse + than one that carries a deep but bounded history. +- **Backup** is a recovery artifact whose size the user sees. History could + multiply it by a large factor. **Exclude for now**, and say so in the backup + screen's copy rather than letting someone discover it at restore time. *Still + open.* + +Both are reversible later; the format is versioned. + +### Deletion + +`deleteGroup(gid)` must remove the log and the summary. Leaving a group today +removes the MLS state; leaving plaintext history behind would be a quiet +regression in exactly the property this feature sells. Explicit test. + +## Test plan + +The failure modes here are lifecycle, not logic, and this branch has already +shipped two bugs of that shape. Tests that matter: + +- **crash between append and cursor save** → message survives, re-delivery + dedups on id. The invariant above, tested directly. +- **round-trip** an envelope with tags, an edit, emoji, and empty content. +- **rehydrate equals live** — the annotation fold built from a loaded log + matches the one built by ingesting the same messages. The fold is what the + user sees; equality of the raw list is not enough. +- **`_newest` after load** → the inbox preview shows the last message. +- **`deleteGroup` removes the log** and the summary. +- **cancellation** — a load or append cancelled mid-flight leaves no partial + entry. + +One trap, learned the hard way on this branch: `InMemoryCordnGroupStore`'s +`suspend` methods never reach a suspension point, so cancellation is +*unobservable* in any test using it. The fake for the message store must +actually suspend, or the cancellation test above proves nothing. + +## Non-goals + +No expiry (cordn has none), no search, no paging — the log loads whole. A room +with tens of thousands of messages would want paging; note it, do not build it +until a room gets there. + +## Files + +- `quartz/.../cordn/spec02Envelopes/CordnDeliveredMessageCodec.kt` — new +- `commons/.../cordn/CordnGroupStore.kt` — append/load/summary/delete +- `commons/.../cordn/FileCordnStores.kt` — the log-backed implementation +- `commons/.../cordn/CordnGroupManager.kt` — append at ingest, ordered before + the cursor +- `commons/.../store/EncryptedAppendLog.kt` — moved (decision A) +- `commons/.../marmot/…` — import update only +- `amethyst/.../model/cordn/CordnRuntime.kt` — summary at login, log on open +- `commons/.../cordn/CordnMigrationStores.kt` — carry messages diff --git a/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/KeyStoreCordnBlobCipher.kt b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/KeyStoreCordnBlobCipher.kt new file mode 100644 index 0000000000..f6c22543d3 --- /dev/null +++ b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/KeyStoreCordnBlobCipher.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn + +import com.vitorpamplona.amethyst.commons.keystorage.KeyStoreEncryption + +/** + * The Android [CordnBlobCipher]: AES-GCM under a key held in the Android + * KeyStore (StrongBox-backed where the device has it). + * + * ## Why there is no lock here any more + * + * There used to be one. [KeyStoreEncryption] kept a single `Cipher` in a field + * and ran `init(...)` then `doFinal(...)` against it, which is not atomic, so + * two groups saving at once on `Dispatchers.IO` could interleave and write + * bytes encrypted under the wrong IV — unrecoverable for an `MlsGroupState`. + * + * [KeyStoreEncryption] now holds its `Cipher` in a `ThreadLocal`, so the pair + * can no longer interleave and the lock guards nothing. Keeping it would + * serialise every cordn group save and load on this device for no reason, + * which is the opposite of what a thread pool is for. + */ +class KeyStoreCordnBlobCipher : CordnBlobCipher { + // Not a constructor parameter: KeyStoreEncryption is internal to commons, + // so taking one publicly would leak an internal type. Nothing needs to + // inject it either — a test that wanted a different cipher implements + // [CordnBlobCipher] directly, which is what the seam is for. + // Built on first use, not at construction. This cipher is reached from + // CordnRuntime, which Account builds eagerly, so a KeyStoreEncryption that + // throws here takes the whole account down before any UI exists — the app + // sits on "Loading account" forever. Every other user of KeyStoreEncryption + // already guards it (AccountCacheState falls back to an in-memory Marmot + // store); deferring keeps a cordn-only failure inside cordn. + private val encryption by lazy { KeyStoreEncryption() } + + override fun encrypt(bytes: ByteArray): ByteArray = encryption.encrypt(bytes) + + override fun decrypt(bytes: ByteArray): ByteArray = encryption.decrypt(bytes) +} diff --git a/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/KeyStoreEncryption.kt b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/KeyStoreEncryption.kt index fc0ea04b04..bf4b50fd64 100644 --- a/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/KeyStoreEncryption.kt +++ b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/KeyStoreEncryption.kt @@ -24,26 +24,71 @@ import android.os.Build import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyProperties import android.security.keystore.StrongBoxUnavailableException +import java.security.GeneralSecurityException import java.security.KeyStore import javax.crypto.Cipher import javax.crypto.KeyGenerator import javax.crypto.SecretKey -import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.GCMParameterSpec internal class KeyStoreEncryption { companion object { private const val ALGORITHM = KeyProperties.KEY_ALGORITHM_AES private const val BLOCK_MODE = KeyProperties.BLOCK_MODE_GCM - private const val PADDING = KeyProperties.ENCRYPTION_PADDING_PKCS7 - private const val TRANSFORMATION = "$ALGORITHM/$BLOCK_MODE/$PADDING" + + /** + * GCM is a stream mode: it pads nothing, and `AES/GCM/NoPadding` is the + * only transformation JCA defines for it. [LEGACY_PADDING] is what this + * class has always asked for, and some providers accept it, so the keys + * and ciphertext already on those devices were made under that name. + */ + private const val LEGACY_PADDING = KeyProperties.ENCRYPTION_PADDING_PKCS7 + private const val GCM_PADDING = KeyProperties.ENCRYPTION_PADDING_NONE private const val PURPOSE = KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT private const val KEY_ALIAS = "AMETHYST_AES_KEY" + + /** GCM's authentication tag, in bits. 128 is the default and the maximum. */ + private const val TAG_BITS = 128 + + /** GCM's nonce, in bytes — what [encrypt] prefixes to its output. */ + private const val IV_BYTES = 12 + + private fun transformationFor(padding: String) = "$ALGORITHM/$BLOCK_MODE/$padding" + + /** + * Which padding name this device's providers actually answer to. + * + * Asking for the legacy name where it works keeps every existing key + * and every encrypted file readable — switching unconditionally would + * orphan them, and one of them holds account secrets. Where no provider + * offers it (seen on a Samsung API 34 tablet, where `Cipher.getInstance` + * throws `NoSuchAlgorithmException`), the correct GCM name is used + * instead. Before this, that throw propagated out of the constructor: + * callers that guarded it silently lost persistence, and the one that + * did not — cordn, from `Account`'s constructor — hung the app on + * "Loading account" forever. + * + * Resolved once per process: the answer cannot change under us, and + * every instance would otherwise repeat the failing lookup. + */ + private val resolvedPadding: String by lazy { + try { + Cipher.getInstance(transformationFor(LEGACY_PADDING)) + LEGACY_PADDING + } catch (_: GeneralSecurityException) { + GCM_PADDING + } + } } + private val padding = resolvedPadding + // One Cipher per thread rather than one shared instance: a Cipher holds the // state of the operation in progress, so two callers on different threads - // through the same object would corrupt each other's output. - private val ciphers = ThreadLocal.withInitial { Cipher.getInstance(TRANSFORMATION) } + // through the same object would corrupt each other's output. The + // transformation is resolved once, off [resolvedPadding], so every thread's + // instance is built with the spelling this device actually offers. + private val ciphers = ThreadLocal.withInitial { Cipher.getInstance(transformationFor(padding)) } private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } @@ -69,7 +114,7 @@ internal class KeyStoreEncryption { KeyGenParameterSpec .Builder(KEY_ALIAS, PURPOSE) .setBlockModes(BLOCK_MODE) - .setEncryptionPaddings(PADDING) + .setEncryptionPaddings(padding) .setIsStrongBoxBacked(true) .build() @@ -88,7 +133,7 @@ internal class KeyStoreEncryption { KeyGenParameterSpec .Builder(KEY_ALIAS, PURPOSE) .setBlockModes(BLOCK_MODE) - .setEncryptionPaddings(PADDING) + .setEncryptionPaddings(padding) .build() val generator = KeyGenerator.getInstance(ALGORITHM, "AndroidKeyStore") @@ -116,11 +161,18 @@ internal class KeyStoreEncryption { fun decrypt(bytes: ByteArray): ByteArray { try { - // Extracts IV and decrypts the data - val iv = bytes.copyOfRange(0, 12) // GCM mode uses 12-byte IV - val data = bytes.copyOfRange(12, bytes.size) + val iv = bytes.copyOfRange(0, IV_BYTES) + val data = bytes.copyOfRange(IV_BYTES, bytes.size) val cipher = ciphers.get() - cipher.init(Cipher.DECRYPT_MODE, getKey(), IvParameterSpec(iv)) + // A GCMParameterSpec, not an IvParameterSpec. [BLOCK_MODE] is GCM, + // and AndroidKeyStore's GCM implementation rejects anything else + // outright — "Only GCMParameterSpec supported". Encrypting never + // noticed, because that direction lets the cipher choose its own + // nonce and passes no spec at all, so every store built on this + // class could write bytes it was then unable to read back: cordn + // lost its coordinators on each launch, and Marmot and the + // encrypted DataStore read nothing they had saved. + cipher.init(Cipher.DECRYPT_MODE, getKey(), GCMParameterSpec(TAG_BITS, iv)) return cipher.doFinal(data) } catch (e: Exception) { cachedKey = null diff --git a/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorage.kt b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorage.kt index dbb4efb401..65ce93363e 100644 --- a/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorage.kt +++ b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorage.kt @@ -21,128 +21,130 @@ package com.vitorpamplona.amethyst.commons.keystorage import android.content.Context -import androidx.core.content.edit -import androidx.security.crypto.EncryptedSharedPreferences -import androidx.security.crypto.MasterKey +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.stringPreferencesKey +import com.vitorpamplona.amethyst.commons.model.preferences.EncryptedDataStore +import com.vitorpamplona.amethyst.commons.model.preferences.SecretEncryption +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext +import kotlinx.coroutines.SupervisorJob +import okio.Path.Companion.toOkioPath +import java.io.File /** - * Android implementation of SecureKeyStorage using EncryptedSharedPreferences - * backed by Android Keystore (AES-256-GCM, hardware-backed when available). + * Android implementation of [SecureKeyStorage]: an encrypted DataStore whose + * values are sealed with a key held in the AndroidKeyStore. * - * ## Security Features + * ## Why not EncryptedSharedPreferences * - * - **Hardware Security:** Uses Android Keystore (hardware-backed on supported devices with StrongBox) - * - **Encryption:** AES-256-GCM for both keys and values - * - **Key Derivation:** AES-256-SIV for preference keys, AES-256-GCM for values - * - **Application Context:** Uses applicationContext to prevent memory leaks - * - **Auto-backup Disabled:** EncryptedSharedPreferences automatically excluded from cloud backups + * This used to be `androidx.security.crypto`, which Google deprecated with no + * drop-in successor. [SecretEncryption] talks to the AndroidKeyStore directly — + * AES-256-GCM, StrongBox-backed where the device offers it — so the key still + * never enters app memory, and the library goes away. * - * **Note:** While the encryption keys are protected by hardware security modules (when available), - * the decrypted private keys returned by [getPrivateKey] are still subject to the String memory - * limitation described in [SecureKeyStorage]. + * Nothing is migrated from the old `amethyst_secure_keys` file because nothing + * ever wrote to it: this class is used by the desktop app, and the Android app + * has its own key storage in LocalPreferences. Were that to change, a migration + * would have to come first. + * + * ## Security note + * + * Only values are encrypted; the key names are not. That reveals which npubs + * this installation holds keys for, but not the keys themselves — the same + * trade-off the rest of the encrypted stores make. + * + * The String memory limitation described on [SecureKeyStorage] still applies: + * a decrypted private key cannot be zeroed from a JVM String. */ actual class SecureKeyStorage private actual constructor() { actual companion object { - private const val PREFS_NAME = "amethyst_secure_keys" + private const val STORE_FILE = "datastore/secure_keys.preferences_pb" private const val KEY_PREFIX = "privkey_" private lateinit var appContext: Context - /** - * Creates a SecureKeyStorage instance for Android. - * - * @param context Android Context (will use applicationContext to avoid leaks) - * @return SecureKeyStorage instance - * @throws IllegalArgumentException if context is null or not a valid Context - */ actual fun create(context: Any?): SecureKeyStorage { require(context is Context) { "Android requires a valid Context" } appContext = context.applicationContext return SecureKeyStorage() } + + /** + * One scope and one store for the whole process, not one per instance. + * + * [create] hands out a new [SecureKeyStorage] on every call — harmless + * when the store was `EncryptedSharedPreferences.create`, which is + * idempotent, but DataStore keeps a process-wide registry keyed by file + * path and only releases an entry when the owning scope ends. A + * per-instance store over a fixed path meant the second instance threw + * "multiple DataStores active for the same file" on its first read — + * which, for this store, reads as the account having no private key. + */ + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + private val sharedStore by lazy { + EncryptedDataStore( + PreferenceDataStoreFactory.createWithPath( + scope = scope, + produceFile = { File(appContext.filesDir, STORE_FILE).toOkioPath() }, + ), + scope = scope, + ) + } } - // androidx.security.crypto is deprecated with no drop-in successor; migrating the - // on-disk key store is a separate, security-sensitive effort. - @Suppress("DEPRECATION") - private val masterKey: MasterKey by lazy { - MasterKey - .Builder(appContext, MasterKey.DEFAULT_MASTER_KEY_ALIAS) - .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) - .build() - } + private val store get() = sharedStore - @Suppress("DEPRECATION") - private val encryptedPrefs by lazy { - EncryptedSharedPreferences.create( - appContext, - PREFS_NAME, - masterKey, - EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, - EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, - ) - } + private fun keyFor(npub: String) = stringPreferencesKey(KEY_PREFIX + npub) actual suspend fun savePrivateKey( npub: String, privKeyHex: String, ) { - withContext(Dispatchers.IO) { - try { - encryptedPrefs.edit { putString(KEY_PREFIX + npub, privKeyHex) } - } catch (e: Exception) { - throw SecureStorageException("Failed to save private key", e) - } + try { + store.save(keyFor(npub), privKeyHex) + } catch (e: Exception) { + throw SecureStorageException("Failed to save private key", e) } } actual suspend fun getPrivateKey(npub: String): String? = - withContext(Dispatchers.IO) { - try { - encryptedPrefs.getString(KEY_PREFIX + npub, null) - } catch (e: Exception) { - throw SecureStorageException("Failed to retrieve private key", e) - } + try { + store.get(keyFor(npub)) + } catch (e: Exception) { + throw SecureStorageException("Failed to retrieve private key", e) } /** - * Android backend: EncryptedSharedPreferences.contains + getString has no - * ambiguous-error state comparable to macOS Keychain user-cancel/deny, so - * "key not present" and "key present" are the only two null outcomes. - * Any thrown exception is a genuine failure and propagates. + * Unlike [getPrivateKey], this reads through [EncryptedDataStore.getOrThrow] + * so a store that cannot be read raises instead of reporting the key as + * absent. That distinction is the whole point of this method: callers use + * it to decide whether a key needs creating, and treating a transient read + * failure as "no key here" would overwrite a live one. */ actual suspend fun getPrivateKeyOrThrow(npub: String): String? = - withContext(Dispatchers.IO) { - try { - val key = KEY_PREFIX + npub - if (!encryptedPrefs.contains(key)) { - null - } else { - encryptedPrefs.getString(key, null) - } - } catch (e: Exception) { - throw SecureStorageException("Failed to retrieve private key", e) - } + try { + store.getOrThrow(keyFor(npub)) + } catch (e: Exception) { + throw SecureStorageException("Failed to retrieve private key", e) } + /** + * Removes the key unconditionally, and reports whether one was there. + * + * The presence test deliberately does not decrypt. Gating the removal on a + * successful decrypting read meant a rotated or wiped AndroidKeyStore — + * exactly when the value is unreadable — skipped the delete, leaving the + * private key of a deleted account on disk. + */ actual suspend fun deletePrivateKey(npub: String): Boolean = - withContext(Dispatchers.IO) { - try { - val key = KEY_PREFIX + npub - val existed = encryptedPrefs.contains(key) - if (existed) { - encryptedPrefs.edit { remove(key) } - } - existed - } catch (e: Exception) { - throw SecureStorageException("Failed to delete private key", e) - } + try { + val existed = store.contains(keyFor(npub)) + store.remove(keyFor(npub)) + existed + } catch (e: Exception) { + throw SecureStorageException("Failed to delete private key", e) } - actual suspend fun hasPrivateKey(npub: String): Boolean = - withContext(Dispatchers.IO) { - encryptedPrefs.contains(KEY_PREFIX + npub) - } + actual suspend fun hasPrivateKey(npub: String): Boolean = getPrivateKey(npub) != null } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/KeyStoreEncryption.kt b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/SecretEncryption.android.kt similarity index 96% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/KeyStoreEncryption.kt rename to commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/SecretEncryption.android.kt index 68c4efb75e..6e48a17de5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/KeyStoreEncryption.kt +++ b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/SecretEncryption.android.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.model.preferences +package com.vitorpamplona.amethyst.commons.model.preferences import android.os.Build import android.security.keystore.KeyGenParameterSpec @@ -33,9 +33,9 @@ import javax.crypto.SecretKey import javax.crypto.SecretKeyFactory import javax.crypto.spec.GCMParameterSpec -class KeyStoreEncryption { +actual class SecretEncryption { companion object { - private const val TAG = "KeyStoreEncryption" + private const val TAG = "SecretEncryption" private const val ANDROID_KEY_STORE = "AndroidKeyStore" private const val ALGORITHM = KeyProperties.KEY_ALGORITHM_AES private const val BLOCK_MODE = KeyProperties.BLOCK_MODE_GCM @@ -147,7 +147,7 @@ class KeyStoreEncryption { return createKeyStrongBoxIfAvailable() ?: createKeyRegular() } - fun encrypt(bytes: ByteArray): ByteArray { + actual fun encrypt(bytes: ByteArray): ByteArray { try { // Initializes the cipher in encrypt mode and encrypts data val cipher = ciphers.get() @@ -164,7 +164,7 @@ class KeyStoreEncryption { } } - fun decrypt(bytes: ByteArray): ByteArray? { + actual fun decrypt(bytes: ByteArray): ByteArray? { try { // Extract the 12-byte GCM IV prefix and decrypt the remainder. The // AndroidKeyStore cipher only accepts GCMParameterSpec (not a plain diff --git a/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessDismissedGamesStorage.kt b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessDismissedGamesStorage.kt deleted file mode 100644 index 53e63f7e8e..0000000000 --- a/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessDismissedGamesStorage.kt +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.commons.nip64Chess - -import android.content.Context -import android.content.SharedPreferences -import androidx.core.content.edit - -actual class ChessDismissedGamesStorage private actual constructor() { - private var prefs: SharedPreferences? = null - - actual companion object { - private const val PREFS_NAME = "chess_dismissed_games" - - private fun prefsKey(userPubkey: String) = "dismissed_$userPubkey" - - actual fun create(context: Any?): ChessDismissedGamesStorage { - val storage = ChessDismissedGamesStorage() - val ctx = - context as? Context - ?: throw IllegalArgumentException("Android context required") - storage.prefs = ctx.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - return storage - } - } - - // getStringSet returns a live reference to the internal set — must copy defensively - actual fun load(userPubkey: String): Set = prefs?.getStringSet(prefsKey(userPubkey), null)?.toHashSet() ?: emptySet() - - actual fun save( - userPubkey: String, - ids: Set, - ) { - prefs?.edit { putStringSet(prefsKey(userPubkey), ids) } - } -} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/account/AccountBootstrapEvents.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/account/AccountBootstrapEvents.kt index dbfb17605c..07b0376e67 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/account/AccountBootstrapEvents.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/account/AccountBootstrapEvents.kt @@ -33,11 +33,11 @@ import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip02FollowList.tags.ContactTag -import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent -import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent +import com.vitorpamplona.quartz.nip17Dm.settings.DmRelayListEvent +import com.vitorpamplona.quartz.nip28PublicChat.list.PublicChatListEvent import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.FavoriteRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent -import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent /** @@ -58,12 +58,12 @@ data class AccountBootstrapEvents( val userMetadata: MetadataEvent, val contactList: ContactListEvent, val nip65RelayList: AdvertisedRelayListEvent, - val dmRelayList: ChatMessageRelayListEvent, + val dmRelayList: DmRelayListEvent, val keyPackageRelayList: KeyPackageRelayListEvent, val searchRelayList: SearchRelayListEvent, val indexerRelayList: IndexerRelayListEvent, - val channelList: ChannelListEvent, - val relayFeedsList: RelayFeedsListEvent, + val publicChatList: PublicChatListEvent, + val favoriteRelayList: FavoriteRelayListEvent, ) { /** All nine signed events in publication order. */ fun all(): List = @@ -75,8 +75,8 @@ data class AccountBootstrapEvents( keyPackageRelayList, searchRelayList, indexerRelayList, - channelList, - relayFeedsList, + publicChatList, + favoriteRelayList, ) } @@ -102,13 +102,13 @@ fun bootstrapAccountEvents( signer = signer, ), nip65RelayList = AdvertisedRelayListEvent.create(DefaultNIP65List, signer), - dmRelayList = ChatMessageRelayListEvent.create(DefaultDMRelayList, signer), + dmRelayList = DmRelayListEvent.create(DefaultDMRelayList, signer), // MIP-00: advertise the default outbox relays as KeyPackage hosts // so other users can discover and fetch this account's KeyPackage // events without having to guess where they were published. keyPackageRelayList = KeyPackageRelayListEvent.create(DefaultNIP65RelaySet.toList(), signer), searchRelayList = SearchRelayListEvent.create(DefaultSearchRelayList.toList(), signer), indexerRelayList = IndexerRelayListEvent.create(DefaultIndexerRelayList.toList(), signer), - channelList = ChannelListEvent.create(emptyList(), DefaultChannels, signer), - relayFeedsList = RelayFeedsListEvent.create(DefaultGlobalRelays, signer), + publicChatList = PublicChatListEvent.create(emptyList(), DefaultChannels, signer), + favoriteRelayList = FavoriteRelayListEvent.create(DefaultGlobalRelays, signer), ) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ZapActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ZapActions.kt index 5c6bd6ecc9..d9a75e71d8 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ZapActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ZapActions.kt @@ -25,13 +25,13 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapRequestEvent /** * NIP-57 zap-request building + LN address extraction. * - * Returns a signed [LnZapRequestEvent] (kind:9734) — the artifact a caller + * Returns a signed [ZapRequestEvent] (kind:9734) — the artifact a caller * hands to a LNURL-pay callback to receive a BOLT11 invoice. * * **Caller responsibilities** that the Amethyst Android flow handles but @@ -86,10 +86,10 @@ object ZapActions { amountMillisats: Long, inboxRelays: Set, comment: String = "", - zapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PUBLIC, + zapType: ZapReceiptEvent.ZapType = ZapReceiptEvent.ZapType.PUBLIC, lnurl: String? = null, - ): LnZapRequestEvent = - LnZapRequestEvent.create( + ): ZapRequestEvent = + ZapRequestEvent.create( userHex = recipientPubkey, relays = inboxRelays, signer = signer, @@ -118,12 +118,12 @@ object ZapActions { amountMillisats: Long, inboxRelays: Set, comment: String = "", - zapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PUBLIC, + zapType: ZapReceiptEvent.ZapType = ZapReceiptEvent.ZapType.PUBLIC, toUserPubkey: HexKey? = null, pollOption: Int? = null, lnurl: String? = null, - ): LnZapRequestEvent = - LnZapRequestEvent.create( + ): ZapRequestEvent = + ZapRequestEvent.create( zappedEvent = zappedEvent, relays = inboxRelays, signer = signer, @@ -142,7 +142,7 @@ object ZapActions { data class ZapRequestForSplit( val recipient: ZapSplitResolver.Recipient, val amountMillisats: Long, - val request: LnZapRequestEvent, + val request: ZapRequestEvent, ) /** @@ -172,7 +172,7 @@ object ZapActions { lookupLnAddress: suspend (HexKey) -> String?, lookupInboxRelays: suspend (HexKey) -> Set = { emptySet() }, comment: String = "", - zapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PUBLIC, + zapType: ZapReceiptEvent.ZapType = ZapReceiptEvent.ZapType.PUBLIC, pollOption: Int? = null, ): List { val recipients = ZapSplitResolver.resolve(zappedEvent, lookupLnAddress) @@ -189,7 +189,7 @@ object ZapActions { val recipientInbox = recipient.pubkey?.let { lookupInboxRelays(it) }.orEmpty() val allRelays = senderInboxRelays + recipientInbox + authorInbox val request = - LnZapRequestEvent.create( + ZapRequestEvent.create( zappedEvent = zappedEvent, relays = allRelays, signer = signer, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/BrowserHistoryRegistry.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserHistoryRegistry.kt similarity index 68% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/BrowserHistoryRegistry.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserHistoryRegistry.kt index 5564139886..3d4f010948 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/BrowserHistoryRegistry.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserHistoryRegistry.kt @@ -18,26 +18,23 @@ * 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.favorites +package com.vitorpamplona.amethyst.commons.browser -import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey -import androidx.datastore.preferences.preferencesDataStore -import com.vitorpamplona.amethyst.commons.browser.OmniboxInput import com.vitorpamplona.quartz.nip01Core.core.JsonMapper import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.TimeUtils 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.first import kotlinx.coroutines.launch import kotlinx.serialization.Serializable - -private val Context.browserHistoryDataStore by preferencesDataStore(name = "browser_history") +import kotlin.concurrent.Volatile /** * One device-local visited site, keyed by full [url]. [visitCount]/[lastVisitedAt] drive frecency ranking @@ -55,39 +52,40 @@ data class BrowserHistoryEntry( /** * The browser's visit history — the data behind the omnibox suggestions, alongside the user's favorites. * - * **Only pages that actually loaded land here.** [record] is called from the `:napplet` browser host - * (relayed over IPC through `NappletBrokerService`) on a *successful* main-frame page-finish — never from - * the address bar as the user types — so misspelled/never-resolved hosts never pollute the list. Bounded - * to [MAX_ENTRIES] most-recent entries. + * **Only pages that actually loaded land here.** [record] is meant to be called on a *successful* + * main-frame page-finish — never from the address bar as the user types — so misspelled/never-resolved + * hosts never pollute the list. Bounded to [MAX_ENTRIES] most-recent entries. On Android the call is + * relayed from the `:napplet` browser host over IPC through `NappletBrokerService`. * - * Lives only in the **main process** (the launcher/omnibox consume it; the keyless `:napplet` sandbox - * never reads it). Same shape as [FavoriteAppsRegistry]: an authoritative in-memory [StateFlow] for - * synchronous Compose reads, with write-through persistence to a DataStore on a background scope. + * Same shape as [com.vitorpamplona.amethyst.commons.favorites.FavoriteAppsRegistry]: an authoritative + * in-memory [StateFlow] for synchronous Compose reads, write-through persistence to [store] on [scope], + * and the [DataStore] handed in rather than reached for, so the caller's store holder stays the single + * registry and nothing here depends on a front end. + * + * One instance per process. On Android the launcher/omnibox in the **main** process own it; the keyless + * `:napplet` sandbox never builds one. */ -object BrowserHistoryRegistry { - private val KEY = stringPreferencesKey("history") - private const val MAX_ENTRIES = 500 - +class BrowserHistoryRegistry( + private val store: DataStore, + private val scope: CoroutineScope, +) { private val _history = MutableStateFlow>(emptyList()) val history: StateFlow> = _history.asStateFlow() - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + // Gates persistence until init() has been called: writing before hydration has been scheduled + // would flush a partial list over the stored one. Set synchronously in init(), so the merge it + // launches still persists whatever the session recorded in the meantime. + @Volatile private var started = false - @Volatile private var appContext: Context? = null - - @Volatile private var hydrated = false - - /** Binds the app context and hydrates the on-disk list into [history]. Idempotent. */ - fun init(context: Context) { - if (appContext != null) return - val ctx = context.applicationContext - appContext = ctx + /** Hydrates the on-disk list into [history]. Idempotent. */ + fun init() { + if (started) return + started = true scope.launch { - val json = ctx.browserHistoryDataStore.data.first()[KEY] + val json = store.data.first()[KEY] val loaded = if (json != null) decode(json) else emptyList() // Merge disk under anything already recorded this session (session wins, newest-first). update { current -> dedupeNewestFirst(current + loaded) } - hydrated = true } } @@ -100,7 +98,7 @@ object BrowserHistoryRegistry { title: String, ) { val host = OmniboxInput.hostOf(url) ?: url - val now = System.currentTimeMillis() + val now = TimeUtils.nowMillis() update { current -> val existing = current.firstOrNull { it.url == url } val entry = @@ -136,9 +134,9 @@ object BrowserHistoryRegistry { } private fun persist(json: String) { - val ctx = appContext ?: return + if (!started) return scope.launch { - ctx.browserHistoryDataStore.edit { it[KEY] = json } + store.edit { it[KEY] = json } } } @@ -151,4 +149,12 @@ object BrowserHistoryRegistry { Log.w("BrowserHistoryRegistry", "Failed to decode history", e) emptyList() } + + companion object { + /** Same file the `Context.preferencesDataStore("browser_history")` delegate resolved to. */ + const val FILE_NAME = "browser_history" + + private val KEY = stringPreferencesKey("history") + private const val MAX_ENTRIES = 500 + } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserIconRegistry.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserIconRegistry.kt new file mode 100644 index 0000000000..bdd40d5199 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserIconRegistry.kt @@ -0,0 +1,148 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.browser + +import com.vitorpamplona.amethyst.commons.util.platformFileSystem +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CoroutineScope +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 okio.Path +import kotlin.concurrent.Volatile + +/** + * Device-local favicon store for browsed sites, keyed by host. Favicons are **captured from the WebView + * that already loaded the page** — on Android, in the keyless `:napplet` browser host, where they ride the + * page's own (Tor-routed) network path — and handed here as PNG bytes; this is the privacy-preserving + * alternative to the app fetching `host/favicon.ico` itself, which would bypass Tor and leak the visit. + * Used to decorate favorite cards and omnibox suggestion rows. + * + * Bytes are persisted as one small PNG per host under [iconDir], so the only in-memory state is [keys] — + * the set of hosts that currently have an icon — which exists purely to drive Compose recomposition (and + * to keep filesystem existence checks out of composition). + * + * [iconDir] is a function rather than a path for the same reason [com.vitorpamplona.amethyst.commons.model.preferences.AppPreferenceStores] + * takes `rootFilesDir`: the front end owns where its files live, and resolving it lazily keeps this class + * free of any platform's notion of an app directory. The Android app passes + * `{ appContext.filesDir.toOkioPath() / DIR }`. + * + * One instance per process. On Android the launcher/UI in the **main** process own it; the keyless + * `:napplet` sandbox never builds one and relays captured bytes over IPC instead. + */ +class BrowserIconRegistry( + private val iconDir: () -> Path, + private val scope: CoroutineScope, +) { + // Resolved once, not per call. [iconDir] is a lambda so the front end owns the location and + // nothing resolves at construction; but on Android it is `appContext.filesDir`, and + // Context.getFilesDir() takes a lock and mkdir()s the directory every time it is asked. The + // object this replaced cached the File in init(), and iconModelFor is read from composition on + // the main thread by the bottom bar, the favorites grid, the omnibox suggestions and the + // napplet icon — so re-invoking the lambda there put a filesystem syscall on every frame that + // draws an icon. + private val dir: Path by lazy { iconDir() } + + private val _keys = MutableStateFlow>(emptySet()) + + /** Sanitized host keys that currently have a stored icon. Observe to recompose when an icon arrives. */ + val keys: StateFlow> = _keys.asStateFlow() + + @Volatile private var started = false + + /** + * Indexes already-stored icons. Idempotent. + * + * Only the directory scan is deferred; [iconModelFor] and [record] resolve [iconDir] themselves and + * work immediately. Until the scan lands [keys] is empty, so an icon renders its placeholder for one + * frame and then recomposes — [keys] is a StateFlow precisely so that arrival drives recomposition. + */ + fun init() { + if (started) return + started = true + scope.launch { + try { + platformFileSystem.createDirectories(dir) + val scanned = + platformFileSystem + .list(dir) + .mapNotNull { it.name.removeSuffix(PNG).takeIf { name -> name.isNotBlank() } } + .toSet() + // Merged rather than assigned: a record() that lands while the scan is in flight has + // already written its file and added its key, and overwriting the set wholesale would + // drop it — the icon would sit on disk unshown until the next launch. + _keys.update { it + scanned } + } catch (e: Exception) { + Log.w("BrowserIconRegistry", "Failed to index stored favicons", e) + } + } + } + + /** Persists [bytes] as the favicon for [host] and marks it available. */ + fun record( + host: String, + bytes: ByteArray, + ) { + if (host.isBlank() || bytes.isEmpty()) return + val key = sanitize(host) + // Fire-and-forget: a favicon is a decoration, and the caller (on Android, the broker's IPC + // handler, which runs on the main looper) 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. + scope.launch { + try { + platformFileSystem.createDirectories(dir) + platformFileSystem.write(dir / (key + PNG)) { write(bytes) } + _keys.update { it + key } + } catch (e: Exception) { + Log.w("BrowserIconRegistry", "Failed to store favicon for $host", e) + } + } + } + + /** + * A Coil model (`file://…`) for [host]'s favicon, or null when none is stored. Reads [keys] so callers + * that observe the flow recompose as icons arrive — pass [keys]'s value as a `remember` key. + */ + fun iconModelFor(host: String): String? { + val key = sanitize(host) + if (key !in _keys.value) return null + return "file://" + (dir / (key + PNG)) + } + + companion object { + /** Same directory the Android registry used: `filesDir/browser_icons`. */ + const val DIR = "browser_icons" + + private const val PNG = ".png" + + // Hosts map to a flat, filesystem-safe filename. Collisions (two hosts → one key) only mean a + // shared icon file, which is harmless for a decoration. + private fun sanitize(host: String): String = + host + .lowercase() + .map { if (it.isLetterOrDigit() || it == '.' || it == '-') it else '_' } + .joinToString("") + .take(120) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cashu/CashuKeysetCounterStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cashu/CashuKeysetCounterStore.kt index 3974287c4d..64349e0b3f 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cashu/CashuKeysetCounterStore.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cashu/CashuKeysetCounterStore.kt @@ -27,7 +27,8 @@ package com.vitorpamplona.amethyst.commons.cashu * monotonically increasing counter. Reusing a counter makes the mint reply * `outputs already signed`, so every reservation MUST be persisted **before** * the blinded outputs hit the mint. Implementations therefore make - * [reserve] atomic and durable. + * [reserve] atomic and durable. They suspend because durable storage on + * every target this runs on is a suspending API. * * - Android backs this with `AccountSettings` / `CashuPreferences`. * - `amy` backs this with `~/.amy//cashu.json`. @@ -37,13 +38,13 @@ package com.vitorpamplona.amethyst.commons.cashu */ interface CashuKeysetCounterStore { /** The next counter for [keysetId] without advancing it (0 if unseen). */ - fun peek(keysetId: String): Long + suspend fun peek(keysetId: String): Long /** * Atomically reserve [count] consecutive counters for [keysetId] and * return the first reserved index. Persists before returning. */ - fun reserve( + suspend fun reserve( keysetId: String, count: Int, ): Long @@ -55,7 +56,7 @@ interface CashuKeysetCounterStore { * kept whatever the backing store; a host whose store can write the value in * one atomic op should override it. */ - fun seedIfMissing( + suspend fun seedIfMissing( keysetId: String, legacyValue: Long, ) { @@ -76,9 +77,9 @@ interface CashuKeysetCounterStore { object UnavailableCashuKeysetCounterStore : CashuKeysetCounterStore { private fun fail(): Nothing = error("No durable NUT-13 counter store is wired for this account; refusing to reuse counters.") - override fun peek(keysetId: String): Long = fail() + override suspend fun peek(keysetId: String): Long = fail() - override fun reserve( + override suspend fun reserve( keysetId: String, count: Int, ): Long = fail() diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cashu/DataStoreCashuCounterStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cashu/DataStoreCashuCounterStore.kt new file mode 100644 index 0000000000..3795cd3a7a --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cashu/DataStoreCashuCounterStore.kt @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cashu + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.longPreferencesKey +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.first +import okio.IOException + +/** + * DataStore-backed NUT-13 counter store, shared by every front end that has one. + * + * # Why the counters are not secret + * + * They are indices, not key material: they derive nothing without the wallet + * seed, carry no value, and a leak would at most reveal how many proofs the + * wallet has minted at each keyset. + * + * # Why every write is awaited + * + * Reusing a counter makes the mint reply `outputs already signed` and strands + * the proofs, so [reserve] must reach disk before the secrets derived from it + * reach the mint. DataStore's `edit` suspends until its write completes and + * swaps the file atomically — the same guarantee + * `SharedPreferences.edit(commit = true)` gave, paid at a suspension rather + * than a blocked thread. + * + * Read and write live inside one `edit`, so two concurrent mints cannot + * observe the same starting index; that is what the previous implementation's + * `@Synchronized` was for. + */ +class DataStoreCashuCounterStore( + private val store: DataStore, +) : CashuKeysetCounterStore { + companion object { + const val COUNTER_PREFIX = "counter_" + + fun counterKey(keysetId: String) = longPreferencesKey(COUNTER_PREFIX + keysetId) + } + + private suspend fun read(): Preferences = + store.data + .catch { e -> if (e is IOException) emit(emptyPreferences()) else throw e } + .first() + + override suspend fun peek(keysetId: String): Long = read()[counterKey(keysetId)] ?: 0L + + override suspend fun reserve( + keysetId: String, + count: Int, + ): Long { + require(count > 0) { "Counter reservation must be positive" } + var first = 0L + store.edit { prefs -> + val key = counterKey(keysetId) + first = prefs[key] ?: 0L + prefs[key] = first + count.toLong() + } + return first + } + + /** + * Carry a counter forward from an older store, never backwards. + * + * Writes the value directly in one atomic edit rather than advancing + * through [reserve], and compares inside that edit so a concurrent + * reservation cannot be undone by a stale read. + */ + override suspend fun seedIfMissing( + keysetId: String, + legacyValue: Long, + ) { + if (legacyValue <= 0L) return + store.edit { prefs -> + val key = counterKey(keysetId) + if ((prefs[key] ?: 0L) < legacyValue) prefs[key] = legacyValue + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/DataStoreNostrSignerPermissionStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/DataStoreNostrSignerPermissionStore.kt similarity index 82% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/DataStoreNostrSignerPermissionStore.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/DataStoreNostrSignerPermissionStore.kt index ec38b703b8..0efbba4b5e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/DataStoreNostrSignerPermissionStore.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/DataStoreNostrSignerPermissionStore.kt @@ -18,11 +18,9 @@ * 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.connectedApps +package com.vitorpamplona.amethyst.commons.connectedApps -import android.content.Context import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.PreferenceDataStoreFactory import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey @@ -30,12 +28,13 @@ import com.vitorpamplona.amethyst.commons.connectedApps.signers.AppSignerPolicy import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrOpDecision import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionStore -import com.vitorpamplona.quartz.utils.cache.LargeCache +import com.vitorpamplona.amethyst.commons.model.preferences.AppPreferenceStores +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.utils.sha256.sha256 import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO import kotlinx.coroutines.flow.first import kotlinx.coroutines.withContext -import java.io.File -import java.security.MessageDigest /** * Per-coordinate DataStore-backed [NostrSignerPermissionStore]. One small `.preferences_pb` @@ -46,18 +45,9 @@ import java.security.MessageDigest * reverse-map file → coordinate without scanning the filesystem. */ class DataStoreNostrSignerPermissionStore( - private val filesDir: File, + private val stores: AppPreferenceStores, ) : NostrSignerPermissionStore { - constructor(context: Context) : this(context.applicationContext.filesDir) - - private val cache = LargeCache>() - - private fun storeFor(coordinate: String): DataStore { - val file = File(filesDir, "datastore/nsp_${hash(coordinate)}.preferences_pb") - return cache.getOrCreate(file.absolutePath) { - PreferenceDataStoreFactory.create(produceFile = { file }) - } - } + private fun storeFor(coordinate: String): DataStore = stores.getDataStore(nameFor(coordinate)) override suspend fun loadPolicy(coordinate: String): AppSignerPolicy? { val raw = storeFor(coordinate).data.first()[KEY_POLICY] ?: return null @@ -108,15 +98,9 @@ class DataStoreNostrSignerPermissionStore( // Enumerates the datastore directory + reads each file — blocking disk IO, so keep it off the // caller's thread (callers invoke this from Compose LaunchedEffects on the main dispatcher). withContext(Dispatchers.IO) { - val dir = File(filesDir, "datastore") - if (!dir.exists()) return@withContext emptyMap() val result = mutableMapOf() - for (file in dir.listFiles { f -> f.name.startsWith("nsp_") } ?: emptyArray()) { - val ds = - cache.getOrCreate(file.absolutePath) { - PreferenceDataStoreFactory.create(produceFile = { file }) - } - val coordinate = ds.data.first()[KEY_COORDINATE] ?: continue + for (name in stores.names(NAME_PREFIX)) { + val coordinate = stores.getDataStore(name).data.first()[KEY_COORDINATE] ?: continue val policy = loadPolicy(coordinate) ?: continue result[coordinate] = policy } @@ -188,9 +172,18 @@ class DataStoreNostrSignerPermissionStore( private const val OP_PREFIX = "op:" private const val OP_EXPIRY_SUFFIX = ":exp" - private fun hash(coordinate: String): String { - val digest = MessageDigest.getInstance("SHA-256").digest(coordinate.toByteArray()) - return digest.take(8).joinToString("") { "%02x".format(it) } - } + internal const val NAME_PREFIX = "nsp_" + + /** + * The per-app store's file name. + * + * The hash is part of the file name, so it must keep producing exactly + * what `MessageDigest.getInstance("SHA-256")` plus `"%02x".format(byte)` + * did on Android — a different digest orphans the file rather than + * failing, and with it every permission the user has granted that app. + * Pinned in DataStoreNostrSignerPermissionStoreTest against hashes + * computed outside this codebase. + */ + internal fun nameFor(coordinate: String): String = NAME_PREFIX + sha256(coordinate.encodeToByteArray()).copyOfRange(0, 8).toHexKey() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/nip46/DataStoreNip46ClientStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/DataStoreNip46ClientStore.kt similarity index 77% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/nip46/DataStoreNip46ClientStore.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/DataStoreNip46ClientStore.kt index eaa68c4031..c3ffec1b38 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/nip46/DataStoreNip46ClientStore.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/DataStoreNip46ClientStore.kt @@ -18,20 +18,15 @@ * 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.connectedApps.nip46 +package com.vitorpamplona.amethyst.commons.connectedApps.nip46 -import android.content.Context import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.PreferenceDataStoreFactory import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey -import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46ClientInfo -import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46ClientStore +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.utils.sha256.sha256 import kotlinx.coroutines.flow.first -import java.io.File -import java.security.MessageDigest -import java.util.concurrent.ConcurrentHashMap /** * Single-file DataStore-backed [Nip46ClientStore]. Every connected client's @@ -41,14 +36,10 @@ import java.util.concurrent.ConcurrentHashMap * individually so no serialization library is needed; [relays] is newline-joined. */ class DataStoreNip46ClientStore( - private val filesDir: File, + private val dataStore: DataStore, ) : Nip46ClientStore { - constructor(context: Context) : this(context.applicationContext.filesDir) - - private val store: DataStore get() = dataStoreFor(File(filesDir, "datastore/nip46_clients.preferences_pb")) - override suspend fun load(coordinate: String): Nip46ClientInfo? { - val prefs = store.data.first() + val prefs = dataStore.data.first() if (prefs[coordKey(coordinate)] == null) return null return Nip46ClientInfo( name = prefs[nameKey(coordinate)], @@ -62,7 +53,7 @@ class DataStoreNip46ClientStore( coordinate: String, info: Nip46ClientInfo, ) { - store.edit { prefs -> + dataStore.edit { prefs -> prefs[coordKey(coordinate)] = coordinate info.name?.let { prefs[nameKey(coordinate)] = it } ?: prefs.remove(nameKey(coordinate)) info.url?.let { prefs[urlKey(coordinate)] = it } ?: prefs.remove(urlKey(coordinate)) @@ -72,7 +63,7 @@ class DataStoreNip46ClientStore( } override suspend fun remove(coordinate: String) { - store.edit { prefs -> + dataStore.edit { prefs -> prefs.remove(coordKey(coordinate)) prefs.remove(nameKey(coordinate)) prefs.remove(urlKey(coordinate)) @@ -82,7 +73,7 @@ class DataStoreNip46ClientStore( } override suspend fun all(): Map { - val prefs = store.data.first() + val prefs = dataStore.data.first() val result = mutableMapOf() for ((key, value) in prefs.asMap()) { if (!key.name.startsWith(COORD_PREFIX)) continue @@ -111,18 +102,20 @@ class DataStoreNip46ClientStore( private fun relaysKey(coordinate: String) = stringPreferencesKey("relays:${hash(coordinate)}") companion object { - private val stores = ConcurrentHashMap>() - - private fun dataStoreFor(file: File): DataStore = - stores.computeIfAbsent(file.absolutePath) { - PreferenceDataStoreFactory.create(produceFile = { file }) - } + const val FILE_NAME = "nip46_clients" private const val COORD_PREFIX = "coord:" - private fun hash(coordinate: String): String { - val digest = MessageDigest.getInstance("SHA-256").digest(coordinate.toByteArray()) - return digest.take(8).joinToString("") { "%02x".format(it) } - } + /** + * The first 8 bytes of the coordinate's SHA-256, lower-case hex. + * + * This is a stored key, so it must keep producing exactly what + * `MessageDigest.getInstance("SHA-256")` plus `"%02x".format(byte)` did + * on Android — a different digest here would orphan every client a user + * has already authorized rather than fail loudly. + * `DataStoreNip46ClientStoreTest` pins three coordinates against hashes + * computed outside this codebase. + */ + internal fun hash(coordinate: String): String = sha256(coordinate.encodeToByteArray()).copyOfRange(0, 8).toHexKey() } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/signers/NostrSignerPermissionLedger.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/signers/NostrSignerPermissionLedger.kt index 0991204ea0..1cfcd2f99e 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/signers/NostrSignerPermissionLedger.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/signers/NostrSignerPermissionLedger.kt @@ -28,27 +28,27 @@ import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent import com.vitorpamplona.quartz.nip25Reactions.ExternalReactionEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent -import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent +import com.vitorpamplona.quartz.nip38UserStatus.UserStatusEvent import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent -import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent +import com.vitorpamplona.quartz.nip54Wiki.WikiArticleEvent import com.vitorpamplona.quartz.nip56Reports.ReportEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapRequestEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent -import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent +import com.vitorpamplona.quartz.nip71Video.AddressableNormalVideoEvent +import com.vitorpamplona.quartz.nip71Video.AddressableShortVideoEvent import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent import com.vitorpamplona.quartz.nip71Video.VideoShortEvent -import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent -import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent +import com.vitorpamplona.quartz.nip94FileMetadata.FileMetadataEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent @@ -232,7 +232,7 @@ class NostrSignerPermissionLedger( PublicMessageEvent.KIND, // 24 — NIP-A4 public messages (plaintext, public) ChannelMessageEvent.KIND, // 42 — public chat messages PollResponseEvent.KIND, // 1018 — voting in a poll (additive, like a reaction) - FileHeaderEvent.KIND, // 1063 — NIP-94 file metadata (shares a file reference) + FileMetadataEvent.KIND, // 1063 — NIP-94 file metadata (shares a file reference) PollEvent.KIND, // 1068 — creating a poll (additive public content) CommentEvent.KIND, // 1111 — NIP-22 threaded comments (same risk as kind 1) VoiceEvent.KIND, // 1222 — voice messages (audio post, like a picture/video) @@ -243,12 +243,12 @@ class NostrSignerPermissionLedger( TorrentEvent.KIND, // 2003 — NIP-35 torrent announcements (additive public content) TorrentCommentEvent.KIND, // 2004 — NIP-35 torrent comments HighlightEvent.KIND, // 9802 — highlighted snippets shared publicly - LnZapRequestEvent.KIND, // 9734 — Lightning zap request; the payment itself still prompts - LongTextNoteEvent.KIND, // 30023 — NIP-23 long-form articles (addressable content) - StatusEvent.KIND, // 30315 — ephemeral user status / presence - WikiNoteEvent.KIND, // 30818 — NIP-54 wiki articles (addressable content) - VideoHorizontalEvent.KIND, // 34235 — addressable horizontal video (NIP-71) - VideoVerticalEvent.KIND, // 34236 — addressable vertical video (NIP-71) + ZapRequestEvent.KIND, // 9734 — Lightning zap request; the payment itself still prompts + LongFormContentEvent.KIND, // 30023 — NIP-23 long-form articles (addressable content) + UserStatusEvent.KIND, // 30315 — ephemeral user status / presence + WikiArticleEvent.KIND, // 30818 — NIP-54 wiki articles (addressable content) + AddressableNormalVideoEvent.KIND, // 34235 — addressable horizontal video (NIP-71) + AddressableShortVideoEvent.KIND, // 34236 — addressable vertical video (NIP-71) PublicationIndexEvent.KIND, // 30040 — NKBIP-01 publication index (addressable content) LearningResourceEvent.KIND, // 30142 — learning resources (addressable content) BlossomPieceIndexEvent.KIND, // 32176 — Blossom piece indexes (addressable content) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CoordinatorConfig.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CoordinatorConfig.kt new file mode 100644 index 0000000000..c4e47fa79e --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CoordinatorConfig.kt @@ -0,0 +1,150 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn + +import com.vitorpamplona.quartz.cordn.appGroupRef.CordnGroupRef +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * One coordinator this account talks to. + * + * A coordinator is not a relay and the difference matters to the user: relays + * are interchangeable and redundant, a coordinator is the single authority for + * the groups it serves. Losing it loses the ordering; a second one does not + * mirror the first. So this is a named, first-class thing a user chooses, + * never a URL buried in settings. + * + * [relays] is where the coordinator is *reachable* — the ContextVM kind-25910 + * traffic goes over Nostr, so the coordinator has no address of its own beyond + * its pubkey (§8.5: the relay sees the traffic pattern, the coordinator never + * sees an IP). + */ +data class CoordinatorConfig( + val pubKey: HexKey, + val relays: List, + /** How this account came to know about this coordinator. */ + val origin: Origin = Origin.MANUAL, + /** What the user calls it. Never a claim — a coordinator cannot prove a name. */ + val label: String? = null, +) { + init { + require(pubKey.length == PUBKEY_HEX_LENGTH) { "a coordinator pubkey is 32 bytes of hex" } + require(relays.isNotEmpty()) { "a coordinator with no relays cannot be reached" } + } + + /** Where a coordinator came from, because it changes how much to trust it. */ + enum class Origin { + /** Typed or pasted by the user. */ + MANUAL, + + /** Read out of a `cordn1…` group ref someone shared. */ + GROUP_REF, + + /** + * Picked off a CEP-6 announcement the coordinator published. + * + * Weaker than [MANUAL]: nobody vouched for it. The user saw a list, and + * everything in that list except the pubkey was self-asserted. + */ + ANNOUNCEMENT, + + /** The application default. */ + DEFAULT, + } + + companion object { + private const val PUBKEY_HEX_LENGTH = 64 + + /** + * The coordinator a shared `cordn1…` ref points at, or null when the + * ref carries only a `gid`. + * + * A ref without a coordinator is not broken — §2 makes both optional — + * it just means the recipient has to already know which coordinator + * serves that group. + */ + fun from(ref: CordnGroupRef): CoordinatorConfig? { + val pubKey = ref.coordinatorPubKey ?: return null + val relays = ref.relays.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } + if (relays.isEmpty()) return null + return CoordinatorConfig(pubKey, relays, Origin.GROUP_REF) + } + } +} + +/** + * Whether a coordinator is answering, kept per coordinator. + * + * Deliberately thin. This is not a health *check* — nothing here polls, because + * a poll is a call, and every call to a coordinator is metadata (§8). It + * records what the calls the app was making anyway have observed. + */ +class CoordinatorHealth { + private val _state = MutableStateFlow(State()) + + val state: StateFlow = _state.asStateFlow() + + data class State( + val lastSuccessAt: Long? = null, + val lastFailureAt: Long? = null, + val lastFailure: String? = null, + /** Failures since the last success. Resets on any success. */ + val consecutiveFailures: Int = 0, + ) { + /** + * Nothing has worked since the last success, repeatedly. + * + * A single failure is a network blip and worth no UI at all; the + * threshold is what separates "retrying" from "tell the user their + * groups are not syncing". + */ + val isDown: Boolean get() = consecutiveFailures >= DOWN_AFTER + + /** True before the first call of the session — not the same as down. */ + val isUnknown: Boolean get() = lastSuccessAt == null && lastFailureAt == null + } + + fun recordSuccess(atSeconds: Long) { + _state.value = _state.value.copy(lastSuccessAt = atSeconds, consecutiveFailures = 0, lastFailure = null) + } + + fun recordFailure( + atSeconds: Long, + reason: String?, + ) { + val previous = _state.value + _state.value = + previous.copy( + lastFailureAt = atSeconds, + lastFailure = reason, + consecutiveFailures = previous.consecutiveFailures + 1, + ) + } + + companion object { + const val DOWN_AFTER = 3 + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnBackup.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnBackup.kt new file mode 100644 index 0000000000..cd3790fc0e --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnBackup.kt @@ -0,0 +1,337 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn + +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnDeliveredMessage +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnDeliveredMessageCodec +import com.vitorpamplona.quartz.cordn.sync.GroupCursor +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.codec.TlsWriter +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip44Encryption.crypto.ChaCha20Poly1305 +import com.vitorpamplona.quartz.nip49PrivKeyEnc.SCrypt +import com.vitorpamplona.quartz.utils.RandomInstance + +/** + * A passphrase-encrypted export of one account's cordn state. + * + * ## Why cordn needs this when Marmot does not + * + * A Marmot group lives on relays; a client that lost its state can at least + * see the group exists and be re-added to it. A cordn group exists as MLS + * state on **one device** plus an ordered stream on a coordinator that cannot + * read a byte of it. Lose the device and the group is gone — not "gone until + * someone re-invites you", because a fresh invitation starts at a new epoch + * and the history before it stays unreadable forever. That asymmetry is what + * makes an export worth the risk of having one. + * + * ## §5.4, answered: our own format, and portability was never available + * + * `amethyst/plans/2026-09-19-cordn-ui.md` §5.4 left the call open between + * matching cordn-web's backup and doing our own, noting the trade as "better + * for users, worse for portability". The portability half turns out to be + * unavailable, for the reason §4.6 gives about multi-device: the valuable + * content of any cordn backup is **MLS group state**, and that is an engine's + * internal serialization, not a wire format. Theirs is ts-mls's; ours is + * `MlsGroupState`. Neither can read the other whatever file format wraps it, + * so a byte-compatible container would buy a restore that cannot restore. + * + * (The other half also could not be matched cleanly: their client is outside + * the two MIT packages, and the spec prose is unlicensed.) + * + * ## Restoring replaces a device. It does not add one. + * + * An MLS state export is a **cloneable identity**. Two devices holding the + * same state and both committing fork the ratchet tree, and MLS does not + * recover: every message after the fork fails to decrypt for somebody, with + * no error that says why. §5.3 keeps multi-device a non-goal for exactly this + * reason, and a backup does not quietly become multi-device support because + * it is technically possible to restore twice. + * + * So: restore onto a device that has replaced the old one. A UI offering this + * must say that, and must not present it as sync. + * + * ## The file + * + * ``` + * magic "CORDNBAK" | version:u16 | logN:u8 | salt:16 | nonce:12 | ChaCha20-Poly1305(payload) + * ``` + * + * The header is passed as the AEAD's associated data. Today that is belt and + * braces rather than the thing protecting it: every header field either feeds + * the key derivation (`logN`, `salt`), feeds the cipher (`nonce`), or is + * checked outright (`magic`, `version`), so editing any of them already + * yields a wrong key or an outright refusal. The AAD earns its place if this + * header ever gains a field that does neither — at which point forgetting it + * would be the easy mistake. A mutation removing it survives the suite, and + * that is expected rather than a gap. + */ +object CordnBackup { + /** + * Version 2 adds the delivered messages to each group. + * + * Version 1 carried the MLS state, the cursor and the key packages but not + * a single message, and restoring it handed back the groups with an empty + * history: the messages were not in the file, and the cursor that *was* + * in the file told the sync loop it had already read to the end, so the + * coordinator was never asked to resend them. A backup that loses the + * conversation is not the thing the screen promises. + * + * The history could instead be re-pulled by resetting the cursor, since + * the coordinator holds it, but only for epochs the restored state can + * still open. Carrying the plaintext is what makes the restore whole + * regardless of how many times the group has rotated since. + */ + const val VERSION = 2 + + /** The last version that carried no messages; still readable. */ + private const val VERSION_WITHOUT_MESSAGES = 1 + + /** + * scrypt cost, as `log2(N)`. + * + * Matches NIP-49's default. A backup is decrypted once in a while by its + * owner, never in a loop, so the cost belongs at the high end of what a + * phone will tolerate rather than tuned for throughput. + */ + const val DEFAULT_LOG_N = 16 + + private const val R = 8 + private const val P = 1 + private const val KEY_LENGTH = 32 + private const val SALT_LENGTH = 16 + private const val NONCE_LENGTH = 12 + private val MAGIC = "CORDNBAK".encodeToByteArray() + + /** Everything worth carrying to a replacement device. */ + data class Archive( + val accountPubKey: HexKey, + val coordinators: List, + val groups: List, + val keyPackages: List, + ) { + data class Group( + val coordinatorPubKey: HexKey, + val gid: String, + /** An `MlsGroupState` blob. Opaque here, and engine-specific. */ + val state: ByteArray, + val cursor: GroupCursor?, + val joinedViaRequest: Boolean, + /** + * The group's delivered messages, in cursor order. + * + * Empty when the archive was written by version 1, which carried + * none — a v1 restore still yields an empty conversation, and + * there is nothing in the file to do better with. + */ + val messages: List = emptyList(), + ) { + override fun equals(other: Any?): Boolean = + this === other || + ( + other is Group && + coordinatorPubKey == other.coordinatorPubKey && + gid == other.gid && + state.contentEquals(other.state) && + cursor?.fetchCursor == other.cursor?.fetchCursor && + cursor?.lastCursor == other.cursor?.lastCursor && + joinedViaRequest == other.joinedViaRequest && + messages == other.messages + ) + + override fun hashCode(): Int { + var result = coordinatorPubKey.hashCode() + result = 31 * result + gid.hashCode() + result = 31 * result + state.contentHashCode() + result = 31 * result + (cursor?.fetchCursor?.hashCode() ?: 0) + result = 31 * result + joinedViaRequest.hashCode() + result = 31 * result + messages.hashCode() + return result + } + } + + data class KeyPackage( + val coordinatorPubKey: HexKey, + val keyPackageRef: String, + /** A `KeyPackageBundle` blob — private key material. */ + val bundle: ByteArray, + ) { + override fun equals(other: Any?): Boolean = + this === other || + ( + other is KeyPackage && + coordinatorPubKey == other.coordinatorPubKey && + keyPackageRef == other.keyPackageRef && + bundle.contentEquals(other.bundle) + ) + + override fun hashCode(): Int = 31 * (31 * coordinatorPubKey.hashCode() + keyPackageRef.hashCode()) + bundle.contentHashCode() + } + } + + /** Encrypts [archive] under [passphrase]. */ + fun seal( + archive: Archive, + passphrase: String, + logN: Int = DEFAULT_LOG_N, + ): ByteArray { + val salt = RandomInstance.bytes(SALT_LENGTH) + val nonce = RandomInstance.bytes(NONCE_LENGTH) + val header = header(logN, salt, nonce) + val key = deriveKey(passphrase, salt, logN) + + return header + ChaCha20Poly1305.encrypt(encode(archive), header, nonce, key) + } + + /** + * Opens a sealed archive. + * + * Throws on a wrong passphrase, a truncated file or an edited header — all + * of which arrive as an AEAD authentication failure, which is the right + * answer to every one of them and deliberately does not distinguish + * between them. + */ + fun open( + sealed: ByteArray, + passphrase: String, + ): Archive { + require(sealed.size > HEADER_LENGTH) { "not a cordn backup: too short" } + require(sealed.copyOfRange(0, MAGIC.size).contentEquals(MAGIC)) { "not a cordn backup" } + + val reader = TlsReader(sealed.copyOfRange(MAGIC.size, HEADER_LENGTH)) + val version = reader.readUint16() + require(version == VERSION || version == VERSION_WITHOUT_MESSAGES) { "unknown cordn backup version $version" } + + val logN = reader.readUint8() + val salt = reader.readBytes(SALT_LENGTH) + val nonce = reader.readBytes(NONCE_LENGTH) + + val key = deriveKey(passphrase, salt, logN) + val header = sealed.copyOfRange(0, HEADER_LENGTH) + val plaintext = ChaCha20Poly1305.decrypt(sealed.copyOfRange(HEADER_LENGTH, sealed.size), header, nonce, key) + + return decode(plaintext, version) + } + + private fun header( + logN: Int, + salt: ByteArray, + nonce: ByteArray, + ): ByteArray { + val writer = TlsWriter() + writer.putBytes(MAGIC) + writer.putUint16(VERSION) + writer.putUint8(logN) + writer.putBytes(salt) + writer.putBytes(nonce) + return writer.toByteArray() + } + + private fun deriveKey( + passphrase: String, + salt: ByteArray, + logN: Int, + ): ByteArray { + require(logN in MIN_LOG_N..MAX_LOG_N) { "unreasonable scrypt cost: 2^$logN" } + var n = 1 + repeat(logN) { n *= 2 } + return SCrypt.scrypt(passphrase.encodeToByteArray(), salt, n, R, P, KEY_LENGTH) + } + + private fun encode(archive: Archive): ByteArray { + val writer = TlsWriter() + writer.putOpaque2(archive.accountPubKey.encodeToByteArray()) + writer.putOpaque4(CoordinatorListCodec.encode(archive.coordinators)) + + writer.putUint16(archive.groups.size) + archive.groups.forEach { + writer.putOpaque2(it.coordinatorPubKey.encodeToByteArray()) + writer.putOpaque2(it.gid.encodeToByteArray()) + writer.putOpaque4(it.state) + writer.putUint8(if (it.cursor != null) 1 else 0) + it.cursor?.let { cursor -> + writer.putUint64(cursor.fetchCursor) + writer.putUint64(cursor.lastCursor) + } + writer.putUint8(if (it.joinedViaRequest) 1 else 0) + // uint32: a long-running group's log is not bounded by 65535. + // Each entry is the same JSON the on-disk message log stores, so + // the archive and the store cannot drift out of step. + writer.putUint32(it.messages.size.toLong()) + it.messages.forEach { message -> + writer.putOpaque4(CordnDeliveredMessageCodec.encode(message).encodeToByteArray()) + } + } + + writer.putUint16(archive.keyPackages.size) + archive.keyPackages.forEach { + writer.putOpaque2(it.coordinatorPubKey.encodeToByteArray()) + writer.putOpaque2(it.keyPackageRef.encodeToByteArray()) + writer.putOpaque4(it.bundle) + } + return writer.toByteArray() + } + + private fun decode( + bytes: ByteArray, + version: Int, + ): Archive { + val reader = TlsReader(bytes) + val accountPubKey = reader.readOpaque2().decodeToString() + val coordinators = CoordinatorListCodec.decode(reader.readOpaque4()) + + val groups = + (0 until reader.readUint16()).map { + val coordinator = reader.readOpaque2().decodeToString() + val gid = reader.readOpaque2().decodeToString() + val state = reader.readOpaque4() + val cursor = if (reader.readUint8() == 1) GroupCursor(reader.readUint64(), reader.readUint64()) else null + val joinedViaRequest = reader.readUint8() == 1 + val messages = + if (version == VERSION_WITHOUT_MESSAGES) { + emptyList() + } else { + // A message that will not parse is dropped rather than + // failing the whole restore: losing one row beats + // refusing to bring the account back at all. + (0 until reader.readUint32()).mapNotNull { + CordnDeliveredMessageCodec.decodeOrNull(reader.readOpaque4().decodeToString()) + } + } + Archive.Group(coordinator, gid, state, cursor, joinedViaRequest, messages) + } + + val keyPackages = + (0 until reader.readUint16()).map { + Archive.KeyPackage( + coordinatorPubKey = reader.readOpaque2().decodeToString(), + keyPackageRef = reader.readOpaque2().decodeToString(), + bundle = reader.readOpaque4(), + ) + } + + return Archive(accountPubKey, coordinators, groups, keyPackages) + } + + private const val MIN_LOG_N = 10 + private const val MAX_LOG_N = 22 + private val HEADER_LENGTH = MAGIC.size + 2 + 1 + SALT_LENGTH + NONCE_LENGTH +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickablePhone.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnBlobCipher.kt similarity index 51% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickablePhone.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnBlobCipher.kt index 3eb265a4e7..ba89da6ee4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickablePhone.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnBlobCipher.kt @@ -18,30 +18,27 @@ * 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.components +package com.vitorpamplona.amethyst.commons.cordn -import android.content.Context -import android.content.Intent -import android.net.Uri -import androidx.compose.runtime.Composable -import androidx.compose.ui.platform.LocalContext -import com.vitorpamplona.amethyst.commons.ui.components.ClickableTextPrimary +/** + * Encrypts the blobs the cordn stores put on disk. + * + * A seam, not an abstraction for its own sake. Everything cordn persists is key + * material or derived from it — an `MlsGroupState` carries the ratchet tree and + * epoch secrets, a `KeyPackageBundle` carries the private half that opens a + * Welcome — so "encrypted at rest" is not a preference here, and the stores + * refuse to be built without one. + * + * It exists as an interface for one concrete reason: the file layout, the key + * encoding and the atomic-write behaviour are worth testing, and the Android + * KeyStore cannot run in a JVM unit test. Marmot's equivalent store is welded + * to the KeyStore and consequently has no unit test at all; this one does. + * + * Implementations must be safe to call from several coroutines at once. + */ +interface CordnBlobCipher { + fun encrypt(bytes: ByteArray): ByteArray -@Composable -fun ClickablePhone(phone: String) { - val context = LocalContext.current - - ClickableTextPrimary( - text = phone, - onClick = { context.dial(phone) }, - ) -} - -fun Context.dial(phone: String) { - try { - val intent = Intent(Intent.ACTION_DIAL, Uri.fromParts("tel", phone, null)) - startActivity(intent) - } catch (t: Throwable) { - // TODO: Handle potential exceptions - } + /** @throws Exception if [bytes] were not produced by this cipher. */ + fun decrypt(bytes: ByteArray): ByteArray } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnBlobStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnBlobStore.kt new file mode 100644 index 0000000000..cef0ed5e5e --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnBlobStore.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn + +/** + * A content-addressed store for sealed migration documents (§12). + * + * Injected rather than depended on directly because the only Blossom uploader + * in this codebase needs an Android `Context`, and the migration service has to + * stay runnable from `amy` and from a test with no Android at all. The same + * reason `CordnCoordinatorLinkFactory` is injected. + * + * ## The one requirement that is not about bytes + * + * §12: a Blossom upload's BUD-01 authorization event **MUST be signed by an + * ephemeral key, never the owner `npub`** — consistent with the tip. Signing as + * the owner would publish, in the clear on the storage server, that this + * account is uploading blobs right now, which is exactly the linkage the + * opaque tip exists to prevent. An implementation that authorizes as the owner + * satisfies this interface's types and defeats its purpose. + */ +interface CordnBlobStore { + /** + * Uploads [blob] and returns the servers that accepted it. + * + * The address is `sha256(blob)` and the caller already knows it, so there + * is nothing to return but reachability: a blob nobody hosts is a document + * the other phone cannot fetch, and the tip's `server` list is built from + * this. + */ + suspend fun put(blob: ByteArray): List + + /** + * Fetches the blob at [address], trying [servers] in order. + * + * Returns null when no server served it. Verifying that the bytes hash to + * [address] is the **caller's** job (`CordnDocumentSeal.verifyAddress`) — + * an implementation is a transport and is not trusted to self-certify. + */ + suspend fun get( + address: String, + servers: List, + ): ByteArray? +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnCoordinatorDiscovery.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnCoordinatorDiscovery.kt new file mode 100644 index 0000000000..7f1314397d --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnCoordinatorDiscovery.kt @@ -0,0 +1,216 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn + +import com.vitorpamplona.quartz.contextvm.cep06Announcements.AnnouncedTools +import com.vitorpamplona.quartz.contextvm.cep06Announcements.DiscoverySurface +import com.vitorpamplona.quartz.contextvm.cep06Announcements.ServerAnnouncement +import com.vitorpamplona.quartz.contextvm.core.CvmKinds +import com.vitorpamplona.quartz.cordn.spec00Coordinator.CoordinatorAdvertisement +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllWithHooks +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope + +/** + * A coordinator found by listening for CEP-6 announcements. + * + * ## Everything here except [pubKey] and [relays] is a claim + * + * [surface] is what the coordinator said about itself in a self-signed event. + * A name is not an identity (`spec/00.md` §8.5 — the pubkey is), a website is + * not a proof of anything, and the capability flags are promises. Present this + * as "it says", never as fact, and never branch on it. + * + * [relays] is the one field the announcement did *not* provide, and the reason + * this type exists rather than a bare [ServerAnnouncement]. A CEP-6 + * announcement carries no routing information at all, so there is nothing in + * it that says where to reach the coordinator. What we know instead is which + * relays *delivered* it — the coordinator publishes there, so a request + * published there is one it is positioned to see. That is an observation, not + * a promise, and it is the best the protocol offers today. + */ +data class DiscoveredCoordinator( + val pubKey: HexKey, + /** Relays that delivered an announcement for this pubkey. Never empty. */ + val relays: List, + val surface: DiscoverySurface, + /** The eleven tools it advertised, plus whatever else it serves. */ + val tools: Set, + /** The newest announcement's `created_at`, for judging staleness. */ + val announcedAt: Long, +) { + /** + * What to add to the account. + * + * [CoordinatorConfig.label] is left null on purpose. A label is the user's + * word for a coordinator; the announced name is the coordinator's word for + * itself, and copying one into the other would launder a claim into + * something the UI presents as the user's own choice. + */ + fun toConfig(): CoordinatorConfig = + CoordinatorConfig( + pubKey = pubKey, + relays = relays, + origin = CoordinatorConfig.Origin.ANNOUNCEMENT, + ) +} + +/** + * Finds coordinators by reading CEP-6 announcements off relays. + * + * ## Why this is a relay query and not a coordinator call + * + * [CoordinatorHealth] never polls, because a call to a coordinator is metadata + * it gets to see (§8). This is the opposite shape: it reads events the + * coordinators already published, and touches no coordinator at all. Nothing + * discovered here learns that this account exists. The relays do see a query + * for kinds 11316/11317, which says "this user is shopping for an MCP server" + * and nothing about which one is chosen. + * + * ## Why the toolset is the filter + * + * There is no cordn marker on an announcement — see [CoordinatorAdvertisement]. + * A server is recognised by serving the eleven tools of `spec/00.md`. Measured + * on the public relays, that separates coordinators from unrelated MCP servers + * cleanly, and it is the only signal available. + * + * ## What this list is not + * + * It is not a directory, a ranking, or a set of recommendations, and an + * announcement is not a sign of life: a coordinator that stopped running a + * month ago still has its replaceable announcement sitting on the relay, and a + * coordinator that never announced (the reference deployment's default is + * off) is absent from it entirely. [announcedAt] is exposed so a caller can + * show the age rather than imply freshness — this deliberately does not filter + * by age, because a quiet coordinator and a dead one look identical from here + * and only the user knows which theirs is. + */ +class CordnCoordinatorDiscovery( + private val client: INostrClient, +) { + /** + * Reads announcements from [relays] and returns the coordinators among them. + * + * [limit] caps each relay's response — these are replaceable events, so the + * cap bounds distinct servers rather than history. Results are newest + * announcement first. + * + * ## One fetch per relay, on purpose + * + * `fetchAllWithHooks` deduplicates by event id across every relay in one + * call, keeping only the first `(relay, event)` pair for an id. That is + * right for reading content and wrong here, where the whole point is to + * learn *every* relay that carries a coordinator's announcement — a single + * call would credit each announcement to whichever relay answered first + * and silently drop the rest of the reachable set. Querying each relay in + * its own call scopes the dedup to that relay, so attribution is correct + * by construction. The calls run concurrently, so this costs no wall time. + */ + suspend fun discover( + relays: Set, + limit: Int = DEFAULT_LIMIT, + idleTimeoutMs: Long = DEFAULT_IDLE_TIMEOUT_MS, + ): Result = + coroutineScope { + if (relays.isEmpty()) return@coroutineScope Result(emptyList(), emptySet()) + + val filters = listOf(Filter(kinds = CvmKinds.ANNOUNCEMENTS.toList(), limit = limit)) + + val perRelay = + relays + .map { relay -> + async { + client.fetchAllWithHooks( + filters = mapOf(relay to filters), + idleTimeoutMs = idleTimeoutMs, + onEvent = { _, _ -> true }, + ) + } + }.awaitAll() + + Result( + coordinators = coordinatorsIn(perRelay.flatMap { it.events }), + unreachable = perRelay.flatMapTo(mutableSetOf()) { it.stalled + it.dead.keys }, + ) + } + + /** + * The coordinators among `(relay, event)` pairs. + * + * Split out from [discover] so the grouping rules are testable without a + * relay: an announcement pair is only a coordinator when the 11317 half + * carries the eleven tools, and a 11316 alone says nothing about what a + * server serves. + */ + fun coordinatorsIn(events: List>): List { + val byPubKey = mutableMapOf>>() + events.forEach { (relay, event) -> + ServerAnnouncement.parseOrNull(event)?.let { + byPubKey.getOrPut(it.pubKey) { mutableListOf() }.add(relay to it) + } + } + + return byPubKey + .mapNotNull { (pubKey, heard) -> + val latest = ServerAnnouncement.latestOf(heard.map { it.second }) + val tools = + latest[CvmKinds.TOOLS_LIST] + ?.let { AnnouncedTools.parseOrNull(it.content) } + ?.takeIf(CoordinatorAdvertisement::matches) + ?: return@mapNotNull null + + val server = latest[CvmKinds.SERVER_ANNOUNCEMENT] + + DiscoveredCoordinator( + pubKey = pubKey, + // Deduplicated and ordered by first hearing, so the relay + // that answered first is the one tried first. + relays = heard.map { it.first }.distinct(), + surface = server?.discovery ?: DiscoverySurface(), + tools = tools.names, + announcedAt = latest.values.maxOf { it.createdAt }, + ) + }.sortedByDescending { it.announcedAt } + } + + /** What one [discover] call learned, including who did not answer. */ + data class Result( + val coordinators: List, + /** + * Relays that stalled or failed. + * + * An empty [coordinators] means "nobody is announcing" only when this + * is empty too; otherwise it means "we were not told". + */ + val unreachable: Set, + ) + + companion object { + const val DEFAULT_LIMIT = 200 + const val DEFAULT_IDLE_TIMEOUT_MS = 8_000L + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnCoordinatorLink.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnCoordinatorLink.kt new file mode 100644 index 0000000000..514499921b --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnCoordinatorLink.kt @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn + +import com.vitorpamplona.quartz.contextvm.cep04Encryption.CvmGiftWrap +import com.vitorpamplona.quartz.contextvm.mcp.CvmMcpClient +import com.vitorpamplona.quartz.contextvm.transport.CvmTransport +import com.vitorpamplona.quartz.contextvm.transport.DualSigner +import com.vitorpamplona.quartz.cordn.spec00Coordinator.CoordinatorClient +import com.vitorpamplona.quartz.cordn.spec00Coordinator.CoordinatorServerInfo +import com.vitorpamplona.quartz.cordn.spec00Coordinator.ICoordinator +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal + +/** A live connection to one coordinator, and the way to close it. */ +interface CordnCoordinatorLink { + val coordinator: ICoordinator + + /** + * What the coordinator says about itself, or null. + * + * Deliberately **not** on `ICoordinator`. That interface is "the eleven + * coordinator tools, as a contract" and its KDoc says it adds nothing to + * them; `initialize` is the MCP handshake, not a twelfth tool. It belongs + * to whoever owns the transport, which is this. + * + * The default is null so a substitute link — a fixture, a test double — + * does not have to invent a handshake it never performed. + */ + suspend fun serverInfo(): CoordinatorServerInfo? = null + + suspend fun close() +} + +/** + * Opens the ContextVM transport to a coordinator. + * + * The one piece a scope factory cannot supply on its own: a `CvmTransport` + * needs a relay pool and the account's signers, which belong to whichever + * front end is running. Everything else about a scope — where the bytes go + * and how they are encrypted — is the same on every platform. + */ +fun interface CordnCoordinatorLinkFactory { + suspend fun connect( + accountPubKey: HexKey, + config: CoordinatorConfig, + ): CordnCoordinatorLink +} + +/** + * The production [CordnCoordinatorLinkFactory]: a real ContextVM transport per + * coordinator, over whatever relay client the caller is already running. + * + * Shared rather than per-front-end on purpose. Everything this assembles is a + * protocol decision — which signer signs which call, that gift wrapping stays + * REQUIRED, that `initialize` is not performed at open — and a second copy in + * a second front end is a second place those decisions can drift. Android, the + * desktop app and `amy` all take this one. + * + * ## The ephemeral signer is created here, once, and never persisted + * + * `spec/00.md` §8 splits the identity a coordinator sees: the account key + * signs what must be attributable (publishing a KeyPackage, posting to a + * group), and a throwaway key signs everything else, so the coordinator cannot + * link a session's reads to an account. Which key signs which call is fixed by + * `CoordinatorMethod` and not a choice made here; what IS decided here is that + * the throwaway key lives as long as this factory and no longer. Persisting it + * would quietly undo the split — a "session" key reused across launches is + * just a second account key with worse ergonomics. + * + * One consequence worth naming: a front end that builds a factory per run — + * `amy` does, since every invocation is its own process — gets a fresh + * throwaway key each time, which is the strong end of the split rather than a + * degradation of it. + */ +object CordnLinks { + fun over( + accountSigner: NostrSigner, + client: INostrClient, + ): CordnCoordinatorLinkFactory { + val ephemeralSigner = NostrSignerInternal(KeyPair()) + + return CordnCoordinatorLinkFactory { _, config -> + val transport = + CvmTransport( + relays = NostrClientCvmRelayPool(client, config.relays.toSet()), + signers = DualSigner(accountSigner, ephemeralSigner), + serverPubKey = config.pubKey, + // Left at its default, which is REQUIRED (§8.6). Passing + // anything else here is the one line that would silently + // downgrade every coordinator call to plaintext. + crypto = CvmGiftWrap(), + ) + + object : CordnCoordinatorLink { + override val coordinator = CoordinatorClient(CvmMcpClient(transport)) + + // Handshaken on demand, not at open: `initialize` is a call + // like any other (§8), and a coordinator that only ever hears + // from us when we have something to say tells it less than one + // that is greeted at every launch. + override suspend fun serverInfo() = coordinator.serverInfo() + + override suspend fun close() { + // Nothing to release. CvmTransport opens a subscription + // per request and closes it in the same call — kind 25910 + // is ephemeral, so there is no long-lived stream to tear + // down and no connection of its own. The relays it used + // belong to the shared client, which outlives this link. + } + } + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnCoordinatorRegistry.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnCoordinatorRegistry.kt new file mode 100644 index 0000000000..bd7d8522c1 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnCoordinatorRegistry.kt @@ -0,0 +1,309 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn + +import com.vitorpamplona.quartz.cordn.spec00Coordinator.CoordinatorServerInfo +import com.vitorpamplona.quartz.cordn.spec00Coordinator.ICoordinator +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * Everything one account needs to talk to one coordinator. + * + * The three pieces are scoped together because they are scoped the same way: + * a `gid` (`spec/00.md` §3), a `kp_ref` (§4.2), and a cursor (`spec/02.md` §7) + * are all only meaningful relative to the coordinator that issued them. Handing + * a manager the wrong store is not a subtle bug — it is one group's MLS state + * answering for another's. + */ +interface CordnCoordinatorScope { + val coordinator: ICoordinator + val groupStore: CordnGroupStore + val keyPackageStore: CordnKeyPackageStore + + /** What the coordinator says about itself, if this scope performed a handshake. */ + suspend fun serverInfo(): CoordinatorServerInfo? = null + + /** Releases the transport. The stores outlive it; only the wire closes. */ + suspend fun close() +} + +/** + * Opens a [CordnCoordinatorScope] — the seam where the real transport enters. + * + * Production supplies relay-backed `CvmTransport` and encrypted-at-rest stores; + * tests supply the fixture. Nothing above this interface knows which. + */ +fun interface CordnCoordinatorScopeFactory { + suspend fun open( + accountPubKey: HexKey, + config: CoordinatorConfig, + ): CordnCoordinatorScope +} + +/** One account's live connection to one coordinator. */ +class CordnSession( + config: CoordinatorConfig, + val manager: CordnGroupManager, + val keyPackages: CordnKeyPackages, + val health: CoordinatorHealth, + private val scope: CordnCoordinatorScope, + /** Seconds. Injected so tests are not at the mercy of the wall clock. */ + private val clock: () -> Long = { TimeUtils.now() }, +) { + /** + * This coordinator's address and the user's name for it. + * + * Settable only through [relabel], and only for the name: everything else + * here is the address the transport was opened against, so changing it + * without reopening would leave the session pointing somewhere it is not + * connected. + */ + var config: CoordinatorConfig = config + private set + + val coordinatorPubKey: HexKey get() = config.pubKey + + /** + * A new name for the same coordinator, leaving the connection alone. + * + * A label is not part of a coordinator's address -- §8.5 makes the pubkey + * its identity -- and nothing under this class reads one; + * `CordnCoordinatorStore` is the only reader, when it writes the list to + * disk. So a rename must not take the reopen path [replaceConfig] uses for + * a corrected relay: that builds a new [CordnGroupManager], and anything + * still holding the old one (`CordnSyncLoop` captures it for the life of + * the loop) would be left polling a transport that has been closed. + */ + internal fun relabel(newLabel: String?) { + config = config.copy(label = newLabel) + } + + /** + * What this coordinator learns about [gid], answered from everything the + * session holds rather than everything one object happens to see. + * + * The manager alone cannot say whether this account has a KeyPackage + * published here: it only knows what it published itself, this run, so + * after a relaunch it would report "no" about a KeyPackage sitting on the + * coordinator right now. The KeyPackage store knows, and the session is + * the first place that holds both. + */ + suspend fun exposure(gid: String): GroupExposure = manager.exposure(gid, publishedKeyPackage = keyPackages.hasPublished()) + + /** + * What this coordinator says about itself. Claims, never identity (§8.5). + * + * Counts against [health] like any other call. It is a real round trip to + * the coordinator, and it is usually the *first* one a reader makes — the + * settings screen offers it precisely so someone can find out whether a + * coordinator they just added answers at all. Leaving it out had that + * screen say "Nothing asked of it yet" directly underneath what the + * coordinator had just answered. + * + * An answer counts as a success and a throw as a failure; `null` counts as + * neither. A scope returns `null` when it did no handshake at all, so + * treating that as a success would report a coordinator as reachable on + * the strength of a call that never left the device. + */ + suspend fun serverInfo(): CoordinatorServerInfo? = + try { + scope.serverInfo()?.also { health.recordSuccess(clock()) } + } catch (e: Exception) { + health.recordFailure(clock(), e.message) + throw e + } + + /** How many of this session's responses arrived reassembled over CEP-22. */ + val oversizedTransfers: Int get() = scope.coordinator.oversizedTransfers + + internal suspend fun close() = scope.close() +} + +/** + * One [CordnSession] per coordinator, for one account. + * + * ## Why this exists rather than a single manager + * + * `CordnGroupManager`'s KDoc says a `gid` is unique only within a coordinator. + * That is not a caching detail — it is the reason a registry has to be keyed by + * coordinator and can never merge two. Two coordinators can both serve + * `gid = "abc"`, and they are two unrelated groups with different members, + * different epochs and different ratchet trees. A flat map from `gid` to + * manager would let one answer for the other; from the user's side that looks + * like a group whose history changes when the coordinator it came from does. + * + * ## Why it is idempotent, under a lock + * + * [session] returns the *same* session for a coordinator it already holds, and + * serialises concurrent opens. A second `CordnGroupManager` for one coordinator + * would deserialise its own copy of every `MlsGroup` from the same store; both + * would then commit against the same epoch and each would save over the other. + * MLS has no recovery from that — the ratchet tree forks and every message + * after it fails to decrypt. The UI and the sync loop both reach for a session, + * so "two callers at once" is the ordinary case, not a race to dismiss. + * + * Nothing here is Marmot's, and Marmot has no equivalent to borrow. Marmot + * groups live on relays, so one `MarmotManager` serves an account outright and + * there is nothing to key a registry by; a cordn account has one of these per + * coordinator because the coordinator is what makes its ids mean anything. The + * two stay separate under the §3.1 rule in + * `amethyst/plans/2026-09-19-cordn-ui.md`. + */ +class CordnCoordinatorRegistry( + val accountPubKey: HexKey, + private val scopes: CordnCoordinatorScopeFactory, +) { + private val lock = Mutex() + private val sessions = mutableMapOf() + + private val _coordinators = MutableStateFlow>(emptyList()) + + /** The coordinators this account currently has a session for. */ + val coordinators: StateFlow> = _coordinators.asStateFlow() + + /** + * The session for [config], opening one if this account has none. + * + * Idempotent by coordinator pubkey: the same call twice gives the same + * session, never a second manager over the same store. + * + * A [config] that differs from the open one reopens the transport, because + * the transport is bound to the relays — but the same stores come back, so + * the groups and published KeyPackages survive. Correcting a relay or + * renaming a coordinator has not made it a different coordinator. + */ + suspend fun session(config: CoordinatorConfig): CordnSession = + lock.withLock { + sessions[config.pubKey]?.let { existing -> + when { + existing.config == config -> existing + + // Only the name changed. Reopening for that would cost the + // connection and orphan whoever holds the current manager, + // for a field the transport has never read. + existing.config.copy(label = config.label) == config -> + existing.also { + it.relabel(config.label) + publish() + } + + else -> replaceConfig(existing, config) + } + } ?: open(config).also { publish() } + } + + /** The session for [coordinatorPubKey], or null if none is open. */ + fun sessionOrNull(coordinatorPubKey: HexKey): CordnSession? = sessions[coordinatorPubKey] + + /** + * Restores sessions for [configs] and closes any coordinator not in it. + * + * The closing half is what makes this a restore rather than a bulk add: a + * coordinator the user removed on another launch must not come back to life + * because its session happened to still be open. + */ + suspend fun restore(configs: List) { + configs.forEach { session(it) } + lock.withLock { + val keep = configs.map { it.pubKey }.toSet() + sessions.keys.filter { it !in keep }.forEach { sessions.remove(it)?.close() } + publish() + } + } + + /** + * Closes the session for [coordinatorPubKey] and forgets it. + * + * The stores are **not** wiped. Forgetting a coordinator is not the same as + * leaving its groups: cordn-web separates removing a coordinator from + * purging it (`CoordinatorPurgeDialog`), and quietly destroying group state + * here would make an undo impossible. A purge is a store-level operation and + * belongs to whoever owns the stores. + */ + suspend fun forget(coordinatorPubKey: HexKey) { + lock.withLock { + sessions.remove(coordinatorPubKey)?.close() + publish() + } + } + + /** Closes every session. Call when the account logs out. */ + suspend fun close() { + lock.withLock { + sessions.values.forEach { it.close() } + sessions.clear() + publish() + } + } + + private suspend fun replaceConfig( + existing: CordnSession, + config: CoordinatorConfig, + ): CordnSession { + // Same coordinator, different address or label. The transport is bound + // to the relays, so it is reopened; the stores are not, so the groups + // and published KeyPackages survive untouched. + existing.close() + sessions.remove(config.pubKey) + return open(config).also { publish() } + } + + private suspend fun open(config: CoordinatorConfig): CordnSession { + val scope = scopes.open(accountPubKey, config) + val health = CoordinatorHealth() + val session = + CordnSession( + config = config, + manager = + CordnGroupManager( + accountPubKey = accountPubKey, + config = config, + coordinator = scope.coordinator, + store = scope.groupStore, + health = health, + ), + keyPackages = CordnKeyPackages(accountPubKey, scope.coordinator, scope.keyPackageStore), + health = health, + scope = scope, + ) + // Restored here rather than left to the caller. A session that does not + // know its own groups is not a usable session — it reports none, and + // the first commit it makes for a group it forgot about starts from + // epoch zero against a coordinator that is not there. Forgetting to + // call this would look like "my groups are gone", which is also what a + // real failure looks like. + session.manager.restore() + session.keyPackages.restore() + + sessions[config.pubKey] = session + return session + } + + private fun publish() { + _coordinators.value = sessions.values.map { it.config } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnCoordinatorStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnCoordinatorStore.kt new file mode 100644 index 0000000000..444c5ad29e --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnCoordinatorStore.kt @@ -0,0 +1,120 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn + +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.codec.TlsWriter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer + +/** + * The coordinators one account talks to, across launches. + * + * Separate from [CordnGroupStore] and [CordnKeyPackageStore] because its scope + * is different: those are per (account, coordinator) — a `gid` and a `kp_ref` + * only mean anything relative to one coordinator — while this is the list of + * coordinators itself, and so belongs to the account alone. + * + * Without it every cordn group on the device is unreachable after a relaunch. + * The MLS state survives on disk, but nothing knows which coordinator to ask + * for its stream, and a `gid` with no coordinator is not a group anyone can + * open. That is why this is storage rather than a preference. + * + * **Encrypt it at rest.** The contents are not secret the way key material is, + * but they are the sharpest metadata cordn produces about this account: which + * coordinators it uses is exactly what §8 spends its length keeping a + * coordinator from learning about *other* coordinators. + */ +interface CordnCoordinatorStore { + suspend fun save(configs: List) + + suspend fun load(): List +} + +/** For tests and for a front end that deliberately keeps nothing. */ +class InMemoryCordnCoordinatorStore( + initial: List = emptyList(), +) : CordnCoordinatorStore { + private var configs = initial + + override suspend fun save(configs: List) { + this.configs = configs + } + + override suspend fun load(): List = configs +} + +/** + * The on-disk layout of a coordinator list. + * + * TLS-framed like everything else that gets persisted here, for the reason + * [com.vitorpamplona.quartz.mls.messages.KeyPackageBundleCodec] gives: a + * length-prefixed format cannot silently mis-parse a label containing whatever + * separator a line-based one picked, and a version prefix means a future layout + * change is refused rather than guessed at. + */ +object CoordinatorListCodec { + const val VERSION = 1 + + fun encode(configs: List): ByteArray { + val writer = TlsWriter() + writer.putUint16(VERSION) + writer.putUint16(configs.size) + configs.forEach { config -> + writer.putOpaque2(config.pubKey.encodeToByteArray()) + writer.putOpaque2(config.origin.name.encodeToByteArray()) + writer.putOpaque2(config.label.orEmpty().encodeToByteArray()) + writer.putUint16(config.relays.size) + config.relays.forEach { writer.putOpaque2(it.url.encodeToByteArray()) } + } + return writer.toByteArray() + } + + /** + * Reads a list back, dropping entries that no longer make sense. + * + * A coordinator whose relays all fail to normalise, or whose `origin` this + * build does not know, is skipped rather than throwing: one unreadable + * entry must not take the rest of the account's coordinators with it, and + * a coordinator the app cannot reach is not better represented by a crash + * at login. + */ + fun decode(bytes: ByteArray): List { + val reader = TlsReader(bytes) + val version = reader.readUint16() + require(version == VERSION) { "unknown coordinator list layout version $version" } + + val count = reader.readUint16() + val out = mutableListOf() + repeat(count) { + val pubKey = reader.readOpaque2().decodeToString() + val originName = reader.readOpaque2().decodeToString() + val label = reader.readOpaque2().decodeToString() + val relayCount = reader.readUint16() + val relays = (0 until relayCount).mapNotNull { RelayUrlNormalizer.normalizeOrNull(reader.readOpaque2().decodeToString()) } + + val origin = CoordinatorConfig.Origin.entries.firstOrNull { it.name == originName } + if (relays.isNotEmpty() && origin != null) { + out += CoordinatorConfig(pubKey, relays, origin, label.ifEmpty { null }) + } + } + return out + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnExposure.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnExposure.kt new file mode 100644 index 0000000000..2f6aff4c73 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnExposure.kt @@ -0,0 +1,155 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn + +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** + * What the delivery operator learns, as data rather than as documentation. + * + * §8 of `quartz/plans/2026-09-17-cordn-interop.md` ends with a requirement: + * *"This section should be surfaced in the UI if we ship this, not buried. A + * Marmot group and a cordn group have materially different metadata exposure + * and users cannot infer that from either one looking like a group chat."* + * + * A comment cannot satisfy that; a screen can, and a screen needs a model. So + * the analysis lives here as [GroupExposure], computed from the group's actual + * state rather than written out per group — a group joined from a share link + * really does expose more than one whose members were added directly, and the + * difference is mechanical, not editorial. + * + * The one thing this deliberately does **not** do is rank the two bindings. + * cordn is weaker against the delivery operator and stronger against the + * network (§8.5: the coordinator never sees an IP). Which trade is right + * depends on who runs the coordinator, which is the user's call and not ours. + */ +enum class ExposureLevel { + /** The operator cannot learn this at all. */ + NONE, + + /** Learned, but tied to a throwaway key rather than to the account. */ + PSEUDONYMOUS, + + /** Learned and tied to the real npub. */ + IDENTIFIED, +} + +/** One thing worth telling the user about, with the rule it follows from. */ +enum class ExposureNote { + /** + * §8.1 — admission names both ends. `join_request_store` rides the stable + * identity and carries the `gid`; `welcome_store` names the target's real + * pubkey. For a group joined from a share link the coordinator observes + * real-identity membership directly. Structural, not a bug. + */ + MEMBERSHIP_IS_IDENTIFIED, + + /** + * §8.2 — the ephemeral identity is per-session, not per-message, so one + * pseudonym posts and fetches across every group on that coordinator. The + * set of `gid`s it touches links those groups together and leaks how many + * there are. This is the one the user cannot guess and the reason a plain + * "messages are pseudonymous" would be a lie by omission. + */ + GROUPS_LINKED_BY_SESSION, + + /** + * §8.3 — one operator holds the complete ordered history of every group it + * serves, with timestamps and payload sizes, in one SQLite file. Relays + * are redundant and partitioned; a coordinator is neither. + */ + SINGLE_OPERATOR_HOLDS_HISTORY, + + /** + * §8.4 — `kp_publish` rides the stable identity and the coordinator keeps + * the signed event, by design, to re-serve. That is a verifiable record + * that this account uses cordn, rotation cadence included. + */ + PUBLICATION_IS_A_SIGNED_RECORD, + + /** + * §8.3 — nothing in `spec/03.md` pads the sealed payload, so message sizes + * are visible to the operator. + */ + MESSAGE_SIZES_UNPADDED, + + /** + * §8.6 — the transport is NOT pinned to required encryption, so a + * coordinator that declines to announce `support_encryption` would receive + * plaintext JSON-RPC on public relays. Should be impossible in our client; + * if this ever appears, it is a bug, not a disclosure. + */ + ENCRYPTION_NOT_PINNED, +} + +/** + * The exposure of one cordn group on one coordinator. + * + * @param linkedGroupCount how many groups share this coordinator's ephemeral + * session (§8.2). One is already a link between that group and the account's + * traffic; more is a graph. + */ +data class GroupExposure( + val coordinator: HexKey, + val linkedGroupCount: Int, + val joinedFromShareLink: Boolean, + val publishedKeyPackage: Boolean, + val encryptionPinned: Boolean, +) { + /** §8: double-sealed, and the coordinator is forbidden from parsing. Always. */ + val content: ExposureLevel = ExposureLevel.NONE + + /** §8.1. Admission names real keys on both ends; there is no other way in. */ + val membership: ExposureLevel = ExposureLevel.IDENTIFIED + + /** §8.2. The ephemeral identity covers the message path — and only that. */ + val messaging: ExposureLevel = ExposureLevel.PSEUDONYMOUS + + /** + * The notes worth showing, **most surprising first**. + * + * Order is part of the disclosure, not presentation trivia: a reader gives + * the first two lines real attention and skims the rest. So cross-group + * linkage (§8.2) — the one nobody predicts — comes before message sizes, + * which is the least consequential item here. A bug, if one ever appears, + * outranks everything. + */ + fun notes(): List = + buildList { + if (!encryptionPinned) add(ExposureNote.ENCRYPTION_NOT_PINNED) + add(ExposureNote.MEMBERSHIP_IS_IDENTIFIED) + // Only worth saying once there is actually something to link to. + if (linkedGroupCount > 1) add(ExposureNote.GROUPS_LINKED_BY_SESSION) + add(ExposureNote.SINGLE_OPERATOR_HOLDS_HISTORY) + if (publishedKeyPackage) add(ExposureNote.PUBLICATION_IS_A_SIGNED_RECORD) + add(ExposureNote.MESSAGE_SIZES_UNPADDED) + } + + /** + * Whether this group's exposure differs from a Marmot group's in a way the + * user should be told before they treat the two the same. + * + * Always true today, and written as a function anyway: the interesting case + * is a self-hosted coordinator, where the answer changes without any of + * this code changing. + */ + fun differsFromMarmot(): Boolean = true +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnGroupManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnGroupManager.kt new file mode 100644 index 0000000000..dd3e346048 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnGroupManager.kt @@ -0,0 +1,1181 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn + +import com.vitorpamplona.quartz.cordn.appGroupRef.CordnGroupRef +import com.vitorpamplona.quartz.cordn.groups.CordnCredential +import com.vitorpamplona.quartz.cordn.groups.CordnGroupPolicy +import com.vitorpamplona.quartz.cordn.spec00Coordinator.ConsumedJoinRequestRef +import com.vitorpamplona.quartz.cordn.spec00Coordinator.ConsumedWelcomeRef +import com.vitorpamplona.quartz.cordn.spec00Coordinator.ICoordinator +import com.vitorpamplona.quartz.cordn.spec00Coordinator.JoinRequest +import com.vitorpamplona.quartz.cordn.spec00Coordinator.KeyPackagePublication +import com.vitorpamplona.quartz.cordn.spec00Coordinator.TakenKeyPackage +import com.vitorpamplona.quartz.cordn.spec01GroupMetadata.CordnGroupMetadata +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnApplicationMessage +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnDeliveredMessage +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnEnvelope +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnMessageReferences +import com.vitorpamplona.quartz.cordn.spec02Envelopes.ReceivedMessage +import com.vitorpamplona.quartz.cordn.spec03Payloads.SealedPayload +import com.vitorpamplona.quartz.cordn.sync.CordnGroupSync +import com.vitorpamplona.quartz.cordn.sync.Ingestion +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.framing.ContentType +import com.vitorpamplona.quartz.mls.framing.MlsMessage +import com.vitorpamplona.quartz.mls.framing.WireFormat +import com.vitorpamplona.quartz.mls.group.MlsGroup +import com.vitorpamplona.quartz.mls.group.MlsGroupState +import com.vitorpamplona.quartz.mls.messages.KeyPackageBundle +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.withContext +import kotlin.coroutines.cancellation.CancellationException +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +/** + * The cordn groups this account holds on one coordinator. + * + * The cordn counterpart of `MarmotManager`, and deliberately **not** a reuse of + * it. Marmot's manager is keyed on the Nostr group id throughout and its whole + * delivery model — publish kinds 443/444/445 to relays, subscribe by `h` tag, + * carry publish obligations — has no cordn analogue: here there is one + * coordinator, one ordered stream per `gid`, and a cursor. What the two share + * is the RFC 9420 engine underneath, which is what Stage 1 of the interop plan + * made possible. + * + * ## One manager per coordinator + * + * Not per account. A `gid` is scoped to the coordinator that issued it + * (`spec/00.md` §4-5), so the same string can name two unrelated groups on two + * coordinators, and a cursor from one is meaningless to the other. Keying + * groups by `gid` alone is only safe because the coordinator is fixed here. + * + * It also matches the privacy model: §8.2's ephemeral identity is per session + * per coordinator, so the set of groups one manager touches is exactly the set + * that coordinator can link together. [exposure] reports that from the real + * count rather than from a guess. + */ +@OptIn(ExperimentalEncodingApi::class) +class CordnGroupManager( + /** The account these groups belong to, as lowercase hex. */ + val accountPubKey: HexKey, + val config: CoordinatorConfig, + private val coordinator: ICoordinator, + private val store: CordnGroupStore, + val health: CoordinatorHealth = CoordinatorHealth(), + /** Seconds. Injected so tests are not at the mercy of the wall clock. */ + private val clock: () -> Long = { TimeUtils.now() }, +) : CordnSyncSource { + private val groups = mutableMapOf() + private val sync = CordnGroupSync(coordinator) + + private val _gids = MutableStateFlow>(emptySet()) + + /** The groups this manager holds, for a UI to observe. */ + override val gids: StateFlow> = _gids.asStateFlow() + + private var publishedKeyPackage = false + + /** Acknowledgements a failed [retire] left to ride the next `welcome_take`. */ + private val pendingRetirements = mutableListOf() + + /** As [pendingRetirements], for join requests. */ + private val pendingRequestRetirements = mutableListOf() + + /** What happened to one delivered payload. */ + sealed interface Delivery { + /** + * The group it belongs to. + * + * On the interface because every outcome has one and callers route on + * it — a delivery that cannot be filed under a group is not something + * this type can represent. + */ + val gid: String + + /** An application message that passed every §5 check. */ + data class Message( + override val gid: String, + val cursor: Long, + val received: ReceivedMessage, + ) : Delivery + + /** A handshake message; the group has advanced to [epoch]. */ + data class EpochAdvanced( + override val gid: String, + val cursor: Long, + val epoch: Long, + ) : Delivery + + /** Our own message coming back, already accounted for. */ + data class Echo( + override val gid: String, + val cursor: Long, + ) : Delivery + + /** + * A payload this group could not open. + * + * Not fatal and not silent: the cursor moves past it (the alternative + * is a stream that never advances), but a UI that shows nothing here is + * hiding a gap in a conversation. + */ + data class Undecryptable( + override val gid: String, + val cursor: Long, + val reason: String, + ) : Delivery + } + + // ---- membership ------------------------------------------------------ + + /** The group behind [gid], if this manager holds it. */ + fun group(gid: String): MlsGroup? = groups[gid] + + /** Restores every group and cursor the store holds. Call once at startup. */ + suspend fun restore() { + store.listGroups().forEach { gid -> + val blob = store.loadGroup(gid) ?: return@forEach + groups[gid] = MlsGroup.restore(MlsGroupState.decodeTls(blob), CordnGroupPolicy) + store.loadCursor(gid)?.let { sync.restore(gid, it, store.loadEchoState(gid)) } + } + _gids.value = groups.keys.toSet() + } + + /** + * Creates a group this account administers. + * + * [gid] is the caller's to choose and the coordinator never interprets it + * (`spec/00.md` §4). The reference client uses a random UUID; anything + * unique on this coordinator works, and it must not be derived from the MLS + * `group_id`, which is secret. + */ + suspend fun createGroup( + gid: String, + metadata: CordnGroupMetadata, + ): MlsGroup { + require(gid !in groups) { "already in a group with gid $gid" } + val group = + MlsGroup.create( + identity = CordnCredential.of(accountPubKey).identity, + policy = CordnGroupPolicy, + initialExtensions = listOf(metadata.toExtension()), + // See [gidFrom]: this is what lets a joiner -- ours or theirs -- + // learn the delivery id from the Welcome alone. + groupId = gid.encodeToByteArray(), + ) + groups[gid] = group + persist(gid) + _gids.value = groups.keys.toSet() + return group + } + + /** + * Adds [targetPubKey] to [gid], taking their KeyPackage from the + * coordinator and leaving them a Welcome. + * + * The order is load-bearing: + * + * 1. Take and **verify** the publication payload. §9/§10 require the client + * to check it even though §8 makes the coordinator check too — a + * coordinator that skipped the check could otherwise hand us any + * account's name over any account's key material, and we would invite + * the wrong person. + * 2. Seal the Commit under the **pre-commit** epoch key (`spec/03.md` §5). + * The engine hands that key back on [CommitResult.preCommitExporterSecret] + * precisely because it is unobtainable afterwards: sealing under the + * epoch the Commit *creates* produces a payload only the sender can + * open, and every other member silently stops advancing. + * 3. Post the Commit, then store the Welcome with the cursor it landed at, + * so the joiner starts from the epoch they can actually decrypt rather + * than replaying history that predates them. + * + * ## Wire format + * + * The Commit goes out **public-framed** — `MlsMessage(PublicMessage)`, + * which is what this engine produces. cordn's reference client emits + * private-framed handshake messages instead, but accepts either: its + * `processMessageBase64` admits wireformat 1 and 2 and hands both to + * ts-mls's `processMessage`. Nothing is weakened by the choice, because the + * coordinator sees only the outer ChaCha seal either way (§8) — the framing + * is visible only to members, who can already tell a Commit from a message. + * [ingest] reads both. + */ + suspend fun invite( + gid: String, + targetPubKey: HexKey, + keyPackageRef: String? = null, + ): InviteResult { + val group = requireGroup(gid) + + // By ref when we have one: a join request names the exact KeyPackage + // its sender published for this purpose, and taking a different one of + // theirs would spend a package they meant for someone else. + val taken = + call { coordinator.takeKeyPackage(keyPackageRef ?: targetPubKey) } + ?: throw CordnGroupException( + "the coordinator holds no KeyPackage for $targetPubKey", + CordnGroupException.Reason.NO_KEY_PACKAGE, + ) + val verified = KeyPackagePublication.verify(taken.publicationEvent) + if (verified.pubKey != targetPubKey) { + throw CordnGroupException( + "the KeyPackage served for $targetPubKey belongs to ${verified.pubKey}", + CordnGroupException.Reason.WRONG_KEY_PACKAGE_OWNER, + ) + } + + val result = group.addMember(verified.bytes) + val welcome = result.welcomeBytes ?: throw CordnGroupException("adding a member produced no Welcome", CordnGroupException.Reason.NO_WELCOME) + + // framedCommitBytes, not commitBytes: the latter is the bare RFC 9420 + // Commit struct with no MLSMessage around it, which no receiver can + // parse. preCommitExporterSecret is the epoch key the Commit leaves. + val posted = + call { sync.postCommit(gid, SealedPayload.seal(result.framedCommitBytes, result.preCommitExporterSecret)) } + val welcomeAt = + call { + coordinator.storeWelcome( + targetPubKey = targetPubKey, + keyPackageRef = taken.keyPackageRef, + welcomeBase64 = Base64.encode(welcome), + after = posted.cursor, + ) + } + + persist(gid) + return InviteResult(gid, targetPubKey, taken.keyPackageRef, posted.cursor, welcomeAt) + } + + /** + * Invites several people in ONE commit. + * + * Not a loop over [invite], and not an optimisation either -- the loop was + * unsafe. [MlsGroup.addMember] *applies* its Commit locally before anything + * is posted, so an invite whose `postCommit` fails leaves this group an + * epoch ahead of both the coordinator and disk; the next invite in a loop + * then builds on that forked state and cannot succeed. Batching collapses N + * such windows into one. It does not remove the last one -- the engine has + * no rollback, so a failed post still leaves the local group ahead -- but + * the failure can no longer cascade. + * + * It is also what the reference client does, and one epoch rather than N is + * the observable difference. See [MlsGroup.addMembers]. + * + * Every KeyPackage is taken and verified BEFORE the commit, so a target the + * coordinator holds nothing for fails while nothing has changed; those come + * back in [BatchInviteResult.refused]. A Welcome that fails to store after + * the commit is per-target and harmless to the group, and comes back in + * [BatchInviteResult.undelivered]. + */ + suspend fun inviteAll( + gid: String, + targetPubKeys: List, + ): BatchInviteResult { + if (targetPubKeys.isEmpty()) return BatchInviteResult(gid, emptyList(), emptyMap(), emptyMap()) + val group = requireGroup(gid) + + // Phase 1, before anything is committed: take and verify every + // KeyPackage. A failure here spends a KeyPackage for the targets + // already taken and nothing else -- the group has not moved. + val taken = mutableMapOf() + val verified = mutableMapOf() + val refused = mutableMapOf() + + targetPubKeys.distinct().forEach { target -> + val held = call { coordinator.takeKeyPackage(target) } + if (held == null) { + refused[target] = + CordnGroupException( + "the coordinator holds no KeyPackage for $target", + CordnGroupException.Reason.NO_KEY_PACKAGE, + ) + return@forEach + } + val owner = KeyPackagePublication.verify(held.publicationEvent) + if (owner.pubKey != target) { + refused[target] = + CordnGroupException( + "the KeyPackage served for $target belongs to ${owner.pubKey}", + CordnGroupException.Reason.WRONG_KEY_PACKAGE_OWNER, + ) + return@forEach + } + taken[target] = held + verified[target] = owner.bytes + } + + if (verified.isEmpty()) return BatchInviteResult(gid, emptyList(), emptyMap(), refused) + + // Phase 2: one commit, one post. Past this line the group has moved. + val result = group.addMembers(verified.values.toList()) + val welcome = + result.welcomeBytes + ?: throw CordnGroupException("adding members produced no Welcome", CordnGroupException.Reason.NO_WELCOME) + val posted = + call { sync.postCommit(gid, SealedPayload.seal(result.framedCommitBytes, result.preCommitExporterSecret)) } + + // Phase 3: the same Welcome, addressed once per joiner. RFC 9420 + // §12.4.3.1 puts a separate EncryptedGroupSecrets per added member + // inside it, keyed by KeyPackage reference, so each one finds its own. + val invited = mutableListOf() + val undelivered = mutableMapOf() + + verified.keys.forEach { target -> + val ref = taken.getValue(target).keyPackageRef + try { + val at = + call { + coordinator.storeWelcome( + targetPubKey = target, + keyPackageRef = ref, + welcomeBase64 = Base64.encode(welcome), + after = posted.cursor, + ) + } + invited += InviteResult(gid, target, ref, posted.cursor, at) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + // The commit is posted, so the group already counts them a + // member -- they simply have no Welcome to join with. Reported + // rather than thrown, because the others succeeded. + undelivered[target] = e + } + } + + persist(gid) + return BatchInviteResult(gid, invited, undelivered, refused) + } + + /** + * Removes [targetPubKey] from [gid]. + * + * Admin-gated, and not by this function: [CordnGroupPolicy.authorizeCommit] + * refuses to build the Remove commit when the local member is not named in + * `admin_pubkeys`, so the check cannot be forgotten here or bypassed by + * another call site. A group in egalitarian mode lets any member do it. + * + * Refuses to remove YOU, as the reference client does. cordn has no + * self-removal: MLS wants a SelfRemove proposal an admin then commits, and + * committing your own Remove would advance the group into an epoch whose + * keys you no longer hold — you would be the only member unable to read + * what happened next. + * + * Sealed under the PRE-commit epoch key like every other commit here + * (`spec/03.md` §5), which is also what lets the person being removed read + * the commit that removes them: they still hold that epoch. + */ + suspend fun removeMember( + gid: String, + targetPubKey: HexKey, + ): RemovalResult { + val group = requireGroup(gid) + + require(targetPubKey != accountPubKey) { + "cordn has no self-removal: committing your own Remove would leave you unable to read the group" + } + + val leafIndex = + CordnCredential + .membersOf(group) + .entries + .firstOrNull { it.value == targetPubKey } + ?.key + ?: throw CordnGroupException( + "$targetPubKey is not a member of $gid", + CordnGroupException.Reason.NOT_A_MEMBER, + ) + + val result = group.removeMember(leafIndex) + val posted = + call { sync.postCommit(gid, SealedPayload.seal(result.framedCommitBytes, result.preCommitExporterSecret)) } + + persist(gid) + return RemovalResult(gid, targetPubKey, posted.cursor) + } + + /** + * Replaces [gid]'s `cordn_group_metadata` — its name, description, icon, + * image and admin list — with [metadata]. + * + * Admin-gated the same way [removeMember] is, and through the same hook: + * changing metadata is a GroupContextExtensions commit, which is the third + * proposal type `admin_pubkeys` covers. Which means this is also how the + * admin list itself changes, and why an egalitarian group can be given + * admins by any member while an administered one cannot. + * + * A GroupContextExtensions proposal replaces the WHOLE extension list, so + * every other extension the group carries is kept and only `0xC04D` is + * swapped. Sending the metadata alone would silently drop + * `required_capabilities` and the app-data dictionary, and a group whose + * required capabilities vanished is one whose next commit peers reject. + */ + suspend fun updateGroupMetadata( + gid: String, + metadata: CordnGroupMetadata, + ): MetadataUpdateResult { + val group = requireGroup(gid) + + val kept = group.extensions.filterNot { it.extensionType == CordnGroupMetadata.EXTENSION_TYPE } + group.proposeGroupContextExtensions(kept + metadata.toExtension()) + + val result = group.commit() + val posted = + call { sync.postCommit(gid, SealedPayload.seal(result.framedCommitBytes, result.preCommitExporterSecret)) } + + persist(gid) + return MetadataUpdateResult(gid, posted.cursor) + } + + /** + * Opens every pending Welcome without joining anything. + * + * Splitting "open" from "join" is what makes an accept/decline surface + * possible at all. A Welcome is opaque until it is processed — the `gid`, + * the group's name and who is already in it all live inside it — so a user + * cannot be asked about an invitation that has not been opened, and + * opening must therefore not be the same act as accepting. + * + * [bundleFor] resolves a `kp_ref` to the KeyPackageBundle we published + * under it. A Welcome we have no private half for is skipped and left on + * the coordinator: it belongs to another device of this account, and + * retiring it here would destroy it. + * + * A Welcome for a group this manager already holds is skipped **and** + * retired. That is the stale record [accept] leaves behind when its + * acknowledgement does not reach the coordinator, and letting it pile up + * is what made a second drain destructive. + * + * [gidFor] overrides where the delivery id comes from — see [gidFrom] for + * why it can be missing and what to pass when it is. + */ + suspend fun pendingWelcomes( + bundleFor: suspend (String) -> KeyPackageBundle?, + gidFor: (MlsGroup) -> String? = ::gidFrom, + ): WelcomeInbox { + // Drained into a local first: passing the drain inline emptied the + // queue before the call, so a failing takeWelcomes dropped those acks + // for good and the coordinator kept re-serving what we had consumed. + // The join-request path already re-queues on failure; this matches it. + val acks = drainRetirements() + val pending = + try { + call { coordinator.takeWelcomes(acks) } + } catch (e: Exception) { + pendingRetirements += acks + throw e + } + val opened = mutableListOf() + val skipped = mutableListOf() + val stale = mutableListOf() + + pending.forEach { welcome -> + val bundle = bundleFor(welcome.keyPackageRef) + if (bundle == null) { + skipped += SkippedWelcome(welcome.keyPackageRef, NO_PRIVATE_HALF) + return@forEach + } + val group = + try { + MlsGroup.processWelcome(Base64.decode(welcome.welcomeBase64), bundle, CordnGroupPolicy) + } catch (e: Exception) { + skipped += SkippedWelcome(welcome.keyPackageRef, e.message ?: "the Welcome did not open") + return@forEach + } + val gid = gidFor(group) + if (gid == null) { + skipped += SkippedWelcome(welcome.keyPackageRef, UNKNOWN_GID) + return@forEach + } + if (gid in groups) { + skipped += SkippedWelcome(welcome.keyPackageRef, ALREADY_A_MEMBER) + stale += ConsumedWelcomeRef(welcome.keyPackageRef, welcome.at) + return@forEach + } + + opened += + OpenedWelcome( + gid = gid, + keyPackageRef = welcome.keyPackageRef, + at = welcome.at, + metadata = CordnGroupMetadata.fromExtensions(group.extensions), + // Who is in the group, which is NOT the same as who + // invited us: a Welcome carries the ratchet tree, not the + // identity of whoever signed the Commit that created it. + // Naming an inviter here would be a guess dressed as a + // fact, and in a group of three it would usually be wrong. + members = CordnCredential.memberIdentities(group), + epoch = group.epoch, + group = group, + after = welcome.after, + ) + } + + retire(stale) + return WelcomeInbox(opened, skipped) + } + + /** + * Joins the group [welcome] opens, and retires it. + * + * Refuses a `gid` this manager already holds rather than replacing it. + * Installing a Welcome over a live group rolls its epoch back to the one + * it was issued at, and MLS does not recover from that: every message + * after the rolled-back epoch stops decrypting, silently and permanently. + */ + suspend fun accept(welcome: OpenedWelcome): String { + require(welcome.gid !in groups) { "already in a group with gid ${welcome.gid}" } + + groups[welcome.gid] = welcome.group + // `after` is the inviter saying where this member's history starts. + // Without it a joiner replays epochs from before it existed and + // every one lands as Undecryptable. + welcome.after?.let { sync.restore(welcome.gid, sync.inbox(welcome.gid).cursor.advancedTo(it)) } + persist(welcome.gid) + _gids.value = groups.keys.toSet() + + retire(listOf(ConsumedWelcomeRef(welcome.keyPackageRef, welcome.at))) + return welcome.gid + } + + /** + * Retires [welcome] without joining it. + * + * Declining is permanent and there is no undo, because there is no undo to + * build: a retired Welcome is gone from the coordinator, and the KeyPackage + * it was addressed to has been spent. Re-joining means being invited again. + */ + suspend fun decline(welcome: OpenedWelcome) { + retire(listOf(ConsumedWelcomeRef(welcome.keyPackageRef, welcome.at))) + } + + /** + * Opens every pending Welcome and accepts all of them. + * + * The unattended path — `amy`, tests, anything with no one to ask. A UI + * uses [pendingWelcomes] and [accept]/[decline] instead, because accepting + * an invitation on someone's behalf is a decision, not a sync step. + */ + suspend fun joinPendingWelcomes( + bundleFor: suspend (String) -> KeyPackageBundle?, + gidFor: (MlsGroup) -> String? = ::gidFrom, + ): JoinResults { + val inbox = pendingWelcomes(bundleFor, gidFor) + val joined = inbox.pending.map { accept(it) } + return JoinResults(joined, inbox.skipped) + } + + /** + * Acknowledges [refs] so the coordinator stops serving them. + * + * `welcome_take` carries acknowledgements for the *previous* round rather + * than taking them as their own call, so a retirement can only ride the + * next take. Sending one immediately keeps the common case prompt; a + * failure parks it in [pendingRetirements] instead of being lost, and + * [pendingWelcomes] flushes it on its next call. Failing to retire must + * never fail the join it follows — the group is already installed and + * persisted, and a Welcome served twice is now merely redundant rather + * than destructive. + */ + private suspend fun retire(refs: List) { + if (refs.isEmpty()) return + try { + call { coordinator.takeWelcomes(refs) } + } catch (e: Exception) { + pendingRetirements += refs + } + } + + private fun drainRetirements(): List { + val queued = pendingRetirements.toList() + pendingRetirements.clear() + return queued + } + + // ---- join requests -------------------------------------------------- + + /** + * Asks to be added to [gid], offering [keyPackageRef] as the way in. + * + * The requester side of a share link. It tells the coordinator, and + * through it the group's members, that this account wants in and which + * KeyPackage to use — nothing more. Only a member can actually add anyone, + * and nothing here obliges them to. + * + * This is an attributable call: the coordinator learns this account is + * interested in this group whether or not anyone ever accepts. That is + * unavoidable — there is no way to ask to join without asking — and it is + * the cost the exposure disclosure names. + */ + suspend fun requestToJoin( + gid: String, + keyPackageRef: String, + ): Long = + call { coordinator.storeJoinRequest(gid, keyPackageRef) } + // Written whether or not anyone ever answers. The exposure is the + // asking, not the joining -- the coordinator has the npub either + // way -- so recording it only on success would understate it in + // exactly the case where nothing else on screen mentions it. + .also { store.saveJoinedViaRequest(gid) } + + /** + * Everyone asking to join a group this manager holds. + * + * Only this manager's own `gid`s are asked about, because those are the + * only ones it could act on. Like Welcomes, acknowledgements ride the next + * call, so a previous round's decisions are flushed here. + * + * **Any member can answer these, not only an admin.** `spec/01.md` §5.3 + * makes `admin_pubkeys` presentation metadata and neither the spec nor the + * reference coordinator restricts who may commit — see + * [CordnGroupPolicy]'s "why there is no authorization hook". A UI that + * hid this behind an admin check would be inventing a boundary cordn does + * not have. + */ + suspend fun pendingJoinRequests(): List { + // Only groups this account can actually admit someone to, as both + // reference clients do. Accepting a request is an Add commit, so in a + // group that names admins a non-admin asking for the list would be + // shown people it can only fail to let in. Egalitarian groups name no + // admins and so are all still here. + val administered = groups.filterValues { CordnGroupPolicy.isLocalAdmin(it.view()) }.keys.toList() + if (administered.isEmpty()) return emptyList() + return call { coordinator.takeJoinRequests(administered, drainRequestRetirements()) } + } + + /** + * Adds the account behind [request] to its group, and retires the request. + * + * Retiring only after the invite lands: a request dropped before the + * Welcome exists is a person who asked, was told nothing, and has no way + * to ask again without a fresh link. + */ + suspend fun acceptJoinRequest(request: JoinRequest): InviteResult { + val result = invite(request.gid, request.pubKey, request.keyPackageRef) + retireRequests(listOf(ConsumedJoinRequestRef(request.gid, request.pubKey, request.at))) + return result + } + + /** Retires [request] without adding anyone. */ + suspend fun declineJoinRequest(request: JoinRequest) { + retireRequests(listOf(ConsumedJoinRequestRef(request.gid, request.pubKey, request.at))) + } + + /** As [retire], for join requests: `join_request_take_many` carries the acks. */ + private suspend fun retireRequests(refs: List) { + if (refs.isEmpty() || groups.isEmpty()) return + try { + call { coordinator.takeJoinRequests(groups.keys.toList(), refs) } + } catch (e: Exception) { + pendingRequestRetirements += refs + } + } + + private fun drainRequestRetirements(): List { + val queued = pendingRequestRetirements.toList() + pendingRequestRetirements.clear() + return queued + } + + // ---- messages -------------------------------------------------------- + + /** + * Sends a message that refers to another one — a reply, reaction, edit, + * deletion or pin. + * + * The kind and the tags are decided by + * [CordnMessageReferences.outbound], not here: a kind switch beside a tag + * switch is how the two stop agreeing, and that file holds both halves + * with tests over the round trip. + * + * ## It refuses what the fold would ignore + * + * `CordnAnnotationIndex` enforces cordn's authorization rules when folding + * annotations onto their targets, and edits and deletions are author-only + * there. Without the same check here, editing someone else's message would + * appear to work — the message goes out, the coordinator stores it, and + * every client including ours silently drops it. Failing at the call is + * the difference between a bug someone can report and one nobody can see. + * + * Reactions and pins are deliberately not checked: §5.1 makes a reaction + * anyone's and a pin any member's. + */ + suspend fun post( + gid: String, + content: String = "", + replyTo: CordnMessageReferences.Target? = null, + reactionTo: CordnMessageReferences.Target? = null, + editTo: CordnMessageReferences.Target? = null, + deleteTo: CordnMessageReferences.Target? = null, + pinTo: CordnMessageReferences.Target? = null, + pinOp: CordnMessageReferences.PinOp = CordnMessageReferences.PinOp.ADD, + ): CordnDeliveredMessage { + editTo?.let { require(it.pubKey == accountPubKey) { "only a message's author can edit it" } } + deleteTo?.let { require(it.pubKey == accountPubKey) { "only a message's author can delete it" } } + + val outbound = + CordnMessageReferences.outbound( + content = content, + replyTo = replyTo, + reactionTo = reactionTo, + editTo = editTo, + deleteTo = deleteTo, + pinTo = pinTo, + pinOp = pinOp, + ) + return send(gid, outbound.content, outbound.kind, outbound.tags) + } + + /** + * Sends [content] to [gid] as a cordn application message. + * + * Returns the message as the room will hold it, cursor included. The + * cursor is the coordinator's, taken from the post's own response, so a + * caller that shows the message immediately places it in the same order + * the echo would have — [CordnGroupChatroom.ORDER] sorts on it, and a + * guessed one would put your own message in the wrong place until the + * echo corrected it. + */ + suspend fun send( + gid: String, + content: String, + kind: Int = CHAT_KIND, + tags: Array> = emptyArray(), + ): CordnDeliveredMessage { + val group = requireGroup(gid) + val envelope = + CordnEnvelope.build( + pubKey = accountPubKey, + createdAt = clock(), + kind = kind, + tags = tags, + content = content, + ) + val sealed = CordnApplicationMessage.seal(group, accountPubKey, envelope) + val posted = call { sync.postMessage(gid, sealed) } + // Our own message never arrives as a Delivery.Message — it comes back + // as an Echo, which by design carries nothing — so the only place it + // can be recorded is here, where we still hold the plaintext. + queueForStore(gid, CordnDeliveredMessage(envelope, posted.cursor)) + persist(gid) + return CordnDeliveredMessage(envelope, posted.cursor) + } + + /** Drains history for every group this manager holds. */ + override suspend fun catchUp(onDelivery: (Delivery) -> Unit): Int { + if (groups.isEmpty()) return 0 + return call { sync.catchUp(groups.keys.toList()) { gid, ingestion -> onDelivery(ingest(gid, ingestion)) } } + .also { persistAll() } + } + + /** + * Subscribes to live delivery for every group. Suspends until the + * coordinator closes the stream, so give it its own coroutine. + * + * Persists on the way out, like [catchUp], and for the same reason: + * delivering a message advances the ratchet and the cursor, so a + * subscription that ended without writing would leave that progress only + * in memory. A long-lived host survives that; a process-per-command client + * does not, and neither does a crash. + */ + override suspend fun subscribe( + timeoutMs: Long, + onDelivery: (Delivery) -> Unit, + ) { + if (groups.isEmpty()) return + try { + call { sync.subscribe(groups.keys.toList(), timeoutMs) { gid, ingestion -> onDelivery(ingest(gid, ingestion)) } } + } finally { + // finally, not after: a subscription normally ends by timing out or + // being cancelled, and those are the cases with progress to keep. + // + // NonCancellable because the cancelled case is the one this exists + // for and the one it could not serve: persistAll suspends, and a + // suspend call in a cancelled coroutine throws before it writes + // anything. Without this the `finally` looked like it saved on + // cancellation and silently did not. + withContext(NonCancellable) { persistAll() } + } + } + + private fun ingest( + gid: String, + ingestion: Ingestion, + ): Delivery { + val group = groups[gid] ?: return Delivery.Undecryptable(gid, ingestion.cursor, "no such group") + + val sealed = + when (ingestion) { + is Ingestion.OwnMessage -> return Delivery.Echo(gid, ingestion.cursor) + // Our own Commit, already applied locally when we posted it. + is Ingestion.SelfEchoConfirmed -> return Delivery.Echo(gid, ingestion.cursor) + // Our own Commit that we posted but had not applied -- the echo + // is the instruction to apply it, which is how a client that + // died mid-post recovers. + is Ingestion.SelfEchoUnapplied -> ingestion.sealedBase64 + is Ingestion.Process -> ingestion.sealedBase64 + } + + return try { + // Our current epoch key opens both an application message sent at + // this epoch and a Commit leaving it, which is the same key by + // construction (spec/03.md §5). + val opened = SealedPayload.open(sealed, SealedPayload.applicationKey(group)) + when (MlsMessage.decodeTls(TlsReader(opened)).wireFormat) { + // Public-framed handshake: what this engine emits, and what + // Marmot uses throughout. + WireFormat.PUBLIC_MESSAGE -> { + group.processFramedCommit(opened) + Delivery.EpochAdvanced(gid, ingestion.cursor, group.epoch) + } + // Private-framed: what cordn's reference client emits, for both + // application messages and handshake traffic. + else -> { + val decrypted = group.decrypt(opened) + when (decrypted.contentType) { + ContentType.APPLICATION -> { + val received = CordnApplicationMessage.open(decrypted) + queueForStore(gid, CordnDeliveredMessage(received.envelope, ingestion.cursor)) + Delivery.Message(gid, ingestion.cursor, received) + } + // decrypt() applies a Commit, so the epoch has moved already. + else -> Delivery.EpochAdvanced(gid, ingestion.cursor, group.epoch) + } + } + } + } catch (e: Exception) { + // The cursor has already advanced past it. Reporting rather than + // throwing is what keeps one bad payload from stalling every other + // group in the same page. + Delivery.Undecryptable(gid, ingestion.cursor, e.message ?: "could not open payload") + } + } + + // ---- sharing and disclosure ----------------------------------------- + + /** The `cordn1…` ref to share this group, pointing at this coordinator. */ + fun shareRef(gid: String): CordnGroupRef { + requireGroup(gid) + return CordnGroupRef( + gid = gid, + coordinatorPubKey = config.pubKey, + relays = config.relays.map { it.url }, + ) + } + + /** + * What this coordinator learns about [gid]. See [GroupExposure]. + * + * Every field is read back from durable state rather than from what this + * process happens to have watched. The distinction is the whole point of + * the disclosure: a coordinator does not forget at app restart, so an + * exposure surface that resets to a cheerful default at launch is not a + * softer version of the truth, it is the wrong answer. + * + * @param publishedKeyPackage whether this account has a KeyPackage + * published here, which only whoever holds the KeyPackage store can say + * (§8.4). `CordnSession.exposure` supplies it; the default is the + * weaker "at least what this session did". + */ + suspend fun exposure( + gid: String, + publishedKeyPackage: Boolean = this.publishedKeyPackage, + ): GroupExposure { + requireGroup(gid) + return GroupExposure( + coordinator = config.pubKey, + linkedGroupCount = groups.size, + // §8.1: a join request names the asker's real npub against a + // specific group. Persisted at the moment it happens, because it + // is a thing the coordinator now knows permanently. + joinedFromShareLink = store.loadJoinedViaRequest(gid), + publishedKeyPackage = publishedKeyPackage, + // :contextvm's CvmGiftWrap defaults to REQUIRED and CoordinatorClient + // does not undo it, so this is a fact about our transport, not a + // setting. It is reported rather than assumed so that a future + // configurable transport cannot quietly make it false (§8.6). + encryptionPinned = true, + ) + } + + /** Publishes a KeyPackage so others can add this account. §8.4 applies. */ + suspend fun publishKeyPackage( + keyPackageRef: String, + keyPackageBase64: String, + ) = call { coordinator.publishKeyPackage(keyPackageRef, keyPackageBase64) } + .also { publishedKeyPackage = true } + + /** + * The screen-side state saved for [gid] — an unsent draft and a read + * position. Neither is protocol; both are encrypted anyway, because a + * draft is the plaintext of a message that was about to be sealed. + */ + suspend fun roomState(gid: String): CordnRoomState = store.loadRoomState(gid) + + suspend fun saveRoomState( + gid: String, + state: CordnRoomState, + ) = store.saveRoomState(gid, state) + + // ---- plumbing -------------------------------------------------------- + + private fun requireGroup(gid: String) = groups[gid] ?: throw CordnGroupException("not a member of $gid") + + private suspend fun persist(gid: String) { + val group = groups[gid] ?: return + store.saveGroup(gid, group.saveState().encodeTls()) + + // Messages BEFORE the cursor, and deliberately so. Crash between the + // two and the message is on disk while the cursor still points before + // it: the next catch-up re-delivers it and appendMessage's dedup drops + // it, costing nothing. The other order loses the message permanently — + // both cordn seal keys are epoch-derived, so the copy the coordinator + // still holds can no longer be opened, and the cursor would not offer + // it again anyway. + // + // Living here rather than at the call sites is the point: the ordering + // is the correctness property, so it sits next to the cursor write + // where it cannot be forgotten. + pendingMessages.remove(gid)?.forEach { store.appendMessage(gid, it) } + + sync.cursors()[gid]?.let { store.saveCursor(gid, it) } + // On the same beat as the cursor, because the two are only meaningful + // together: a cursor that outlives the process while the record of + // what is ours does not leaves the next run re-reading its own + // traffic with no way to recognise it. See EchoState. + sync.echoes()[gid]?.let { store.saveEchoState(gid, it) } + } + + /** + * Messages delivered but not yet written, per group. + * + * [ingest] is not a suspend function — it runs inside the sync's own + * delivery callback — so it cannot write. It queues here instead, and + * [persist] drains the queue immediately before the cursor. Held in memory + * only for as long as the cursor is: neither is on disk until a run ends, + * so a crash mid-run loses both together and the next catch-up refetches + * from the last cursor that *was* written. + */ + private val pendingMessages = mutableMapOf>() + + private fun queueForStore( + gid: String, + message: CordnDeliveredMessage, + ) { + pendingMessages.getOrPut(gid) { mutableListOf() }.add(message) + } + + /** Every message this group has stored, oldest first. */ + suspend fun storedMessages(gid: String): List = store.loadMessages(gid) + + /** The newest stored message and the count, without reading the log. */ + suspend fun storedMessageSummary(gid: String): CordnMessageSummary? = store.loadMessageSummary(gid) + + private suspend fun persistAll() { + groups.keys.forEach { persist(it) } + } + + /** Runs a coordinator call, recording the outcome against [health]. */ + private suspend fun call(block: suspend () -> T): T = + try { + block().also { health.recordSuccess(clock()) } + } catch (e: Exception) { + health.recordFailure(clock(), e.message) + throw e + } + + companion object { + /** `spec/02.md` §6: a cordn chat message is a NIP-C7 kind 9. */ + const val CHAT_KIND = 9 + + // Why a Welcome was left in the inbox. Each is actionable and each + // reaches a user, so they are constants rather than strings written at + // the throw site: a reason nobody can act on is just an error message. + + /** The Welcome is for another device of this account. Leave it alone. */ + const val NO_PRIVATE_HALF = "no KeyPackage private half for this ref" + + /** Opened, but it does not say where to fetch this group's stream from. */ + const val UNKNOWN_GID = "cannot tell which delivery group this Welcome is for" + + /** A stale invitation to a group we are already in. Nothing to decide. */ + const val ALREADY_A_MEMBER = "already a member of this group" + + /** + * The delivery `gid` a Welcome implies, or null when it implies none. + * + * **A Welcome does not carry the `gid`.** It carries the MLS + * `group_id`, and `spec/03.md` §2 is explicit that the two are + * decoupled — the coordinator's delivery id is not the MLS group id and + * must not be assumed to be. So in principle a joiner has no way to + * learn where to fetch from, and needs the `cordn1…` ref out of band. + * + * In practice the reference client closes that gap by convention: + * cordn-web sets `group_id = utf8(gid)` when it creates a group and + * reads the `gid` straight back out of the group context on join + * (`chatGroupLifecycle.svelte.ts`). Verified against staircase's + * fixtures, whose `group_id` decodes to exactly their published `gid`. + * + * This follows that convention — [createGroup] writes it and this reads + * it — while treating it as what it is: an observation about one + * implementation, not a guarantee. A group id that is not valid UTF-8 + * cannot be a `gid`, and a conformant peer is free to produce one, so + * the answer is null and the caller is told rather than handed a + * mojibake key that would quietly fetch nothing forever. Pass `gidFor` + * to supply the id from a share ref instead. + */ + fun gidFrom(group: MlsGroup): String? = + try { + group.groupId.decodeToString(throwOnInvalidSequence = true).takeIf { it.isNotEmpty() } + } catch (e: CharacterCodingException) { + null + } + } +} + +/** + * Something went wrong that is this manager's to explain, not MLS's. + * + * [reason] exists so a screen can say what happened in its own words. The + * [message] is written for a log — it names pubkeys and gids in full, which is + * what you want when reading one and never what you want in a chat — so a UI + * that rendered `e.message` showed a person who had just tapped a face a + * 64-character hex string. Branching on the reason lets it name the person + * instead, without matching on English that is free to change. + */ +class CordnGroupException( + message: String, + val reason: Reason = Reason.OTHER, +) : IllegalStateException(message) { + enum class Reason { + /** Nobody has published a KeyPackage for this person to this coordinator. */ + NO_KEY_PACKAGE, + + /** The coordinator served a KeyPackage belonging to somebody else. */ + WRONG_KEY_PACKAGE_OWNER, + + /** The add produced no Welcome, so the invitee could never open the group. */ + NO_WELCOME, + + /** The person named is not in this group. */ + NOT_A_MEMBER, + + /** Anything with no better wording than the message itself. */ + OTHER, + } +} + +/** + * A Welcome that has been opened but not joined. + * + * Everything on it was read out of the Welcome itself, so it is what can be + * shown before a decision is made. [group] is the MLS group the Welcome + * produces; holding it is why [CordnGroupManager.accept] does not have to + * re-open the Welcome and spend the KeyPackage twice. + */ +class OpenedWelcome internal constructor( + val gid: String, + val keyPackageRef: String, + val at: Long, + val metadata: CordnGroupMetadata?, + /** Who is already in the group. Not the inviter — see [CordnGroupManager.pendingWelcomes]. */ + val members: Set, + val epoch: Long, + internal val group: MlsGroup, + internal val after: Long?, +) + +/** Every pending Welcome, split into the ones that opened and the ones that did not. */ +data class WelcomeInbox( + val pending: List, + val skipped: List, +) + +/** What [CordnGroupManager.joinPendingWelcomes] did with each pending Welcome. */ +data class JoinResults( + val joined: List, + /** + * Welcomes left pending, with why. Not an error list: a Welcome for a + * KeyPackage this device never held belongs to another device of the same + * account, and draining it here would destroy it. + */ + val skipped: List, +) + +/** One Welcome that stayed in the inbox, and the reason. */ +data class SkippedWelcome( + val keyPackageRef: String, + val reason: String, +) + +/** The result of adding a member: where the Commit and the Welcome landed. */ +data class InviteResult( + val gid: String, + val invited: HexKey, + /** + * The KeyPackage this invite consumed. + * + * Worth reporting rather than swallowing: a KeyPackage is one-time, so + * this names something the invitee can no longer be invited with by + * anyone else. It is also the handle their client needs to find the + * Welcome we just left them. + */ + val keyPackageRef: String, + val commitCursor: Long, + val welcomeAt: Long, +) + +/** What [CordnGroupManager.removeMember] did. */ +data class RemovalResult( + val gid: String, + val removed: HexKey, + /** Where the coordinator filed the Remove commit. */ + val cursor: Long, +) + +/** What [CordnGroupManager.updateGroupMetadata] did. */ +data class MetadataUpdateResult( + val gid: String, + /** Where the coordinator filed the metadata commit. */ + val cursor: Long, +) + +/** + * What one [CordnGroupManager.inviteAll] did, per person. + * + * Three outcomes rather than success-or-throw, because each needs a different + * answer: [refused] never reached the group and can be retried or replaced with + * a share link; [undelivered] are in the group already and need only the Welcome + * resent; [invited] are done. + */ +data class BatchInviteResult( + val gid: String, + val invited: List, + val undelivered: Map, + val refused: Map, +) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnGroupStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnGroupStore.kt new file mode 100644 index 0000000000..3102e3c744 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnGroupStore.kt @@ -0,0 +1,384 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn + +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnDeliveredMessage +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnDeliveredMessageCodec +import com.vitorpamplona.quartz.cordn.sync.EchoState +import com.vitorpamplona.quartz.cordn.sync.GroupCursor +import com.vitorpamplona.quartz.cordn.sync.PendingEpochOperation +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.codec.TlsWriter + +/** + * Local storage for cordn group state, keyed by the delivery `gid`. + * + * **Implementations MUST encrypt at rest.** The blobs are + * `MlsGroupState.encodeTls()` output: private keys and epoch secrets. + * + * Separate from Marmot's `MlsGroupStateStore` rather than shared, and the key + * is the reason. That interface is keyed on the Nostr group id — the MIP-01 `h` + * tag — which cordn has no equivalent of; cordn's key is the coordinator's + * delivery `gid`, which is opaque, is not the MLS `group_id`, and is not even + * unique across coordinators. The two look alike and mean different things, so + * one interface serving both would be an invitation to hand the wrong id to the + * wrong store and silently find no group. + * + * The cursor lives here too because it is worthless apart from the state it + * belongs to: a cursor restored against a group at a different epoch replays + * messages the group can no longer decrypt. + */ +interface CordnGroupStore { + suspend fun saveGroup( + gid: String, + state: ByteArray, + ) + + suspend fun loadGroup(gid: String): ByteArray? + + suspend fun deleteGroup(gid: String) + + /** Every `gid` with saved state, for restoring memberships at startup. */ + suspend fun listGroups(): List + + suspend fun saveCursor( + gid: String, + cursor: GroupCursor, + ) + + suspend fun loadCursor(gid: String): GroupCursor? + + /** + * Saves the record of which stream entries are this client's own. + * + * Beside the cursor because it is only meaningful with it, and durable for + * the same reason: posting does not advance the cursor (a lower one may + * still hold somebody else's unprocessed message), so the next run + * re-reads what this one posted. Without this it cannot tell that it is + * its own — a Commit sealed under an epoch key it has since left, a + * message from a ratchet generation already consumed — and reports a gap + * in its own conversation. See `EchoState`. + */ + suspend fun saveEchoState( + gid: String, + state: EchoState, + ) + + /** The saved echo bookkeeping for [gid], or an empty one. */ + suspend fun loadEchoState(gid: String): EchoState + + /** + * Records that admission to [gid] went through `join_request_store`. + * + * Per-group side state beside the cursor, and durable for the same reason: + * it describes something the coordinator will not forget. §8.1 — a join + * request names the asker's real npub against a specific group, so once it + * has happened the coordinator can tie this account to this group forever. + * Whether *we* still remember changes nothing about what it knows, which + * is why this outlives the session that did it rather than resetting to a + * cheerful default at every launch. + */ + suspend fun saveJoinedViaRequest(gid: String) + + /** Whether [gid] was admitted through a join request. */ + suspend fun loadJoinedViaRequest(gid: String): Boolean + + /** + * Saves the per-room state a screen needs but the protocol does not. + * + * An unsent draft and a read position are not MLS state and never leave + * the device — but they go through the same store, and so the same + * encryption, for one reason: a draft is the plaintext of a message that + * was about to be end-to-end encrypted. Writing it somewhere softer than + * the conversation it belongs to would make the composer the weakest point + * in the whole feature. + */ + suspend fun saveRoomState( + gid: String, + state: CordnRoomState, + ) + + /** The saved room state for [gid], or a blank one. */ + suspend fun loadRoomState(gid: String): CordnRoomState + + /** + * Records one delivered message, as the only copy there will ever be. + * + * Not a cache. Both of a cordn payload's seal keys are derived per epoch, + * so once the group ratchets forward the ciphertext the coordinator still + * holds cannot be opened again — and the cursor has advanced past it in any + * case, so it will not even be offered. Ingestion is the one moment the + * message is in the clear. + * + * MUST be idempotent on the envelope's id. A client that dies between this + * call and [saveCursor] re-fetches the message on the next catch-up, which + * is the crash window the append order is chosen to land in — see + * `commons/plans/2026-09-24-cordn-message-store.md`. + */ + suspend fun appendMessage( + gid: String, + message: CordnDeliveredMessage, + ) + + /** Every stored message for [gid], oldest first. Empty when there are none. */ + suspend fun loadMessages(gid: String): List + + /** + * The newest message and the count, without reading the whole log. + * + * The inbox needs a preview line for every room before any room is opened, + * and reading every group's history at login is what + * `CordnRuntime.restoreRoomState` deliberately avoids. This is the small + * read that makes the preview possible: one key per group, written on the + * same beat as [appendMessage]. + */ + suspend fun loadMessageSummary(gid: String): CordnMessageSummary? +} + +/** + * What the inbox needs to know about a room without opening it. + * + * @param newest the last message delivered, for the preview line. + * @param count how many are stored, for anything that wants to say "empty" and + * mean it rather than meaning "not loaded yet". + */ +data class CordnMessageSummary( + val newest: CordnDeliveredMessage, + val count: Int, +) + +/** + * What a cordn room remembers between visits. + * + * @param draft text typed and not sent. + * @param lastReadCursor the newest cursor this account has seen in the room. + * A cursor rather than a timestamp, because the coordinator's order is the + * only one every member agrees on (`spec/00.md` §4) and a sender's clock is + * a claim — the same reason the room itself sorts on it. + */ +data class CordnRoomState( + val draft: String = "", + val lastReadCursor: Long = 0L, +) { + val isBlank: Boolean get() = draft.isEmpty() && lastReadCursor == 0L +} + +/** + * The on-disk form of a [CordnMessageSummary]. + * + * The message itself rides as its own JSON entry rather than being taken apart + * into TLS fields, so there is exactly one definition of what a stored message + * looks like and the summary cannot drift from the log it summarises. + */ +object CordnMessageSummaryCodec { + const val VERSION = 1 + + fun encode( + newest: CordnDeliveredMessage, + count: Int, + ): ByteArray { + val writer = TlsWriter() + writer.putUint16(VERSION) + writer.putUint32(count.toLong()) + writer.putOpaque2(CordnDeliveredMessageCodec.encode(newest).encodeToByteArray()) + return writer.toByteArray() + } + + /** Null on anything unreadable: a preview line is not worth failing a login over. */ + fun decode(bytes: ByteArray): CordnMessageSummary? = + try { + val reader = TlsReader(bytes) + if (reader.readUint16() != VERSION) { + null + } else { + val count = reader.readUint32().toInt() + val newest = CordnDeliveredMessageCodec.decode(reader.readOpaque2().decodeToString()) + CordnMessageSummary(newest, count) + } + } catch (e: Exception) { + null + } +} + +/** The on-disk layout of a [CordnRoomState]. */ +object CordnRoomStateCodec { + const val VERSION = 1 + + fun encode(state: CordnRoomState): ByteArray { + val writer = TlsWriter() + writer.putUint16(VERSION) + writer.putOpaque2(state.draft.encodeToByteArray()) + writer.putUint64(state.lastReadCursor) + return writer.toByteArray() + } + + /** Blank on anything unreadable: a lost draft must not cost the room. */ + fun decode(bytes: ByteArray): CordnRoomState = + try { + val reader = TlsReader(bytes) + if (reader.readUint16() != VERSION) { + CordnRoomState() + } else { + CordnRoomState(reader.readOpaque2().decodeToString(), reader.readUint64()) + } + } catch (e: Exception) { + CordnRoomState() + } +} + +/** + * The on-disk layout of an [EchoState]. + * + * ``` + * version:u16 + * pending_commits: vector2 of { sealed:opaque2, applied:u8 } + * own_cursors: vector2 of u64 + * ``` + * + * Empty on anything unreadable, which is the safe direction: losing the record + * costs a client one run of reporting its own traffic as a gap, while + * accepting a half-decoded one could let it skip somebody else's message as + * though it were its own. + */ +object EchoStateCodec { + const val VERSION = 1 + + fun encode(state: EchoState): ByteArray { + val writer = TlsWriter() + writer.putUint16(VERSION) + + val commits = TlsWriter() + state.pendingCommits.forEach { + commits.putOpaque2(it.sealedBase64.encodeToByteArray()) + commits.putUint8(if (it.localStateApplied) 1 else 0) + } + writer.putOpaque2(commits.toByteArray()) + + val cursors = TlsWriter() + state.ownMessageCursors.forEach { cursors.putUint64(it) } + writer.putOpaque2(cursors.toByteArray()) + + return writer.toByteArray() + } + + fun decode(bytes: ByteArray): EchoState = + try { + val reader = TlsReader(bytes) + if (reader.readUint16() != VERSION) { + EchoState() + } else { + val commits = TlsReader(reader.readOpaque2()) + val pending = mutableListOf() + while (commits.hasRemaining) { + val sealed = commits.readOpaque2().decodeToString() + pending += PendingEpochOperation(sealed, commits.readUint8() == 1) + } + + val cursors = TlsReader(reader.readOpaque2()) + val own = mutableListOf() + while (cursors.hasRemaining) { + own += cursors.readUint64() + } + + EchoState(pending, own) + } + } catch (e: Exception) { + EchoState() + } +} + +/** A [CordnGroupStore] that keeps everything in memory. Tests, and nothing else. */ +class InMemoryCordnGroupStore : CordnGroupStore { + private val groups = mutableMapOf() + private val cursors = mutableMapOf() + private val viaRequest = mutableSetOf() + private val roomStates = mutableMapOf() + private val echoStates = mutableMapOf() + private val messages = mutableMapOf>() + + override suspend fun saveGroup( + gid: String, + state: ByteArray, + ) { + groups[gid] = state + } + + override suspend fun loadGroup(gid: String): ByteArray? = groups[gid] + + override suspend fun deleteGroup(gid: String) { + groups.remove(gid) + cursors.remove(gid) + viaRequest.remove(gid) + roomStates.remove(gid) + echoStates.remove(gid) + // Leaving a group that left its history behind would keep the plaintext + // of an end-to-end encrypted conversation on disk after the one thing + // that could read it was thrown away. + messages.remove(gid) + } + + override suspend fun listGroups(): List = groups.keys.toList() + + override suspend fun saveJoinedViaRequest(gid: String) { + viaRequest += gid + } + + override suspend fun loadJoinedViaRequest(gid: String): Boolean = gid in viaRequest + + override suspend fun saveRoomState( + gid: String, + state: CordnRoomState, + ) { + roomStates[gid] = state + } + + override suspend fun loadRoomState(gid: String): CordnRoomState = roomStates[gid] ?: CordnRoomState() + + override suspend fun appendMessage( + gid: String, + message: CordnDeliveredMessage, + ) { + val log = messages.getOrPut(gid) { mutableListOf() } + if (log.none { it.envelope.id == message.envelope.id }) log.add(message) + } + + override suspend fun loadMessages(gid: String): List = messages[gid]?.toList() ?: emptyList() + + override suspend fun loadMessageSummary(gid: String): CordnMessageSummary? = messages[gid]?.lastOrNull()?.let { CordnMessageSummary(it, messages[gid]?.size ?: 0) } + + override suspend fun saveEchoState( + gid: String, + state: EchoState, + ) { + echoStates[gid] = state + } + + override suspend fun loadEchoState(gid: String): EchoState = echoStates[gid] ?: EchoState() + + override suspend fun saveCursor( + gid: String, + cursor: GroupCursor, + ) { + cursors[gid] = cursor + } + + override suspend fun loadCursor(gid: String): GroupCursor? = cursors[gid] +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnHandoffState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnHandoffState.kt new file mode 100644 index 0000000000..da28d2a449 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnHandoffState.kt @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Whether this device has handed its cordn state to another one. + * + * ## The property that makes migration safe + * + * `multi-device.md` §10 has one unresolved case: two devices of an identity + * committing inside a single delivery round-trip reach epoch N+1 with different + * states, and §15 concedes *equal-epoch MLS states have no merge function*. The + * whole reason migration is buildable where continuous sync is not is that a + * handoff has one writer. + * + * That is only true if the old phone actually stops. Two phones holding one + * leaf and both committing fork the ratchet tree, and MLS does not recover — + * after the fork every message silently fails to decrypt for somebody. So the + * handoff is not finished when the code is scanned; it is finished when this + * device has stood down, and [handedOff] is what says so. + * + * ## Why it is reversible + * + * A migration can fail after the export: the other phone is out of battery, + * the QR does not scan, the user changes their mind. Locking irreversibly + * would strand an account on a device that still holds the only copy of its + * state. [resume] exists for that, and is safe exactly while the new device + * has not started committing — which is why the UI asks rather than assumes. + * + * ## What it does not do + * + * It does not tell the coordinator anything, and it does not delete anything. + * The state stays on disk so [resume] can work, and so a user who migrated by + * mistake has not lost their groups. + */ +class CordnHandoffState( + private val store: CordnHandoffStore, +) { + private val _handedOff = MutableStateFlow(false) + + /** True once this device has exported and stood down. */ + val handedOff: StateFlow = _handedOff.asStateFlow() + + /** Reads the flag off disk. Call before the first sync loop starts. */ + suspend fun restore() { + _handedOff.value = store.load() + } + + /** + * Records that this device has handed off. + * + * The caller is responsible for having stopped the sync loops first: this + * records a decision, it does not enforce one. + */ + suspend fun markHandedOff() { + store.save(true) + _handedOff.value = true + } + + /** Takes the device back, for a migration that did not complete. */ + suspend fun resume() { + store.save(false) + _handedOff.value = false + } + + /** + * Throws if this device has handed off. + * + * Called on every path that would advance an epoch. A read is still + * allowed: showing the user the conversations they had is harmless, and + * refusing it would make a failed migration look like data loss. + */ + fun requireNotHandedOff() { + if (_handedOff.value) { + throw CordnHandedOffException( + "this device handed its cordn groups to another one; " + + "sending from both would fork the group state", + ) + } + } +} + +/** An attempt to write from a device that has handed off. */ +class CordnHandedOffException( + message: String, +) : Exception(message) + +/** Where the handoff flag lives between runs. */ +interface CordnHandoffStore { + suspend fun load(): Boolean + + suspend fun save(handedOff: Boolean) +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnKeyPackages.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnKeyPackages.kt new file mode 100644 index 0000000000..f86800ce4d --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnKeyPackages.kt @@ -0,0 +1,396 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn + +import com.vitorpamplona.quartz.cordn.groups.CordnCredential +import com.vitorpamplona.quartz.cordn.groups.CordnGroupPolicy +import com.vitorpamplona.quartz.cordn.spec00Coordinator.AvailableKeyPackage +import com.vitorpamplona.quartz.cordn.spec00Coordinator.ICoordinator +import com.vitorpamplona.quartz.mls.group.MlsGroup +import com.vitorpamplona.quartz.mls.messages.KeyPackageBundle +import com.vitorpamplona.quartz.mls.messages.KeyPackageBundleCodec +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlin.coroutines.cancellation.CancellationException +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +/** + * The private halves of the KeyPackages this account has published. + * + * **Implementations MUST encrypt at rest.** A bundle is key material: whoever + * holds it can open the Welcome that admits its owner to a group. + * + * Separate from Marmot's `KeyPackageBundleStore` and not a refactor of it. That + * one persists a single snapshot carrying Marmot's rotation state alongside the + * bundles; cordn has no rotation state, no kind-443 event, and no publish + * obligations — its KeyPackages live entirely in coordinator calls + * (`spec/00.md` §4.2). The shapes differ because the protocols do. + */ +interface CordnKeyPackageStore { + suspend fun save( + keyPackageRef: String, + bundle: ByteArray, + ) + + suspend fun load(keyPackageRef: String): ByteArray? + + suspend fun delete(keyPackageRef: String) + + /** Every ref with a stored private half. */ + suspend fun list(): List +} + +/** In-memory [CordnKeyPackageStore]. Tests, and nothing else. */ +class InMemoryCordnKeyPackageStore : CordnKeyPackageStore { + private val bundles = mutableMapOf() + + override suspend fun save( + keyPackageRef: String, + bundle: ByteArray, + ) { + bundles[keyPackageRef] = bundle + } + + override suspend fun load(keyPackageRef: String): ByteArray? = bundles[keyPackageRef] + + override suspend fun delete(keyPackageRef: String) { + bundles.remove(keyPackageRef) + } + + override suspend fun list(): List = bundles.keys.toList() +} + +/** + * Publishing KeyPackages so other people can add this account to groups. + * + * ## Why this is not Marmot's rotation manager with the names changed + * + * Marmot publishes a KeyPackage as a kind-443 event to relays: a real Nostr + * event, replaceable, discoverable, with its own relay list and its own publish + * obligations. cordn has **no KeyPackage event kind at all** (`spec/00.md` + * §4.2). Publishing is a `kp_publish` call, and the "signed publication + * payload" §7 requires is the ContextVM request event the transport happens to + * sign — which the coordinator stores and serves back verbatim. There is no + * relay copy, nothing to rotate against, and nothing to re-publish if a relay + * forgets. None of Marmot's machinery transfers; all of it would be wrong here. + * + * ## `kp_ref` is the RFC 9420 KeyPackageRef and nothing else + * + * It is the coordinator's primary key for a KeyPackage and the address a + * Welcome is delivered to. Two implementations that compute it differently do + * not fail loudly: every `kp_take` misses, every Welcome lands at an address + * nobody is listening on, and the group simply never forms. This class owns the + * computation so no caller invents one — [MlsKeyPackage.reference] is checked + * against ts-mls's in `CordnLifecycleInteropTest`. + */ +@OptIn(ExperimentalEncodingApi::class) +class CordnKeyPackages( + private val accountPubKey: HexKey, + private val coordinator: ICoordinator, + private val store: CordnKeyPackageStore, +) { + private val _published = MutableStateFlow>(emptySet()) + + /** Refs this account has published and still holds the private half for. */ + val published: StateFlow> = _published.asStateFlow() + + /** One published KeyPackage, as this account sees it. */ + data class Published( + val keyPackageRef: String, + val lastResort: Boolean, + val at: Long, + ) + + private val snapshotLock = Mutex() + private var snapshot: Snapshot? = null + + /** + * What `kp_list` reported, the last time we asked. + * + * `kp_list` takes no arguments and returns every KeyPackage the coordinator + * holds for **every** identity (`spec/00.md` leaves retrieval scoping to the + * coordinator; the reference implementation answers with the whole table). + * So one call answers a question about anybody, and asking it once per + * person would be the same download repeated. + * + * [identities] keeps only pubkeys, not the entries. Another account's + * `kp_ref` is useless to us -- a Welcome is addressed to a ref we obtained + * by *taking* the package, never to one we read out of a listing -- and on + * a busy coordinator those refs are the bulk of the response. Dropping them + * bounds what we retain even though nothing bounds what arrives. + */ + class Snapshot( + /** Every identity the coordinator holds at least one KeyPackage for. */ + val identities: Set, + /** The full entries for this account, which are the ones we act on. */ + val mine: List, + /** When this was fetched, for [snapshot]'s age check. */ + val at: Long, + ) + + /** + * Refetches unconditionally and replaces the snapshot. + * + * Every decision about whether to *publish* reads through here rather than + * through [snapshot]. Minting against a stale listing is the one place + * staleness does damage: the coordinator keeps a single last-resort package + * per identity, so publishing a second silently evicts the first and + * strands every invite that referenced it. + */ + suspend fun refresh(): Snapshot = snapshotLock.withLock { fetchLocked() } + + /** + * The snapshot, refetched if it is older than [maxAgeSeconds]. + * + * For reads that only *describe* what the coordinator holds. A minute of + * staleness costs at most a missing badge for somebody who published within + * it, and the act itself still fails loudly at the coordinator if the + * listing was wrong -- whereas every refresh is another full-table + * download, so asking less often is the cheap side of the trade. + */ + suspend fun snapshot(maxAgeSeconds: Long = SNAPSHOT_TTL_SECONDS): Snapshot = + snapshotLock.withLock { + snapshot?.takeIf { TimeUtils.now() - it.at < maxAgeSeconds } ?: fetchLocked() + } + + /** + * Which identities the coordinator can deliver a Welcome to, or null. + * + * Null means **we do not know** -- the coordinator is unreachable, throttled + * us, or answered with more than the transport admits. It does not mean + * nobody has published. Callers must keep those apart: rendering a failed + * lookup as "this person cannot be added" states something about a person + * on the strength of a network error. + */ + suspend fun identitiesWithKeyPackages(): Set? = + try { + snapshot().identities + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + null + } + + /** Must hold [snapshotLock]. */ + private suspend fun fetchLocked(): Snapshot { + val all = coordinator.listKeyPackages() + return Snapshot( + identities = all.mapTo(mutableSetOf()) { it.pubKey }, + mine = all.filter { it.pubKey == accountPubKey }, + at = TimeUtils.now(), + ).also { snapshot = it } + } + + /** Forgets the snapshot after we changed what the coordinator holds. */ + private suspend fun invalidate() = snapshotLock.withLock { snapshot = null } + + /** Reloads which refs we hold private halves for. Call at startup. */ + suspend fun restore() { + _published.value = store.list().toSet() + } + + /** + * Whether this account has a KeyPackage published on this coordinator. + * + * Read from the store rather than from [published], so it is right before + * [restore] has run and right after a relaunch. It answers the §8.4 half + * of the exposure disclosure, which is about what the coordinator holds — + * not about what this process has done since it started. + * + * A private half with no published counterpart is possible (the publish + * failed and the store was not cleaned), and reporting that as "published" + * overstates the exposure by one KeyPackage rather than understating it by + * all of them — the direction a privacy surface should err in. + */ + suspend fun hasPublished(): Boolean = store.list().isNotEmpty() + + /** + * What the coordinator currently serves for this account. + * + * The coordinator's answer, not ours. `kp_take` consumes a single-use + * package, so our own record of what we published drifts upward from what + * is actually available the moment anyone invites us — and it is what is + * available that decides whether the next invitation can happen. + */ + suspend fun listPublished(): List = refresh().mine + + /** + * Generates a KeyPackage, keeps its private half, and publishes it. + * + * The private half is stored **before** the call, not after: a publish that + * succeeds on the coordinator and then fails locally would leave a + * KeyPackage other people can use to invite an account that can no longer + * open the Welcome. Storing first makes the failure the harmless direction — + * an unused bundle on disk. + * + * @param lastResort marks it reusable. `spec/00.md` lets a last-resort + * package back several Welcomes, which is why a Welcome record is keyed + * by `(kp_ref, at)` rather than by ref alone. + */ + suspend fun publishNew(lastResort: Boolean = false): Published { + val bundle = generate(lastResort) + val ref = refOf(bundle) + + store.save(ref, KeyPackageBundleCodec.encode(bundle)) + _published.value = _published.value + ref + + val result = + try { + coordinator.publishKeyPackage(ref, Base64.encode(bundle.keyPackage.toTlsBytes())) + } catch (e: Exception) { + // The coordinator never took it, so nobody can invite us with + // it. Drop the local half rather than accumulate bundles that + // will never be used. + store.delete(ref) + _published.value = _published.value - ref + throw e + } + + invalidate() + return Published(result.keyPackageRef, result.lastResort || lastResort, result.at) + } + + /** + * The private half for [keyPackageRef], if this device published it. + * + * Hand this to [CordnGroupManager.joinPendingWelcomes]. Null is ordinary and + * not an error: a Welcome addressed to a KeyPackage another device of the + * same account published belongs to that device, and draining it here would + * destroy it. + */ + suspend fun bundleFor(keyPackageRef: String): KeyPackageBundle? = + store.load(keyPackageRef)?.let { + try { + KeyPackageBundleCodec.decode(it) + } catch (e: IllegalArgumentException) { + // A bundle we cannot read is a bundle we cannot use. Reporting + // null sends the Welcome back to the inbox, where another + // device — or a later version of this one — may manage it. + null + } + } + + /** + * Withdraws [keyPackageRefs] from the coordinator and forgets their halves. + * + * Order matters the other way here: the coordinator is told first, because + * deleting locally while the coordinator still serves the package is the + * bad direction — somebody invites us and we cannot open the Welcome. + */ + suspend fun withdraw(keyPackageRefs: List): List { + if (keyPackageRefs.isEmpty()) return emptyList() + val removed = coordinator.removeKeyPackages(keyPackageRefs) + removed.forEach { store.delete(it) } + _published.value = _published.value - removed.toSet() + invalidate() + return removed + } + + /** + * Tops up to [minimum] single-use KeyPackages on the coordinator. + * + * Counts what the coordinator actually holds for this account rather than + * what we think we published: `kp_take` consumes a single-use package, so + * the coordinator's count falls as people invite us and ours does not. + * + * @return the refs published by this call. + */ + suspend fun topUp(minimum: Int = DEFAULT_POOL): List { + require(minimum >= 0) { "a KeyPackage pool cannot be negative" } + val theirs = refresh().mine.filter { !it.lastResort } + val missing = minimum - theirs.size + if (missing <= 0) return emptyList() + return List(missing) { publishNew().keyPackageRef } + } + + /** + * Ensures exactly one last-resort KeyPackage is published. + * + * Its job is that an invite can always be made, even after the single-use + * pool is drained. More than one is not better — each is an extra reusable + * package, and `spec/00.md` treats a last-resort package as the fallback, + * not the norm. + */ + suspend fun ensureLastResort(): String? { + val existing = refresh().mine.filter { it.lastResort } + // Only one, and only one we can still open: a last-resort package whose + // private half this device never had is useless to it. + val usable = existing.firstOrNull { store.load(it.keyPackageRef) != null } + if (usable != null) return usable.keyPackageRef + return publishNew(lastResort = true).keyPackageRef + } + + /** A fresh bundle carrying this account's cordn credential. */ + private fun generate(lastResort: Boolean): KeyPackageBundle { + // A scratch group only to reach the engine's KeyPackage builder; it is + // never joined, committed to, or stored. + val identity = CordnCredential.of(accountPubKey).identity + val scratch = MlsGroup.create(identity, policy = CordnGroupPolicy) + return scratch.createKeyPackage( + identity = identity, + // Ignored by the engine, which generates the leaf signature keypair + // itself and signs with that. Every caller in the tree passes empty + // for the same reason; it is a dead parameter, not a key we are + // failing to supply. + signingKey = ByteArray(0), + keyPackageExtensions = if (lastResort) listOf(CordnGroupPolicy.lastResortExtension()) else emptyList(), + // A last-resort package carries its marker inside app_data_dictionary + // (0x0006), so its leaf has to say it understands that carrier. + // CordnGroupPolicy has had the right set for this all along and + // nothing was calling it, which left every last-resort KeyPackage + // advertising one type short of what it used. + capabilities = + if (lastResort) CordnGroupPolicy.lastResortLeafCapabilities() else CordnGroupPolicy.defaultLeafCapabilities, + ) + } + + /** The RFC 9420 KeyPackageRef, hex. See the class KDoc. */ + fun refOf(bundle: KeyPackageBundle): String = bundle.keyPackage.reference().toHexKey() + + companion object { + /** + * How many single-use KeyPackages to keep available. + * + * Each one is one invite somebody can make without falling back to the + * reusable package. Small because they cost a round trip each and the + * last-resort package is the safety net. + */ + const val DEFAULT_POOL = 5 + + /** + * How long a [Snapshot] may be reused by [snapshot]. + * + * Bounded by what a refresh costs rather than by how fast the answer + * changes: `kp_list` is unpaginated, so each one downloads the + * coordinator's whole table. A minute is long enough that opening a + * screen repeatedly costs one call and short enough that somebody who + * has just published becomes visible while you are still looking. + */ + const val SNAPSHOT_TTL_SECONDS = 60L + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnLinkInspection.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnLinkInspection.kt new file mode 100644 index 0000000000..6d8cd6fbb3 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnLinkInspection.kt @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn + +import com.vitorpamplona.quartz.cordn.appGroupRef.CordnGroupRef + +/** + * What a pasted `cordn1…` link turns out to be. + * + * A group ref is the one cordn artifact a person handles directly — it arrives + * in a chat message or a QR code — and it is also the moment where §8 matters: + * before joining, not after. So the parse and the disclosure are computed + * together here, in headless code a test can drive, and the screen only draws + * the result. + */ +sealed interface CordnLinkInspection { + /** Not a cordn link, with the rule it broke, in the decoder's own words. */ + data class Invalid( + val reason: String, + ) : CordnLinkInspection + + /** + * A well-formed link. + * + * [coordinator] and [exposure] are null together, and legitimately so: + * `spec/applications/group-ref.md` §2 makes the coordinator optional, and a + * ref carrying only a `gid` names no operator — so there is no one to + * disclose anything about, and pretending otherwise would be inventing a + * threat model for a server we cannot identify. + */ + data class Valid( + val ref: CordnGroupRef, + val coordinator: CoordinatorConfig?, + val exposure: GroupExposure?, + ) : CordnLinkInspection { + /** Whether this link can be acted on without asking the sender for more. */ + val isFollowable: Boolean get() = coordinator != null + } + + companion object { + /** + * Inspects [input]. + * + * @param existingGroupsOnCoordinator how many groups this account + * already has on that coordinator, so §8.2's linkage count describes + * what joining would actually create rather than a hypothetical. The + * default assumes none, which is the conservative reading — it + * under-reports linkage rather than inventing it. + */ + fun of( + input: String, + existingGroupsOnCoordinator: Int = 0, + publishedKeyPackage: Boolean = false, + ): CordnLinkInspection { + val trimmed = input.trim() + if (trimmed.isEmpty()) return Invalid("empty") + + val ref = + try { + CordnGroupRef.decode(trimmed) + } catch (e: IllegalArgumentException) { + return Invalid(e.message ?: "not a cordn group reference") + } + + val coordinator = CoordinatorConfig.from(ref) + return Valid( + ref = ref, + coordinator = coordinator, + exposure = + coordinator?.let { + GroupExposure( + coordinator = it.pubKey, + // The group being inspected is the one that would be added. + linkedGroupCount = existingGroupsOnCoordinator + 1, + // A link is how a stranger joins, which is exactly the + // `join_request_store` path §8.1 describes. + joinedFromShareLink = true, + publishedKeyPackage = publishedKeyPackage, + encryptionPinned = true, + ) + }, + ) + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnMentions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnMentions.kt new file mode 100644 index 0000000000..817cc8e818 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnMentions.kt @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull + +/** + * Splitting a cordn message into the parts a screen renders differently. + * + * ## Why cordn needs its own, small version of this + * + * Amethyst's rich-text rendering is built on `Note`, and a `Note` comes from + * `LocalCache`. Nothing cordn receives may go in there — a cordn message is an + * MLS payload that never touched a relay, and giving it a `Note` would make + * private group chat searchable and notifiable alongside published events. + * So the parsing happens here, on the envelope's own content, and the result + * is plain data a composable can walk. + * + * Resolving a mentioned pubkey to a **display name** is a different question + * and a safe one: a profile is public relay data that the cache holds anyway, + * and reading one puts nothing of the conversation into it. The rule is about + * what goes in, not what is looked up. + * + * ## Scope + * + * Mentions only — `nostr:npub1…` and `nostr:nprofile1…`. Not URLs, not + * hashtags, not embedded events. Each of those renders as something, and + * something that renders is something that can leak: an auto-loaded preview + * would fetch a URL the moment a private message arrived, telling its host + * that a specific person opened a specific message. That belongs with the + * media work, behind the same decisions, not smuggled in with mentions. + */ +object CordnMentions { + /** One run of a message: either text to print, or a person to name. */ + sealed interface Segment { + data class Text( + val value: String, + ) : Segment + + data class Mention( + val pubKey: HexKey, + /** The exact text matched, for a renderer that wants to fall back to it. */ + val raw: String, + ) : Segment + } + + /** + * Splits [content] into text and mention runs, in order. + * + * Concatenating every segment's original text reproduces [content] exactly + * — there is a test for that. A renderer that drops a segment type would + * otherwise silently eat part of someone's message, which is worse than + * not rendering mentions at all. + */ + fun segment(content: String): List { + if (content.isEmpty()) return emptyList() + + val out = mutableListOf() + var last = 0 + + MENTION.findAll(content).forEach { match -> + val pubKey = decodePublicKeyAsHexOrNull(match.value.removePrefix(NOSTR_PREFIX)) ?: return@forEach + + if (match.range.first > last) { + out += Segment.Text(content.substring(last, match.range.first)) + } + out += Segment.Mention(pubKey, match.value) + last = match.range.last + 1 + } + + if (last < content.length) out += Segment.Text(content.substring(last)) + return out + } + + /** Every account mentioned in [content], deduplicated, in first-seen order. */ + fun mentioned(content: String): List = + segment(content) + .filterIsInstance() + .map { it.pubKey } + .distinct() + + private const val NOSTR_PREFIX = "nostr:" + + /** + * `nostr:` is required, unlike NIP-19's looser scan. + * + * A bare `npub1…` in the middle of a sentence is as likely to be someone + * quoting a key as mentioning a person, and turning it into a name changes + * what they wrote. The bech32 alphabet excludes `1`, `b`, `i` and `o`, + * which is what keeps the match from running past the entity into ordinary + * words. + */ + private val MENTION = Regex("nostr:(npub1|nprofile1)[qpzry9x8gf2tvdw0s3jn54khce6mua7l]+") +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnMigration.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnMigration.kt new file mode 100644 index 0000000000..c2e5b73ef0 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnMigration.kt @@ -0,0 +1,341 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn + +import com.vitorpamplona.quartz.cordn.appMultiDevice.CordnCarriedKeyPackage +import com.vitorpamplona.quartz.cordn.appMultiDevice.CordnDeviceTip +import com.vitorpamplona.quartz.cordn.appMultiDevice.CordnDocumentException +import com.vitorpamplona.quartz.cordn.appMultiDevice.CordnDocumentSeal +import com.vitorpamplona.quartz.cordn.appMultiDevice.CordnGroupDocument +import com.vitorpamplona.quartz.cordn.appMultiDevice.CordnHandoffCode +import com.vitorpamplona.quartz.cordn.appMultiDevice.CordnLastResortKeyPackage +import com.vitorpamplona.quartz.cordn.appMultiDevice.CordnMetaDocument +import com.vitorpamplona.quartz.cordn.appMultiDevice.CordnTipEntry +import com.vitorpamplona.quartz.cordn.appMultiDevice.CordnTipInventory +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.crypto.verify +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirm +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.utils.RandomInstance +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * Moving one account's cordn state from the phone it is on to a new one. + * + * ## Why this is safe when continuous sync is not + * + * `multi-device.md` §10 leaves one case unresolved: two devices of one + * identity committing inside a single delivery round-trip both reach epoch N+1 + * with different states, and §15 concedes that *equal-epoch MLS states have no + * merge function*. That race needs two live writers. A handoff has one — the + * old phone writes a snapshot and then stops (see [CordnMigrationSnapshot] and + * the handoff lock) — so the race is out of reach by construction rather than + * by mitigation. + * + * Everything here is therefore §9 seeding plus the §6/§7 transport. There are + * no `prev` chains (§8.5), no sibling-Commit convergence (§10) and no + * publish-on-every-Commit (§10.5): each of those exists to keep two *live* + * devices in step. + * + * ## The honest cost + * + * The sealed documents leave the device. They are NIP-44 v2 ciphertext under a + * DEK reachable only through a seal to the owner's own `npub`, so a storage + * server learns nothing but size and timing — but the group state, including + * leaf private keys, is on someone else's disk until the blobs are deleted. + * That is the trade the tip transport makes, and a UI should say so rather + * than let a user discover it. + */ +class CordnMigration( + private val client: INostrClient, + private val signer: NostrSigner, + private val blobs: CordnBlobStore, +) { + /** + * Seals, uploads and advertises [snapshot]; returns the code to scan. + * + * The ephemeral keypair and the `d` value are both minted here and both + * random. §6 forbids deriving the signing key from the owner identity: a + * public derivation would let anyone compute the tip's author from an + * `npub` and go looking for it, which is the linkage the opaque tip exists + * to prevent. + * + * @param relays where to publish the tip. + * @throws CordnMigrationException when nothing could be stored or published. + */ + suspend fun publish( + snapshot: CordnMigrationSnapshot, + relays: Set, + ): CordnHandoffCode { + require(relays.isNotEmpty()) { "a migration needs at least one relay to publish the tip on" } + + val dek = CordnDocumentSeal.newKey() + val issuedAt = TimeUtils.now() * MILLIS_PER_SECOND + + val hosts = mutableSetOf() + + val groupEntries = + snapshot.groups.map { group -> + val document = + CordnGroupDocument( + gid = group.gid, + coordinator = group.coordinatorPubKey, + clientState = group.clientStateBase64, + cursor = group.cursor, + issuedAt = issuedAt, + roomState = group.roomStateBase64, + echoState = group.echoStateBase64, + joinedViaRequest = group.joinedViaRequest, + messages = group.messages.takeIf { it.isNotEmpty() }, + coordinatorRelays = group.coordinatorRelays, + ) + val blob = CordnDocumentSeal.seal(document, dek) + val servers = blobs.put(blob) + if (servers.isEmpty()) { + throw CordnMigrationException("no server accepted the document for group ${group.gid}") + } + hosts += servers + CordnTipEntry(address = CordnDocumentSeal.address(blob), gid = group.gid) + } + + val metaBlob = + CordnDocumentSeal.seal( + CordnMetaDocument( + lastResortKeyPackage = snapshot.lastResortKeyPackage, + keyPackages = snapshot.keyPackages, + issuedAt = issuedAt, + ), + dek, + ) + val metaServers = blobs.put(metaBlob) + if (metaServers.isEmpty()) throw CordnMigrationException("no server accepted the meta document") + hosts += metaServers + + val inventory = + CordnTipInventory( + groups = groupEntries, + meta = CordnDocumentSeal.address(metaBlob), + dekPrivateKey = dek.privKey!!.toHexString(), + servers = hosts.toList(), + ) + + val ephemeral = KeyPair() + val dTag = RandomInstance.randomChars(D_TAG_LENGTH) + + val inner = signer.signInner(inventory) + val sealedInner = signer.nip44Encrypt(inner.toJson(), signer.pubKey) + + val outer = + NostrSignerInternal(ephemeral).sign( + createdAt = TimeUtils.now(), + kind = CordnDeviceTip.OUTER_KIND, + tags = arrayOf(arrayOf(CordnDeviceTip.TAG_D, dTag)), + content = sealedInner, + ) + + if (!client.publishAndConfirm(outer, relays)) { + throw CordnMigrationException("no relay accepted the tip") + } + + return CordnHandoffCode( + ephemeralPubKey = ephemeral.pubKey.toHexString(), + dTag = dTag, + relays = relays.map { it.url }, + ) + } + + /** + * Reads back what [code] points at. + * + * The outer event is signed by a key anyone holding the code could hold, so + * it establishes nothing. Confidentiality and authorship both come from the + * seal: the content is NIP-44 to the owner's own pubkey, and only the owner + * can compute that conversation key — so an outsider can neither read the + * tip nor write one the owner will read. + * + * The inner signature check on top of that is defence in depth against a + * writer-side bug rather than an outsider: adopting MLS state whose + * credential names another account would leave the new phone making Commits + * the rest of every group rejects. Each blob's address is checked too, and + * before decryption, because that one IS an outsider's opening — the + * storage server chooses the bytes. + */ + suspend fun fetch(code: CordnHandoffCode): CordnMigrationSnapshot { + val relays = code.relays.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }.toSet() + if (relays.isEmpty()) throw CordnMigrationException("the handoff code names no usable relay") + + val outer = + client.fetchFirst( + filters = + relays.associateWith { + listOf( + Filter( + kinds = listOf(code.kind), + authors = listOf(code.ephemeralPubKey), + tags = mapOf(CordnDeviceTip.TAG_D to listOf(code.dTag)), + limit = 1, + ), + ) + }, + ) ?: throw CordnMigrationException("no relay had a tip for this code") + + val innerJson = + try { + signer.nip44Decrypt(outer.content, signer.pubKey) + } catch (e: Exception) { + throw CordnMigrationException("the tip did not decrypt — is this the same account?") + } + + val inner = + try { + Event.fromJson(innerJson) + } catch (e: Exception) { + throw CordnMigrationException("the tip's inner event is not readable: ${e.message}") + } + + // The authenticity root. Without it, anyone holding the code could + // repoint the tip at an inventory of their own choosing. + if (inner.pubKey != signer.pubKey) { + throw CordnMigrationException("the tip was signed by a different account") + } + if (!inner.verify()) { + throw CordnMigrationException("the tip's inner signature does not verify") + } + + val inventory = CordnDeviceTip.parse(inner) + val dek = KeyPair(privKey = inventory.dekPrivateKey.hexToByteArray()) + + val groups = + inventory.groups.map { entry -> + val document = fetchDocument(entry.address, inventory.servers, dek) + if (document !is CordnGroupDocument) { + throw CordnMigrationException("the document for ${entry.gid} is not a group document") + } + if (!document.isReadableHere) { + throw CordnMigrationException( + "group ${entry.gid} was written by a different MLS engine " + + "(${document.clientStateFormat ?: "unmarked"}) and cannot be read here", + ) + } + CordnMigrationGroup( + coordinatorPubKey = document.coordinator, + coordinatorRelays = document.coordinatorRelays, + gid = document.gid, + clientStateBase64 = document.clientState, + cursor = document.cursor, + roomStateBase64 = document.roomState, + echoStateBase64 = document.echoState, + joinedViaRequest = document.joinedViaRequest, + messages = document.messages.orEmpty(), + ) + } + + val meta = + inventory.meta?.let { + fetchDocument(it, inventory.servers, dek) as? CordnMetaDocument + ?: throw CordnMigrationException("the meta address does not hold a meta document") + } + + return CordnMigrationSnapshot( + accountPubKey = signer.pubKey, + groups = groups, + lastResortKeyPackage = meta?.lastResortKeyPackage, + keyPackages = meta?.keyPackages.orEmpty(), + ) + } + + private suspend fun fetchDocument( + address: String, + servers: List, + dek: KeyPair, + ) = run { + val blob = + blobs.get(address, servers) + ?: throw CordnMigrationException("no server served the document at $address") + + // §6: check the address BEFORE decrypting, so a store serving the + // wrong bytes never gets its plaintext in front of the parser. + if (!CordnDocumentSeal.verifyAddress(blob, address)) { + throw CordnMigrationException("the blob at $address does not hash to its address") + } + + try { + CordnDocumentSeal.open(blob, dek) + } catch (e: CordnDocumentException) { + throw CordnMigrationException("the document at $address is unreadable: ${e.message}") + } + } + + private suspend fun NostrSigner.signInner(inventory: CordnTipInventory): Event = + sign( + createdAt = TimeUtils.now(), + kind = CordnDeviceTip.INNER_KIND, + tags = CordnDeviceTip.tags(inventory), + content = "", + ) + + companion object { + private const val D_TAG_LENGTH = 16 + private const val MILLIS_PER_SECOND = 1000L + } +} + +/** A migration that could not be completed, with the step that stopped it. */ +class CordnMigrationException( + message: String, +) : Exception(message) + +/** + * Everything a replacement device needs, read from disk rather than memory. + * + * Reading from the stores is deliberate and copied from `exportArchive`: a + * coordinator whose session failed to open today is still migrated, because a + * handoff that silently omitted the groups the app could not reach would be + * wrong precisely when it matters. + */ +data class CordnMigrationSnapshot( + val accountPubKey: HexKey, + val groups: List, + val lastResortKeyPackage: CordnLastResortKeyPackage? = null, + val keyPackages: List = emptyList(), +) + +/** One group in a migration. All blobs are base64 of their on-disk encoding. */ +data class CordnMigrationGroup( + val coordinatorPubKey: HexKey, + val coordinatorRelays: List, + val gid: String, + val clientStateBase64: String, + val cursor: Long, + val roomStateBase64: String? = null, + val echoStateBase64: String? = null, + val joinedViaRequest: Boolean = false, + /** The conversation, as `CordnDeliveredMessageCodec` entries, oldest first. */ + val messages: List = emptyList(), +) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnSyncLoop.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnSyncLoop.kt new file mode 100644 index 0000000000..29c7313912 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnSyncLoop.kt @@ -0,0 +1,281 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.async +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.selects.select + +/** + * The three things a sync loop needs from a group manager. + * + * [CordnGroupManager] implements it and is the only production implementation; + * the interface exists so the loop can be tested for what it actually does. + * Everything interesting about it — not spinning on an empty account, not + * dying on a coordinator outage, resetting its backoff, re-subscribing when a + * group is joined — is a reaction to a coordinator that is slow, failing or + * changing underfoot. Driving those through a real transport would mean + * provoking a network failure on a schedule, and the timing test that resulted + * would be the flakiest thing in the suite. + */ +interface CordnSyncSource { + /** The groups to sync. The loop re-subscribes when this changes. */ + val gids: StateFlow> + + suspend fun catchUp(onDelivery: (CordnGroupManager.Delivery) -> Unit): Int + + /** Suspends until the coordinator closes the stream. */ + suspend fun subscribe( + timeoutMs: Long, + onDelivery: (CordnGroupManager.Delivery) -> Unit, + ) +} + +/** + * Keeps one coordinator's groups up to date for as long as it runs. + * + * The shape is `spec/02.md`'s: drain history with `catch_up`, then hold a live + * `subscribe`, and when that stream closes, do it again. The cursor makes the + * seam safe — a subscription resumes from where the catch-up stopped, so the + * two phases cannot leave a hole between them. + * + * ## What this class is actually for + * + * `catchUp` and `subscribe` are one call each; wrapping them would be pointless + * if the happy path were the whole story. It is not. Three things make an + * unattended loop different from a call: + * + * - **A failure must not end the loop.** A coordinator that is down for a + * minute is ordinary. A loop that propagates that exception stops syncing for + * the rest of the session and looks, from the UI, exactly like a quiet group. + * So every attempt is caught, recorded on [CoordinatorHealth], and retried + * with a backoff that **resets on success** — without the reset, one bad + * stretch leaves the loop at its maximum delay forever. + * - **An account with no groups must not spin.** Both calls return immediately + * when the manager holds nothing, so the obvious `while (true)` burns a core + * on a brand-new account. This one parks on [CordnGroupManager.gids] until + * there is something to sync. + * - **A group joined mid-subscription must not wait.** The subscription is + * opened for a fixed set of `gid`s, so a group joined a moment later is not + * in it. Rather than leave the user staring at an empty room until the + * stream times out, a change to the group set cancels the subscription and + * re-opens it. + * + * Nothing here is Marmot's sync. Marmot subscribes to relays by filter and lets + * the relay decide what to send; cordn asks one coordinator for one group's + * stream from one cursor. They are different enough that a shared loop would be + * a conditional, not an abstraction. + */ +class CordnSyncLoop( + private val source: CordnSyncSource, + /** + * Where deliveries go. + * + * A callback rather than a `SharedFlow`, deliberately: a flow with no + * replay drops what it emits when nothing is collecting, and "the app was + * backgrounded so those messages are gone" is not a thing a chat client may + * do. The caller decides what durable place these land in. + */ + private val onDelivery: (CordnGroupManager.Delivery) -> Unit, + private val subscribeTimeoutMs: Long = DEFAULT_SUBSCRIBE_TIMEOUT_MS, + private val minBackoffMs: Long = DEFAULT_MIN_BACKOFF_MS, + private val maxBackoffMs: Long = DEFAULT_MAX_BACKOFF_MS, +) { + private var job: Job? = null + + private val _state = MutableStateFlow(State.Stopped) + + /** What the loop is doing, for the UI to show without inventing it. */ + val state: StateFlow = _state.asStateFlow() + + sealed interface State { + data object Stopped : State + + /** Nothing to sync: this account holds no groups on this coordinator. */ + data object NoGroups : State + + data object CatchingUp : State + + /** A subscription is open. */ + data object Live : State + + /** + * The last attempt failed and the next is [inMs] away. + * + * Carries the reason because the alternative is a UI that can only say + * "not syncing", which is the same thing it says when the loop is fine + * and the group is quiet. + */ + data class Retrying( + val attempt: Int, + val inMs: Long, + val reason: String?, + ) : State + } + + /** + * Starts the loop in [scope]; a second call while it runs does nothing. + * + * Two loops on one session would both `catch_up` the same groups and both + * feed [onDelivery], so every message would surface twice. + */ + fun start(scope: CoroutineScope) { + if (job?.isActive == true) return + job = scope.launch { run() } + } + + /** + * Stops the loop and waits for it to actually be stopped. Safe to call + * when it is not running. + * + * `cancel()` alone returns while the loop's `finally` blocks are still + * running, and one of those persists the group state a subscription + * ingested. `CordnRuntime.importArchive` stops the loops, deletes the + * account's directory and restores from the archive — so a persist that + * landed a moment late wrote a group back onto disk after the delete, and + * the restore then read it in again. That is the merge importArchive + * exists to prevent. + * + * Nothing was noticed while the persist silently failed on cancellation; + * making it survive cancellation is what made the missing join matter. + */ + suspend fun stop() { + job?.cancelAndJoin() + job = null + _state.value = State.Stopped + } + + private suspend fun run() = + coroutineScope { + var attempt = 0 + while (isActive) { + try { + val gids = source.gids.value + if (gids.isEmpty()) { + // Park rather than spin: both calls below are no-ops + // with no groups, so looping here would be a busy wait. + _state.value = State.NoGroups + source.gids.first { it.isNotEmpty() } + continue + } + + _state.value = State.CatchingUp + source.catchUp(onDelivery) + + _state.value = State.Live + subscribeUntilGroupsChange(gids) + + // Any completed pass is a working coordinator, including a + // subscription that simply timed out. + attempt = 0 + } catch (e: CancellationException) { + // Only OUR cancellation may end the loop. A coordinator + // that does not answer inside the RPC budget surfaces as + // `TimeoutCancellationException`, which IS a + // `CancellationException` -- rethrowing it ended the loop + // for the rest of the session, so one 20-second hiccup + // turned into permanent silence with the UI still saying + // "catching up". Measured on device: the first + // `msg_fetch_many` timeout stopped cordn sync outright. + if (!isActive) throw e + + attempt++ + val wait = backoffFor(attempt) + _state.value = State.Retrying(attempt, wait, e.message) + delay(wait) + } catch (e: Exception) { + attempt++ + val wait = backoffFor(attempt) + _state.value = State.Retrying(attempt, wait, e.message) + delay(wait) + } + } + } + + /** + * Holds a subscription until it closes or the group set changes. + * + * The watcher is what makes a freshly joined group live immediately instead + * of at the next timeout. + */ + private suspend fun subscribeUntilGroupsChange(opened: Set) = + coroutineScope { + val subscription = + async { + // `msg_sub_many` spends a total-time budget and throws when + // it runs out. That is the stream ending on schedule, not + // an outage -- the loop's job is to open the next one -- so + // it must not reach the backoff, and it must not reach the + // cancellation clause above either. + try { + source.subscribe(subscribeTimeoutMs, onDelivery) + } catch (e: TimeoutCancellationException) { + // budget spent; fall through and re-subscribe + } + } + val changed = async { source.gids.first { it != opened } } + + select { + subscription.onAwait { changed.cancel() } + changed.onAwait { subscription.cancel() } + } + } + + /** Exponential, capped, and reset by the caller on any success. */ + private fun backoffFor(attempt: Int): Long { + var wait = minBackoffMs + repeat(attempt - 1) { + if (wait >= maxBackoffMs) return maxBackoffMs + wait *= 2 + } + return wait.coerceAtMost(maxBackoffMs) + } + + companion object { + /** + * How long one subscription stays open before the loop re-opens it. + * + * A ceiling on how long a silently dead stream goes unnoticed, not a + * polling interval — the coordinator pushes within it. + */ + const val DEFAULT_SUBSCRIBE_TIMEOUT_MS = 60_000L + + const val DEFAULT_MIN_BACKOFF_MS = 1_000L + + /** + * Long enough not to hammer a coordinator that is down, short enough + * that a user who reopens the app is not waiting on a dead timer. + */ + const val DEFAULT_MAX_BACKOFF_MS = 60_000L + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/KeyedCordnBlobCipher.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/KeyedCordnBlobCipher.kt new file mode 100644 index 0000000000..d79fa347aa --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/KeyedCordnBlobCipher.kt @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn + +import com.vitorpamplona.quartz.nip44Encryption.crypto.ChaCha20Poly1305 +import com.vitorpamplona.quartz.utils.RandomInstance + +/** + * A [CordnBlobCipher] under a key the caller supplies: ChaCha20-Poly1305 with + * a fresh random nonce per blob, framed `nonce:12 || ciphertext || tag:16`. + * + * For every platform that has no OS-held key store to hide the key in — the + * desktop app and `amy`. It is the honest half of "encrypted at rest": the + * cipher is real, and where the key lives decides what that buys. Android's + * [CordnBlobCipher] keeps its key in hardware, so a blob read off the device + * is useless; a caller that keeps the key in a file beside the blobs is + * protecting against a stolen backup, not against a process running as the + * same user. Say which one you are doing where the key is created, not here. + * + * ## The nonce is random, not a counter + * + * One key covers every blob this cipher writes, and the stores rewrite the + * same group's state on every epoch change, so a counter would have to be + * persisted and survive a crash to stay unique. A 96-bit random nonce does + * not: at the volumes a client writes (thousands of blobs, not billions) the + * collision probability is negligible, and a repeat would be a confidentiality + * break rather than a corrupted file. Nonce reuse under ChaCha20-Poly1305 + * leaks the XOR of two plaintexts and the Poly1305 key — for an + * `MlsGroupState` that is epoch secrets, so this is the one parameter here + * worth being careful about. + * + * No associated data: the frame has no header worth binding, and the blob's + * path is not authenticated on purpose. The stores move a file into place + * atomically and a blob that lands at the wrong path fails to parse as the + * thing that path expects, which is where that belongs. + * + * Stateless, so safe to call from several coroutines at once. + */ +class KeyedCordnBlobCipher( + private val key: ByteArray, +) : CordnBlobCipher { + init { + require(key.size == KEY_LENGTH) { "a cordn blob key is $KEY_LENGTH bytes, got ${key.size}" } + } + + override fun encrypt(bytes: ByteArray): ByteArray { + val nonce = RandomInstance.bytes(NONCE_LENGTH) + return nonce + ChaCha20Poly1305.encrypt(bytes, EMPTY, nonce, key) + } + + override fun decrypt(bytes: ByteArray): ByteArray { + require(bytes.size > NONCE_LENGTH) { "not a cordn blob: ${bytes.size} bytes" } + return ChaCha20Poly1305.decrypt( + bytes.copyOfRange(NONCE_LENGTH, bytes.size), + EMPTY, + bytes.copyOfRange(0, NONCE_LENGTH), + key, + ) + } + + companion object { + const val KEY_LENGTH = 32 + const val NONCE_LENGTH = 12 + + private val EMPTY = ByteArray(0) + + /** A new key, for a caller about to persist one. */ + fun newKey(): ByteArray = RandomInstance.bytes(KEY_LENGTH) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/NostrClientCvmRelayPool.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/NostrClientCvmRelayPool.kt new file mode 100644 index 0000000000..83f7010d30 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/NostrClientCvmRelayPool.kt @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn + +import com.vitorpamplona.quartz.contextvm.transport.CvmRelayPool +import com.vitorpamplona.quartz.contextvm.transport.CvmSubscription +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.RandomInstance + +/** + * A Nostr relay client, as the [CvmRelayPool] ContextVM expects. + * + * The adapter between the two halves: [INostrClient] is what every Amethyst + * front end already runs, and [CvmRelayPool] is the two operations ContextVM + * needs from one. Nothing here is platform-specific, so Android, the desktop + * app and `amy` share it. + * + * [relays] is the coordinator's own relay list, not the account's. A + * coordinator has no address beyond its pubkey (`spec/00.md` §8.5), so the + * only place its traffic can be found is the relays whoever published the + * coordinator said to use. Sending it to the account's outbox relays instead + * would both miss the coordinator and tell relays that have no business + * knowing that this account talks to it. + */ +class NostrClientCvmRelayPool( + private val client: INostrClient, + private val relays: Set, +) : CvmRelayPool { + override fun subscribe( + pubKey: HexKey, + kinds: IntArray, + onEvent: (Event) -> Unit, + ): CvmSubscription { + // Random rather than a counter, which is how the rest of the + // relay client names subscriptions (`newSubId`). A shared `nextId++` + // is not atomic, and `CvmTransport` opens one subscription per request + // and closes it in the same call — the sync loop and the UI issue + // requests concurrently, so two coroutines reading the same value is + // ordinary. Two subscriptions sharing an id means the first `close()` + // unsubscribes both, and the other request waits out its full + // 20-second timeout for a response the relay stopped sending. + val subId = "cordn-${pubKey.take(8)}-${RandomInstance.randomChars(8)}" + val filter = Filter(kinds = kinds.toList(), tags = mapOf("p" to listOf(pubKey))) + + client.subscribe( + subId = subId, + filters = relays.associateWith { listOf(filter) }, + listener = + object : SubscriptionListener { + override suspend fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + // Stored-vs-live is not a distinction kind 25910 has: + // it is ephemeral, so anything that arrives at all is + // live by definition. Filtering on isLive here would + // drop responses on a relay that replays its buffer. + onEvent(event) + } + + override fun onClosed( + message: String, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + Log.d(TAG) { "coordinator subscription closed by $relay: $message" } + } + }, + ) + + return object : CvmSubscription { + override fun close() { + client.unsubscribe(subId) + } + } + } + + override suspend fun publish(event: Event) { + client.publish(event, relays) + } + + companion object { + private const val TAG = "CordnRelayPool" + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppsRegistry.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/favorites/FavoriteAppsRegistry.kt similarity index 68% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppsRegistry.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/favorites/FavoriteAppsRegistry.kt index 99e36cf19b..74c1480770 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppsRegistry.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/favorites/FavoriteAppsRegistry.kt @@ -18,79 +18,77 @@ * 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.favorites +package com.vitorpamplona.amethyst.commons.favorites -import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey -import androidx.datastore.preferences.preferencesDataStore -import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp +import com.vitorpamplona.amethyst.commons.util.ConcurrentSet import com.vitorpamplona.quartz.nip01Core.core.JsonMapper import com.vitorpamplona.quartz.utils.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.first import kotlinx.coroutines.launch import kotlinx.serialization.Serializable -import java.util.concurrent.ConcurrentHashMap - -private val Context.favoriteAppsDataStore by preferencesDataStore(name = "favorite_apps") +import kotlin.concurrent.Volatile /** * The user's device-local list of [FavoriteApp]s — the single source of truth shared by the bottom * bar, the Favorite Apps grid, and the browser launcher. Ordered (the user can reorder); de-duplicated * by [FavoriteApp.id]. * - * Lives only in the **main process** (the launcher/UI consume it); the keyless `:napplet` sandbox never - * touches it. An in-memory [StateFlow] is authoritative for the session so Compose can observe it - * synchronously, with write-through persistence to a DataStore on a background scope. The list is - * stored as a single JSON array under one key (small, bounded, hand-curated data — no need for one key - * per entry). + * An in-memory [StateFlow] is authoritative for the session so Compose can observe it synchronously, + * with write-through persistence to [store] on [scope]. The list is stored as a single JSON array + * under one key (small, bounded, hand-curated data — no need for one key per entry). + * + * Takes its [DataStore] rather than reaching for one, for the same reason + * `DataStoreSearchHistoryStorage` does: DataStore refuses a second live instance on a path that + * already has one, so the caller's store holder stays the single registry. That is also what keeps + * this class off any one front end — the Android app builds it in `AppModules` from + * `appStores.getDataStore(FILE_NAME)`, and nothing here knows about `Context` or the app singleton. + * + * One instance per process. On Android the launcher/UI in the **main** process own it; the keyless + * `:napplet` sandbox never builds one. */ -object FavoriteAppsRegistry { - private val KEY = stringPreferencesKey("favorites") - - // Raw manifest event JSON for each favorited [FavoriteApp.NostrApp], keyed by its addressable - // coordinate. Cached so a pinned nsite/napplet resolves instantly on the next cold start — and - // offline — instead of waiting on a relay round-trip the way a [FavoriteApp.WebApp]'s URL never - // has to. The relay subscription that warms these favorites keeps the cache fresh. - private val MANIFESTS_KEY = stringPreferencesKey("manifests") - +class FavoriteAppsRegistry( + private val store: DataStore, + private val scope: CoroutineScope, +) { private val _favorites = MutableStateFlow>(emptyList()) val favorites: StateFlow> = _favorites.asStateFlow() private val manifestCache = MutableStateFlow>(emptyMap()) - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + // Gates persistence until init() has been called: writing before hydration has been scheduled + // would flush a partial list over the stored one. Set synchronously in init(), so the merge it + // launches still persists whatever the session added in the meantime. + @Volatile private var started = false - @Volatile private var appContext: Context? = null - - // Hydration runs async on a background scope, so the user can add/remove before the disk list - // merges in. [removedBeforeHydration] tombstones any id removed in that window, so the merge can't + // Hydration runs async on [scope], so the user can add/remove before the disk list merges in. + // [removedBeforeHydration] tombstones any id removed in that window, so the merge can't // resurrect a just-deleted favorite from disk. @Volatile private var hydrated = false - private val removedBeforeHydration = ConcurrentHashMap.newKeySet() + private val removedBeforeHydration = ConcurrentSet() - /** Binds the app context and hydrates the on-disk list into [favorites]. Idempotent. */ - fun init(context: Context) { - if (appContext != null) return - val ctx = context.applicationContext - appContext = ctx + /** Hydrates the on-disk list into [favorites]. Idempotent. */ + fun init() { + if (started) return + started = true scope.launch { - val prefs = ctx.favoriteAppsDataStore.data.first() + val prefs = store.data.first() val loaded = prefs[KEY]?.let { decode(it) } ?: emptyList() // Don't clobber adds made in this session before hydration finished, and don't resurrect // anything the user removed in that same window. - update { current -> (loaded.filterNot { it.id in removedBeforeHydration } + current).distinctBy { it.id } } + update { current -> (loaded.filterNot { removedBeforeHydration.contains(it.id) } + current).distinctBy { it.id } } // Same race rules for the manifest cache: a cacheManifest() in this session wins over the // disk copy, and a manifest whose favorite was removed pre-hydration must not come back. val loadedManifests = prefs[MANIFESTS_KEY]?.let { decodeManifests(it) } ?: emptyMap() - updateManifests { current -> loadedManifests.filterKeys { "nostr:$it" !in removedBeforeHydration } + current } + updateManifests { current -> loadedManifests.filterKeys { !removedBeforeHydration.contains("nostr:$it") } + current } hydrated = true removedBeforeHydration.clear() @@ -128,27 +126,23 @@ object FavoriteAppsRegistry { val next = transform(_favorites.value) if (next == _favorites.value) return _favorites.value = next - persist(encode(next)) + persist(KEY, encode(next)) } private inline fun updateManifests(transform: (Map) -> Map) { val next = transform(manifestCache.value) if (next == manifestCache.value) return manifestCache.value = next - persistManifests(encodeManifests(next)) + persist(MANIFESTS_KEY, encodeManifests(next)) } - private fun persist(json: String) { - val ctx = appContext ?: return + private fun persist( + key: Preferences.Key, + json: String, + ) { + if (!started) return scope.launch { - ctx.favoriteAppsDataStore.edit { it[KEY] = json } - } - } - - private fun persistManifests(json: String) { - val ctx = appContext ?: return - scope.launch { - ctx.favoriteAppsDataStore.edit { it[MANIFESTS_KEY] = json } + store.edit { it[key] = json } } } @@ -189,9 +183,6 @@ object FavoriteAppsRegistry { emptyList() } - private const val TYPE_NOSTR = "nostr" - private const val TYPE_URL = "url" - // --- Manifest cache persistence ------------------------------------------------------------- // Stored as a flat list of (coordinate, json) records under one key — same single-key, hand-curated // shape as the favorites list, so we never serialize a raw polymorphic map. @@ -211,4 +202,20 @@ object FavoriteAppsRegistry { Log.w("FavoriteAppsRegistry", "Failed to decode favorite manifests", e) emptyMap() } + + companion object { + /** Same file the `Context.preferencesDataStore("favorite_apps")` delegate resolved to. */ + const val FILE_NAME = "favorite_apps" + + private val KEY = stringPreferencesKey("favorites") + + // Raw manifest event JSON for each favorited [FavoriteApp.NostrApp], keyed by its addressable + // coordinate. Cached so a pinned nsite/napplet resolves instantly on the next cold start — and + // offline — instead of waiting on a relay round-trip the way a [FavoriteApp.WebApp]'s URL never + // has to. The relay subscription that warms these favorites keeps the cache fresh. + private val MANIFESTS_KEY = stringPreferencesKey("manifests") + + private const val TYPE_NOSTR = "nostr" + private const val TYPE_URL = "url" + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/ChannelFeedContentState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/ChannelFeedContentState.kt similarity index 92% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/ChannelFeedContentState.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/ChannelFeedContentState.kt index 611d27ff59..296893376c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/ChannelFeedContentState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/ChannelFeedContentState.kt @@ -18,26 +18,24 @@ * 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.feeds +package com.vitorpamplona.amethyst.commons.feeds import androidx.compose.runtime.MutableState import androidx.compose.runtime.Stable import androidx.compose.runtime.mutableStateOf -import com.vitorpamplona.amethyst.commons.feeds.AdditiveComplexFeedFilter -import com.vitorpamplona.amethyst.commons.feeds.InvalidatableContent -import com.vitorpamplona.amethyst.commons.feeds.LoadedFeedState import com.vitorpamplona.amethyst.commons.model.Channel import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.service.BundledInsert import com.vitorpamplona.amethyst.commons.service.BundledUpdate -import com.vitorpamplona.amethyst.service.checkNotInMainThread -import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists +import com.vitorpamplona.amethyst.commons.util.equalImmutableLists import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.flattenToSet import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch @@ -75,7 +73,7 @@ class ChannelFeedContentState( } fun refreshSuspended() { - checkNotInMainThread() + LocalCache.appHost.assertNotMainThread() isRefreshing.value = true try { @@ -178,7 +176,7 @@ class ChannelFeedContentState( } fun destroy() { - Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" } + Log.d("Init") { "OnCleared: ${this::class.simpleName}" } bundlerInsert.cancel() bundler.cancel() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/ChannelFeedState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/ChannelFeedState.kt similarity index 93% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/ChannelFeedState.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/ChannelFeedState.kt index be96af6803..b3cb58b1e9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/ChannelFeedState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/ChannelFeedState.kt @@ -18,10 +18,9 @@ * 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.feeds +package com.vitorpamplona.amethyst.commons.feeds import androidx.compose.runtime.Stable -import com.vitorpamplona.amethyst.commons.feeds.LoadedFeedState import com.vitorpamplona.amethyst.commons.model.Channel import kotlinx.coroutines.flow.MutableStateFlow diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/FeedContentState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/FeedContentState.kt index 02767f31d0..550621707b 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/FeedContentState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/FeedContentState.kt @@ -28,7 +28,7 @@ import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider import com.vitorpamplona.amethyst.commons.service.BasicBundledInsert import com.vitorpamplona.amethyst.commons.service.BasicBundledUpdate import com.vitorpamplona.amethyst.commons.util.equalImmutableLists -import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent import com.vitorpamplona.quartz.utils.flattenToSet import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList @@ -149,7 +149,7 @@ class FeedContentState( val oldNotesState = _feedContent.value if (localFilter is AdditiveFeedFilter && lastFeedKey == localFilter.feedKey()) { if (oldNotesState is FeedState.Loaded) { - val deletionEvents: List = newItems.mapNotNull { it.event as? DeletionEvent } + val deletionEvents: List = newItems.mapNotNull { it.event as? DeletionRequestEvent } val oldList = if (deletionEvents.isEmpty()) { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/InMemoryMlsGroupStateStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/InMemoryMlsGroupStateStore.kt index a5edbd0bd2..116ab56968 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/InMemoryMlsGroupStateStore.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/InMemoryMlsGroupStateStore.kt @@ -20,13 +20,13 @@ */ package com.vitorpamplona.amethyst.commons.marmot -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore +import com.vitorpamplona.quartz.marmot.groups.MlsGroupStateStore import com.vitorpamplona.quartz.utils.concurrent.ConcurrentMap /** * In-memory fallback implementation of [MlsGroupStateStore]. * - * Used only when [AndroidMlsGroupStateStore] cannot be initialized (e.g., when the + * Used only when [EncryptedMlsGroupStateStore] cannot be initialized (e.g., when the * Android KeyStore is unavailable). State is lost on app restart, but this lets * Marmot group operations at least work within a single session instead of failing * with "Marmot not initialized". diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt index b2acf3c3fe..b100491b67 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt @@ -51,6 +51,13 @@ import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotGroupSnapshot import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotMessageEdit import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotSystemEvent import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotSystemRowDiff +import com.vitorpamplona.quartz.marmot.groups.MarmotGroupPolicy +import com.vitorpamplona.quartz.marmot.groups.MarmotMessageStore +import com.vitorpamplona.quartz.marmot.groups.MlsGroupManager +import com.vitorpamplona.quartz.marmot.groups.MlsGroupStateStore +import com.vitorpamplona.quartz.marmot.groups.agentTextStreamSecret +import com.vitorpamplona.quartz.marmot.groups.currentGroupState +import com.vitorpamplona.quartz.marmot.groups.currentMarmotData import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRotationManager @@ -59,18 +66,15 @@ import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEventEncryption -import com.vitorpamplona.quartz.marmot.mls.group.MarmotMessageStore -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupManager -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore -import com.vitorpamplona.quartz.marmot.mls.messages.CommitResult -import com.vitorpamplona.quartz.marmot.mls.tree.Credential import com.vitorpamplona.quartz.marmot.protocolCore.GroupLifecycleState import com.vitorpamplona.quartz.marmot.protocolCore.LocalOutboundGate import com.vitorpamplona.quartz.marmot.protocolCore.MarmotPublishGate import com.vitorpamplona.quartz.marmot.protocolCore.MarmotPublishObligation import com.vitorpamplona.quartz.marmot.protocolCore.MarmotPublishObligationStore import com.vitorpamplona.quartz.marmot.protocolCore.PublishOutcome +import com.vitorpamplona.quartz.mls.group.MlsGroup +import com.vitorpamplona.quartz.mls.messages.CommitResult +import com.vitorpamplona.quartz.mls.tree.Credential import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray @@ -80,7 +84,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip01Core.tags.people.pTags -import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent import com.vitorpamplona.quartz.nip18Reposts.quotes.QEventTag import com.vitorpamplona.quartz.nip18Reposts.quotes.quote import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler @@ -324,9 +328,11 @@ class MarmotManager( ): ByteArray? = try { val preCommitKey = - MlsGroup - .restore(obligation.priorState) - .exporterSecret("marmot", "group-event".encodeToByteArray(), 32) + MarmotGroupPolicy.commitExporter.let { exporter -> + MlsGroup + .restore(obligation.priorState, MarmotGroupPolicy) + .exporterSecret(exporter.label, exporter.context, exporter.length) + } GroupEventEncryption.decrypt(event.content, preCommitKey) } catch (e: Exception) { Log.w( @@ -734,8 +740,8 @@ class MarmotManager( persistOwn: Boolean = true, ): TextMessageBundle { require(targetEvents.isNotEmpty()) { "buildDeletionMessage: targetEvents must not be empty" } - val template = DeletionEvent.build(targetEvents) - val innerEvent = RumorAssembler.assembleRumor(signer.pubKey, template) + val template = DeletionRequestEvent.build(targetEvents) + val innerEvent = RumorAssembler.assembleRumor(signer.pubKey, template) val outbound = buildGroupMessage(nostrGroupId, innerEvent) if (persistOwn) persistDecryptedMessage(nostrGroupId, innerEvent.toJson()) return TextMessageBundle(outbound = outbound, innerEvent = innerEvent) @@ -1496,7 +1502,7 @@ class MarmotManager( val authorOf = HashMap(messages.size) val claims = ArrayList>() for (event in messages) { - if (event.kind == DeletionEvent.KIND) { + if (event.kind == DeletionRequestEvent.KIND) { for (tag in event.tags) { if (tag.size >= 2 && tag[0] == "e") claims.add(tag[1] to event.pubKey) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/DvmHeartbeat.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/DvmHeartbeat.kt index 449decb3cd..dcbcefa483 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/DvmHeartbeat.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/DvmHeartbeat.kt @@ -25,7 +25,7 @@ import com.vitorpamplona.amethyst.commons.model.cache.filterIntoSet import com.vitorpamplona.amethyst.commons.model.nip90DVMs.DvmHeartbeatRegistry import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent -import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryRequest.NIP90ContentDiscoveryRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryRequest.DvmContentDiscoveryRequestEvent import com.vitorpamplona.quartz.nip90Dvms.dvmHeartbeat.DvmHeartbeatEvent import com.vitorpamplona.quartz.utils.TimeUtils @@ -56,7 +56,7 @@ fun LocalCache.cachedDvmAnnouncements(limit: Int = 100): List (note.event as? AppDefinitionEvent)?.let { - it.appMetaData()?.subscription != true && it.includeKind(NIP90ContentDiscoveryRequestEvent.KIND) + it.appMetaData()?.subscription != true && it.includeKind(DvmContentDiscoveryRequestEvent.KIND) } == true }.mapNotNull { it.event as? AppDefinitionEvent } .sortedByDescending { it.createdAt } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/HomeFeedType.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/HomeFeedType.kt index eb2ebb6c06..204864b038 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/HomeFeedType.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/HomeFeedType.kt @@ -40,19 +40,19 @@ import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent -import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent +import com.vitorpamplona.quartz.nip54Wiki.WikiArticleEvent import com.vitorpamplona.quartz.nip64Chess.challenge.offer.LiveChessGameChallengeEvent import com.vitorpamplona.quartz.nip64Chess.end.LiveChessGameEndEvent import com.vitorpamplona.quartz.nip64Chess.game.ChessGameEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent -import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent +import com.vitorpamplona.quartz.nip71Video.AddressableNormalVideoEvent +import com.vitorpamplona.quartz.nip71Video.AddressableShortVideoEvent import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent import com.vitorpamplona.quartz.nip71Video.VideoShortEvent -import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent @@ -80,10 +80,10 @@ enum class HomeFeedType( REPOSTS("reposts", listOf(RepostEvent.KIND, GenericRepostEvent.KIND)), COMMENTS("comments", listOf(CommentEvent.KIND)), PICTURES("pictures", listOf(PictureEvent.KIND)), - VIDEOS("videos", listOf(VideoNormalEvent.KIND, VideoHorizontalEvent.KIND)), - SHORTS("shorts", listOf(VideoShortEvent.KIND, VideoVerticalEvent.KIND)), - ARTICLES("articles", listOf(LongTextNoteEvent.KIND)), - WIKI("wiki", listOf(WikiNoteEvent.KIND)), + VIDEOS("videos", listOf(VideoNormalEvent.KIND, AddressableNormalVideoEvent.KIND)), + SHORTS("shorts", listOf(VideoShortEvent.KIND, AddressableShortVideoEvent.KIND)), + ARTICLES("articles", listOf(LongFormContentEvent.KIND)), + WIKI("wiki", listOf(WikiArticleEvent.KIND)), HIGHLIGHTS("highlights", listOf(HighlightEvent.KIND)), POLLS("polls", listOf(PollEvent.KIND, ZapPollEvent.KIND, PollResponseEvent.KIND)), CLASSIFIEDS("classifieds", listOf(ClassifiedsEvent.KIND)), diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/IAccount.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/IAccount.kt index 6b1e4d1640..54d776c06e 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/IAccount.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/IAccount.kt @@ -24,11 +24,11 @@ import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupList import com.vitorpamplona.amethyst.commons.model.privateChats.ChatroomList import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip04Dm.messages.EncryptedDmEvent import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent -import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent -import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcRequestEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcResponseEvent import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response import com.vitorpamplona.quartz.nip57Zaps.IPrivateZapsDecryptionCache @@ -40,9 +40,9 @@ import com.vitorpamplona.quartz.utils.DualCase * Used by Note.kt for checking NWC payment status. */ interface INwcSignerState { - suspend fun decryptResponse(event: LnZapPaymentResponseEvent): Response? + suspend fun decryptResponse(event: NwcResponseEvent): Response? - suspend fun decryptRequest(event: LnZapPaymentRequestEvent): Request? + suspend fun decryptRequest(event: NwcRequestEvent): Request? fun isNIP47Author(pubKey: String?): Boolean } @@ -127,7 +127,7 @@ interface IAccount { fun isAcceptable(note: Note): Boolean /** Send a NIP-04 encrypted direct message */ - suspend fun sendNip04PrivateMessage(eventTemplate: EventTemplate) + suspend fun sendNip04PrivateMessage(eventTemplate: EventTemplate) /** Send a NIP-17 gift-wrapped direct message */ suspend fun sendNip17PrivateMessage(template: EventTemplate) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt index 3dde3a6d1e..342e88fda1 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt @@ -51,16 +51,16 @@ import com.vitorpamplona.quartz.nip28PublicChat.base.IsInPublicChatChannel import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent -import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent -import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcRequestEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcResponseEvent import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceMethod import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceSuccessResponse import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import com.vitorpamplona.quartz.nip56Reports.ReportEvent import com.vitorpamplona.quartz.nip56Reports.ReportType -import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapRequestEvent import com.vitorpamplona.quartz.nip59Giftwrap.HostStub import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent @@ -1038,7 +1038,7 @@ open class Note( } return anyAsync(zapPayments) { next -> - val zapResponseEvent = next.second?.event as? LnZapPaymentResponseEvent + val zapResponseEvent = next.second?.event as? NwcResponseEvent if (zapResponseEvent != null) { val response = account.nip47SignerState.decryptResponse(zapResponseEvent) @@ -1066,11 +1066,11 @@ open class Note( return false } - val parallelDecrypt = mutableListOf>() + val parallelDecrypt = mutableListOf>() zapEvents.forEach { next -> - val zapRequest = next.key.event as LnZapRequestEvent - val zapEvent = next.value?.event as? LnZapEvent + val zapRequest = next.key.event as ZapRequestEvent + val zapEvent = next.value?.event as? ZapReceiptEvent if (zapEvent != null) { if (!zapRequest.isPrivateZap()) { @@ -1227,7 +1227,7 @@ open class Note( // Regular Zap Receipts zaps.forEach { val noteEvent = it.value?.event - if (noteEvent is LnZapEvent) { + if (noteEvent is ZapReceiptEvent) { sumOfAmounts += noteEvent.amount ?: BigDecimal(0) } } @@ -1299,8 +1299,8 @@ open class Note( paymentResponse: Note?, signerState: INwcSignerState, ): InvoiceAmount? { - val nwcRequest = paymentRequest.event as? LnZapPaymentRequestEvent - val nwcResponse = paymentResponse?.event as? LnZapPaymentResponseEvent + val nwcRequest = paymentRequest.event as? NwcRequestEvent + val nwcResponse = paymentResponse?.event as? NwcResponseEvent return if (nwcRequest != null && nwcResponse != null) { processZapAmountFromResponse( @@ -1319,8 +1319,8 @@ open class Note( ) private suspend fun processZapAmountFromResponse( - nwcRequest: LnZapPaymentRequestEvent, - nwcResponse: LnZapPaymentResponseEvent, + nwcRequest: NwcRequestEvent, + nwcResponse: NwcResponseEvent, signerState: INwcSignerState, ): InvoiceAmount? { // if we can decrypt the reply @@ -1361,7 +1361,7 @@ open class Note( } val invoiceSet = LinkedHashSet(zaps.size + zapPayments.size) - zaps.forEach { (it.value?.event as? LnZapEvent)?.lnInvoice()?.let { invoiceSet.add(it) } } + zaps.forEach { (it.value?.event as? ZapReceiptEvent)?.lnInvoice()?.let { invoiceSet.add(it) } } return zappedAmountCalculation( zapsAmount, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ThreadAssembler.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ThreadAssembler.kt index e07c1c98f9..d75e4ab742 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ThreadAssembler.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/ThreadAssembler.kt @@ -28,7 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent @@ -183,7 +183,7 @@ class ThreadAssembler( */ fun Event?.anchorsItsOwnThread(): Boolean = when (this) { - is ReactionEvent, is LnZapEvent, is NutzapEvent, is OnchainZapEvent, is Bolt12ZapEvent -> true + is ReactionEvent, is ZapReceiptEvent, is NutzapEvent, is OnchainZapEvent, is Bolt12ZapEvent -> true else -> false } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/User.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/User.kt index ac221fef95..aaa614f854 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/User.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/User.kt @@ -37,7 +37,7 @@ import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.people.PTag -import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.nip17Dm.settings.DmRelayListEvent import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile import com.vitorpamplona.quartz.nip19Bech32.toNpub import com.vitorpamplona.quartz.nip61Nutzaps.info.NutzapInfoEvent @@ -76,7 +76,7 @@ class User( val nip65RelayListNote: Note = context.addressableNote(AdvertisedRelayListEvent.createAddress(pubkeyHex)) - val dmRelayListNote: Note = context.addressableNote(ChatMessageRelayListEvent.createAddress(pubkeyHex)) + val dmRelayListNote: Note = context.addressableNote(DmRelayListEvent.createAddress(pubkeyHex)) val nutzapInfoNote: Note = context.addressableNote(NutzapInfoEvent.createAddress(pubkeyHex)) @@ -114,7 +114,7 @@ class User( fun pubkeyDisplayHex() = pubkeyNpub().toShortDisplay(5) - fun dmInboxRelayList() = dmRelayListNote.event as? ChatMessageRelayListEvent + fun dmInboxRelayList() = dmRelayListNote.event as? DmRelayListEvent fun authorRelayList() = nip65RelayListNote.event as? AdvertisedRelayListEvent diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/backups/ReplaceableBackupConflict.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/backups/ReplaceableBackupConflict.kt index dd348ce0da..b1ffb3334d 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/backups/ReplaceableBackupConflict.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/backups/ReplaceableBackupConflict.kt @@ -32,18 +32,18 @@ import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip13Pow.tags.PoWTag -import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent -import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent +import com.vitorpamplona.quartz.nip17Dm.settings.DmRelayListEvent +import com.vitorpamplona.quartz.nip28PublicChat.list.PublicChatListEvent import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayListEvent import com.vitorpamplona.quartz.nip40Expiration.ExpirationTag import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListEvent import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent -import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent +import com.vitorpamplona.quartz.nip51Lists.interestList.InterestListEvent import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.FavoriteRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent -import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.SimpleGroupListEvent import com.vitorpamplona.quartz.nip60Cashu.wallet.CashuWalletEvent @@ -93,18 +93,18 @@ enum class BackupEventType { ContactListEvent.KIND -> FOLLOW_LIST MuteListEvent.KIND -> MUTE_LIST AdvertisedRelayListEvent.KIND -> OUTBOX_INBOX_RELAYS - ChatMessageRelayListEvent.KIND -> DM_RELAYS + DmRelayListEvent.KIND -> DM_RELAYS KeyPackageRelayListEvent.KIND -> KEY_PACKAGE_RELAYS SearchRelayListEvent.KIND -> SEARCH_RELAYS IndexerRelayListEvent.KIND -> INDEXER_RELAYS - RelayFeedsListEvent.KIND -> RELAY_FEEDS + FavoriteRelayListEvent.KIND -> RELAY_FEEDS BlockedRelayListEvent.KIND -> BLOCKED_RELAYS TrustedRelayListEvent.KIND -> TRUSTED_RELAYS PrivateOutboxRelayListEvent.KIND -> PRIVATE_OUTBOX_RELAYS AppSpecificDataEvent.KIND -> APP_SETTINGS - ChannelListEvent.KIND -> PUBLIC_CHATS + PublicChatListEvent.KIND -> PUBLIC_CHATS CommunityListEvent.KIND -> COMMUNITIES - HashtagListEvent.KIND -> HASHTAGS + InterestListEvent.KIND -> HASHTAGS GeohashListEvent.KIND -> GEOHASHES FavoriteAlgoFeedsListEvent.KIND -> FAVORITE_ALGO_FEEDS EphemeralChatListEvent.KIND -> EPHEMERAL_CHATS diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/CachePruner.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/CachePruner.kt index 8f26268712..119f2ff33e 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/CachePruner.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/CachePruner.kt @@ -35,19 +35,19 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUsers import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent -import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip04Dm.messages.EncryptedDmEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip17Dm.base.BaseDMGroupEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.quotes.taggedQuoteIds import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent -import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent +import com.vitorpamplona.quartz.nip38UserStatus.UserStatusEvent import com.vitorpamplona.quartz.nip40Expiration.isExpirationBefore import com.vitorpamplona.quartz.nip56Reports.ReportEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapRequestEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent -import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent +import com.vitorpamplona.quartz.nip85TrustedAssertions.users.UserAssertionEvent import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils @@ -207,7 +207,7 @@ class CachePruner( val roomNip04Pruned = HashMap() // chatroom.nip04History is lazy — only touch (allocate) it when this room actually drops a // kind:4 message, so rooms that never paged conversation history pay nothing. - val roomNip04Floor = if (toBeRemoved.any { it.event is PrivateDmEvent }) chatroom.nip04History.floor else null + val roomNip04Floor = if (toBeRemoved.any { it.event is EncryptedDmEvent }) chatroom.nip04History.floor else null toBeRemoved.forEach { note -> when (val ev = note.event) { @@ -216,7 +216,7 @@ class CachePruner( val outerUntil = note.rumorHost?.createdAt ?: ev.createdAt if (outerUntil < giftWrapFloor) note.relays.forEach { giftWrapPruned.mergeMax(it, outerUntil) } } - is PrivateDmEvent -> { + is EncryptedDmEvent -> { val until = ev.createdAt if (accountNip04Floor != null && until < accountNip04Floor) note.relays.forEach { accountNip04Pruned.mergeMax(it, until) } if (roomNip04Floor != null && until < roomNip04Floor) note.relays.forEach { roomNip04Pruned.mergeMax(it, until) } @@ -317,8 +317,8 @@ class CachePruner( ( (note.event is TextNoteEvent && !note.isNewThread()) || note.event is ReactionEvent || - note.event is LnZapEvent || - note.event is LnZapRequestEvent || + note.event is ZapReceiptEvent || + note.event is ZapRequestEvent || note.event is ReportEvent || note.event is GenericRepostEvent ) && @@ -413,11 +413,11 @@ class CachePruner( } } - if (note is AddressableNote && noteEvent is ContactCardEvent) { + if (note is AddressableNote && noteEvent is UserAssertionEvent) { cache.getUserIfExists(noteEvent.aboutUser())?.cardsOrNull()?.removeCard(note) } - if (note is AddressableNote && noteEvent is StatusEvent) { + if (note is AddressableNote && noteEvent is UserStatusEvent) { note.author?.statusStateOrNull()?.removeStatus(note) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt index 67d5dc3186..0954ef7a9e 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt @@ -139,6 +139,8 @@ import com.vitorpamplona.quartz.buzz.wpWorkspaceProfile.SetWorkspaceProfileEvent import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent +import com.vitorpamplona.quartz.contextvm.cep06Announcements.CvmServerAnnouncementEvent +import com.vitorpamplona.quartz.contextvm.cep06Announcements.CvmToolsListEvent import com.vitorpamplona.quartz.cyberspace.CyberspaceBagEvent import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoAvatarEvent import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoObjectEvent @@ -219,28 +221,28 @@ import com.vitorpamplona.quartz.nip01Core.tags.events.GenericETag import com.vitorpamplona.quartz.nip01Core.tags.events.taggedEvents import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent -import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent -import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip04Dm.messages.EncryptedDmEvent import com.vitorpamplona.quartz.nip09Deletions.DeletionIndex +import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent import com.vitorpamplona.quartz.nip10Notes.BaseNoteEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent -import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.nip17Dm.settings.DmRelayListEvent import com.vitorpamplona.quartz.nip18Reposts.BaseRepostEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip18Reposts.quotes.taggedQuoteIds import com.vitorpamplona.quartz.nip19Bech32.isATag import com.vitorpamplona.quartz.nip22Comments.CommentEvent -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent import com.vitorpamplona.quartz.nip25Reactions.ExternalReactionEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelHideMessageEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMuteUserEvent -import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent +import com.vitorpamplona.quartz.nip28PublicChat.list.PublicChatListEvent import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent import com.vitorpamplona.quartz.nip29RelayGroups.GroupId import com.vitorpamplona.quartz.nip29RelayGroups.groupId @@ -249,19 +251,19 @@ import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupParticipantsEvent import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupPinnedEvent -import com.vitorpamplona.quartz.nip29RelayGroups.metadata.SupportedRolesEvent +import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupRolesEvent import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateGroupEvent -import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateInviteEvent -import com.vitorpamplona.quartz.nip29RelayGroups.moderation.DeleteEventEvent import com.vitorpamplona.quartz.nip29RelayGroups.moderation.DeleteGroupEvent -import com.vitorpamplona.quartz.nip29RelayGroups.moderation.EditMetadataEvent -import com.vitorpamplona.quartz.nip29RelayGroups.moderation.PutUserEvent -import com.vitorpamplona.quartz.nip29RelayGroups.moderation.RemoveUserEvent -import com.vitorpamplona.quartz.nip29RelayGroups.moderation.UpdatePinListEvent -import com.vitorpamplona.quartz.nip29RelayGroups.request.JoinRequestEvent -import com.vitorpamplona.quartz.nip29RelayGroups.request.LeaveRequestEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.GroupCreateInviteEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.GroupDeleteEventEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.GroupEditMetadataEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.GroupPutUserEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.GroupRemoveUserEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.GroupUpdatePinListEvent +import com.vitorpamplona.quartz.nip29RelayGroups.request.GroupJoinRequestEvent +import com.vitorpamplona.quartz.nip29RelayGroups.request.GroupLeaveRequestEvent import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent -import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEvent +import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiListEvent import com.vitorpamplona.quartz.nip32Labeling.LabelEvent import com.vitorpamplona.quartz.nip34Git.grasp.UserGraspListEvent import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent @@ -276,39 +278,39 @@ import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayListEvent -import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent +import com.vitorpamplona.quartz.nip38UserStatus.UserStatusEvent import com.vitorpamplona.quartz.nip39ExtIdentities.ExternalIdentitiesEvent import com.vitorpamplona.quartz.nip40Expiration.isExpired import com.vitorpamplona.quartz.nip43RelayMembers.addMember.RelayAddMemberEvent import com.vitorpamplona.quartz.nip43RelayMembers.list.RelayMembershipListEvent import com.vitorpamplona.quartz.nip43RelayMembers.removeMember.RelayRemoveMemberEvent -import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent -import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcRequestEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcResponseEvent import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent import com.vitorpamplona.quartz.nip51Lists.PinListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkSet.BookmarkSetEvent import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListEvent -import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import com.vitorpamplona.quartz.nip51Lists.followSet.FollowSetEvent import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent -import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent +import com.vitorpamplona.quartz.nip51Lists.interestList.InterestListEvent import com.vitorpamplona.quartz.nip51Lists.interestSet.InterestSetEvent -import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.LabeledBookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent -import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BroadcastRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.FavoriteRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent -import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relaySets.RelaySetEvent import com.vitorpamplona.quartz.nip51Lists.releaseArtifactSet.ReleaseArtifactSetEvent import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.SimpleGroupListEvent +import com.vitorpamplona.quartz.nip51Lists.starterPack.StarterPackEvent import com.vitorpamplona.quartz.nip51Lists.videoCurationSet.VideoCurationSetEvent import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent -import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent +import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarCollectionEvent import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent @@ -318,22 +320,22 @@ import com.vitorpamplona.quartz.nip53LiveActivities.nestsServers.NestsServersEve import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent import com.vitorpamplona.quartz.nip53LiveActivities.raid.LiveActivitiesRaidEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent +import com.vitorpamplona.quartz.nip54Wiki.WikiArticleEvent import com.vitorpamplona.quartz.nip54Wiki.WikiMergeAcceptanceEvent import com.vitorpamplona.quartz.nip54Wiki.WikiMergeRequestEvent -import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip54Wiki.WikiRedirectEvent import com.vitorpamplona.quartz.nip56Reports.ReportEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent -import com.vitorpamplona.quartz.nip57Zaps.validate.LnZapReceiptValidator +import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapRequestEvent import com.vitorpamplona.quartz.nip57Zaps.validate.LnurlEndpointResolver import com.vitorpamplona.quartz.nip57Zaps.validate.LnurlForm +import com.vitorpamplona.quartz.nip57Zaps.validate.ZapReceiptValidator import com.vitorpamplona.quartz.nip58Badges.accepted.AcceptedBadgeSetEvent import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent import com.vitorpamplona.quartz.nip58Badges.profile.ProfileBadgesEvent import com.vitorpamplona.quartz.nip59Giftwrap.HasInnerEvent -import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent +import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip5aStaticWebsites.NamedSiteEvent import com.vitorpamplona.quartz.nip5aStaticWebsites.RootSiteEvent @@ -358,20 +360,20 @@ import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.RelayDiscoveryEvent import com.vitorpamplona.quartz.nip66RelayMonitor.monitor.RelayMonitorEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent -import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent +import com.vitorpamplona.quartz.nip71Video.AddressableNormalVideoEvent +import com.vitorpamplona.quartz.nip71Video.AddressableShortVideoEvent import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent import com.vitorpamplona.quartz.nip71Video.VideoShortEvent -import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent import com.vitorpamplona.quartz.nip71Video.textTrack.TextTrackEvent import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent -import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent +import com.vitorpamplona.quartz.nip75ZapGoals.ZapGoalEvent import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent -import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent +import com.vitorpamplona.quartz.nip85TrustedAssertions.users.UserAssertionEvent import com.vitorpamplona.quartz.nip87Ecash.cashu.CashuMintEvent import com.vitorpamplona.quartz.nip87Ecash.fedimint.FedimintEvent import com.vitorpamplona.quartz.nip87Ecash.recommendation.MintRecommendationEvent @@ -379,13 +381,13 @@ import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.AppRecommendationEvent -import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryRequest.NIP90ContentDiscoveryRequestEvent -import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryRequest.DvmContentDiscoveryRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.DvmContentDiscoveryResponseEvent import com.vitorpamplona.quartz.nip90Dvms.dvmHeartbeat.DvmHeartbeatEvent -import com.vitorpamplona.quartz.nip90Dvms.status.NIP90StatusEvent -import com.vitorpamplona.quartz.nip90Dvms.userDiscoveryRequest.NIP90UserDiscoveryRequestEvent -import com.vitorpamplona.quartz.nip90Dvms.userDiscoveryResponse.NIP90UserDiscoveryResponseEvent -import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent +import com.vitorpamplona.quartz.nip90Dvms.status.DvmStatusEvent +import com.vitorpamplona.quartz.nip90Dvms.userDiscoveryRequest.DvmUserDiscoveryRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.userDiscoveryResponse.DvmUserDiscoveryResponseEvent +import com.vitorpamplona.quartz.nip94FileMetadata.FileMetadataEvent import com.vitorpamplona.quartz.nip96FileStorage.config.FileServersEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent @@ -500,7 +502,7 @@ open class EventCache : val bolt12ZapValidator = Bolt12ZapValidator() /** - * Resolver for LNURL provider metadata used by [consume]`(LnZapEvent)` to + * Resolver for LNURL provider metadata used by [consume]`(ZapReceiptEvent)` to * validate NIP-57 Appendix F. `null` skips the receipt-signer check (the * receipt is still accepted on signature verification alone — matches legacy * behavior); set this in app init so receipts can be verified against the @@ -1208,7 +1210,7 @@ open class EventCache : } fun consume( - event: LongTextNoteEvent, + event: LongFormContentEvent, relay: NormalizedRelayUrl?, wasVerified: Boolean, ): Boolean { @@ -1296,7 +1298,7 @@ open class EventCache : } fun consume( - event: WikiNoteEvent, + event: WikiArticleEvent, relay: NormalizedRelayUrl?, wasVerified: Boolean, ): Boolean { @@ -1345,7 +1347,7 @@ open class EventCache : event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) } } - is LongTextNoteEvent -> { + is LongFormContentEvent -> { event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) } } @@ -1403,7 +1405,7 @@ open class EventCache : event.taggedEvents().mapNotNull { checkGetOrCreateNote(it) } } - is LnZapEvent -> { + is ZapReceiptEvent -> { event.zappedPost().mapNotNull { checkGetOrCreateNote(it) } + event.taggedAddresses().map { getOrCreateAddressableNote(it) } + (event.zapRequest?.taggedAddresses()?.map { getOrCreateAddressableNote(it) } ?: emptyList()) @@ -1436,11 +1438,11 @@ open class EventCache : // The zapped event is carried in the kind:9321's `e` tags // (and optionally an `a` tag for addressables). Whichever // notes those resolve to receive the nutzap entry — - // analogous to how LnZapEvent flows into `addZap`. + // analogous to how ZapReceiptEvent flows into `addZap`. event.linkedEventIds().mapNotNull { checkGetOrCreateNote(it) } } - is LnZapRequestEvent -> { + is ZapRequestEvent -> { event.zappedPost().mapNotNull { checkGetOrCreateNote(it) } + event.taggedAddresses().map { getOrCreateAddressableNote(it) } } @@ -1454,7 +1456,7 @@ open class EventCache : event.awardDefinition().map { getOrCreateAddressableNote(it) } } - is PrivateDmEvent -> { + is EncryptedDmEvent -> { event.taggedEvents().mapNotNull { checkGetOrCreateNote(it) } } @@ -1555,7 +1557,7 @@ open class EventCache : } fun consume( - event: StatusEvent, + event: UserStatusEvent, relay: NormalizedRelayUrl?, wasVerified: Boolean, ): Boolean { @@ -1575,7 +1577,7 @@ open class EventCache : fun AddressableEvent.toAddressableNote() = getOrCreateAddressableNote(address()) fun consume( - event: ContactCardEvent, + event: UserAssertionEvent, relay: NormalizedRelayUrl?, wasVerified: Boolean, ): Boolean { @@ -1665,7 +1667,7 @@ open class EventCache : } fun consume( - event: DeletionEvent, + event: DeletionRequestEvent, relay: NormalizedRelayUrl?, wasVerified: Boolean, ): Boolean { @@ -2243,14 +2245,14 @@ open class EventCache : /** NIP-29 relay-declared supported roles (kind 39003) → the group's role set. */ fun consume( - event: SupportedRolesEvent, + event: GroupRolesEvent, relay: NormalizedRelayUrl?, wasVerified: Boolean, ): Boolean { val new = consumeBaseReplaceable(event, relay, wasVerified) if (relay != null && isRelaySignedGroupEvent(event, relay)) { - val latest = getOrCreateAddressableNote(event.address()).event as? SupportedRolesEvent - latest?.let { getOrCreateRelayGroupChannel(GroupId(it.groupId(), relay)).updateSupportedRoles(it) } + val latest = getOrCreateAddressableNote(event.address()).event as? GroupRolesEvent + latest?.let { getOrCreateRelayGroupChannel(GroupId(it.groupId(), relay)).updateGroupRoles(it) } } return new } @@ -2659,7 +2661,7 @@ open class EventCache : ): Boolean = false fun consume( - event: LnZapEvent, + event: ZapReceiptEvent, relay: NormalizedRelayUrl?, wasVerified: Boolean, ): Boolean { @@ -2685,7 +2687,7 @@ open class EventCache : val zapRequest = event.zapRequest?.id?.let { getNoteIfExists(it) } - if (zapRequest == null || zapRequest.event !is LnZapRequestEvent) { + if (zapRequest == null || zapRequest.event !is ZapRequestEvent) { Log.d("ZP") { "Zap Request not found. Unable to process Zap {${event.toJson()}}" } return false } @@ -2710,18 +2712,18 @@ open class EventCache : if (cachedInfo != null) { val result = - LnZapReceiptValidator.validate( + ZapReceiptValidator.validate( receipt = event, expectedNostrPubkey = cachedInfo.nostrPubkey, expectedLnurl = recipientLnurl, ) - if (result is LnZapReceiptValidator.Result.Invalid && - result.reason != LnZapReceiptValidator.Result.Reason.MISMATCHED_LNURL + if (result is ZapReceiptValidator.Result.Invalid && + result.reason != ZapReceiptValidator.Result.Reason.MISMATCHED_LNURL ) { Log.w("ZP") { "dropping zap receipt ${event.id}: ${result.reason} ${result.detail ?: ""}" } return false } - if (result is LnZapReceiptValidator.Result.Invalid) { + if (result is ZapReceiptValidator.Result.Invalid) { Log.w("ZP") { "zap receipt ${event.id} has mismatched lnurl tag (accepting per SHOULD)" } } @@ -2757,18 +2759,18 @@ open class EventCache : return@launch } val result = - LnZapReceiptValidator.validate( + ZapReceiptValidator.validate( receipt = event, expectedNostrPubkey = info.nostrPubkey, expectedLnurl = recipientLnurl, ) - if (result is LnZapReceiptValidator.Result.Invalid && - result.reason != LnZapReceiptValidator.Result.Reason.MISMATCHED_LNURL + if (result is ZapReceiptValidator.Result.Invalid && + result.reason != ZapReceiptValidator.Result.Reason.MISMATCHED_LNURL ) { Log.w("ZP") { "dropping zap receipt ${event.id}: ${result.reason} ${result.detail ?: ""}" } return@launch } - if (result is LnZapReceiptValidator.Result.Invalid) { + if (result is ZapReceiptValidator.Result.Invalid) { Log.w("ZP") { "zap receipt ${event.id} has mismatched lnurl tag (accepting per SHOULD)" } } repliesTo.forEach { it.addZap(zapRequest, note) } @@ -2786,7 +2788,7 @@ open class EventCache : * zap target). Returns null if we have no metadata, or the user has neither * lud16 nor lud06. */ - private fun recipientLnurl(event: LnZapEvent): String? { + private fun recipientLnurl(event: ZapReceiptEvent): String? { val recipientPubkey = event.zappedAuthor().firstOrNull() ?: return null val user = getUserIfExists(recipientPubkey) ?: return null return user.lnAddress()?.takeIf { it.isNotBlank() } @@ -2923,7 +2925,7 @@ open class EventCache : } private fun attachZapToLiveActivityChannel( - event: LnZapEvent, + event: ZapReceiptEvent, note: Note, relay: NormalizedRelayUrl?, ) { @@ -2961,7 +2963,7 @@ open class EventCache : } fun consume( - event: LnZapRequestEvent, + event: ZapRequestEvent, relay: NormalizedRelayUrl?, wasVerified: Boolean, ): Boolean { @@ -3164,7 +3166,7 @@ open class EventCache : } fun consume( - event: LnZapPaymentRequestEvent, + event: NwcRequestEvent, relay: NormalizedRelayUrl?, wasVerified: Boolean, ): Boolean { @@ -3173,11 +3175,11 @@ open class EventCache : } fun consume( - event: LnZapPaymentRequestEvent, + event: NwcRequestEvent, zappedNote: Note?, wasVerified: Boolean, relay: NormalizedRelayUrl?, - onResponse: suspend (LnZapPaymentResponseEvent) -> Unit, + onResponse: suspend (NwcResponseEvent) -> Unit, ): Boolean { val note = getOrCreateNote(event.id) val author = getOrCreateUser(event.pubKey) @@ -3211,7 +3213,7 @@ open class EventCache : } fun consume( - event: LnZapPaymentResponseEvent, + event: NwcResponseEvent, relay: NormalizedRelayUrl?, wasVerified: Boolean, ): Boolean { @@ -3283,7 +3285,7 @@ open class EventCache : return false } - fun getPeopleListNotesFor(user: User): List = addressables.filter(PeopleListEvent.KIND, user.pubkeyHex) + fun getPeopleListNotesFor(user: User): List = addressables.filter(FollowSetEvent.KIND, user.pubkeyHex) override fun markAsSeen( eventId: String, @@ -3582,7 +3584,7 @@ open class EventCache : is ChannelMetadataEvent -> consume(event, relay, wasVerified) is ChannelMuteUserEvent -> consume(event, relay, wasVerified) is CommunityPostApprovalEvent -> consume(event, relay, wasVerified) - is DeletionEvent -> consume(event, relay, wasVerified) + is DeletionRequestEvent -> consume(event, relay, wasVerified) is DraftWrapEvent -> consume(event, relay, wasVerified) is EphemeralChatEvent -> consume(event, relay, wasVerified) is GeohashChatEvent -> consume(event, relay, wasVerified) @@ -3593,7 +3595,7 @@ open class EventCache : // 39003 (relay-declared roles) is durable group state like 39000/39001/39002: // route it onto the channel so a moderation UI can offer the relay's role set. - is SupportedRolesEvent -> consume(event, relay, wasVerified) + is GroupRolesEvent -> consume(event, relay, wasVerified) is GenericRepostEvent -> consume(event, relay, wasVerified) is FileStorageEvent -> consume(event, relay, wasVerified) @@ -3625,26 +3627,26 @@ open class EventCache : } } - is LnZapEvent -> consume(event, relay, wasVerified) - is LnZapRequestEvent -> consume(event, relay, wasVerified) + is ZapReceiptEvent -> consume(event, relay, wasVerified) + is ZapRequestEvent -> consume(event, relay, wasVerified) is OnchainZapEvent -> consume(event, relay, wasVerified) is Bolt12ZapEvent -> consume(event, relay, wasVerified) - is LnZapPaymentRequestEvent -> consume(event, relay, wasVerified) - is LnZapPaymentResponseEvent -> consume(event, relay, wasVerified) - is LongTextNoteEvent -> consume(event, relay, wasVerified) + is NwcRequestEvent -> consume(event, relay, wasVerified) + is NwcResponseEvent -> consume(event, relay, wasVerified) + is LongFormContentEvent -> consume(event, relay, wasVerified) is MetadataEvent -> consume(event, relay, wasVerified) is NipTextEvent -> consume(event, relay, wasVerified) is OtsEvent -> consume(event, relay, wasVerified) is PollResponseEvent -> consume(event, relay, wasVerified) is ReactionEvent -> consume(event, relay, wasVerified) is LabelEvent -> consume(event, relay, wasVerified) - is ContactCardEvent -> consume(event, relay, wasVerified) + is UserAssertionEvent -> consume(event, relay, wasVerified) is ReportEvent -> consume(event, relay, wasVerified) is RepostEvent -> consume(event, relay, wasVerified) - is StatusEvent -> consume(event, relay, wasVerified) + is UserStatusEvent -> consume(event, relay, wasVerified) is TextNoteModificationEvent -> consume(event, relay, wasVerified) is ConcordChatEditEvent -> consume(event, relay, wasVerified) - is WikiNoteEvent -> consume(event, relay, wasVerified) + is WikiArticleEvent -> consume(event, relay, wasVerified) is PaymentTargetsEvent -> consume(event, relay, wasVerified) is ChatEvent -> { @@ -3669,7 +3671,7 @@ open class EventCache : // Buzz workspace kinds (block/buzz — the Buzz dialect of NIP-29). // Timeline kinds attach into the group's BuzzWorkspaceChannel; the // rest are stored for query/state. Kinds 9041/39005/49001 are absent - // on purpose: their numbers belong to GoalEvent, GroupPinnedEvent and + // on purpose: their numbers belong to ZapGoalEvent, GroupPinnedEvent and // a non-wire audit kind. Kind 20001 is shared with GeohashPresenceEvent // (BitChat); EventFactory routes it to PresenceUpdateEvent only when the // BitChat `g` tag is absent, and it is handled below with the ephemerals. @@ -3780,6 +3782,8 @@ open class EventCache : // ============================================================ is AcceptedBadgeSetEvent, is AdvertisedRelayListEvent, + is CvmServerAnnouncementEvent, + is CvmToolsListEvent, is AppDefinitionEvent, is AppRecommendationEvent, is AppSpecificDataEvent, @@ -3795,14 +3799,14 @@ open class EventCache : is BroadcastRelayListEvent, is BookmarkListEvent, is OldBookmarkListEvent, - is CalendarEvent, + is CalendarCollectionEvent, is CalendarDateSlotEvent, is CalendarTimeSlotEvent, is CalendarRSVPEvent, is CashuWalletEvent, is NutzapInfoEvent, - is ChannelListEvent, - is ChatMessageRelayListEvent, + is PublicChatListEvent, + is DmRelayListEvent, is Bolt12OfferListEvent, is ClassifiedsEvent, is FundraiserEvent, @@ -3821,7 +3825,7 @@ open class EventCache : DvmHeartbeatRegistry.record(event.address(), event.createdAt) } is EmojiPackEvent, - is EmojiPackSelectionEvent, + is EmojiListEvent, is EphemeralChatListEvent, // NIP-51 "simple groups" list (kind 10009): the user's joined NIP-29 groups + // servers. Replaceable like its sibling lists; RelayGroupListState reads it from the @@ -3836,7 +3840,7 @@ open class EventCache : is GroupParticipantsEvent, is ExternalIdentitiesEvent, is FileServersEvent, - is FollowListEvent, + is StarterPackEvent, is GeocacheListingEvent, is GeocacheCurationListEvent, is GeohashListEvent, @@ -3850,7 +3854,7 @@ open class EventCache : is NamedSiteEvent, is RootNappletEvent, is NamedNappletEvent, - is RelayFeedsListEvent, + is FavoriteRelayListEvent, is KeyPackageEvent, is KeyPackageRelayListEvent, is LiveChessGameChallengeEvent, @@ -3858,14 +3862,14 @@ open class EventCache : is LiveChessMoveEvent, is LiveChessGameEndEvent, is LiveChessDrawOfferEvent, - is HashtagListEvent, + is InterestListEvent, is FavoriteAlgoFeedsListEvent, is IndexerRelayListEvent, is InteractiveStoryPrologueEvent, is InteractiveStorySceneEvent, is InteractiveStoryReadingStateEvent, is InterestSetEvent, - is LabeledBookmarkListEvent, + is BookmarkSetEvent, is MeetingSpaceEvent, is MeetingRoomEvent, is MusicTrackEvent, @@ -3880,7 +3884,7 @@ open class EventCache : is ProfileBadgesEvent, is ProxyRelayListEvent, is PinListEvent, - is PeopleListEvent, + is FollowSetEvent, // Buzz addressable/replaceable state. is PersonaEvent, is TeamEvent, @@ -3900,8 +3904,8 @@ open class EventCache : is SoftwareApplicationEvent, is TrustedRelayListEvent, is TrustProviderListEvent, - is VideoHorizontalEvent, - is VideoVerticalEvent, + is AddressableNormalVideoEvent, + is AddressableShortVideoEvent, is TextTrackEvent, is VideoCollaborationEvent, is VideoCurationSetEvent, @@ -3949,21 +3953,21 @@ open class EventCache : // one-shot events the relay is authoritative for (it applies them and // republishes the 39000/39001/39002); we store them so they're queryable, // but we don't act on them client-side. - is PutUserEvent, - is RemoveUserEvent, - is EditMetadataEvent, - is DeleteEventEvent, - is UpdatePinListEvent, + is GroupPutUserEvent, + is GroupRemoveUserEvent, + is GroupEditMetadataEvent, + is GroupDeleteEventEvent, + is GroupUpdatePinListEvent, is DeleteGroupEvent, is CreateGroupEvent, - is CreateInviteEvent, - is JoinRequestEvent, - is LeaveRequestEvent, + is GroupCreateInviteEvent, + is GroupJoinRequestEvent, + is GroupLeaveRequestEvent, is FhirResourceEvent, - is FileHeaderEvent, + is FileMetadataEvent, is ProfileGalleryEntryEvent, is FileStorageHeaderEvent, - is GoalEvent, + is ZapGoalEvent, is GroupEvent, is GitIssueEvent, is GitReplyEvent, @@ -3976,13 +3980,13 @@ open class EventCache : is JesterEvent, is HighlightEvent, is PodcastEpisodeEvent, - is NIP90StatusEvent, - is NIP90ContentDiscoveryResponseEvent, - is NIP90ContentDiscoveryRequestEvent, - is NIP90UserDiscoveryResponseEvent, - is NIP90UserDiscoveryRequestEvent, + is DvmStatusEvent, + is DvmContentDiscoveryResponseEvent, + is DvmContentDiscoveryRequestEvent, + is DvmUserDiscoveryResponseEvent, + is DvmUserDiscoveryRequestEvent, is PictureEvent, - is PrivateDmEvent, + is EncryptedDmEvent, is PublicMessageEvent, is RequestToVanishEvent, is CodeSnippetEvent, @@ -3996,7 +4000,7 @@ open class EventCache : is GeocacheVerificationEvent, is RoadEventReportEvent, is RoadEventConfirmationEvent, - is SealedRumorEvent, + is SealEvent, is SoftwareAssetEvent, is ExternalReactionEvent, is ExternalCitationEvent, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/chats/ChatFeedType.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/chats/ChatFeedType.kt index 3e5a581a72..ab864a0adf 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/chats/ChatFeedType.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/chats/ChatFeedType.kt @@ -49,6 +49,17 @@ enum class ChatFeedType( /** Concord encrypted communities (gift-wrapped plane streams). */ CONCORD("concord"), + /** + * cordn — MLS group chat coordinated by an MCP server rather than a relay. + * + * Listed beside Marmot and sharing nothing with it: the two are separate + * protocols that happen to both be MLS, and the inbox is one of the few + * places they meet at all (see §3.1 of + * `amethyst/plans/2026-09-19-cordn-ui.md`). Toggling this off hides cordn + * rows and stops its sync loops; it does not touch Marmot. + */ + CORDN("cordn"), + /** Geohash location channels (kind 20000). */ GEOHASH("geohash"), diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cordnGroups/CordnGroupChatroom.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cordnGroups/CordnGroupChatroom.kt new file mode 100644 index 0000000000..3a093273b8 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cordnGroups/CordnGroupChatroom.kt @@ -0,0 +1,347 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.cordnGroups + +import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.model.NotesGatherer +import com.vitorpamplona.quartz.cordn.groups.CordnCredential +import com.vitorpamplona.quartz.cordn.spec01GroupMetadata.CordnGroupMetadata +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnAnnotationIndex +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnDeliveredMessage +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnMessageKinds +import com.vitorpamplona.quartz.mls.group.MlsGroup +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * One cordn group, as a screen sees it. + * + * ## Keyed by envelope id, never by cursor + * + * `spec/02.md` §7 is explicit that the envelope id is the message identity and + * the cursor is a delivery primitive. They are easy to confuse because both are + * unique within a group and both arrive together, and confusing them is not a + * cosmetic bug: a cursor is coordinator-local, so the same message re-delivered + * after a re-sync carries a different one. Keying on it would duplicate every + * message the client ever re-fetches, and dedupe would silently stop working + * exactly when a group is recovering. + * + * ## Ordered by the cursor, not by the sender's clock + * + * `created_at` is a claim the sender makes about their own device. Sorting on + * it is the classic Nostr failure: one wrong clock scatters that member's + * messages through the room, and a sender who wants to can pin a message to + * the top of the history permanently by backdating it. The cursor has neither + * problem — the coordinator assigns it, so it is the one order every member + * already agrees on, and no sender can choose their own. + * + * This does trust the coordinator to order the stream, which it is trusted for + * anyway: `spec/00.md` §4 makes it the sole authority for a group's stream. It + * is **not** trusted for content, and is not being trusted for any more here — + * MLS authenticates what a message says and who sent it, the coordinator says + * only when it arrived. `created_at` is still shown; it is just not what the + * list is built from. + * + * ## Not a Marmot room with the names changed + * + * `MarmotGroupChatroom` holds `Note`s, carries Marmot's GroupContext state + * (lifecycle, outbound gates, legacy-profile flags, encrypted-media policy) and + * is fed from `LocalCache`. None of that exists here: cordn has no lifecycle + * extension, no outbound gate, no profile split, and its messages arrive as + * envelopes from a coordinator rather than events from a relay. The two look + * alike only in that both are group chats — see §3.1 of + * `amethyst/plans/2026-09-19-cordn-ui.md`, which the isolation guards enforce. + */ +@Stable +class CordnGroupChatroom( + val gid: String, + val coordinatorPubKey: HexKey, + /** Whose room this is, so [unreadCount] can tell news from an echo. */ + val accountPubKey: HexKey = "", + /** + * Told when [newest] becomes a different message, and only then. + * + * The inbox sorts rooms on this room's newest message, so it has to rebuild + * when that changes -- and the change can come from anywhere: a delivery + * filed through [CordnGroupList], an optimistic send that calls [add] on + * this room directly, or a restore. Hanging the signal off + * `CordnGroupList.add` caught only the first of those, which left the + * commonest case -- you send a message -- not re-sorting at all. + * + * Not called for a reaction, an edit or a re-delivered echo: those leave the + * newest message as it was, and rebuilding the whole inbox for one would be + * a rebuild per annotation for no visible change. + */ + private val onNewestChanged: () -> Unit = {}, +) : NotesGatherer { + private val byId = LinkedHashMap() + + private val _messages = MutableStateFlow>(emptyList()) + + /** Renderable messages, oldest first, annotations already removed. */ + val messages: StateFlow> = _messages.asStateFlow() + + private val _annotations = MutableStateFlow(CordnAnnotationIndex.of(emptyList())) + + /** Reactions, edits, deletions and pins folded onto their targets. */ + val annotations: StateFlow = _annotations.asStateFlow() + + private val _name = MutableStateFlow(null) + private val _description = MutableStateFlow(null) + private val _adminPubkeys = MutableStateFlow>(emptyList()) + private val _members = MutableStateFlow>(emptyList()) + private val _epoch = MutableStateFlow(0L) + + val name: StateFlow = _name.asStateFlow() + val description: StateFlow = _description.asStateFlow() + + /** Admins, or empty for egalitarian — `spec/01.md` §5.3 makes that permanent. */ + val adminPubkeys: StateFlow> = _adminPubkeys.asStateFlow() + + /** Member account pubkeys, from each leaf's cordn credential (`spec/01.md`). */ + val members: StateFlow> = _members.asStateFlow() + val epoch: StateFlow = _epoch.asStateFlow() + + /** + * Re-reads the room's group state off [group]. + * + * Everything above is derived, never stored: the group's name, its admins + * and its membership live in MLS state — the metadata extension + * (`spec/01.md`) and the ratchet tree — and the only honest way to show + * them is to read them back out after every change. A Commit can rename a + * group or remove a member, so a cached copy is a copy that goes stale in + * exactly the cases that matter. + * + * Deliberately not `MlsGroup.memberIdentityHex`: that hex-encodes the + * credential bytes, and a cordn credential already IS hex, so it would + * report 128 characters of hex-of-hex. [CordnCredential.memberIdentities] + * is the cordn-aware reader. + */ + fun refreshFrom(group: MlsGroup) { + val metadata = CordnGroupMetadata.fromExtensions(group.extensions) + _name.value = metadata?.name + _description.value = metadata?.description + _adminPubkeys.value = metadata?.adminPubkeys.orEmpty() + // Leaf order, which is stable across reads, so a member list does not + // reshuffle itself under the user on an unrelated delivery. + _members.value = CordnCredential.memberIdentities(group).toList() + _epoch.value = group.epoch + } + + /** + * The newest message, for an inbox row. + * + * Annotations are excluded, so a room whose last traffic was a reaction + * still previews the message that was reacted to rather than a "+" nobody + * can read. + */ + private val _newest = MutableStateFlow(null) + val newest: StateFlow = _newest.asStateFlow() + + /** Text typed here and not sent. Restored from the store when the room opens. */ + val draft = MutableStateFlow("") + + private val _lastReadCursor = MutableStateFlow(0L) + + /** The newest cursor this account has seen. See [unreadCount]. */ + val lastReadCursor: StateFlow = _lastReadCursor.asStateFlow() + + private val _unreadCount = MutableStateFlow(0) + + /** + * How many messages have arrived past [lastReadCursor]. + * + * Counted on the cursor rather than on `created_at` for the reason the + * room is ordered on it: a sender's clock is a claim, and one wrong clock + * would otherwise make a room permanently unread or silently swallow new + * messages. Annotations are excluded — a reaction to something already + * read is not an unread message — and so is this account's own traffic, + * because arriving back as an echo does not make it news. + */ + val unreadCount: StateFlow = _unreadCount.asStateFlow() + + /** Marks everything currently in the room as read. */ + fun markRead() { + _lastReadCursor.value = maxOf(_lastReadCursor.value, byId.values.maxOfOrNull { it.cursor } ?: 0L) + recountUnread() + } + + /** + * Seeds the inbox's preview line without loading the conversation. + * + * The inbox needs a last message for every room before any room is opened, + * and reading every group's whole history at login to get one would be + * paying for the rooms nobody visits. The store keeps a one-entry summary + * for exactly this. + * + * Ignored once the room holds anything, so a summary read cannot overwrite + * a newer message that live delivery already put here. + */ + fun restorePreview(newest: CordnDeliveredMessage) { + if (byId.isEmpty()) setNewest(newest) + } + + /** Restores what the store remembered for this room. */ + fun restoreState( + draft: String, + lastReadCursor: Long, + ) { + this.draft.value = draft + _lastReadCursor.value = lastReadCursor + recountUnread() + } + + private fun recountUnread() { + val read = _lastReadCursor.value + _unreadCount.value = + byId.values.count { message -> + message.cursor > read && + message.envelope.pubKey != accountPubKey && + message.envelope.kind in CONVERSATIONAL_KINDS + } + } + + /** + * Adds [delivered], returning false when this room already had it. + * + * Idempotent because a re-sync re-delivers: `catch_up` after a restart + * walks the stream from a persisted cursor and hands back messages the + * room may already hold. + */ + fun add(delivered: CordnDeliveredMessage): Boolean { + if (byId.containsKey(delivered.envelope.id)) return false + byId[delivered.envelope.id] = delivered + recompute() + return true + } + + /** Adds several, recomputing once. Returns how many were new. */ + fun addAll(delivered: Collection): Int { + val added = delivered.count { byId.putIfAbsentCompat(it.envelope.id, it) } + if (added > 0) recompute() + return added + } + + /** Every message this room holds, annotations included. For the fold. */ + fun all(): List = byId.values.toList() + + private fun recompute() { + recountUnread() + + val everything = byId.values.toList() + _annotations.value = CordnAnnotationIndex.of(everything) + + val visible = + everything + .filterNot { CordnMessageKinds.isAnnotation(it.envelope.kind) } + .sortedWith(ORDER) + + _messages.value = visible + setNewest(visible.lastOrNull()) + } + + /** + * Publishes a new preview, and says so exactly once per real change. + * + * Keyed on the envelope id rather than the message: [recompute] runs for + * every arrival, annotations included, and re-publishing an identical + * newest would tell the inbox to re-sort over and over for nothing. + */ + private fun setNewest(value: CordnDeliveredMessage?) { + if (_newest.value?.envelope?.id == value?.envelope?.id) return + + _newest.value = value + onNewestChanged() + } + + private var cachedRow: Note? = null + + /** + * The `Note` that carries this room into the unified Messages inbox. + * + * The inbox is a list of `Note`s and cordn messages are not Notes — they + * are MLS envelopes, and the whole point of `spec/02.md` is that they never + * touch a relay. So rather than fabricate a Note per message and keep two + * copies of every conversation, one Note stands for the whole room: the + * inbox finds the room through [Note.inGatherers] and reads the name and + * preview from the room itself, which is the live data. + * + * Marmot uses the same trick, but only for a group with no messages yet + * (`MarmotGroupChatroom.placeholderNote`). For cordn it is the permanent + * mechanism, because there is no second representation to fall back to — + * and that is the point: nothing here puts a cordn message into + * `LocalCache`, where it would become searchable, notifiable, and + * indistinguishable from an event that was actually published somewhere. + */ + fun inboxRow(): Note = + cachedRow ?: CordnInboxRowNote(this).also { + it.addGatherer(this) + cachedRow = it + } + + /** + * Nothing to do: the inbox row is not one of this room's messages, and + * this room's messages are not Notes. Present because the inbox reaches a + * room through [NotesGatherer]. + */ + override fun removeNote(note: Note) = Unit + + companion object { + /** Distinct per (coordinator, gid), because a `gid` alone is not unique (§4). */ + fun rowIdHex( + coordinatorPubKey: HexKey, + gid: String, + ): String = "cordn-$coordinatorPubKey-$gid" + + /** + * What counts as an unread message. + * + * Only what someone said. A reaction, an edit, a deletion or a pin + * landing on something already read is not a new message, and badging + * the room for one would train people to ignore the badge. + */ + val CONVERSATIONAL_KINDS = setOf(CordnMessageKinds.TEXT, CordnMessageKinds.THREAD_REPLY) + + /** + * The coordinator's order, with the sender's clock only as a tiebreak. + * + * See the class KDoc. The tiebreak should never fire — a coordinator + * assigns each message its own cursor — but leaving the comparator + * total costs nothing and keeps two clients from disagreeing if one + * ever does. + */ + val ORDER: Comparator = + compareBy { it.cursor }.thenBy { it.envelope.createdAt } + } +} + +/** `putIfAbsent` returning whether it inserted, on every KMP target. */ +private fun MutableMap.putIfAbsentCompat( + key: K, + value: V, +): Boolean { + if (containsKey(key)) return false + put(key, value) + return true +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cordnGroups/CordnGroupList.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cordnGroups/CordnGroupList.kt new file mode 100644 index 0000000000..f7f284a7b4 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cordnGroups/CordnGroupList.kt @@ -0,0 +1,149 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.cordnGroups + +import androidx.compose.runtime.Stable +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnDeliveredMessage +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +/** + * One account's cordn rooms, across every coordinator it talks to. + * + * ## Keyed by (coordinator, gid), never by gid alone + * + * A `gid` is unique only within one coordinator (`spec/00.md` §4) — two of them + * can both serve `gid = "abc"` as unrelated groups with different members and + * different ratchet trees. A map keyed by `gid` would let one answer for the + * other, and from the user's side that reads as a room whose history changes + * depending on which coordinator last synced. The same rule keys + * `CordnCoordinatorRegistry` and the on-disk stores; this is the third place + * it shows up, and it is the same rule each time. + */ +@Stable +class CordnGroupList( + /** Whose rooms these are; handed to every room for its unread count. */ + private val accountPubKey: HexKey = "", +) { + /** A room's identity: the coordinator that serves it, plus its `gid`. */ + data class RoomKey( + val coordinatorPubKey: HexKey, + val gid: String, + ) + + private val rooms = LinkedHashMap() + + private val _all = MutableStateFlow>(emptyList()) + + /** Every room, for the inbox and the group list. */ + val all: StateFlow> = _all.asStateFlow() + + private val _revision = MutableStateFlow(0L) + + /** + * `update` rather than `value++`: deliveries arrive off the sync loop while + * a send can be filing one from the UI, and a read-modify-write would drop + * bumps under exactly the interleaving the inbox needs to notice. + */ + private fun bumpRevision() = _revision.update { it + 1 } + + /** + * Bumps on every change the inbox cares about: a room added or dropped, + * **and** every message filed into one. + * + * [all] cannot serve that second purpose, which is the trap this exists to + * close. It re-emits only when the room *set* changes -- a message for a + * room that already exists returns early from [getOrCreate] and never + * reassigns it -- so a feed rebuilt off `all` alone rebuilds when a room is + * joined and never again. The rows then keep whatever order that first + * build gave them no matter what arrives, which is invisible until the + * inbox is sorted by recency. + * + * The Concord control plane exposes its own `revision` for the same reason; + * both are meant to be `sample()`d, since a re-sync files a burst. + */ + val revision: StateFlow = _revision.asStateFlow() + + fun getOrCreate( + coordinatorPubKey: HexKey, + gid: String, + ): CordnGroupChatroom { + val key = RoomKey(coordinatorPubKey, gid) + rooms[key]?.let { return it } + val room = + CordnGroupChatroom( + gid = gid, + coordinatorPubKey = coordinatorPubKey, + accountPubKey = accountPubKey, + // The room is the only place that sees every way its newest + // message can change, so it is the room that reports one. + onNewestChanged = { bumpRevision() }, + ) + rooms[key] = room + _all.value = rooms.values.toList() + bumpRevision() + return room + } + + fun get( + coordinatorPubKey: HexKey, + gid: String, + ): CordnGroupChatroom? = rooms[RoomKey(coordinatorPubKey, gid)] + + /** + * Files [delivered] into its room, creating it if needed. + * + * Returns false when the room already held it — a re-sync re-delivers, so + * a caller that counts unread messages must not count this one twice. + */ + fun add( + coordinatorPubKey: HexKey, + gid: String, + delivered: CordnDeliveredMessage, + ): Boolean = getOrCreate(coordinatorPubKey, gid).add(delivered) + + /** Drops a room. The caller decides whether the stored state goes too. */ + fun forget( + coordinatorPubKey: HexKey, + gid: String, + ) { + if (rooms.remove(RoomKey(coordinatorPubKey, gid)) != null) { + _all.value = rooms.values.toList() + bumpRevision() + } + } + + /** Drops every room served by [coordinatorPubKey]. For a purge. */ + fun forgetCoordinator(coordinatorPubKey: HexKey) { + rooms.keys.filter { it.coordinatorPubKey == coordinatorPubKey }.forEach { rooms.remove(it) } + _all.value = rooms.values.toList() + bumpRevision() + } + + fun clear() { + rooms.clear() + _all.value = emptyList() + bumpRevision() + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cordnGroups/CordnInboxRowNote.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cordnGroups/CordnInboxRowNote.kt new file mode 100644 index 0000000000..80977855bd --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cordnGroups/CordnInboxRowNote.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.cordnGroups + +import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.commons.model.Note + +/** + * The Messages-list row for a cordn room, carrying the one thing a plain [Note] + * cannot work out for itself: when the room last said something. + * + * A cordn row has no event. Its messages are MLS envelopes from a coordinator + * and never enter `LocalCache`, so `Note.createdAt()` — which reads + * `event?.createdAt` — returns null for it. The Messages feed sorts on exactly + * that value and maps null to `0L`, so every cordn room used to sink to the + * bottom of the inbox permanently, tie-broken alphabetically by [idHex] against + * the other cordn rooms. The row itself displayed the right time the whole + * while; only the sort could not see it. + * + * Overriding [createdAt] is how the other event-less inbox rows already solve + * this — see `RelayGroupServerRoomNote` and `ConcordServerRoomNote`, both of + * which mirror their newest message for the same reason. cordn is the one that + * was missing it. + */ +@Stable +class CordnInboxRowNote( + val room: CordnGroupChatroom, +) : Note(CordnGroupChatroom.rowIdHex(room.coordinatorPubKey, room.gid)) { + override fun createdAt(): Long? = + room.newest.value + ?.envelope + ?.createdAt +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/navigation/DrawerSectionId.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/navigation/DrawerSectionId.kt new file mode 100644 index 0000000000..cdb41678a5 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/navigation/DrawerSectionId.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.navigation + +enum class DrawerSectionId { + YOU, + NAVIGATE, + FEEDS, + + /** Composer entry points. Carries no catalog destinations, so nothing in it is configurable. */ + CREATE, + + /** Also renders the relay-status row, which isn't a catalog destination (it shows a live counter). */ + SYSTEM, +} + +private val DrawerSectionIdsByName = DrawerSectionId.entries.associateBy { it.name } + +/** + * Parses the persisted names of the headings the user has collapsed, silently dropping any this + * build doesn't know. Mirrors [com.vitorpamplona.amethyst.commons.model.navigation.navBarItemsFromNames]: + * names rather than ordinals, so reordering this enum renames nothing by accident, and a value left + * by a build with one more section costs that heading rather than the whole read. + * + * The stored set holds the **collapsed** headings rather than the expanded ones, for the same reason + * [DrawerItemVisibility] stores the hidden rows: a heading nobody has ever collapsed simply isn't in + * the set, so a section added in a later release opens expanded for everyone with no migration. + */ +fun drawerSectionIdsFromNames(names: Collection): Set = names.mapNotNullTo(mutableSetOf()) { DrawerSectionIdsByName[it] } + +/** + * The inverse of [drawerSectionIdsFromNames]. Unlike the NavBarItem codec this returns an unsorted + * Set rather than a sorted List: the destination is a DataStore string set, whose equality is + * already order-independent, so there is no serialized form to keep deterministic. + */ +fun Set.toNames(): Set = mapTo(mutableSetOf()) { it.name } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/navigation/NavBarItem.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/navigation/NavBarItem.kt index 14fea0e82d..e2c745f525 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/navigation/NavBarItem.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/navigation/NavBarItem.kt @@ -68,6 +68,7 @@ enum class NavBarItem { RELAY_GROUPS, CONCORD, MARMOT_GROUPS, + CORDN_GROUPS, GEOHASH_CHATS, FOLLOW_PACKS, LIVE_STREAMS, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/navigation/Routes.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/navigation/Routes.kt index 76cf349bf5..70b8daf092 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/navigation/Routes.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/navigation/Routes.kt @@ -634,6 +634,44 @@ sealed class Route { @Serializable object EditNestsServers : Route() + @Serializable object CordnLink : Route() + + /** + * One cordn room. + * + * Both halves are the address: a `gid` is unique only within one + * coordinator (`spec/00.md` §4), so a route carrying the gid alone would + * open whichever of two same-named groups happened to be found first. + */ + @Serializable data class CordnGroupChat( + val coordinatorPubKey: HexKey, + val gid: String, + ) : Route() + + @Serializable data class CordnGroupInfo( + val coordinatorPubKey: HexKey, + val gid: String, + ) : Route() + + @Serializable object CordnGroupList : Route() + + @Serializable object CordnCreateGroup : Route() + + @Serializable object CordnCreateGroupMembers : Route() + + @Serializable object CordnInvitations : Route() + + @Serializable object CordnCoordinators : Route() + + @Serializable object CordnKeyPackages : Route() + + @Serializable object CordnBackup : Route() + + /** The one cordn entry in settings; everything else hangs off it. */ + @Serializable object CordnHub : Route() + + @Serializable object CordnMigrate : Route() + @Serializable data class AgentConsole( val relayUrl: String, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip23LongContent/LongFormPublishAction.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip23LongContent/LongFormPublishAction.kt index f34b9a0792..4526e93e9c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip23LongContent/LongFormPublishAction.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip23LongContent/LongFormPublishAction.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.commons.model.nip23LongContent import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtag -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent import com.vitorpamplona.quartz.utils.TimeUtils /** @@ -42,7 +42,7 @@ object LongFormPublishAction { * @param tags List of hashtag topics * @param dTag Unique identifier for this addressable event (slug) * @param signer The NostrSigner to sign the event - * @return Signed LongTextNoteEvent ready to broadcast + * @return Signed LongFormContentEvent ready to broadcast * @throws IllegalStateException if signer is not writeable */ suspend fun publish( @@ -53,7 +53,7 @@ object LongFormPublishAction { tags: List, dTag: String, signer: NostrSigner, - ): LongTextNoteEvent { + ): LongFormContentEvent { if (!signer.isWriteable()) { throw IllegalStateException("Cannot publish: signer is not writeable") } @@ -63,7 +63,7 @@ object LongFormPublishAction { } val template = - LongTextNoteEvent.build( + LongFormContentEvent.build( description = content, title = title, summary = summary, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip28PublicChats/PublicChatListDecryptionCache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip28PublicChats/PublicChatListDecryptionCache.kt index e8f16823c9..702240aee3 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip28PublicChats/PublicChatListDecryptionCache.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip28PublicChats/PublicChatListDecryptionCache.kt @@ -21,7 +21,7 @@ package com.vitorpamplona.amethyst.commons.model.nip28PublicChats import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent +import com.vitorpamplona.quartz.nip28PublicChat.list.PublicChatListEvent import com.vitorpamplona.quartz.nip28PublicChat.list.channelSet import com.vitorpamplona.quartz.nip28PublicChat.list.channels import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEventCache @@ -29,13 +29,13 @@ import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEventCache class PublicChatListDecryptionCache( val signer: NostrSigner, ) { - val cachedPrivateLists = PrivateTagArrayEventCache(signer) + val cachedPrivateLists = PrivateTagArrayEventCache(signer) - fun cachedChannelSet(event: ChannelListEvent) = cachedPrivateLists.mergeTagListPrecached(event).channelSet() + fun cachedChannelSet(event: PublicChatListEvent) = cachedPrivateLists.mergeTagListPrecached(event).channelSet() - fun cachedChannels(event: ChannelListEvent) = cachedPrivateLists.mergeTagListPrecached(event).channels() + fun cachedChannels(event: PublicChatListEvent) = cachedPrivateLists.mergeTagListPrecached(event).channels() - suspend fun channelSet(event: ChannelListEvent) = cachedPrivateLists.mergeTagList(event).channelSet() + suspend fun channelSet(event: PublicChatListEvent) = cachedPrivateLists.mergeTagList(event).channelSet() - suspend fun channels(event: ChannelListEvent) = cachedPrivateLists.mergeTagList(event).channels() + suspend fun channels(event: PublicChatListEvent) = cachedPrivateLists.mergeTagList(event).channels() } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip28PublicChats/PublicChatListState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip28PublicChats/PublicChatListState.kt index 2c59434441..da9e804fcb 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip28PublicChats/PublicChatListState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip28PublicChats/PublicChatListState.kt @@ -25,7 +25,7 @@ import com.vitorpamplona.amethyst.commons.model.NoteState import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent +import com.vitorpamplona.quartz.nip28PublicChat.list.PublicChatListEvent import com.vitorpamplona.quartz.nip28PublicChat.list.tags.ChannelTag import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineScope @@ -44,9 +44,9 @@ import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.launch interface PublicChatListRepository { - fun channelList(): ChannelListEvent? + fun publicChatList(): PublicChatListEvent? - fun updateChannelListTo(newChannelList: ChannelListEvent?) + fun updatePublicChatListTo(newPublicChatList: PublicChatListEvent?) } class PublicChatListState( @@ -57,22 +57,22 @@ class PublicChatListState( val settings: PublicChatListRepository, ) { // Creates a long-term reference for this note so that the GC doesn't collect the note it self - val publicChatListNote = cache.getOrCreateAddressableNote(getChannelListAddress()) + val publicChatListNote = cache.getOrCreateAddressableNote(getPublicChatListAddress()) - fun getChannelListAddress() = ChannelListEvent.createAddress(signer.pubKey) + fun getPublicChatListAddress() = PublicChatListEvent.createAddress(signer.pubKey) - fun getChannelListFlow(): StateFlow = publicChatListNote.flow().metadata.stateFlow + fun getPublicChatListFlow(): StateFlow = publicChatListNote.flow().metadata.stateFlow - fun getChannelList(): ChannelListEvent? = publicChatListNote.event as? ChannelListEvent + fun getPublicChatList(): PublicChatListEvent? = publicChatListNote.event as? PublicChatListEvent suspend fun publicChatListWithBackup(note: Note): Set { - val event = note.event as? ChannelListEvent ?: settings.channelList() + val event = note.event as? PublicChatListEvent ?: settings.publicChatList() return event?.let { decryptionCache.channelSet(it) } ?: emptySet() } @OptIn(ExperimentalCoroutinesApi::class) val flow: StateFlow> = - getChannelListFlow() + getPublicChatListFlow() .transformLatest { noteState -> emit(publicChatListWithBackup(noteState.note)) }.onStart { @@ -111,39 +111,39 @@ class PublicChatListState( emptyList(), ) - suspend fun follow(channel: PublicChatChannel): ChannelListEvent { - val publicChatList = getChannelList() + suspend fun follow(channel: PublicChatChannel): PublicChatListEvent { + val publicChatList = getPublicChatList() return if (publicChatList == null) { - ChannelListEvent.create(ChannelTag(channel.idHex, channel.relayHintUrl()), true, signer) + PublicChatListEvent.create(ChannelTag(channel.idHex, channel.relayHintUrl()), true, signer) } else { - ChannelListEvent.add(publicChatList, ChannelTag(channel.idHex, channel.relayHintUrl()), true, signer) + PublicChatListEvent.add(publicChatList, ChannelTag(channel.idHex, channel.relayHintUrl()), true, signer) } } - suspend fun follow(channels: List): ChannelListEvent { - val publicChatList = getChannelList() + suspend fun follow(channels: List): PublicChatListEvent { + val publicChatList = getPublicChatList() val channelTags = channels.map { ChannelTag(it.idHex, it.relayHintUrl()) } return if (publicChatList == null) { - ChannelListEvent.create(channelTags, true, signer) + PublicChatListEvent.create(channelTags, true, signer) } else { - ChannelListEvent.add(publicChatList, channelTags, true, signer) + PublicChatListEvent.add(publicChatList, channelTags, true, signer) } } - suspend fun unfollow(channel: PublicChatChannel): ChannelListEvent? { - val publicChatList = getChannelList() + suspend fun unfollow(channel: PublicChatChannel): PublicChatListEvent? { + val publicChatList = getPublicChatList() return if (publicChatList != null) { - ChannelListEvent.remove(publicChatList, ChannelTag(channel.idHex, channel.relayHintUrl()), signer) + PublicChatListEvent.remove(publicChatList, ChannelTag(channel.idHex, channel.relayHintUrl()), signer) } else { null } } init { - settings.channelList()?.let { event -> + settings.publicChatList()?.let { event -> Log.d("AccountRegisterObservers") { "Loading saved channel list ${event.toJson()}" } @OptIn(DelicateCoroutinesApi::class) scope.launch(Dispatchers.IO) { @@ -153,10 +153,10 @@ class PublicChatListState( scope.launch(Dispatchers.IO) { Log.d("AccountRegisterObservers", "Channel List Collector Start") - getChannelListFlow().collect { + getPublicChatListFlow().collect { Log.d("AccountRegisterObservers") { "Channel List for ${signer.pubKey}" } - (it.note.event as? ChannelListEvent)?.let { - settings.updateChannelListTo(it) + (it.note.event as? PublicChatListEvent)?.let { + settings.updatePublicChatListTo(it) } } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt index 9c029296f8..7849dfd3d0 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt @@ -35,7 +35,7 @@ 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 +import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupRolesEvent import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupAdminTag import com.vitorpamplona.quartz.nip29RelayGroups.tags.RoleTag import com.vitorpamplona.quartz.utils.cache.LargeCache @@ -109,9 +109,9 @@ class RelayGroupChannel( * the role names a moderation UI should offer when assigning a role, since the exact * set is relay-defined (NIP-29 §Group management). */ - var supportedRoles: List = emptyList() + var groupRoles: List = emptyList() private set - private var supportedRolesUpdatedAt: Long = 0 + private var groupRolesUpdatedAt: Long = 0 /** * Members ∪ admins, recomputed only when a roster event lands. [memberCount] and the discovery @@ -164,7 +164,7 @@ class RelayGroupChannel( event != null || members.isNotEmpty() || admins.isNotEmpty() || - supportedRoles.isNotEmpty() || + groupRoles.isNotEmpty() || pinnedEventIds.isNotEmpty() /** A relay group lives on exactly one relay: its host. */ @@ -237,11 +237,11 @@ class RelayGroupChannel( updateChannelInfo() } - fun updateSupportedRoles(event: SupportedRolesEvent) { + fun updateGroupRoles(event: GroupRolesEvent) { // Only newer definitions supersede; equal-or-older is dropped (no redundant emit). - if (event.createdAt <= supportedRolesUpdatedAt) return - supportedRoles = event.roles() - supportedRolesUpdatedAt = event.createdAt + if (event.createdAt <= groupRolesUpdatedAt) return + groupRoles = event.roles() + groupRolesUpdatedAt = event.createdAt updateChannelInfo() } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip30CustomEmojis/EmojiPackState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip30CustomEmojis/EmojiPackState.kt index decf5f345e..eea948a198 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip30CustomEmojis/EmojiPackState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip30CustomEmojis/EmojiPackState.kt @@ -28,7 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.aTag.taggedAddresses import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent -import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEvent +import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiListEvent import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -53,15 +53,15 @@ class EmojiPackState( ) // Creates a long-term reference for this note so that the GC doesn't collect the note it self - val emojiPackListNote = cache.getOrCreateAddressableNote(getEmojiPackSelectionAddress()) + val emojiPackListNote = cache.getOrCreateAddressableNote(getEmojiListAddress()) - fun getEmojiPackSelectionAddress() = EmojiPackSelectionEvent.createAddress(signer.pubKey) + fun getEmojiListAddress() = EmojiListEvent.createAddress(signer.pubKey) - fun getEmojiPackSelection(): EmojiPackSelectionEvent? = emojiPackListNote.event as? EmojiPackSelectionEvent + fun getEmojiList(): EmojiListEvent? = emojiPackListNote.event as? EmojiListEvent - fun getEmojiPackSelectionFlow(): StateFlow = emojiPackListNote.flow().metadata.stateFlow + fun getEmojiListFlow(): StateFlow = emojiPackListNote.flow().metadata.stateFlow - fun convertEmojiSelectionPack(selection: EmojiPackSelectionEvent?): List>? = + fun convertEmojiSelectionPack(selection: EmojiListEvent?): List>? = selection?.taggedAddresses()?.map { cache .getOrCreateAddressableNote(it) @@ -71,11 +71,11 @@ class EmojiPackState( @OptIn(ExperimentalCoroutinesApi::class) val flow: StateFlow>?> = - getEmojiPackSelectionFlow() + getEmojiListFlow() .transformLatest { - emit(convertEmojiSelectionPack(it.note.event as? EmojiPackSelectionEvent)) + emit(convertEmojiSelectionPack(it.note.event as? EmojiListEvent)) }.onStart { - emit(convertEmojiSelectionPack(getEmojiPackSelection())) + emit(convertEmojiSelectionPack(getEmojiList())) }.flowOn(Dispatchers.IO) .stateIn( scope, @@ -115,7 +115,7 @@ class EmojiPackState( emit( mergePackWithPrivate( convertEmojiSelectionPack( - getEmojiPackSelection(), + getEmojiList(), )?.map { it.value }?.toTypedArray() ?: emptyArray(), ), ) @@ -143,29 +143,29 @@ class EmojiPackState( } } - suspend fun addEmojiPack(emojiPack: Note): EmojiPackSelectionEvent { + suspend fun addEmojiPack(emojiPack: Note): EmojiListEvent { val emojiPackEvent = emojiPack.event if (emojiPackEvent !is EmojiPackEvent) throw IllegalArgumentException("Note is not an EmojiPackEvent; cannot add to emoji list.") val eventHint = emojiPack.toEventHint() ?: throw IllegalArgumentException("Cannot build event hint for this emoji pack.") - val usersEmojiList = getEmojiPackSelection() + val usersEmojiList = getEmojiList() return if (usersEmojiList == null) { - val template = EmojiPackSelectionEvent.build(listOf(eventHint)) + val template = EmojiListEvent.build(listOf(eventHint)) signer.sign(template) } else { - val template = EmojiPackSelectionEvent.add(usersEmojiList, eventHint) + val template = EmojiListEvent.add(usersEmojiList, eventHint) signer.sign(template) } } - suspend fun removeEmojiPack(emojiPack: Note): EmojiPackSelectionEvent? { - val usersEmojiList = getEmojiPackSelection() ?: throw IllegalArgumentException("No emoji pack selection exists to remove from.") + suspend fun removeEmojiPack(emojiPack: Note): EmojiListEvent? { + val usersEmojiList = getEmojiList() ?: throw IllegalArgumentException("No emoji pack selection exists to remove from.") val emojiPackEvent = emojiPack.event if (emojiPackEvent !is EmojiPackEvent) return null - val template = EmojiPackSelectionEvent.remove(usersEmojiList, emojiPackEvent) + val template = EmojiListEvent.remove(usersEmojiList, emojiPackEvent) return signer.sign(template) } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/UserStatusAction.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/UserStatusAction.kt index 0040e9e0d8..4fac63e096 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/UserStatusAction.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip38UserStatuses/UserStatusAction.kt @@ -23,38 +23,38 @@ package com.vitorpamplona.amethyst.commons.model.nip38UserStatuses import com.vitorpamplona.amethyst.commons.model.AddressableNote import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent -import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent +import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent +import com.vitorpamplona.quartz.nip38UserStatus.UserStatusEvent class UserStatusAction { companion object { suspend fun create( newStatus: String, signer: NostrSigner, - type: String = StatusEvent.GENERAL, - ): StatusEvent = StatusEvent.create(newStatus, type, signer = signer) + type: String = UserStatusEvent.GENERAL, + ): UserStatusEvent = UserStatusEvent.create(newStatus, type, signer = signer) suspend fun update( oldStatus: AddressableNote, newStatus: String, signer: NostrSigner, - ): StatusEvent { - val oldEvent = oldStatus.event as? StatusEvent ?: throw IllegalStateException("Tried to update a non-status event") + ): UserStatusEvent { + val oldEvent = oldStatus.event as? UserStatusEvent ?: throw IllegalStateException("Tried to update a non-status event") - return StatusEvent.update(oldEvent, newStatus, signer) + return UserStatusEvent.update(oldEvent, newStatus, signer) } suspend fun delete( oldStatus: AddressableNote, signer: NostrSigner, ): List { - val oldEvent = oldStatus.event as? StatusEvent ?: throw IllegalStateException("Tried to update a non-status event") + val oldEvent = oldStatus.event as? UserStatusEvent ?: throw IllegalStateException("Tried to update a non-status event") - val event = StatusEvent.clear(oldEvent, signer) + val event = UserStatusEvent.clear(oldEvent, signer) val deletion = signer.sign( - DeletionEvent.buildForVersionOnly(listOf(event)), + DeletionRequestEvent.buildForVersionOnly(listOf(event)), ) return listOf(event, deletion) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/blockPeopleList/BlockPeopleListState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/blockPeopleList/BlockPeopleListState.kt index a1d0403978..e0c991c18a 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/blockPeopleList/BlockPeopleListState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/blockPeopleList/BlockPeopleListState.kt @@ -23,12 +23,12 @@ package com.vitorpamplona.amethyst.commons.model.nip51Lists.blockPeopleList import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.NoteState import com.vitorpamplona.amethyst.commons.model.cache.LocalCache -import com.vitorpamplona.amethyst.commons.model.nip51Lists.peopleList.PeopleListDecryptionCache +import com.vitorpamplona.amethyst.commons.model.nip51Lists.followSets.FollowSetDecryptionCache import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip51Lists.followSet.FollowSetEvent import com.vitorpamplona.quartz.nip51Lists.muteList.tags.MuteTag import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag import com.vitorpamplona.quartz.nip51Lists.muteList.tags.WordTag -import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO @@ -42,20 +42,20 @@ import kotlinx.coroutines.flow.stateIn class BlockPeopleListState( val signer: NostrSigner, val cache: LocalCache, - val decryptionCache: PeopleListDecryptionCache, + val decryptionCache: FollowSetDecryptionCache, val scope: CoroutineScope, ) { // Creates a long-term reference for this note so that the GC doesn't collect the note it self val blockListNote = cache.getOrCreateAddressableNote(getBlockListAddress()) - fun getBlockListAddress() = PeopleListEvent.createBlockAddress(signer.pubKey) + fun getBlockListAddress() = FollowSetEvent.createBlockAddress(signer.pubKey) fun getBlockListFlow(): StateFlow = blockListNote.flow().metadata.stateFlow - fun getBlockList(): PeopleListEvent? = blockListNote.event as? PeopleListEvent + fun getBlockList(): FollowSetEvent? = blockListNote.event as? FollowSetEvent suspend fun blockListWithBackup(note: Note): List { - val event = note.event as? PeopleListEvent + val event = note.event as? FollowSetEvent return event?.let { decryptionCache.usersAndWords(it) } ?: emptyList() } @@ -70,32 +70,32 @@ class BlockPeopleListState( emptyList(), ) - suspend fun hideUser(pubkeyHex: String): PeopleListEvent { + suspend fun hideUser(pubkeyHex: String): FollowSetEvent { val blockList = getBlockList() return if (blockList != null) { - PeopleListEvent.add( + FollowSetEvent.add( earlierVersion = blockList, person = UserTag(pubkeyHex), isPrivate = true, signer = signer, ) } else { - PeopleListEvent.create( - title = PeopleListEvent.BLOCK_LIST_D_TAG, + FollowSetEvent.create( + title = FollowSetEvent.BLOCK_LIST_D_TAG, person = UserTag(pubkeyHex), isPrivate = true, signer = signer, - dTag = PeopleListEvent.BLOCK_LIST_D_TAG, + dTag = FollowSetEvent.BLOCK_LIST_D_TAG, ) } } - suspend fun showUser(pubkeyHex: String): PeopleListEvent? { + suspend fun showUser(pubkeyHex: String): FollowSetEvent? { val blockList = getBlockList() return if (blockList != null) { - PeopleListEvent.remove( + FollowSetEvent.remove( earlierVersion = blockList, person = UserTag(pubkeyHex), signer = signer, @@ -105,11 +105,11 @@ class BlockPeopleListState( } } - suspend fun showWord(word: String): PeopleListEvent? { + suspend fun showWord(word: String): FollowSetEvent? { val blockList = getBlockList() return if (blockList != null) { - PeopleListEvent.remove( + FollowSetEvent.remove( earlierVersion = blockList, person = WordTag(word), signer = signer, @@ -119,20 +119,20 @@ class BlockPeopleListState( } } - suspend fun showUsers(pubkeys: List): PeopleListEvent? { + suspend fun showUsers(pubkeys: List): FollowSetEvent? { if (pubkeys.isEmpty()) return null val blockList = getBlockList() ?: return null - return PeopleListEvent.removeAll( + return FollowSetEvent.removeAll( earlierVersion = blockList, persons = pubkeys.map { UserTag(it) }, signer = signer, ) } - suspend fun showWords(words: List): PeopleListEvent? { + suspend fun showWords(words: List): FollowSetEvent? { if (words.isEmpty()) return null val blockList = getBlockList() ?: return null - return PeopleListEvent.removeAll( + return FollowSetEvent.removeAll( earlierVersion = blockList, persons = words.map { WordTag(it) }, signer = signer, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/labeledBookmarkLists/LabeledBookmarkList.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/bookmarkSets/BookmarkSet.kt similarity index 94% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/labeledBookmarkLists/LabeledBookmarkList.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/bookmarkSets/BookmarkSet.kt index a3e8e03d70..3187738854 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/labeledBookmarkLists/LabeledBookmarkList.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/bookmarkSets/BookmarkSet.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.commons.model.nip51Lists.labeledBookmarkLists +package com.vitorpamplona.amethyst.commons.model.nip51Lists.bookmarkSets import androidx.compose.runtime.Stable import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark @@ -26,7 +26,7 @@ import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.BookmarkIdTag import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark @Stable -data class LabeledBookmarkList( +data class BookmarkSet( val identifier: String, val title: String, val description: String?, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/relayFeeds/RelayFeedsListDecryptionCache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/favoriteRelays/FavoriteRelayListDecryptionCache.kt similarity index 83% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/relayFeeds/RelayFeedsListDecryptionCache.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/favoriteRelays/FavoriteRelayListDecryptionCache.kt index 3d262d28b9..65f7dc608e 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/relayFeeds/RelayFeedsListDecryptionCache.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/favoriteRelays/FavoriteRelayListDecryptionCache.kt @@ -18,12 +18,12 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.commons.model.nip51Lists.relayFeeds +package com.vitorpamplona.amethyst.commons.model.nip51Lists.favoriteRelays import com.vitorpamplona.amethyst.commons.model.nip51Lists.relayLists.GenericRelayListCache import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.FavoriteRelayListEvent -class RelayFeedsListDecryptionCache( +class FavoriteRelayListDecryptionCache( signer: NostrSigner, -) : GenericRelayListCache(signer) +) : GenericRelayListCache(signer) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/peopleList/PeopleListDecryptionCache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/followSets/FollowSetDecryptionCache.kt similarity index 50% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/peopleList/PeopleListDecryptionCache.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/followSets/FollowSetDecryptionCache.kt index e8f1af7638..b53e2a351b 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/peopleList/PeopleListDecryptionCache.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/followSets/FollowSetDecryptionCache.kt @@ -18,43 +18,43 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.commons.model.nip51Lists.peopleList +package com.vitorpamplona.amethyst.commons.model.nip51Lists.followSets import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEventCache +import com.vitorpamplona.quartz.nip51Lists.followSet.FollowSetEvent import com.vitorpamplona.quartz.nip51Lists.muteList.mutedUserIdSet import com.vitorpamplona.quartz.nip51Lists.muteList.mutedUsers import com.vitorpamplona.quartz.nip51Lists.muteList.mutedUsersAndWords import com.vitorpamplona.quartz.nip51Lists.muteList.mutedWordSet import com.vitorpamplona.quartz.nip51Lists.muteList.mutedWords -import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent -class PeopleListDecryptionCache( +class FollowSetDecryptionCache( val signer: NostrSigner, ) { - val cachedPrivateLists = PrivateTagArrayEventCache(signer) + val cachedPrivateLists = PrivateTagArrayEventCache(signer) - fun cachedUsersAndWords(event: PeopleListEvent) = cachedPrivateLists.mergeTagListPrecached(event).mutedUsersAndWords() + fun cachedUsersAndWords(event: FollowSetEvent) = cachedPrivateLists.mergeTagListPrecached(event).mutedUsersAndWords() - fun cachedUsers(event: PeopleListEvent) = cachedPrivateLists.mergeTagListPrecached(event).mutedUsers() + fun cachedUsers(event: FollowSetEvent) = cachedPrivateLists.mergeTagListPrecached(event).mutedUsers() - fun cachedUserIdSet(event: PeopleListEvent) = cachedPrivateLists.mergeTagListPrecached(event).mutedUserIdSet() + fun cachedUserIdSet(event: FollowSetEvent) = cachedPrivateLists.mergeTagListPrecached(event).mutedUserIdSet() - fun cachedWords(event: PeopleListEvent) = cachedPrivateLists.mergeTagListPrecached(event).mutedWords() + fun cachedWords(event: FollowSetEvent) = cachedPrivateLists.mergeTagListPrecached(event).mutedWords() - fun cachedWordSet(event: PeopleListEvent) = cachedPrivateLists.mergeTagListPrecached(event).mutedWordSet() + fun cachedWordSet(event: FollowSetEvent) = cachedPrivateLists.mergeTagListPrecached(event).mutedWordSet() - suspend fun usersAndWords(event: PeopleListEvent) = cachedPrivateLists.mergeTagList(event).mutedUsersAndWords() + suspend fun usersAndWords(event: FollowSetEvent) = cachedPrivateLists.mergeTagList(event).mutedUsersAndWords() - suspend fun users(event: PeopleListEvent) = cachedPrivateLists.mergeTagList(event).mutedUsers() + suspend fun users(event: FollowSetEvent) = cachedPrivateLists.mergeTagList(event).mutedUsers() - suspend fun userIdSet(event: PeopleListEvent) = cachedPrivateLists.mergeTagList(event).mutedUserIdSet() + suspend fun userIdSet(event: FollowSetEvent) = cachedPrivateLists.mergeTagList(event).mutedUserIdSet() - suspend fun words(event: PeopleListEvent) = cachedPrivateLists.mergeTagList(event).mutedWords() + suspend fun words(event: FollowSetEvent) = cachedPrivateLists.mergeTagList(event).mutedWords() - suspend fun wordSet(event: PeopleListEvent) = cachedPrivateLists.mergeTagList(event).mutedWordSet() + suspend fun wordSet(event: FollowSetEvent) = cachedPrivateLists.mergeTagList(event).mutedWordSet() - suspend fun privateUsers(event: PeopleListEvent) = cachedPrivateLists.privateTags(event)?.mutedUsers() ?: emptyList() + suspend fun privateUsers(event: FollowSetEvent) = cachedPrivateLists.privateTags(event)?.mutedUsers() ?: emptyList() - suspend fun privateUserIdSet(event: PeopleListEvent) = cachedPrivateLists.privateTags(event)?.mutedUserIdSet() ?: emptySet() + suspend fun privateUserIdSet(event: FollowSetEvent) = cachedPrivateLists.privateTags(event)?.mutedUserIdSet() ?: emptySet() } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/peopleList/PeopleList.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/followSets/PeopleList.kt similarity index 96% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/peopleList/PeopleList.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/followSets/PeopleList.kt index d0366e3dd9..8e412478c5 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/peopleList/PeopleList.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/followSets/PeopleList.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.commons.model.nip51Lists.peopleList +package com.vitorpamplona.amethyst.commons.model.nip51Lists.followSets import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.model.User diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/hashtagLists/HashtagListDecryptionCache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/interestLists/InterestListDecryptionCache.kt similarity index 70% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/hashtagLists/HashtagListDecryptionCache.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/interestLists/InterestListDecryptionCache.kt index 6aa9da1171..2fcd45b9c0 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/hashtagLists/HashtagListDecryptionCache.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/interestLists/InterestListDecryptionCache.kt @@ -18,19 +18,19 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.commons.model.nip51Lists.hashtagLists +package com.vitorpamplona.amethyst.commons.model.nip51Lists.interestLists import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEventCache -import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent -import com.vitorpamplona.quartz.nip51Lists.hashtagList.hashtagSet +import com.vitorpamplona.quartz.nip51Lists.interestList.InterestListEvent +import com.vitorpamplona.quartz.nip51Lists.interestList.hashtagSet -class HashtagListDecryptionCache( +class InterestListDecryptionCache( val signer: NostrSigner, ) { - val cachedPrivateLists = PrivateTagArrayEventCache(signer) + val cachedPrivateLists = PrivateTagArrayEventCache(signer) - fun cachedHashtags(event: HashtagListEvent) = cachedPrivateLists.mergeTagListPrecached(event).hashtagSet() + fun cachedHashtags(event: InterestListEvent) = cachedPrivateLists.mergeTagListPrecached(event).hashtagSet() - suspend fun hashtags(event: HashtagListEvent) = cachedPrivateLists.mergeTagList(event).hashtagSet() + suspend fun hashtags(event: InterestListEvent) = cachedPrivateLists.mergeTagList(event).hashtagSet() } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardDecryptionCache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/UserAssertionDecryptionCache.kt similarity index 79% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardDecryptionCache.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/UserAssertionDecryptionCache.kt index e6e5477791..7fd54fb060 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardDecryptionCache.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/UserAssertionDecryptionCache.kt @@ -24,7 +24,7 @@ import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists import com.vitorpamplona.quartz.nip01Core.core.TagArray import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEventCache -import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent +import com.vitorpamplona.quartz.nip85TrustedAssertions.users.UserAssertionEvent import com.vitorpamplona.quartz.nip85TrustedAssertions.users.petName import com.vitorpamplona.quartz.nip85TrustedAssertions.users.summary @@ -34,27 +34,27 @@ import com.vitorpamplona.quartz.nip85TrustedAssertions.users.summary * reading them requires the account's main key ([signer]); cards signed by any * other key never decrypt here. */ -class ContactCardDecryptionCache( +class UserAssertionDecryptionCache( val signer: NostrSigner, ) { - val cachedPrivateCards = PrivateTagArrayEventCache(signer, cacheSize = 100) + val cachedPrivateCards = PrivateTagArrayEventCache(signer, cacheSize = 100) - suspend fun petName(event: ContactCardEvent) = cachedPrivateCards.mergeTagList(event).petName() + suspend fun petName(event: UserAssertionEvent) = cachedPrivateCards.mergeTagList(event).petName() - suspend fun summary(event: ContactCardEvent) = cachedPrivateCards.mergeTagList(event).summary() + suspend fun summary(event: UserAssertionEvent) = cachedPrivateCards.mergeTagList(event).summary() /** * The decrypted petname and summary plus the card's full decrypted tag list, * so renderers can resolve the NIP-30 `emoji` mappings stored alongside them. */ - suspend fun nickname(event: ContactCardEvent): Nickname? = cachedPrivateCards.mergeTagList(event).toNickname() + suspend fun nickname(event: UserAssertionEvent): Nickname? = cachedPrivateCards.mergeTagList(event).toNickname() /** * Synchronous variant that only reads an already-decrypted card, for use as * the immediate value of UI flows. Returns null until the suspend path has * decrypted the card once. */ - fun cachedNickname(event: ContactCardEvent): Nickname? = cachedPrivateCards.mergeTagListPrecached(event).toNickname() + fun cachedNickname(event: UserAssertionEvent): Nickname? = cachedPrivateCards.mergeTagListPrecached(event).toNickname() private fun TagArray.toNickname(): Nickname? { val name = petName() diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardsState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/UserAssertionsState.kt similarity index 90% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardsState.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/UserAssertionsState.kt index f643429c5c..1837e2334c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardsState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/UserAssertionsState.kt @@ -28,7 +28,7 @@ import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent +import com.vitorpamplona.quartz.nip85TrustedAssertions.users.UserAssertionEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.IO @@ -51,19 +51,19 @@ import kotlinx.coroutines.flow.mapLatest * account itself ([signer]'s pubkey). */ @Stable -class ContactCardsState( +class UserAssertionsState( val signer: NostrSigner, val cache: ICacheProvider, - val decryptionCache: ContactCardDecryptionCache, + val decryptionCache: UserAssertionDecryptionCache, val emojiPacks: EmojiPackState, ) { private val accountUser: User? by lazy { cache.getOrCreateUser(signer.pubKey) } - fun createCardAddress(target: HexKey): Address = ContactCardEvent.createAddress(signer.pubKey, target) + fun createCardAddress(target: HexKey): Address = UserAssertionEvent.createAddress(signer.pubKey, target) fun getCardNote(target: HexKey): AddressableNote = cache.getOrCreateAddressableNote(createCardAddress(target)) - fun getCard(target: HexKey): ContactCardEvent? = getCardNote(target).event as? ContactCardEvent + fun getCard(target: HexKey): UserAssertionEvent? = getCardNote(target).event as? UserAssertionEvent /** * The account's own card about [target], as attached to the target user's @@ -71,7 +71,7 @@ class ContactCardsState( * per-user cache (like `metadata()` does) so a card arriving later is seen. */ @OptIn(ExperimentalCoroutinesApi::class) - fun myCardFlow(target: User): Flow = + fun myCardFlow(target: User): Flow = target .cards() .receivedCards @@ -82,7 +82,7 @@ class ContactCardsState( ?.flow() ?.metadata ?.stateFlow - ?.map { it.note.event as? ContactCardEvent } + ?.map { it.note.event as? UserAssertionEvent } ?: flowOf(null) } @@ -110,7 +110,7 @@ class ContactCardsState( ?.receivedCards ?.value ?.get(accountUser) - ?.event as? ContactCardEvent ?: return null + ?.event as? UserAssertionEvent ?: return null return decryptionCache.cachedNickname(card) } @@ -145,12 +145,12 @@ class ContactCardsState( target: HexKey, petName: String?, summary: String?, - ): ContactCardEvent { + ): UserAssertionEvent { val emojis = emojiPacks.findEmojiTags(listOfNotNull(petName, summary).joinToString(" ")) val existing = getCard(target) return if (existing != null) { signer.sign( - ContactCardEvent.updatePetNameAndSummary( + UserAssertionEvent.updatePetNameAndSummary( earlierVersion = existing, petName = petName, summary = summary, @@ -159,7 +159,7 @@ class ContactCardsState( ), ) } else { - ContactCardEvent.create( + UserAssertionEvent.create( targetUser = target, petName = petName, summary = summary, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/UserCardsCache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/UserCardsCache.kt index 5077850035..afe53a380b 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/UserCardsCache.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/UserCardsCache.kt @@ -25,7 +25,7 @@ import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.amethyst.commons.model.UserDependencies import com.vitorpamplona.amethyst.commons.relays.EOSERelayList import com.vitorpamplona.amethyst.commons.util.PlatformNumberFormatter -import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent +import com.vitorpamplona.quartz.nip85TrustedAssertions.users.UserAssertionEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.flow.MutableStateFlow @@ -104,7 +104,7 @@ class UserCardsCache : UserDependencies { emit(null) } }.map { - (it?.note?.event as? ContactCardEvent)?.rank() + (it?.note?.event as? UserAssertionEvent)?.rank() }.flowOn(Dispatchers.IO) private val formatter = PlatformNumberFormatter() @@ -132,7 +132,7 @@ class UserCardsCache : UserDependencies { emit(null) } }.map { - val value = (it?.note?.event as? ContactCardEvent)?.followerCount() + val value = (it?.note?.event as? UserAssertionEvent)?.followerCount() if (value != null && value > 0) { formatter.format(value.toLong()) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountIdentityStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountIdentityStore.kt new file mode 100644 index 0000000000..f562540913 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountIdentityStore.kt @@ -0,0 +1,153 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.preferences + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.core.stringSetPreferencesKey +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.first +import okio.IOException + +/** + * Who this account is, and the handful of per-account settings that sat beside + * that in the legacy file. + * + * [pubKeyHex] is the one value in the whole preference layer that the account + * cannot be loaded without: the loader returns null the moment it is missing, + * and the account disappears from the app even with its private key safe in the + * key store. Everything here is public — a pubkey, a signer's package name, the + * relay URLs the user typed — and the store file is already named after the + * account's npub, so keeping it in the plain per-account store reveals nothing + * the file name does not. + * + * `hasBackedUpKeys` is deliberately *not* a field here. It is written on its + * own, by the key-backup nudge, at moments unrelated to any of these; folding + * it into the group would mean every [AccountIdentityStore.save] carried a + * value its caller never knew about and would flip the nudge back on. It gets + * its own accessors below. + */ +data class AccountIdentity( + val pubKeyHex: String? = null, + val loginWithExternalSigner: Boolean = false, + val externalSignerPackageName: String? = null, + val localRelayServers: Set = emptySet(), + val openBackupConflictsJson: String? = null, +) + +/** Reads and writes [AccountIdentity] in the account's DataStore. */ +class AccountIdentityStore( + private val store: DataStore, +) { + companion object { + val pubKeyHex = stringPreferencesKey("nostr_pubkey") + val loginWithExternalSigner = booleanPreferencesKey("login_with_external_signer") + val externalSignerPackageName = stringPreferencesKey("signer_package_name") + val localRelayServers = stringSetPreferencesKey("localRelayServers") + val openBackupConflictsJson = stringPreferencesKey("openBackupConflicts") + val hasBackedUpKeys = booleanPreferencesKey("has_backed_up_keys") + + /** + * What the `secret_keeper_` file called these, for the one-shot copy. + * + * `has_backed_up_keys` is carried here even though it is not part of + * [AccountIdentity]: the copy is per *key*, not per group, and losing + * it would put the "back up your key" nudge back in front of every + * user who had already dismissed it. + */ + val legacyTable = + LegacyKeyTable( + "migrated.identity", + listOf( + LegacyStringKey("nostr_pubkey", pubKeyHex), + LegacyBooleanKey("login_with_external_signer", loginWithExternalSigner), + LegacyStringKey("signer_package_name", externalSignerPackageName), + LegacyStringSetKey("localRelayServers", localRelayServers), + LegacyStringKey("openBackupConflicts", openBackupConflictsJson), + LegacyBooleanKey("has_backed_up_keys", hasBackedUpKeys), + ), + ) + } + + private suspend fun read(): Preferences = + store.data + .catch { e -> if (e is IOException) emit(emptyPreferences()) else throw e } + .first() + + suspend fun load(): AccountIdentity { + val prefs = read() + + return AccountIdentity( + pubKeyHex = prefs[pubKeyHex], + loginWithExternalSigner = prefs[loginWithExternalSigner] ?: false, + externalSignerPackageName = prefs[externalSignerPackageName], + localRelayServers = prefs[localRelayServers] ?: emptySet(), + openBackupConflictsJson = prefs[openBackupConflictsJson], + ) + } + + /** + * Writes the whole group in one edit, so a crash cannot half-apply it. + * + * The removes matter as much as the puts and mirror the legacy block + * exactly: an account that drops its external signer, clears its local + * relays or answers its last backup conflict has to end up with those keys + * *absent*, not holding yesterday's value. + */ + suspend fun save(value: AccountIdentity) { + store.edit { prefs -> + value.pubKeyHex.let { if (it != null) prefs[pubKeyHex] = it else prefs.remove(pubKeyHex) } + prefs[loginWithExternalSigner] = value.loginWithExternalSigner + value.externalSignerPackageName.let { if (it != null) prefs[externalSignerPackageName] = it else prefs.remove(externalSignerPackageName) } + value.localRelayServers.let { if (it.isNotEmpty()) prefs[localRelayServers] = it else prefs.remove(localRelayServers) } + value.openBackupConflictsJson.let { if (it != null) prefs[openBackupConflictsJson] = it else prefs.remove(openBackupConflictsJson) } + } + } + + /** + * True unless a freshly generated account still has its key only in the + * app. Absent means true: every account logged in from an existing nsec, + * bunker or external signer already holds its key elsewhere and must not + * be nudged. + */ + suspend fun hasBackedUpKeys(): Boolean = read()[hasBackedUpKeys] ?: true + + suspend fun setHasBackedUpKeys(value: Boolean) { + store.edit { prefs -> prefs[hasBackedUpKeys] = value } + } +} + +/** + * Falls back to [legacy] as a whole when this identity cannot be used. + * + * The test is [AccountIdentity.pubKeyHex], and the fallback is all-or-nothing + * on purpose. A missing pubkey means the store answered from + * `emptyPreferences()` — its file is unreadable, or the one-shot copy never + * ran — and in that state the other fields are equally untrustworthy: an + * absent boolean and a `false` one are the same value here, so merging field + * by field would quietly report an external-signer account as a local one. + * A pubkey present means the store is live and authoritative. + */ +fun AccountIdentity.orIfUnusable(legacy: () -> AccountIdentity): AccountIdentity = if (pubKeyHex != null) this else legacy() diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountPreferenceStores.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountPreferenceStores.kt new file mode 100644 index 0000000000..1cdcc25588 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountPreferenceStores.kt @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.preferences + +import androidx.datastore.core.DataMigration +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import com.vitorpamplona.amethyst.commons.util.platformFileSystem +import com.vitorpamplona.quartz.utils.cache.LargeCache +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.job +import okio.Path + +/** + * The per-account, non-secret preference store: one DataStore file per npub, + * at `/datastore/.preferences_pb`. + * + * The root directory is injected rather than discovered so every front end can + * say where its data lives — `filesDir` on Android, the app data directory on + * desktop, a temp folder in tests — and so this class needs no platform API of + * its own. + * + * Built on [PreferenceDataStoreFactory.createWithPath], the okio-based factory, + * because the `java.io.File` overloads are absent on Apple targets. + * + * [migrations] runs once per account, before that account's store answers its + * first read. Android supplies one that copies out of the legacy + * SharedPreferences; front ends with no history supply none. + */ +class AccountPreferenceStores( + val rootFilesDir: () -> Path, + private val migrations: (npub: String) -> List> = { emptyList() }, +) { + /** + * One store per account, each on a scope this class can cancel. + * + * DataStore keeps a process-wide registry keyed by file path and only + * releases an entry when the owning scope ends. Left to create its own + * internal scope, a store is never released, and deleting an account then + * adding it again in the same session throws "multiple DataStores active + * for the same file". + */ + private class Entry( + val scope: CoroutineScope, + val store: DataStore, + ) + + private val storeCache = LargeCache() + + fun file(npub: String): Path = rootFilesDir() / "datastore" / "$npub.preferences_pb" + + fun getDataStore(npub: String): DataStore = + storeCache + .getOrCreate(npub) { + val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + Entry( + scope, + PreferenceDataStoreFactory.createWithPath( + scope = scope, + migrations = migrations(npub), + produceFile = { file(npub) }, + ), + ) + }.store + + /** + * Drops the account's stored preferences. + * + * The live store is shut down first: deleting the file underneath one + * would leave it writing the account's settings back out on the next edit, + * re-creating what this call is meant to erase — and would keep the path + * registered, so the same account could not be added again. + * + * Cancelling is not enough on its own, and this is suspend for that + * reason. `cancel()` only *asks*; DataStore releases the path when the + * scope's job actually completes, so a store opened on it before then + * still throws "multiple DataStores active for the same file". The window + * is small enough to pass locally and fail on a loaded CI runner. + */ + suspend fun removeAccount(npub: String): Boolean { + storeCache.get(npub)?.scope?.let { + it.cancel() + it.coroutineContext.job.join() + } + storeCache.remove(npub) + val path = file(npub) + if (!platformFileSystem.exists(path)) return false + platformFileSystem.delete(path) + return true + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AppPreferenceStores.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AppPreferenceStores.kt new file mode 100644 index 0000000000..a188cb3719 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AppPreferenceStores.kt @@ -0,0 +1,171 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.preferences + +import androidx.datastore.core.DataMigration +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import com.vitorpamplona.amethyst.commons.util.platformFileSystem +import com.vitorpamplona.quartz.utils.cache.LargeCache +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.job +import okio.Path + +/** + * The app-wide DataStore files — the ones that belong to the install rather + * than to an account. + * + * [AccountPreferenceStores] is the same idea keyed by npub; this is keyed by + * file name, because these stores are one-per-app and several of them share a + * single file under different key prefixes (see [SHARED_SETTINGS]). + * + * # Why a holder rather than a `Context` delegate + * + * Android's `Context.preferencesDataStore(name)` delegate does this job, but + * only on Android and only from a `Context`. Taking the root directory as a + * parameter is what lets the stores themselves live in `commonMain` — every + * front end says where its data lives: `filesDir` on Android, the app data + * directory on desktop, a temp folder in tests. + * + * # The paths are the delegate's paths + * + * `preferencesDataStore(name = "x")` resolves to + * `filesDir/datastore/x.preferences_pb`, and [file] reproduces that exactly. + * Wired with `filesDir` as the root, a store moved off the delegate onto this + * holder opens the file it was already using, so nothing has to be migrated + * and a rollback finds its data where it left it. Changing [file]'s shape + * would silently orphan every existing install's settings. + * + * @param migrations the migrations to attach to a file, by name. Taken here + * rather than at [getDataStore] because DataStore runs a file's migrations + * once, when that file is first opened — and several stores share + * [SHARED_SETTINGS], so which one opens it is a race. Wiring the migrations + * to the file rather than to a caller is what makes the copy happen no + * matter who wins. + */ +class AppPreferenceStores( + val rootFilesDir: () -> Path, + private val migrations: (String) -> List> = { emptyList() }, +) { + companion object { + /** + * The file that UI, Tor, OTS, Namecoin and several smaller settings + * groups all share, each under its own key prefix (`ui.`, `tor.`, …). + * + * One file rather than one per group, which is how it has always been + * on Android: these are read together at startup, and a DataStore is + * a whole-file read. + */ + const val SHARED_SETTINGS = "shared_settings" + + private const val SUFFIX = ".preferences_pb" + } + + /** + * One store per file, each on a scope this class owns. + * + * DataStore keeps a process-wide registry keyed by file path and refuses a + * second store on a path that already has a live one. Handing out a new + * store per call is therefore a crash rather than a waste — it has already + * happened twice in this codebase — so going through the cache is the + * point of the class, not an optimisation. + */ + private class Entry( + val scope: CoroutineScope, + val store: DataStore, + ) + + private val storeCache = LargeCache() + + fun file(name: String): Path = rootFilesDir() / "datastore" / "$name$SUFFIX" + + fun getDataStore(name: String): DataStore = + storeCache + .getOrCreate(name) { + val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + Entry( + scope, + PreferenceDataStoreFactory.createWithPath( + scope = scope, + migrations = migrations(name), + produceFile = { file(name) }, + ), + ) + }.store + + /** The file UI, Tor, OTS, Namecoin and friends share. */ + fun sharedSettings(): DataStore = getDataStore(SHARED_SETTINGS) + + /** + * Releases the store for [name], so the file can be opened again. + * + * DataStore keeps a process-wide registry keyed by path and refuses a second + * live instance, and `cancel()` only *asks* a scope to stop — the registry + * entry survives until the owning job actually completes, which is why this + * joins. Getting that wrong produced "there are multiple DataStores active + * for the same file" twice in this codebase already. + * + * Production has no reason to call this: these stores live as long as the + * process. It exists so a test can write a file, let go of it, and reopen it + * to check what is actually on disk — the one thing that was impossible + * before, and the reason the migration-guard test had to be driven against + * the DataMigration directly instead. + * + * Returns false if nothing was open under that name. + */ + suspend fun release(name: String): Boolean { + val entry = storeCache.get(name) ?: return false + + entry.scope.cancel() + entry.scope.coroutineContext.job + .join() + storeCache.remove(name) + return true + } + + /** + * The names of stores already on disk whose name starts with [prefix]. + * + * For the store families that are one file per key rather than one file — + * the signer permissions keep an `nsp_` file per app — where the only + * way to enumerate what exists is to look. Reads the directory, so callers + * keep it off the main thread. + * + * Returns names in the form [getDataStore] takes, with the directory and + * the `.preferences_pb` suffix stripped, and an empty list when nothing has + * been written yet. + */ + fun names(prefix: String): List { + val dir = rootFilesDir() / "datastore" + if (!platformFileSystem.exists(dir)) return emptyList() + + return platformFileSystem + .list(dir) + .map { it.name } + .filter { it.startsWith(prefix) && it.endsWith(SUFFIX) } + .map { it.removeSuffix(SUFFIX) } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/BuzzAttestationPreferences.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/BuzzAttestationStore.kt similarity index 95% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/BuzzAttestationPreferences.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/BuzzAttestationStore.kt index 618518e919..c7336b9cfd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/BuzzAttestationPreferences.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/BuzzAttestationStore.kt @@ -18,10 +18,11 @@ * 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.preferences +package com.vitorpamplona.amethyst.commons.model.preferences -import android.content.Context import androidx.compose.runtime.Stable +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey import com.vitorpamplona.amethyst.commons.model.buzz.BuzzHeldAttestations @@ -49,8 +50,8 @@ import kotlin.coroutines.cancellation.CancellationException * by the same gate that rejects a mistyped one. Construct once per account, eagerly. */ @Stable -class BuzzAttestationPreferences( - private val context: Context, +class BuzzAttestationStore( + private val store: DataStore, private val scope: CoroutineScope, private val pubKeyHex: HexKey, private val attestation: BuzzHeldAttestations, @@ -84,7 +85,7 @@ class BuzzAttestationPreferences( private suspend fun restoreFromDisk() { try { - val prefs = context.sharedPreferencesDataStore.data.first() + val prefs = store.data.first() // put() verifies, so a credential that no longer checks out is dropped either way. restoreFrom(prefs[key], prefs[LEGACY_KEY], pubKeyHex)?.let(attestation::put) } catch (e: Exception) { @@ -95,7 +96,7 @@ class BuzzAttestationPreferences( private suspend fun persist(held: OwnerAttestation?) { try { - context.sharedPreferencesDataStore.edit { prefs -> + store.edit { prefs -> // Write [NONE] rather than removing the key: removing it is indistinguishable from // never having migrated, which would let the legacy list re-seed a credential the // user just deleted. See [restoreFrom]. @@ -126,7 +127,7 @@ class BuzzAttestationPreferences( /** * Which attestation to reinstate, given this account's saved value and the pre-namespacing - * device-global list. Pure, so the migration precedence is testable without a `Context`. + * device-global list. Pure, so the migration precedence is testable without a store. * * [saved] wins whenever it is present, [NONE] included. Only a never-migrated account falls * back to [legacy], and it takes just the entry issued to its own key — that list was diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/BuzzChannelStarPreferences.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/BuzzChannelStarStore.kt similarity index 92% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/BuzzChannelStarPreferences.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/BuzzChannelStarStore.kt index 96c62f23eb..d4597fb1e3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/BuzzChannelStarPreferences.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/BuzzChannelStarStore.kt @@ -18,10 +18,11 @@ * 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.preferences +package com.vitorpamplona.amethyst.commons.model.preferences -import android.content.Context import androidx.compose.runtime.Stable +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringSetPreferencesKey import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelStars @@ -42,8 +43,8 @@ import kotlin.coroutines.cancellation.CancellationException * then writes every later change back. Construct once per account, eagerly. */ @Stable -class BuzzChannelStarPreferences( - private val context: Context, +class BuzzChannelStarStore( + private val store: DataStore, private val scope: CoroutineScope, private val pubKeyHex: HexKey, private val stars: BuzzChannelStars, @@ -60,7 +61,7 @@ class BuzzChannelStarPreferences( private suspend fun restoreFromDisk() { try { - val prefs = context.sharedPreferencesDataStore.data.first() + val prefs = store.data.first() // Fall back to the pre-namespacing device-global key so an upgrade doesn't unpin // everything. That set is what every account already saw; the next toggle writes to this // account's own key and takes over. The legacy key is left for other accounts to seed @@ -78,7 +79,7 @@ class BuzzChannelStarPreferences( // Always write the starred set, empty included — never remove the key. An absent key // means "never migrated" and re-seeds from the legacy one above, so removing it // would undo the user's last removal on the next launch. - context.sharedPreferencesDataStore.edit { prefs -> prefs[key] = ids } + store.edit { prefs -> prefs[key] = ids } } catch (e: Exception) { if (e is CancellationException) throw e Log.e("BuzzChannelStarPrefs") { "Error writing starred channels: ${e.message}" } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/BuzzWorkspacePreferences.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/BuzzWorkspaceStore.kt similarity index 94% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/BuzzWorkspacePreferences.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/BuzzWorkspaceStore.kt index 10cc89faa1..60d051c855 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/BuzzWorkspacePreferences.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/BuzzWorkspaceStore.kt @@ -18,10 +18,11 @@ * 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.preferences +package com.vitorpamplona.amethyst.commons.model.preferences -import android.content.Context import androidx.compose.runtime.Stable +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringSetPreferencesKey import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaces @@ -57,8 +58,8 @@ import kotlin.coroutines.cancellation.CancellationException * per account, eagerly. */ @Stable -class BuzzWorkspacePreferences( - private val context: Context, +class BuzzWorkspaceStore( + private val store: DataStore, private val scope: CoroutineScope, private val pubKeyHex: HexKey, private val workspaces: BuzzWorkspaces, @@ -76,7 +77,7 @@ class BuzzWorkspacePreferences( private suspend fun restoreFromDisk() { try { - val prefs = context.sharedPreferencesDataStore.data.first() + val prefs = store.data.first() // Fall back to the pre-namespacing device-global key so an upgrade doesn't empty the // workspaces hub. That set is whatever any account joined, which is exactly what every // account already saw before this became per-account — so seeding from it changes @@ -97,7 +98,7 @@ class BuzzWorkspacePreferences( // Always write the joined set, empty included — never remove the key. An absent key // means "never migrated" and re-seeds from the legacy one above, so removing it // would undo the user's last removal on the next launch. - context.sharedPreferencesDataStore.edit { prefs -> + store.edit { prefs -> prefs[key] = relays.map { it.url }.toSet() } } catch (e: Exception) { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/CalendarReminderLogStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/CalendarReminderLogStore.kt new file mode 100644 index 0000000000..0cf1590083 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/CalendarReminderLogStore.kt @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.preferences + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.longPreferencesKey +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.first +import okio.IOException + +/** + * The "already reminded about this" log for calendar appointments. + * + * Without it, every worker run after a restart would re-notify for the same + * upcoming event until it started, since LocalCache has no memory of past + * reminders. + * + * Each key stores the event-start time the reminder fired for, not a bare flag. + * That is what makes a moved meeting work: if the author changes the start, the + * stored value no longer matches and a fresh reminder fires, rather than the + * new time being silently skipped. + */ +class CalendarReminderLogStore( + private val store: DataStore, +) { + companion object { + private const val KEY_PREFIX = "notified:" + + fun keyFor(eventId: String) = longPreferencesKey(KEY_PREFIX + eventId) + + internal fun isLogKey(key: Preferences.Key<*>) = key.name.startsWith(KEY_PREFIX) + } + + private suspend fun read(): Preferences = + store.data + .catch { e -> if (e is IOException) emit(emptyPreferences()) else throw e } + .first() + + suspend fun wasNotified( + eventId: String, + eventStartSeconds: Long, + ): Boolean = read()[keyFor(eventId)] == eventStartSeconds + + suspend fun markNotified( + eventId: String, + eventStartSeconds: Long, + ) { + store.edit { it[keyFor(eventId)] = eventStartSeconds } + } + + /** + * Drops entries whose recorded event-start is older than [cutoffSeconds], + * keeping the log bounded — an event that has long since ended cannot fire + * a second reminder, so its entry is dead weight. + * + * Only keys carrying the log's own prefix are considered, so a future + * setting sharing this store cannot be pruned away by a stale cutoff. + */ + suspend fun forgetBefore(cutoffSeconds: Long) { + store.edit { prefs -> + prefs + .asMap() + .filter { (key, value) -> isLogKey(key) && value is Long && value < cutoffSeconds } + .forEach { (key, _) -> prefs.remove(key) } + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/CalendarReminderSettingsStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/CalendarReminderSettingsStore.kt new file mode 100644 index 0000000000..adcb7b17f6 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/CalendarReminderSettingsStore.kt @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.preferences + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.intPreferencesKey +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import okio.IOException + +/** + * Device-wide settings for the calendar reminder worker. + * + * Device scope rather than per-account, as before: the worker that consults + * them runs globally, and multiplexing per-account settings would need + * account-context plumbing into WorkManager that the rest of the app does not + * have. A user who flips between accounts on one device shares one lead time. + */ +data class CalendarReminderSettings( + val enabled: Boolean = DEFAULT_ENABLED, + val leadMinutes: Int = DEFAULT_LEAD_MINUTES, +) { + companion object { + const val DEFAULT_LEAD_MINUTES = 15 + const val DEFAULT_ENABLED = true + + /** + * Choices presented in the settings UI. Anchored to the worker cadence — + * lead times smaller than the cadence (15 min) cannot be honoured + * reliably; 60 is the largest the UX shape supports without an extra + * "hours" picker. + */ + val LEAD_TIME_CHOICES = listOf(5, 15, 30, 60) + } +} + +class CalendarReminderSettingsStore( + private val store: DataStore, +) { + companion object { + val enabled = booleanPreferencesKey("enabled") + val leadMinutes = intPreferencesKey("lead_minutes") + } + + private fun Flow.guarded() = catch { e -> if (e is IOException) emit(emptyPreferences()) else throw e } + + /** Observes the settings, so a screen can react to a change made elsewhere. */ + val flow: Flow = + store.data.guarded().map { prefs -> + CalendarReminderSettings( + enabled = prefs[enabled] ?: CalendarReminderSettings.DEFAULT_ENABLED, + leadMinutes = prefs[leadMinutes] ?: CalendarReminderSettings.DEFAULT_LEAD_MINUTES, + ) + } + + suspend fun load(): CalendarReminderSettings = flow.first() + + suspend fun setEnabled(value: Boolean) { + store.edit { it[enabled] = value } + } + + suspend fun setLeadMinutes(value: Int) { + store.edit { it[leadMinutes] = value } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/CopyOnceMigration.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/CopyOnceMigration.kt new file mode 100644 index 0000000000..671684e847 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/CopyOnceMigration.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.preferences + +import androidx.datastore.core.DataMigration +import androidx.datastore.preferences.core.MutablePreferences +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey + +/** + * Copies values from an older store into this one, once, the first time the + * DataStore is read. + * + * Deliberately a copy and not a move. `SharedPreferencesMigration`, the stock + * implementation, deletes each key it migrates — which is how it knows not to + * run twice, and which makes the migration a one-way door: a build that rolls + * back to reading the old store finds the user's settings gone. Here a marker + * key in the *destination* records that the copy happened, so the source is + * left untouched and a rollback still works. + * + * Deleting the legacy data is a separate and later decision, and a narrower one + * than it looks: because this runs lazily — on the first read of the + * destination, not at install time — the source has to stay *readable* + * indefinitely for installs that skip the release which introduced the + * destination. What can be retired is writing to the source, once nothing + * still reads a key only it holds. + * + * [copy] receives the destination and writes whatever it has, so a migration + * can carry strings, booleans, int and string sets alike. It is only called + * when the migration actually runs, so opening the legacy store is not a cost + * paid on every launch. A key it does not write is left absent rather than + * written blank, so it keeps reading as "unset" and falls back to its default. + * + * @param markerName key recording, in this store, that the copy has run. + * Distinct per migration, so several can run against the same store. + */ +class CopyOnceMigration( + markerName: String, + private val copy: suspend (MutablePreferences) -> Unit, +) : DataMigration { + private val marker = booleanPreferencesKey(markerName) + + override suspend fun shouldMigrate(currentData: Preferences): Boolean = currentData[marker] != true + + override suspend fun migrate(currentData: Preferences): Preferences { + val updated = currentData.toMutablePreferences() + + copy(updated) + updated[marker] = true + + return updated.toPreferences() + } + + override suspend fun cleanUp() = Unit +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/DataStoreExt.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/DataStoreExt.kt similarity index 68% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/DataStoreExt.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/DataStoreExt.kt index 453dd70212..0358899d51 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/DataStoreExt.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/DataStoreExt.kt @@ -18,18 +18,28 @@ * 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.preferences +package com.vitorpamplona.amethyst.commons.model.preferences import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.emptyPreferences -import com.vitorpamplona.amethyst.commons.model.preferences.UpdatablePropertyFlow import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.map -import java.io.IOException +import okio.IOException +/** + * Exposes one DataStore key as an [UpdatablePropertyFlow]. + * + * A missing key, a blank serialization and an explicit null all mean the same + * thing here — the property is absent — so each of them removes the key rather + * than storing an empty string that would later parse into a bogus value. + * + * Uses okio's [IOException] rather than `java.io.IOException`: on JVM targets + * okio aliases it to exactly that type, so the read-error branch keeps catching + * what DataStore throws while the file stays compilable for Apple targets. + */ fun DataStore.getProperty( key: Preferences.Key, parser: (String) -> T, @@ -42,29 +52,14 @@ fun DataStore.getProperty( .catch { e -> if (e is IOException) emit(emptyPreferences()) else throw e }.map { prefs -> - val value = prefs[key] - if (value != null) { - parser(value) - } else { - null - } + prefs[key]?.let(parser) }, update = { newValue -> - if (newValue != null) { - val serialized = serializer(newValue) - if (serialized.isNotBlank()) { - edit { prefs -> - prefs[key] = serialized - } - } else { - edit { prefs -> - prefs.remove(key) - } - } + val serialized = newValue?.let(serializer) + if (serialized != null && serialized.isNotBlank()) { + edit { prefs -> prefs[key] = serialized } } else { - edit { prefs -> - prefs.remove(key) - } + edit { prefs -> prefs.remove(key) } } }, scope = scope, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/DialogDismissalStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/DialogDismissalStore.kt new file mode 100644 index 0000000000..5a53bfdacd --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/DialogDismissalStore.kt @@ -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.commons.model.preferences + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.core.stringSetPreferencesKey +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.first +import okio.IOException + +/** + * What this account has dismissed and does not want shown again — + * confirmation dialogs, individual polls and invites, the donation card. + * + * Everything here defaults to "not dismissed", so a lost value costs the user + * one more prompt rather than hiding something they never dismissed. + * + * Defaults match what the SharedPreferences implementation returned for a + * missing key, so an account that never touched a setting behaves identically + * before and after the migration. + */ +data class DialogDismissal( + val hideDeleteRequestDialog: Boolean = false, + val hideBlockAlertDialog: Boolean = false, + val hideNip17WarningDialog: Boolean = false, + val hideCommunityRulesViolations: Boolean = false, + val dismissedPollNoteIds: Set = emptySet(), + val dismissedChannelInvites: Set = emptySet(), + val mutedPublicChats: Set = emptySet(), + val hasDonatedInVersion: Set = emptySet(), + val viewedPollResultNoteIdsJson: String? = null, +) + +/** Reads and writes [DialogDismissal] in the account's DataStore. */ +class DialogDismissalStore( + private val store: DataStore, +) { + companion object { + val hideDeleteRequestDialog = booleanPreferencesKey("hide_delete_request_dialog") + val hideBlockAlertDialog = booleanPreferencesKey("hide_block_alert_dialog") + val hideNip17WarningDialog = booleanPreferencesKey("hide_nip24_warning_dialog") + val hideCommunityRulesViolations = booleanPreferencesKey("hideCommunityRulesViolations") + val dismissedPollNoteIds = stringSetPreferencesKey("dismissedPollNoteIds") + val dismissedChannelInvites = stringSetPreferencesKey("dismissedChannelInvites") + val mutedPublicChats = stringSetPreferencesKey("mutedPublicChats") + val hasDonatedInVersion = stringSetPreferencesKey("hasDonatedInVersion") + val viewedPollResultNoteIdsJson = stringPreferencesKey("viewedPollResultNoteIds") + + /** + * What the `secret_keeper_` file called these, for the one-shot copy. + * + * Five of the nine were renamed on the way in, so the pairs below are + * not derivable from either side alone. + */ + val legacyTable = + LegacyKeyTable( + "migrated.dialogDismissal", + listOf( + LegacyBooleanKey("hide_delete_request_dialog", hideDeleteRequestDialog), + LegacyBooleanKey("hide_block_alert_dialog", hideBlockAlertDialog), + LegacyBooleanKey("hide_nip24_warning_dialog", hideNip17WarningDialog), + LegacyBooleanKey("hideCommunityRulesViolations", hideCommunityRulesViolations), + LegacyStringSetKey("dismissed_poll_note_ids", dismissedPollNoteIds), + LegacyStringSetKey("dismissed_channel_invites", dismissedChannelInvites), + LegacyStringSetKey("muted_public_chats", mutedPublicChats), + LegacyStringSetKey("has_donated_in_version", hasDonatedInVersion), + LegacyStringKey("viewed_poll_result_note_ids", viewedPollResultNoteIdsJson), + ), + ) + } + + suspend fun load(): DialogDismissal { + val prefs = + store.data + .catch { e -> if (e is IOException) emit(emptyPreferences()) else throw e } + .first() + + return DialogDismissal( + hideDeleteRequestDialog = prefs[hideDeleteRequestDialog] ?: false, + hideBlockAlertDialog = prefs[hideBlockAlertDialog] ?: false, + hideNip17WarningDialog = prefs[hideNip17WarningDialog] ?: false, + hideCommunityRulesViolations = prefs[hideCommunityRulesViolations] ?: false, + dismissedPollNoteIds = prefs[dismissedPollNoteIds] ?: emptySet(), + dismissedChannelInvites = prefs[dismissedChannelInvites] ?: emptySet(), + mutedPublicChats = prefs[mutedPublicChats] ?: emptySet(), + hasDonatedInVersion = prefs[hasDonatedInVersion] ?: emptySet(), + viewedPollResultNoteIdsJson = prefs[viewedPollResultNoteIdsJson], + ) + } + + /** Writes the whole group in one edit, so a crash cannot half-apply it. */ + suspend fun save(value: DialogDismissal) { + store.edit { prefs -> + prefs[hideDeleteRequestDialog] = value.hideDeleteRequestDialog + prefs[hideBlockAlertDialog] = value.hideBlockAlertDialog + prefs[hideNip17WarningDialog] = value.hideNip17WarningDialog + prefs[hideCommunityRulesViolations] = value.hideCommunityRulesViolations + prefs[dismissedPollNoteIds] = value.dismissedPollNoteIds + prefs[dismissedChannelInvites] = value.dismissedChannelInvites + prefs[mutedPublicChats] = value.mutedPublicChats + prefs[hasDonatedInVersion] = value.hasDonatedInVersion + value.viewedPollResultNoteIdsJson.let { if (it != null) prefs[viewedPollResultNoteIdsJson] = it else prefs.remove(viewedPollResultNoteIdsJson) } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/DrawerSectionCollapsePreferences.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/DrawerSectionCollapsePreferences.kt similarity index 92% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/DrawerSectionCollapsePreferences.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/DrawerSectionCollapsePreferences.kt index acca11936f..6e7f5fd745 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/DrawerSectionCollapsePreferences.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/DrawerSectionCollapsePreferences.kt @@ -18,16 +18,16 @@ * 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.preferences +package com.vitorpamplona.amethyst.commons.model.preferences import androidx.compose.runtime.Stable import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringSetPreferencesKey -import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerSectionId -import com.vitorpamplona.amethyst.ui.navigation.drawer.drawerSectionIdsFromNames -import com.vitorpamplona.amethyst.ui.navigation.drawer.toNames +import com.vitorpamplona.amethyst.commons.model.navigation.DrawerSectionId +import com.vitorpamplona.amethyst.commons.model.navigation.drawerSectionIdsFromNames +import com.vitorpamplona.amethyst.commons.model.navigation.toNames import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow @@ -45,7 +45,7 @@ import kotlin.coroutines.cancellation.CancellationException * headings are folded is a per-device view choice, so unlike the hidden rows beside it in the drawer * it is never published to relays. * - * Mirrors [RelayGroupDeletionPreferences]: app-wide (not per-account), loads the saved names on + * Mirrors [RelayGroupDeletionStore]: app-wide (not per-account), loads the saved names on * construction, then writes every later change back. Takes the [DataStore] rather than a `Context` * so the whole cycle is exercised by a plain unit test against a temp file. * diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/FeedVisibilityStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/FeedVisibilityStore.kt new file mode 100644 index 0000000000..a72a628c97 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/FeedVisibilityStore.kt @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.preferences + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.stringPreferencesKey +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.first +import okio.IOException + +/** + * Which feeds this account shows and how they are laid out. + + * The two "disabled" keys store what is switched OFF, not what is on, so an + * absent value means everything is enabled and a feed type added later defaults + * to on for accounts that customised before it existed. + * + * Defaults match what the SharedPreferences implementation returned for a + * missing key, so an account that never touched a setting behaves identically + * before and after the migration. + */ +data class FeedVisibility( + val disabledChatFeeds: String? = null, + val disabledHomeFeedTypes: String? = null, + val relayGroupViewMode: String? = null, + val concordViewMode: String? = null, + val callsEnabled: Boolean = true, +) + +/** Reads and writes [FeedVisibility] in the account's DataStore. */ +class FeedVisibilityStore( + private val store: DataStore, +) { + companion object { + val disabledChatFeeds = stringPreferencesKey("disabled_chat_feeds") + val disabledHomeFeedTypes = stringPreferencesKey("disabled_home_feed_types") + val relayGroupViewMode = stringPreferencesKey("relay_group_view_mode") + val concordViewMode = stringPreferencesKey("concord_view_mode") + val callsEnabled = booleanPreferencesKey("calls_enabled") + + /** What the `secret_keeper_` file called these, for the one-shot copy. */ + val legacyTable = + LegacyKeyTable( + "migrated.feedVisibility", + listOf( + LegacyStringKey("disabled_chat_feeds", disabledChatFeeds), + LegacyStringKey("disabled_home_feed_types", disabledHomeFeedTypes), + LegacyStringKey("relay_group_view_mode", relayGroupViewMode), + LegacyStringKey("concord_view_mode", concordViewMode), + LegacyBooleanKey("calls_enabled", callsEnabled), + ), + ) + } + + suspend fun load(): FeedVisibility { + val prefs = + store.data + .catch { e -> if (e is IOException) emit(emptyPreferences()) else throw e } + .first() + + return FeedVisibility( + disabledChatFeeds = prefs[disabledChatFeeds], + disabledHomeFeedTypes = prefs[disabledHomeFeedTypes], + relayGroupViewMode = prefs[relayGroupViewMode], + concordViewMode = prefs[concordViewMode], + callsEnabled = prefs[callsEnabled] ?: true, + ) + } + + /** Writes the whole group in one edit, so a crash cannot half-apply it. */ + suspend fun save(value: FeedVisibility) { + store.edit { prefs -> + value.disabledChatFeeds.let { if (it != null) prefs[disabledChatFeeds] = it else prefs.remove(disabledChatFeeds) } + value.disabledHomeFeedTypes.let { if (it != null) prefs[disabledHomeFeedTypes] = it else prefs.remove(disabledHomeFeedTypes) } + value.relayGroupViewMode.let { if (it != null) prefs[relayGroupViewMode] = it else prefs.remove(relayGroupViewMode) } + value.concordViewMode.let { if (it != null) prefs[concordViewMode] = it else prefs.remove(concordViewMode) } + prefs[callsEnabled] = value.callsEnabled + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/LatestEventCacheStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/LatestEventCacheStore.kt new file mode 100644 index 0000000000..f743239662 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/LatestEventCacheStore.kt @@ -0,0 +1,126 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.preferences + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.stringPreferencesKey +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.first +import okio.IOException + +/** + * The account-level events cached on disk so a cold start can show something + * before any relay answers — the user's own metadata, contact list, relay + * lists, mute list and the rest. + * + * As with [FollowListSlot], the key strings are the ones the Android app has + * always written, so renaming one throws away that cache for existing users. + * Losing it is not fatal — the event is re-fetched from relays — but it costs a + * blank first screen, so the names are still a compatibility surface. + */ +enum class LatestEventSlot( + val prefKey: String, +) { + CONTACT_LIST("latestContactList"), + USER_METADATA("latestUserMetadata"), + DM_RELAY_LIST("latestDMRelayList"), + NIP65_RELAY_LIST("latestNIP65RelayList"), + SEARCH_RELAY_LIST("latestSearchRelayList"), + INDEX_RELAY_LIST("latestIndexRelayList"), + RELAY_FEEDS_LIST("latestRelayFeedsList"), + BLOCKED_RELAY_LIST("latestBlockedRelayList"), + TRUSTED_RELAY_LIST("latestTrustedRelayList"), + MUTE_LIST("latestMuteList"), + PRIVATE_HOME_RELAY_LIST("latestPrivateHomeRelayList"), + APP_SPECIFIC_DATA("latestAppSpecificData"), + CHANNEL_LIST("latestChannelList"), + COMMUNITY_LIST("latestCommunityList"), + HASHTAG_LIST("latestHashtagList"), + GEOHASH_LIST("latestGeohashList"), + EPHEMERAL_LIST("latestEphemeralChatList"), + RELAY_GROUP_LIST("latestRelayGroupList"), + CONCORD_LIST("latestConcordList"), + TRUST_PROVIDER_LIST("latestTrustProviderList"), + KEY_PACKAGE_RELAY_LIST("latestKeyPackageRelayList"), + FAVORITE_ALGO_FEEDS_LIST("latestFavoriteAlgoFeedsList"), + PAYMENT_TARGETS("latestPaymentTargets"), + BOLT12_OFFERS("latestBolt12Offers"), + CASHU_WALLET("latestCashuWallet"), + NUTZAP_INFO("latestNutzapInfo"), + ; + + val key: Preferences.Key = stringPreferencesKey(prefKey) +} + +/** + * Raw storage for [LatestEventSlot], deliberately untyped. + * + * Each slot holds a different event type and the app parses them in parallel + * with the right parser for each, so this store moves strings and leaves + * encoding to the caller. That also keeps the bytes identical to what the + * SharedPreferences implementation wrote, which the migration relies on. + */ +class LatestEventCacheStore( + private val store: DataStore, +) { + companion object { + /** + * The one-shot copy out of `secret_keeper_`. + * + * Both names come off the same enum entry, so this table cannot drift + * from the slots the store actually reads. + */ + val legacyTable = + LegacyKeyTable( + "migrated.latestEvents", + LatestEventSlot.entries.map { LegacyStringKey(it.prefKey, it.key) }, + ) + } + + /** Only the slots actually present; an absent slot means nothing was cached. */ + suspend fun load(): Map { + val prefs = + store.data + .catch { e -> if (e is IOException) emit(emptyPreferences()) else throw e } + .first() + + return LatestEventSlot.entries + .mapNotNull { slot -> + prefs[slot.key]?.let { Pair(slot, it) } + }.toMap() + } + + /** + * Writes every slot in one edit. A null value removes the key, so an event + * the account no longer has stops being served from cache instead of + * lingering as a stale copy. + */ + suspend fun saveAll(values: Map) { + store.edit { prefs -> + values.forEach { (slot, json) -> + if (json != null) prefs[slot.key] = json else prefs.remove(slot.key) + } + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/LegacyKeyTable.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/LegacyKeyTable.kt new file mode 100644 index 0000000000..6d4e275996 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/LegacyKeyTable.kt @@ -0,0 +1,150 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.preferences + +import androidx.datastore.core.DataMigration +import androidx.datastore.preferences.core.MutablePreferences +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.withContext + +/** + * The older preference file a migration reads from, reduced to the four calls + * a migration makes. + * + * An interface because the legacy store is an Android + * `EncryptedSharedPreferences`, which commonMain cannot name — and because a + * test wants to hand a migration a map rather than a file. + * + * Every getter returns null for an absent key rather than a default, so a + * migration can tell "the user turned this off" from "the user never touched + * it" — the distinction the whole copy depends on, since a key left absent + * keeps reading as unset and falls back to its own default. + */ +interface LegacyPreferenceSource { + /** Every key the file holds, used to spot ones no migration claims. */ + fun keys(): Set + + fun getBoolean(name: String): Boolean? + + fun getString(name: String): String? + + fun getStringSet(name: String): Set? +} + +/** + * One legacy key, and the [Preferences.Key] it lands on. + * + * The legacy name is a compatibility surface: it is the string the Android app + * has written since its first release, and it is frequently *not* the new key's + * name (`has_donated_in_version` became `hasDonatedInVersion`, and five of + * [DialogDismissalStore]'s nine keys were renamed like that). So both names are + * spelled out here rather than derived from one another. + */ +sealed class LegacyKey( + val legacyName: String, + val key: Preferences.Key, +) { + abstract fun read(source: LegacyPreferenceSource): T? + + /** Absent stays absent — see [LegacyPreferenceSource]. */ + fun copyInto( + source: LegacyPreferenceSource, + out: MutablePreferences, + ) { + read(source)?.let { out[key] = it } + } +} + +class LegacyBooleanKey( + legacyName: String, + key: Preferences.Key, +) : LegacyKey(legacyName, key) { + override fun read(source: LegacyPreferenceSource) = source.getBoolean(legacyName) +} + +class LegacyStringKey( + legacyName: String, + key: Preferences.Key, +) : LegacyKey(legacyName, key) { + override fun read(source: LegacyPreferenceSource) = source.getString(legacyName) +} + +class LegacyStringSetKey( + legacyName: String, + key: Preferences.Key>, +) : LegacyKey>(legacyName, key) { + override fun read(source: LegacyPreferenceSource) = source.getStringSet(legacyName) +} + +/** + * The keys one migration carries, as data. + * + * The point of the table is that the copy and the later *check* that the copy + * happened read from the same list. A migration written as a block of + * `if (legacy.contains(k)) out[key] = legacy.getBoolean(k)` lines cannot be + * asked what it covers, so anything verifying it has to restate the list — and + * a key added to one copy and not the other is exactly the silent hole that + * makes deleting the legacy file unsafe. + * + * @param markerName the key recording, in the destination, that this copy has + * run. Distinct per table, so several can run against one store. + */ +class LegacyKeyTable( + val markerName: String, + val keys: List>, +) { + private val marker = booleanPreferencesKey(markerName) + + val legacyNames: Set = keys.mapTo(mutableSetOf()) { it.legacyName } + + /** + * Builds the one-shot copy. + * + * [openSource] is called only when the migration actually runs, so opening + * the legacy file is not a cost paid on every launch — decrypting an + * `EncryptedSharedPreferences` is not free. + */ + fun migration(openSource: () -> LegacyPreferenceSource): DataMigration = + CopyOnceMigration(markerName) { out -> + withContext(Dispatchers.IO) { + val source = openSource() + keys.forEach { it.copyInto(source, out) } + } + } + + /** + * Whether the copy has run against [destination]. + * + * This, and not a value-by-value comparison, is what says the legacy keys + * made it across. [CopyOnceMigration] writes the values and this marker as + * one `Preferences`, which DataStore commits atomically, so the marker + * being set means every value the copy read was written with it. + * + * A comparison would be the wrong question anyway: once migrated, these + * groups are written *only* to the new store, so the legacy file is a + * frozen snapshot and the two are expected to diverge the moment the user + * changes a setting. + */ + fun hasRun(destination: Preferences): Boolean = destination[marker] == true +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/NamecoinSharedPreferences.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/NamecoinSettingsStore.kt similarity index 90% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/NamecoinSharedPreferences.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/NamecoinSettingsStore.kt index 0844905ab4..964157ff42 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/NamecoinSharedPreferences.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/NamecoinSettingsStore.kt @@ -18,10 +18,11 @@ * 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.preferences +package com.vitorpamplona.amethyst.commons.model.preferences -import android.content.Context import androidx.compose.runtime.Stable +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey @@ -39,20 +40,20 @@ import kotlinx.serialization.json.Json import kotlin.coroutines.cancellation.CancellationException /** - * Persistent storage for [NamecoinSettings], following the same pattern as - * [TorSharedPreferences]. + * Persistent storage for [NamecoinSettings] — which ElectrumX servers and + * backend resolve `.bit` names, and the certificates the user has pinned. * - * Uses the app-wide [sharedPreferencesDataStore] so Namecoin resolution - * settings (like Tor settings) are global — not per-account. + * App-wide, not per-account, and shares [AppPreferenceStores.SHARED_SETTINGS] + * with the other global settings groups under its own `namecoin.` key prefix. * * The current settings are available synchronously via [settings] (a * [StateFlow]) and can be read in non-suspend contexts (e.g. in a * `serverListProvider` lambda). */ @Stable -class NamecoinSharedPreferences( - private val context: Context, - private val scope: CoroutineScope, +class NamecoinSettingsStore( + private val store: DataStore, + scope: CoroutineScope, ) { private val json = Json { ignoreUnknownKeys = true } @@ -148,18 +149,18 @@ class NamecoinSharedPreferences( private suspend fun savePinnedCerts(certs: List) { try { - context.sharedPreferencesDataStore.edit { prefs -> + store.edit { prefs -> prefs[KEY_PINNED_CERTS] = json.encodeToString(certs) } } catch (e: Exception) { if (e is CancellationException) throw e - Log.e("NamecoinPrefs") { "Error writing pinned certs: ${e.message}" } + Log.e("NamecoinSettingsStore") { "Error writing pinned certs: ${e.message}" } } } private suspend fun loadPinnedCertsFromDisk(): List = try { - val prefs = context.sharedPreferencesDataStore.data.first() + val prefs = store.data.first() val certsJson = prefs[KEY_PINNED_CERTS] if (certsJson != null) { json.decodeFromString>(certsJson) @@ -176,7 +177,7 @@ class NamecoinSharedPreferences( private suspend fun persist(settings: NamecoinSettings) { _settings.value = settings try { - context.sharedPreferencesDataStore.edit { prefs -> + store.edit { prefs -> prefs[KEY_ENABLED] = settings.enabled prefs[KEY_CUSTOM_SERVERS] = json.encodeToString( @@ -189,13 +190,13 @@ class NamecoinSharedPreferences( } } catch (e: Exception) { if (e is CancellationException) throw e - Log.e("NamecoinPrefs") { "Error writing DataStore: ${e.message}" } + Log.e("NamecoinSettingsStore") { "Error writing DataStore: ${e.message}" } } } private suspend fun loadFromDisk(): NamecoinSettings? = try { - val prefs = context.sharedPreferencesDataStore.data.first() + val prefs = store.data.first() val enabled = prefs[KEY_ENABLED] ?: true val serversJson = prefs[KEY_CUSTOM_SERVERS] val servers = @@ -236,7 +237,7 @@ class NamecoinSharedPreferences( ) } catch (e: Exception) { if (e is CancellationException) throw e - Log.e("NamecoinPrefs") { "Error reading DataStore: ${e.message}" } + Log.e("NamecoinSettingsStore") { "Error reading DataStore: ${e.message}" } null } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/NotificationPrefsStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/NotificationPrefsStore.kt new file mode 100644 index 0000000000..fba9ded945 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/NotificationPrefsStore.kt @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.preferences + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.first +import okio.IOException + +/** + * This account's share of the notification settings. + * + * Two keys from the legacy file are deliberately absent: + * `notif_global_to_curated_migrated` and `last_read_per_route` are accepted + * losses rather than migrations — re-running a one-shot filter migration, or + * marking feeds unread once, costs less than the code to carry them. + + * The global on/off switch is not here — it lives in plain, non-encrypted + * storage because the restart layer must read it synchronously from a fresh + * process (boot receiver, WorkManager) before any account is loaded. + * + * Defaults match what the SharedPreferences implementation returned for a + * missing key, so an account that never touched a setting behaves identically + * before and after the migration. + */ +data class NotificationPrefs( + val alwaysOnService: Boolean = false, + val showMessagesInNotifications: Boolean = true, + val splitNotificationsEnabled: Boolean = false, +) + +/** Reads and writes [NotificationPrefs] in the account's DataStore. */ +class NotificationPrefsStore( + private val store: DataStore, +) { + companion object { + val alwaysOnService = booleanPreferencesKey("always_on_notification_service") + val showMessagesInNotifications = booleanPreferencesKey("show_messages_in_notifications") + val splitNotificationsEnabled = booleanPreferencesKey("split_notifications_enabled") + + /** What the `secret_keeper_` file called these, for the one-shot copy. */ + val legacyTable = + LegacyKeyTable( + "migrated.notificationPrefs", + listOf( + LegacyBooleanKey("always_on_notification_service", alwaysOnService), + LegacyBooleanKey("show_messages_in_notifications", showMessagesInNotifications), + LegacyBooleanKey("split_notifications_enabled", splitNotificationsEnabled), + ), + ) + } + + private suspend fun read(): Preferences = + store.data + .catch { e -> if (e is IOException) emit(emptyPreferences()) else throw e } + .first() + + suspend fun load(): NotificationPrefs { + val prefs = read() + + return NotificationPrefs( + alwaysOnService = prefs[alwaysOnService] ?: false, + showMessagesInNotifications = prefs[showMessagesInNotifications] ?: true, + splitNotificationsEnabled = prefs[splitNotificationsEnabled] ?: false, + ) + } + + /** Writes the whole group in one edit, so a crash cannot half-apply it. */ + suspend fun save(value: NotificationPrefs) { + store.edit { prefs -> + prefs[alwaysOnService] = value.alwaysOnService + prefs[showMessagesInNotifications] = value.showMessagesInNotifications + prefs[splitNotificationsEnabled] = value.splitNotificationsEnabled + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/RelayAuthStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/RelayAuthStore.kt new file mode 100644 index 0000000000..70e3b981e6 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/RelayAuthStore.kt @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.preferences + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.stringPreferencesKey +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.first +import okio.IOException + +/** + * When this account answers a relay's NIP-42 AUTH challenge. + + * The policy key is absent for accounts saved before the setting existed; the + * caller maps that absence to CUSTOM rather than to the constructor default, so + * an existing user's behaviour does not change under them. + * + * Defaults match what the SharedPreferences implementation returned for a + * missing key, so an account that never touched a setting behaves identically + * before and after the migration. + */ +data class RelayAuth( + val policyName: String? = null, + val trustMyRelays: Boolean = true, + val trustReadFollows: Boolean = true, + val trustMessageFollows: Boolean = true, + val trustMessageStrangers: Boolean = false, +) + +/** Reads and writes [RelayAuth] in the account's DataStore. */ +class RelayAuthStore( + private val store: DataStore, +) { + companion object { + val policyName = stringPreferencesKey("default_relay_auth_policy") + val trustMyRelays = booleanPreferencesKey("relay_auth_trust_my_relays") + val trustReadFollows = booleanPreferencesKey("relay_auth_trust_read_follows") + val trustMessageFollows = booleanPreferencesKey("relay_auth_trust_message_follows") + val trustMessageStrangers = booleanPreferencesKey("relay_auth_trust_message_strangers") + + /** + * What the `secret_keeper_` file called these, for the one-shot copy. + * + * The two "trust my relays" spellings differ: the legacy key grew a + * `_and_venues` suffix that the new one dropped. + */ + val legacyTable = + LegacyKeyTable( + "migrated.relayAuth", + listOf( + LegacyStringKey("default_relay_auth_policy", policyName), + LegacyBooleanKey("relay_auth_trust_my_relays_and_venues", trustMyRelays), + LegacyBooleanKey("relay_auth_trust_read_follows", trustReadFollows), + LegacyBooleanKey("relay_auth_trust_message_follows", trustMessageFollows), + LegacyBooleanKey("relay_auth_trust_message_strangers", trustMessageStrangers), + ), + ) + } + + suspend fun load(): RelayAuth { + val prefs = + store.data + .catch { e -> if (e is IOException) emit(emptyPreferences()) else throw e } + .first() + + return RelayAuth( + policyName = prefs[policyName], + trustMyRelays = prefs[trustMyRelays] ?: true, + trustReadFollows = prefs[trustReadFollows] ?: true, + trustMessageFollows = prefs[trustMessageFollows] ?: true, + trustMessageStrangers = prefs[trustMessageStrangers] ?: false, + ) + } + + /** Writes the whole group in one edit, so a crash cannot half-apply it. */ + suspend fun save(value: RelayAuth) { + store.edit { prefs -> + value.policyName.let { if (it != null) prefs[policyName] = it else prefs.remove(policyName) } + prefs[trustMyRelays] = value.trustMyRelays + prefs[trustReadFollows] = value.trustReadFollows + prefs[trustMessageFollows] = value.trustMessageFollows + prefs[trustMessageStrangers] = value.trustMessageStrangers + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/RelayGroupDeletionPreferences.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/RelayGroupDeletionStore.kt similarity index 89% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/RelayGroupDeletionPreferences.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/RelayGroupDeletionStore.kt index 99e1f99c01..2a00912633 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/RelayGroupDeletionPreferences.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/RelayGroupDeletionStore.kt @@ -18,10 +18,11 @@ * 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.preferences +package com.vitorpamplona.amethyst.commons.model.preferences -import android.content.Context import androidx.compose.runtime.Stable +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringSetPreferencesKey import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupDeletions @@ -40,8 +41,8 @@ import kotlin.coroutines.cancellation.CancellationException * back. Construct once, eagerly. */ @Stable -class RelayGroupDeletionPreferences( - private val context: Context, +class RelayGroupDeletionStore( + private val store: DataStore, private val scope: CoroutineScope, ) { init { @@ -54,7 +55,7 @@ class RelayGroupDeletionPreferences( private suspend fun restoreFromDisk() { try { - val raw = context.sharedPreferencesDataStore.data.first()[KEY] ?: return + val raw = store.data.first()[KEY] ?: return if (raw.isNotEmpty()) RelayGroupDeletions.restore(raw) } catch (e: Exception) { if (e is CancellationException) throw e @@ -64,7 +65,7 @@ class RelayGroupDeletionPreferences( private suspend fun persist(keys: Set) { try { - context.sharedPreferencesDataStore.edit { prefs -> prefs[KEY] = keys } + store.edit { prefs -> prefs[KEY] = keys } } catch (e: Exception) { if (e is CancellationException) throw e Log.e("RelayGroupDeletionPrefs") { "Error writing deleted channels: ${e.message}" } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/TopNavFollowListStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/TopNavFollowListStore.kt new file mode 100644 index 0000000000..e14d87a219 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/TopNavFollowListStore.kt @@ -0,0 +1,155 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.preferences + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.stringPreferencesKey +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter +import com.vitorpamplona.quartz.nip01Core.core.JsonMapper +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.first +import okio.IOException + +/** + * Every top-nav feed whose author filter the user can change, with the + * preference key it has always been stored under and the filter it falls back + * to when unset. + * + * The keys are the strings the Android app wrote from its first release, so + * this table is a compatibility surface: renaming an entry silently resets that + * feed for everyone who had customised it. + * + * Defaults are not uniform and the differences are deliberate — [PRODUCTS] and + * [GEOCACHES] open to what is physically nearby, [BADGES] and + * [RELAY_GROUPS_DISCOVERY] to the user's own, most others to Global. + */ +enum class FollowListSlot( + val prefKey: String, + val default: TopFilter, +) { + HOME("defaultHomeFollowList", TopFilter.AllFollows), + STORIES("defaultStoriesFollowList", TopFilter.Global), + NOTIFICATION("defaultNotificationFollowList", TopFilter.Selected), + DISCOVERY("defaultDiscoveryFollowList", TopFilter.Global), + POLLS("defaultPollsFollowList", TopFilter.Global), + PICTURES("defaultPicturesFollowList", TopFilter.Global), + RELAY_GROUPS_DISCOVERY("defaultRelayGroupsDiscoveryFollowList", TopFilter.Mine), + NAPPLETS("defaultNappletsFollowList", TopFilter.Global), + NSITES("defaultNsitesFollowList", TopFilter.Global), + WORKOUTS("defaultWorkoutsFollowList", TopFilter.Global), + GIT_REPOSITORIES("defaultGitRepositoriesFollowList", TopFilter.Global), + HIGHLIGHTS("defaultHighlightsFollowList", TopFilter.Global), + CALENDARS("defaultCalendarsFollowList", TopFilter.Global), + PRODUCTS("defaultProductsFollowList", TopFilter.AroundMe), + GEOCACHES("defaultGeocachesFollowList", TopFilter.AroundMe), + SHORTS("defaultShortsFollowList", TopFilter.Global), + PUBLIC_CHATS("defaultPublicChatsFollowList", TopFilter.Global), + LIVE_STREAMS("defaultLiveStreamsFollowList", TopFilter.Global), + NESTS("defaultNestsFollowList", TopFilter.Global), + LONGS("defaultLongsFollowList", TopFilter.Global), + ARTICLES("defaultArticlesFollowList", TopFilter.AllFollows), + MUSIC_TRACKS("defaultMusicTracksFollowList", TopFilter.Global), + MUSIC_PLAYLISTS("defaultMusicPlaylistsFollowList", TopFilter.Global), + PODCAST_EPISODES("defaultPodcastEpisodesFollowList", TopFilter.Global), + PODCASTS("defaultPodcastsFollowList", TopFilter.Global), + SOFTWARE_APPS("defaultSoftwareAppsFollowList", TopFilter.Global), + BADGES("defaultBadgesFollowList", TopFilter.Mine), + BROWSE_EMOJI_SETS("defaultBrowseEmojiSetsFollowList", TopFilter.Global), + COMMUNITIES("defaultCommunitiesFollowList", TopFilter.AllFollows), + FOLLOW_PACKS("defaultFollowPacksFollowList", TopFilter.Global), + APP_RECOMMENDATIONS("defaultAppRecommendationsFollowList", TopFilter.Global), + ; + + val key: Preferences.Key = stringPreferencesKey(prefKey) +} + +/** + * The per-account top-nav filter selections, stored one key per feed. + * + * Values are [TopFilter] as JSON, the same encoding the SharedPreferences + * implementation used, so a migrated store and a legacy one hold byte-identical + * strings. + */ +class TopNavFollowListStore( + private val store: DataStore, +) { + companion object { + /** + * The one-shot copy out of `secret_keeper_`. + * + * Both names come off the same enum entry, so this table cannot drift + * from the slots the store actually reads. + */ + val legacyTable = + LegacyKeyTable( + "migrated.followLists", + FollowListSlot.entries.map { LegacyStringKey(it.prefKey, it.key) }, + ) + } + + /** + * Every slot's current filter, falling back to [FollowListSlot.default] + * where the key is unset or unreadable. + * + * A value that fails to parse yields the default rather than propagating: + * one corrupt entry should cost the user that feed's filter, not the whole + * account load. + */ + suspend fun load(): Map { + val prefs = + store.data + .catch { e -> if (e is IOException) emit(emptyPreferences()) else throw e } + .first() + + return FollowListSlot.entries.associateWith { slot -> parse(prefs[slot.key], slot) } + } + + /** Writes every slot in one edit, so a crash cannot leave a half-applied set. */ + suspend fun saveAll(values: Map) { + store.edit { prefs -> + values.forEach { (slot, filter) -> prefs[slot.key] = JsonMapper.toJson(filter) } + } + } + + suspend fun save( + slot: FollowListSlot, + filter: TopFilter, + ) { + store.edit { prefs -> prefs[slot.key] = JsonMapper.toJson(filter) } + } + + private fun parse( + value: String?, + slot: FollowListSlot, + ): TopFilter { + if (value.isNullOrEmpty() || value == "null") return slot.default + return try { + JsonMapper.fromJson(value) + } catch (e: Exception) { + Log.w("TopNavFollowListStore") { "Could not decode ${slot.prefKey}; falling back to its default: ${e.message}" } + slot.default + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/TorSharedPreferences.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/TorSettingsStore.kt similarity index 85% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/TorSharedPreferences.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/TorSettingsStore.kt index 900cc3e78f..45c654835e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/TorSharedPreferences.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/TorSettingsStore.kt @@ -18,23 +18,25 @@ * 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.preferences +package com.vitorpamplona.amethyst.commons.model.preferences -import android.content.Context import androidx.compose.runtime.Stable +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey +import com.vitorpamplona.amethyst.commons.tor.TorPreferencesPort import com.vitorpamplona.amethyst.commons.tor.TorSettings +import com.vitorpamplona.amethyst.commons.tor.TorSettingsFlow import com.vitorpamplona.amethyst.commons.tor.TorType -import com.vitorpamplona.amethyst.ui.tor.TorPreferencesPort -import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.IO import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.debounce @@ -46,9 +48,9 @@ import kotlinx.coroutines.flow.stateIn import kotlin.coroutines.cancellation.CancellationException @Stable -class TorSharedPreferences( +class TorSettingsStore( prefs: TorSettings, - val context: Context, + val store: DataStore, val scope: CoroutineScope, ) : TorPreferencesPort { // Tor Preferences. Makes sure to wait for it to avoid connecting with random IPs @@ -63,7 +65,7 @@ class TorSharedPreferences( .debounce(1000) .distinctUntilChanged() .onEach { - save(it, context) + save(it, store) }.flowOn(Dispatchers.IO) .stateIn( scope, @@ -71,9 +73,9 @@ class TorSharedPreferences( value.toSettings(), ) - override suspend fun loadLastBypassApprovalMs(): Long = TorSharedPreferences.loadLastBypassApprovalMs(context) + override suspend fun loadLastBypassApprovalMs(): Long = loadLastBypassApprovalMs(store) - override suspend fun saveLastBypassApprovalMs(value: Long) = TorSharedPreferences.saveLastBypassApprovalMs(value, context) + override suspend fun saveLastBypassApprovalMs(value: Long) = saveLastBypassApprovalMs(value, store) companion object { // loads faster when individualized @@ -92,10 +94,10 @@ class TorSharedPreferences( val NIP05_VERIFICATIONS_VIA_TOR_KEY = booleanPreferencesKey("tor.nip05VerificationsViaTor") val MEDIA_UPLOADS_VIA_TOR_KEY = booleanPreferencesKey("tor.mediaUploadsViaTor") - suspend fun torPreferences(context: Context): TorSettings? = + suspend fun torPreferences(store: DataStore): TorSettings? = try { // Get the preference flow and take the first value. - val preferences = context.sharedPreferencesDataStore.data.first() + val preferences = store.data.first() TorSettings( torType = preferences[TOR_TYPE_KEY]?.let { TorType.valueOf(it) } ?: TorType.INTERNAL, externalSocksPort = preferences[EXTERNAL_SOCKS_PORT_KEY] ?: 9050, @@ -114,16 +116,16 @@ class TorSharedPreferences( } catch (e: Exception) { if (e is CancellationException) throw e // Log any errors that occur while reading the DataStore. - Log.e("SharedPreferences") { "Error reading DataStore preferences: ${e.message}" } + Log.e("TorSettingsStore") { "Error reading DataStore preferences: ${e.message}" } null } suspend fun save( torSettings: TorSettings, - context: Context, + store: DataStore, ) { try { - context.sharedPreferencesDataStore.edit { preferences -> + store.edit { preferences -> preferences[TOR_TYPE_KEY] = torSettings.torType.name preferences[EXTERNAL_SOCKS_PORT_KEY] = torSettings.externalSocksPort preferences[ONION_RELAYS_VIA_TOR_KEY] = torSettings.onionRelaysViaTor @@ -141,30 +143,30 @@ class TorSharedPreferences( } catch (e: Exception) { if (e is CancellationException) throw e // Log any errors that occur while reading the DataStore. - Log.e("SharedPreferences") { "Error saving DataStore preferences: ${e.message}" } + Log.e("TorSettingsStore") { "Error saving DataStore preferences: ${e.message}" } } } - suspend fun loadLastBypassApprovalMs(context: Context): Long = + suspend fun loadLastBypassApprovalMs(store: DataStore): Long = try { - context.sharedPreferencesDataStore.data.first()[LAST_BYPASS_APPROVAL_MS_KEY] ?: 0L + store.data.first()[LAST_BYPASS_APPROVAL_MS_KEY] ?: 0L } catch (e: Exception) { if (e is CancellationException) throw e - Log.e("SharedPreferences") { "Error reading lastBypassApprovalMs: ${e.message}" } + Log.e("TorSettingsStore") { "Error reading lastBypassApprovalMs: ${e.message}" } 0L } suspend fun saveLastBypassApprovalMs( value: Long, - context: Context, + store: DataStore, ) { try { - context.sharedPreferencesDataStore.edit { prefs -> + store.edit { prefs -> prefs[LAST_BYPASS_APPROVAL_MS_KEY] = value } } catch (e: Exception) { if (e is CancellationException) throw e - Log.e("SharedPreferences") { "Error saving lastBypassApprovalMs: ${e.message}" } + Log.e("TorSettingsStore") { "Error saving lastBypassApprovalMs: ${e.message}" } } } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/UiSettingsStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/UiSettingsStore.kt new file mode 100644 index 0000000000..c1606b0440 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/UiSettingsStore.kt @@ -0,0 +1,247 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.preferences + +import androidx.datastore.core.DataMigration +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.MutablePreferences +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import com.vitorpamplona.amethyst.commons.model.AccentColorType +import com.vitorpamplona.amethyst.commons.model.BooleanType +import com.vitorpamplona.amethyst.commons.model.ConnectivityType +import com.vitorpamplona.amethyst.commons.model.FeatureSetType +import com.vitorpamplona.amethyst.commons.model.FontFamilyType +import com.vitorpamplona.amethyst.commons.model.FontSizeType +import com.vitorpamplona.amethyst.commons.model.ProfileGalleryType +import com.vitorpamplona.amethyst.commons.model.ThemeType +import com.vitorpamplona.amethyst.commons.model.UiSettings +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext +import kotlin.coroutines.cancellation.CancellationException + +/** + * The app-wide UI settings — theme, language, what loads on cellular, which + * profile and home tabs are shown. + * + * Lives on the [AppPreferenceStores.SHARED_SETTINGS] file under the `ui.` key + * prefix, one key per setting rather than a single blob: a DataStore read is + * whole-file anyway, but individual keys mean a setting added later does not + * invalidate the ones already stored. + * + * Headless on purpose. Applying a theme or a locale is platform work — on + * Android it is `UiModeManager` and `AppCompatDelegate`, which have no desktop + * equivalent — so that half stays in each front end and only the persistence + * is shared. + * + * @param loadLegacy reads the single `shared_settings` JSON blob these settings + * used to live in. Injected because the legacy file is Android's, and this + * store is not. Front ends without one pass nothing. + */ +class UiSettingsStore( + private val store: DataStore, + private val loadLegacy: suspend () -> UiSettings? = { null }, +) { + /** + * Reads every setting, falling back to the legacy blob if the store itself + * cannot be read. + * + * Returns null when neither can be read, which the caller shows as "still + * loading" rather than as defaults — writing defaults over an unreadable + * store would make a transient failure permanent on the next save. + */ + suspend fun load(): UiSettings? = + try { + read(store.data.first()) + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("UiSettingsStore") { "Error reading DataStore preferences: ${e.message}" } + + try { + loadLegacy()?.also { save(it) } + } catch (e: Exception) { + if (e is CancellationException) throw e + null + } + } + + suspend fun save(settings: UiSettings) { + try { + store.edit { it.write(settings) } + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("UiSettingsStore") { "Error saving DataStore preferences: ${e.message}" } + } + } + + companion object { + // loads faster when individualized + val UI_THEME = stringPreferencesKey("ui.theme") + val UI_LANGUAGE = stringPreferencesKey("ui.language") + val UI_SHOW_IMAGES = stringPreferencesKey("ui.show_images") + val UI_START_PLAYBACK = stringPreferencesKey("ui.start_playback") + val UI_PLAY_VIDEOS = stringPreferencesKey("ui.play_videos") + val UI_SHOW_URL_PREVIEW = stringPreferencesKey("ui.show_url_preview") + val UI_HIDE_NAVIGATION_BARS = stringPreferencesKey("ui.hide_navigation_bars") + val UI_SHOW_PROFILE_PICTURES = stringPreferencesKey("ui.show_profile_pictures") + val UI_DONT_SHOW_PUSH_NOTIFICATION_SELECTOR = booleanPreferencesKey("ui.dont_show_push_notification_selector") + val UI_DONT_ASK_FOR_NOTIFICATION_PERMISSIONS = booleanPreferencesKey("ui.dont_ask_for_notification_permissions") + val UI_FEATURE_SET = stringPreferencesKey("ui.feature_set") + val UI_GALLERY_SET = stringPreferencesKey("ui.gallery_set") + val UI_PROPOSE_AI_IMPROVEMENTS = stringPreferencesKey("ui.propose_ai_improvements") + val UI_USE_TRACKED_BROADCASTS = stringPreferencesKey("ui.use_tracked_broadcasts") + val UI_AUTOMATICALLY_CREATE_DRAFTS = stringPreferencesKey("ui.automatically_create_drafts") + val UI_SHOW_HOME_NEW_THREADS_TAB = booleanPreferencesKey("ui.show_home_new_threads_tab") + val UI_SHOW_HOME_CONVERSATIONS_TAB = booleanPreferencesKey("ui.show_home_conversations_tab") + val UI_SHOW_HOME_EVERYTHING_TAB = booleanPreferencesKey("ui.show_home_everything_tab") + val UI_SHOW_PROFILE_BADGES = booleanPreferencesKey("ui.show_profile_badges") + val UI_SHOW_PROFILE_APP_RECOMMENDATIONS = booleanPreferencesKey("ui.show_profile_app_recommendations") + val UI_SHOW_PROFILE_ZAP_RECEIVED_FEED = booleanPreferencesKey("ui.show_profile_zap_received_feed") + val UI_SHOW_PROFILE_FOLLOWERS_FEED = booleanPreferencesKey("ui.show_profile_followers_feed") + val UI_DONT_SHOW_ONCHAIN_PUBLIC_WARNING = booleanPreferencesKey("ui.dont_show_onchain_public_warning") + val UI_SUGGEST_WORKOUTS_FROM_HEALTH_CONNECT = stringPreferencesKey("ui.suggest_workouts_from_health_connect") + val UI_ACCENT_COLOR = stringPreferencesKey("ui.accent_color") + val UI_FONT_FAMILY = stringPreferencesKey("ui.font_family") + val UI_FONT_SIZE = stringPreferencesKey("ui.font_size") + val UI_COMPOSE_SIGNATURE = stringPreferencesKey("ui.compose_signature") + val UI_SHOW_ONCHAIN_WALLET = booleanPreferencesKey("ui.show_onchain_wallet") + val UI_SHOW_PAYTO_ZAP_CHIP = booleanPreferencesKey("ui.show_payto_zap_chip") + + /** + * Every setting's default matches what the old `getBoolean(key, default)` + * call returned for a missing key. Several of them are `true`, so reading + * a default of `false` here would silently turn features off for every + * install that never touched them. + */ + fun read(preferences: Preferences): UiSettings { + val featureSet = preferences[UI_FEATURE_SET]?.let { FeatureSetType.valueOf(it) } ?: FeatureSetType.SIMPLIFIED + + return UiSettings( + theme = preferences[UI_THEME]?.let { ThemeType.valueOf(it) } ?: ThemeType.SYSTEM, + preferredLanguage = preferences[UI_LANGUAGE]?.ifBlank { null }, + automaticallyShowImages = preferences[UI_SHOW_IMAGES]?.let { ConnectivityType.valueOf(it) } ?: ConnectivityType.ALWAYS, + automaticallyStartPlayback = preferences[UI_START_PLAYBACK]?.let { ConnectivityType.valueOf(it) } ?: ConnectivityType.ALWAYS, + automaticallyPlayVideos = preferences[UI_PLAY_VIDEOS]?.let { BooleanType.valueOf(it) } ?: BooleanType.ALWAYS, + automaticallyShowUrlPreview = preferences[UI_SHOW_URL_PREVIEW]?.let { ConnectivityType.valueOf(it) } ?: ConnectivityType.ALWAYS, + automaticallyHideNavigationBars = preferences[UI_HIDE_NAVIGATION_BARS]?.let { BooleanType.valueOf(it) } ?: BooleanType.ALWAYS, + automaticallyShowProfilePictures = preferences[UI_SHOW_PROFILE_PICTURES]?.let { ConnectivityType.valueOf(it) } ?: ConnectivityType.ALWAYS, + dontShowPushNotificationSelector = preferences[UI_DONT_SHOW_PUSH_NOTIFICATION_SELECTOR] ?: false, + dontAskForNotificationPermissions = preferences[UI_DONT_ASK_FOR_NOTIFICATION_PERMISSIONS] ?: false, + featureSet = featureSet, + gallerySet = preferences[UI_GALLERY_SET]?.let { ProfileGalleryType.valueOf(it) } ?: ProfileGalleryType.CLASSIC, + automaticallyProposeAiImprovements = preferences[UI_PROPOSE_AI_IMPROVEMENTS]?.let { BooleanType.valueOf(it) } ?: BooleanType.ALWAYS, + useTrackedBroadcasts = + preferences[UI_USE_TRACKED_BROADCASTS]?.let { BooleanType.valueOf(it) } + ?: if (featureSet == FeatureSetType.COMPLETE) BooleanType.ALWAYS else BooleanType.NEVER, + automaticallyCreateDrafts = preferences[UI_AUTOMATICALLY_CREATE_DRAFTS]?.let { BooleanType.valueOf(it) } ?: BooleanType.ALWAYS, + showHomeNewThreadsTab = preferences[UI_SHOW_HOME_NEW_THREADS_TAB] ?: true, + showHomeConversationsTab = preferences[UI_SHOW_HOME_CONVERSATIONS_TAB] ?: true, + showHomeEverythingTab = preferences[UI_SHOW_HOME_EVERYTHING_TAB] ?: false, + showProfileBadges = preferences[UI_SHOW_PROFILE_BADGES] ?: true, + showProfileAppRecommendations = preferences[UI_SHOW_PROFILE_APP_RECOMMENDATIONS] ?: true, + showProfileZapReceivedFeed = preferences[UI_SHOW_PROFILE_ZAP_RECEIVED_FEED] ?: true, + showProfileFollowersFeed = preferences[UI_SHOW_PROFILE_FOLLOWERS_FEED] ?: true, + dontShowOnchainPublicWarning = preferences[UI_DONT_SHOW_ONCHAIN_PUBLIC_WARNING] ?: false, + suggestWorkoutsFromHealthConnect = + preferences[UI_SUGGEST_WORKOUTS_FROM_HEALTH_CONNECT]?.let { BooleanType.valueOf(it) } ?: BooleanType.ALWAYS, + accentColor = preferences[UI_ACCENT_COLOR]?.let { AccentColorType.valueOf(it) } ?: AccentColorType.PURPLE, + fontFamily = preferences[UI_FONT_FAMILY]?.let { FontFamilyType.valueOf(it) } ?: FontFamilyType.SYSTEM, + fontSize = preferences[UI_FONT_SIZE]?.let { FontSizeType.valueOf(it) } ?: FontSizeType.NORMAL, + composeSignature = preferences[UI_COMPOSE_SIGNATURE] ?: "", + showOnchainWallet = preferences[UI_SHOW_ONCHAIN_WALLET] ?: true, + showPayToZapChip = preferences[UI_SHOW_PAYTO_ZAP_CHIP] ?: true, + ) + } + + /** + * Writes every UI setting into [this]. + * + * Shared by [save] and by the one-shot copy out of the old + * `shared_settings` blob, so the two cannot come to disagree about + * which keys a complete set of UI settings has. + */ + fun MutablePreferences.write(sharedSettings: UiSettings) { + val preferences = this + preferences[UI_THEME] = sharedSettings.theme.name + preferences[UI_LANGUAGE] = sharedSettings.preferredLanguage ?: "" + preferences[UI_SHOW_IMAGES] = sharedSettings.automaticallyShowImages.name + preferences[UI_START_PLAYBACK] = sharedSettings.automaticallyStartPlayback.name + preferences[UI_PLAY_VIDEOS] = sharedSettings.automaticallyPlayVideos.name + preferences[UI_SHOW_URL_PREVIEW] = sharedSettings.automaticallyShowUrlPreview.name + preferences[UI_HIDE_NAVIGATION_BARS] = sharedSettings.automaticallyHideNavigationBars.name + preferences[UI_SHOW_PROFILE_PICTURES] = sharedSettings.automaticallyShowProfilePictures.name + preferences[UI_DONT_SHOW_PUSH_NOTIFICATION_SELECTOR] = sharedSettings.dontShowPushNotificationSelector + preferences[UI_DONT_ASK_FOR_NOTIFICATION_PERMISSIONS] = sharedSettings.dontAskForNotificationPermissions + preferences[UI_FEATURE_SET] = sharedSettings.featureSet.name + preferences[UI_GALLERY_SET] = sharedSettings.gallerySet.name + preferences[UI_PROPOSE_AI_IMPROVEMENTS] = sharedSettings.automaticallyProposeAiImprovements.name + preferences[UI_USE_TRACKED_BROADCASTS] = sharedSettings.useTrackedBroadcasts.name + preferences[UI_AUTOMATICALLY_CREATE_DRAFTS] = sharedSettings.automaticallyCreateDrafts.name + preferences[UI_SHOW_HOME_NEW_THREADS_TAB] = sharedSettings.showHomeNewThreadsTab + preferences[UI_SHOW_HOME_CONVERSATIONS_TAB] = sharedSettings.showHomeConversationsTab + preferences[UI_SHOW_HOME_EVERYTHING_TAB] = sharedSettings.showHomeEverythingTab + preferences[UI_SHOW_PROFILE_BADGES] = sharedSettings.showProfileBadges + preferences[UI_SHOW_PROFILE_APP_RECOMMENDATIONS] = sharedSettings.showProfileAppRecommendations + preferences[UI_SHOW_PROFILE_ZAP_RECEIVED_FEED] = sharedSettings.showProfileZapReceivedFeed + preferences[UI_SHOW_PROFILE_FOLLOWERS_FEED] = sharedSettings.showProfileFollowersFeed + preferences[UI_DONT_SHOW_ONCHAIN_PUBLIC_WARNING] = sharedSettings.dontShowOnchainPublicWarning + preferences[UI_SUGGEST_WORKOUTS_FROM_HEALTH_CONNECT] = sharedSettings.suggestWorkoutsFromHealthConnect.name + preferences[UI_ACCENT_COLOR] = sharedSettings.accentColor.name + preferences[UI_FONT_FAMILY] = sharedSettings.fontFamily.name + preferences[UI_FONT_SIZE] = sharedSettings.fontSize.name + preferences[UI_COMPOSE_SIGNATURE] = sharedSettings.composeSignature + preferences[UI_SHOW_ONCHAIN_WALLET] = sharedSettings.showOnchainWallet + preferences[UI_SHOW_PAYTO_ZAP_CHIP] = sharedSettings.showPayToZapChip + } + + /** + * The one-shot copy out of the single `shared_settings` JSON blob these + * settings used to be kept as, in the global encrypted file. + * + * Guarded, and it has to be. Unlike the per-account migrations, this + * store has been the real home of these settings for a while, so most + * installs already have a populated one — and copying an old blob over + * it would undo every UI change the user has made since. [UI_THEME] is + * the test: [write] sets every key unconditionally and is the only + * writer, so its absence means this store has never been saved, which + * is exactly the install whose settings are still only in the legacy + * file. + * + * Must be handed to the store at construction, so every consumer of the + * shared file gets it no matter which one opens the file first. + */ + fun migrations(loadLegacy: suspend () -> UiSettings?): List> = + listOf( + CopyOnceMigration("migrated.sharedSettings") { out -> + if (out[UI_THEME] == null) { + withContext(Dispatchers.IO) { + loadLegacy()?.let { out.write(it) } + } + } + }, + ) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/UploadSettingsStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/UploadSettingsStore.kt new file mode 100644 index 0000000000..632afa0670 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/UploadSettingsStore.kt @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.preferences + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.stringPreferencesKey +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.first +import okio.IOException + +/** + * How this account uploads media: which server, what is stripped, what is + * mirrored and what is cached locally. + * + * Defaults match what the SharedPreferences implementation returned for a + * missing key, so an account that never touched a setting behaves identically + * before and after the migration. + */ +data class UploadSettings( + val stripLocationOnUpload: Boolean = true, + val optimizeMediaOnUpload: Boolean = false, + val mirrorUploadsToAllServers: Boolean = true, + val useLocalBlossomCache: Boolean = true, + val localBlossomCacheProfilePicturesOnly: Boolean = false, + val defaultFileServerJson: String? = null, +) + +/** Reads and writes [UploadSettings] in the account's DataStore. */ +class UploadSettingsStore( + private val store: DataStore, +) { + companion object { + val stripLocationOnUpload = booleanPreferencesKey("stripLocationOnUpload") + val optimizeMediaOnUpload = booleanPreferencesKey("optimizeMediaOnUpload") + val mirrorUploadsToAllServers = booleanPreferencesKey("mirrorUploadsToAllServers") + val useLocalBlossomCache = booleanPreferencesKey("useLocalBlossomCache") + val localBlossomCacheProfilePicturesOnly = booleanPreferencesKey("localBlossomCacheProfilePicturesOnly") + val defaultFileServerJson = stringPreferencesKey("defaultFileServer") + + /** What the `secret_keeper_` file called these, for the one-shot copy. */ + val legacyTable = + LegacyKeyTable( + "migrated.uploadSettings", + listOf( + LegacyBooleanKey("stripLocationOnUpload", stripLocationOnUpload), + LegacyBooleanKey("optimizeMediaOnUpload", optimizeMediaOnUpload), + LegacyBooleanKey("mirrorUploadsToAllServers", mirrorUploadsToAllServers), + LegacyBooleanKey("useLocalBlossomCache", useLocalBlossomCache), + LegacyBooleanKey("localBlossomCacheProfilePicturesOnly", localBlossomCacheProfilePicturesOnly), + LegacyStringKey("defaultFileServer", defaultFileServerJson), + ), + ) + } + + suspend fun load(): UploadSettings { + val prefs = + store.data + .catch { e -> if (e is IOException) emit(emptyPreferences()) else throw e } + .first() + + return UploadSettings( + stripLocationOnUpload = prefs[stripLocationOnUpload] ?: true, + optimizeMediaOnUpload = prefs[optimizeMediaOnUpload] ?: false, + mirrorUploadsToAllServers = prefs[mirrorUploadsToAllServers] ?: true, + useLocalBlossomCache = prefs[useLocalBlossomCache] ?: true, + localBlossomCacheProfilePicturesOnly = prefs[localBlossomCacheProfilePicturesOnly] ?: false, + defaultFileServerJson = prefs[defaultFileServerJson], + ) + } + + /** Writes the whole group in one edit, so a crash cannot half-apply it. */ + suspend fun save(value: UploadSettings) { + store.edit { prefs -> + prefs[stripLocationOnUpload] = value.stripLocationOnUpload + prefs[optimizeMediaOnUpload] = value.optimizeMediaOnUpload + prefs[mirrorUploadsToAllServers] = value.mirrorUploadsToAllServers + prefs[useLocalBlossomCache] = value.useLocalBlossomCache + prefs[localBlossomCacheProfilePicturesOnly] = value.localBlossomCacheProfilePicturesOnly + value.defaultFileServerJson.let { if (it != null) prefs[defaultFileServerJson] = it else prefs.remove(defaultFileServerJson) } + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/ChatPreview.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/ChatPreview.kt index aa3502cc0b..55d278e140 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/ChatPreview.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/ChatPreview.kt @@ -22,12 +22,12 @@ package com.vitorpamplona.amethyst.commons.model.privateChats import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip04Dm.messages.EncryptedDmEvent import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent import com.vitorpamplona.quartz.nip51Lists.PrivateReplaceableTagArrayEvent import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent -import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapRequestEvent +import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent /** @@ -44,11 +44,11 @@ import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent */ fun Event.hasEncryptedContent(): Boolean = when (this) { - is PrivateDmEvent -> true + is EncryptedDmEvent -> true is DraftWrapEvent -> true - is SealedRumorEvent -> true + is SealEvent -> true is GiftWrapEvent -> true - is LnZapRequestEvent -> isPrivateZap() + is ZapRequestEvent -> isPrivateZap() // Every NIP-51 list keeps its private members as a NIP-44 payload in `content`. The two // base classes cover all ~30 list kinds, present and future, which is the point: naming // them one at a time is how kind 30005 came to print its own ciphertext on screen. @@ -105,7 +105,7 @@ fun chatPreviewOf( if (!canDecrypt) return ChatPreview.Undecryptable // A kind:4 addressed to neither me nor from me can't be opened with my key, ever. - if (event is PrivateDmEvent && myPubKey != null && !event.isIncluded(myPubKey)) { + if (event is EncryptedDmEvent && myPubKey != null && !event.isIncluded(myPubKey)) { return ChatPreview.Undecryptable } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/Chatroom.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/Chatroom.kt index b197e44971..61c493964c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/Chatroom.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/Chatroom.kt @@ -31,7 +31,7 @@ import com.vitorpamplona.amethyst.commons.util.WeakReference import com.vitorpamplona.amethyst.commons.util.withLock import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayLoadingCursors -import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip04Dm.messages.EncryptedDmEvent import com.vitorpamplona.quartz.nip14Subject.subject import com.vitorpamplona.quartz.nip17Dm.base.BaseDMGroupEvent import kotlinx.coroutines.channels.BufferOverflow @@ -151,8 +151,8 @@ class Chatroom : NotesGatherer { } else { // Old conversation, keep the last one. sorted.take(1).toSet() - } + sorted.filter { it.flowSet?.isInUse() ?: false } + sorted.filter { it.event !is PrivateDmEvent && it.event !is BaseDMGroupEvent } - // Both DM protocols are pruned by the recency rule above: NIP-04 (PrivateDmEvent) and NIP-17 + } + sorted.filter { it.flowSet?.isInUse() ?: false } + sorted.filter { it.event !is EncryptedDmEvent && it.event !is BaseDMGroupEvent } + // Both DM protocols are pruned by the recency rule above: NIP-04 (EncryptedDmEvent) and NIP-17 // (BaseDMGroupEvent rumors — ChatMessageEvent / file headers). Anything else that ever lands in a // room is kept. The caller realigns the per-relay download window for the dropped messages so // they can be paged again later (see LocalCache.pruneOldMessages + RelayLoadingCursors.rewindTo). diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/FeedDecryptionCaches.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/FeedDecryptionCaches.kt index 2df60e89d5..ce85cb0878 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/FeedDecryptionCaches.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/FeedDecryptionCaches.kt @@ -20,16 +20,16 @@ */ package com.vitorpamplona.amethyst.commons.model.topNavFeeds +import com.vitorpamplona.amethyst.commons.model.nip51Lists.followSets.FollowSetDecryptionCache import com.vitorpamplona.amethyst.commons.model.nip51Lists.geohashLists.GeohashListDecryptionCache -import com.vitorpamplona.amethyst.commons.model.nip51Lists.hashtagLists.HashtagListDecryptionCache +import com.vitorpamplona.amethyst.commons.model.nip51Lists.interestLists.InterestListDecryptionCache import com.vitorpamplona.amethyst.commons.model.nip51Lists.muteList.MuteListDecryptionCache -import com.vitorpamplona.amethyst.commons.model.nip51Lists.peopleList.PeopleListDecryptionCache import com.vitorpamplona.amethyst.commons.model.nip72Communities.CommunityListDecryptionCache class FeedDecryptionCaches( - val peopleListCache: PeopleListDecryptionCache, + val peopleListCache: FollowSetDecryptionCache, val muteListCache: MuteListDecryptionCache, val communityListCache: CommunityListDecryptionCache, - val hashtagCache: HashtagListDecryptionCache, + val hashtagCache: InterestListDecryptionCache, val geohashCache: GeohashListDecryptionCache, ) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/UsingRelayUnwrapper.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/UsingRelayUnwrapper.kt index 195c303fdb..c0824d52d2 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/UsingRelayUnwrapper.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/UsingRelayUnwrapper.kt @@ -25,7 +25,7 @@ import com.vitorpamplona.amethyst.commons.model.NoteState import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.nip17Dm.settings.DmRelayListEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.utils.mapOfSet import kotlinx.coroutines.flow.Flow @@ -49,7 +49,7 @@ class UsingRelayUnwrapper { val relays = when (val noteEvent = outboxNote.note.event) { is AdvertisedRelayListEvent -> noteEvent.relaysNorm() - is ChatMessageRelayListEvent -> noteEvent.relays() + is DmRelayListEvent -> noteEvent.relays() else -> emptySet() } @@ -81,7 +81,7 @@ class UsingRelayUnwrapper { users .map { pubkeyHex -> cache - .getOrCreateAddressableNote(ChatMessageRelayListEvent.createAddress(pubkeyHex)) + .getOrCreateAddressableNote(DmRelayListEvent.createAddress(pubkeyHex)) .flow() .metadata.stateFlow.value }.toTypedArray() @@ -100,7 +100,7 @@ class UsingRelayUnwrapper { note.flow().metadata.stateFlow } + users.map { pubkeyHex -> - val note = cache.getOrCreateAddressableNote(ChatMessageRelayListEvent.createAddress(pubkeyHex)) + val note = cache.getOrCreateAddressableNote(DmRelayListEvent.createAddress(pubkeyHex)) note.flow().metadata.stateFlow } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/noteBased/NoteFeedFlow.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/noteBased/NoteFeedFlow.kt index eaf06a32b7..bf9b8604aa 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/noteBased/NoteFeedFlow.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/topNavFeeds/noteBased/NoteFeedFlow.kt @@ -35,11 +35,11 @@ import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.Mute import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import com.vitorpamplona.quartz.nip51Lists.followSet.FollowSetEvent import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent -import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent +import com.vitorpamplona.quartz.nip51Lists.interestList.InterestListEvent import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent -import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent +import com.vitorpamplona.quartz.nip51Lists.starterPack.StarterPackEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -61,8 +61,8 @@ class NoteFeedFlow( outboxRelays: Set, ): IFeedTopNavFilter = when (noteEvent) { - is PeopleListEvent -> { - if (noteEvent.dTag() == PeopleListEvent.BLOCK_LIST_D_TAG) { + is FollowSetEvent -> { + if (noteEvent.dTag() == FollowSetEvent.BLOCK_LIST_D_TAG) { MutedAuthorsByOutboxTopNavFilter(caches.peopleListCache.cachedUserIdSet(noteEvent), blockedRelays) } else { AuthorsByOutboxTopNavFilter(caches.peopleListCache.cachedUserIdSet(noteEvent), blockedRelays) @@ -73,7 +73,7 @@ class NoteFeedFlow( MutedAuthorsByOutboxTopNavFilter(caches.muteListCache.cachedUserIdSet(noteEvent), blockedRelays) } - is FollowListEvent -> { + is StarterPackEvent -> { AuthorsByOutboxTopNavFilter(noteEvent.followIdSet(), blockedRelays) } @@ -81,7 +81,7 @@ class NoteFeedFlow( AllCommunitiesTopNavFilter(caches.communityListCache.cachedCommunityIdSet(noteEvent), blockedRelays) } - is HashtagListEvent -> { + is InterestListEvent -> { HashtagTopNavFilter(caches.hashtagCache.cachedHashtags(noteEvent), outboxRelays) } @@ -108,8 +108,8 @@ class NoteFeedFlow( outboxRelays: Set, ) { when (noteEvent) { - is PeopleListEvent -> { - if (noteEvent.dTag() == PeopleListEvent.BLOCK_LIST_D_TAG) { + is FollowSetEvent -> { + if (noteEvent.dTag() == FollowSetEvent.BLOCK_LIST_D_TAG) { emit(MutedAuthorsByOutboxTopNavFilter(caches.peopleListCache.userIdSet(noteEvent), blockedRelays)) } else { emit(AuthorsByOutboxTopNavFilter(caches.peopleListCache.userIdSet(noteEvent), blockedRelays)) @@ -120,7 +120,7 @@ class NoteFeedFlow( emit(MutedAuthorsByOutboxTopNavFilter(caches.muteListCache.mutedUserIdSet(noteEvent), blockedRelays)) } - is FollowListEvent -> { + is StarterPackEvent -> { emit(AuthorsByOutboxTopNavFilter(noteEvent.followIdSet(), blockedRelays)) } @@ -128,7 +128,7 @@ class NoteFeedFlow( emit(AllCommunitiesTopNavFilter(caches.communityListCache.communityIdSet(noteEvent), blockedRelays)) } - is HashtagListEvent -> { + is InterestListEvent -> { emit(HashtagTopNavFilter(caches.hashtagCache.hashtags(noteEvent), outboxRelays)) } @@ -160,8 +160,8 @@ class NoteFeedFlow( proxyRelays: Set, ): IFeedTopNavFilter = when (noteEvent) { - is PeopleListEvent -> { - if (noteEvent.dTag() == PeopleListEvent.BLOCK_LIST_D_TAG) { + is FollowSetEvent -> { + if (noteEvent.dTag() == FollowSetEvent.BLOCK_LIST_D_TAG) { MutedAuthorsByProxyTopNavFilter(caches.peopleListCache.cachedUserIdSet(noteEvent), proxyRelays) } else { AuthorsByProxyTopNavFilter(caches.peopleListCache.cachedUserIdSet(noteEvent), proxyRelays) @@ -172,7 +172,7 @@ class NoteFeedFlow( MutedAuthorsByProxyTopNavFilter(caches.muteListCache.cachedUserIdSet(noteEvent), proxyRelays) } - is FollowListEvent -> { + is StarterPackEvent -> { AuthorsByProxyTopNavFilter(noteEvent.followIdSet(), proxyRelays) } @@ -180,7 +180,7 @@ class NoteFeedFlow( AllCommunitiesTopNavFilter(caches.communityListCache.cachedCommunityIdSet(noteEvent), blockedRelays) } - is HashtagListEvent -> { + is InterestListEvent -> { HashtagTopNavFilter(caches.hashtagCache.cachedHashtags(noteEvent), proxyRelays) } @@ -207,8 +207,8 @@ class NoteFeedFlow( proxyRelays: Set, ) { when (noteEvent) { - is PeopleListEvent -> { - if (noteEvent.dTag() == PeopleListEvent.BLOCK_LIST_D_TAG) { + is FollowSetEvent -> { + if (noteEvent.dTag() == FollowSetEvent.BLOCK_LIST_D_TAG) { emit(MutedAuthorsByProxyTopNavFilter(caches.peopleListCache.userIdSet(noteEvent), proxyRelays)) } else { emit(AuthorsByProxyTopNavFilter(caches.peopleListCache.userIdSet(noteEvent), proxyRelays)) @@ -219,7 +219,7 @@ class NoteFeedFlow( emit(MutedAuthorsByProxyTopNavFilter(caches.muteListCache.mutedUserIdSet(noteEvent), proxyRelays)) } - is FollowListEvent -> { + is StarterPackEvent -> { emit(AuthorsByProxyTopNavFilter(noteEvent.followIdSet(), proxyRelays)) } @@ -227,7 +227,7 @@ class NoteFeedFlow( emit(AllCommunitiesTopNavFilter(caches.communityListCache.communityIdSet(noteEvent), blockedRelays)) } - is HashtagListEvent -> { + is InterestListEvent -> { emit(HashtagTopNavFilter(caches.hashtagCache.hashtags(noteEvent), proxyRelays)) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/HashtagSpamCheck.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/HashtagSpamCheck.kt index d704e81e7a..e72e8201ee 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/HashtagSpamCheck.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/HashtagSpamCheck.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.commons.moderation import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasMoreHashtagsThan -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent /** * Pure decision: should this note be collapsed because it abuses hashtag `t` tags? @@ -46,7 +46,7 @@ object HashtagSpamCheck { ): Boolean { if (!enabled) return false if (displayedEvent == null) return false - if (displayedEvent.kind == LongTextNoteEvent.KIND) return false + if (displayedEvent.kind == LongFormContentEvent.KIND) return false if (authorPubkey != null && authorPubkey in exemptKeys) return false return displayedEvent.tags.hasMoreHashtagsThan(threshold) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/notifications/NotificationKinds.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/notifications/NotificationKinds.kt index 1d911cd5bc..2c582e00c6 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/notifications/NotificationKinds.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/notifications/NotificationKinds.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.commons.moderation.notifications import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter -import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip04Dm.messages.EncryptedDmEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent @@ -32,7 +32,7 @@ import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent @@ -60,7 +60,7 @@ object NotificationKinds { val SUBSCRIPTION_KINDS: List = listOf( TextNoteEvent.KIND, // 1 — mentions + replies - PrivateDmEvent.KIND, // 4 — NIP-04 legacy DM + EncryptedDmEvent.KIND, // 4 — NIP-04 legacy DM RepostEvent.KIND, // 6 ReactionEvent.KIND, // 7 ChatMessageEvent.KIND, // 14 — NIP-17 DM rumor (rarely arrives raw; gift-wrap is more common) @@ -69,7 +69,7 @@ object NotificationKinds { CommentEvent.KIND, // 1111 — NIP-22 threaded comment GiftWrapEvent.KIND, // 1059 — NIP-17 gift-wrapped DM NutzapEvent.KIND, // 9321 — NIP-61 Cashu nutzap - LnZapEvent.KIND, // 9735 — NIP-57 zap receipt + ZapReceiptEvent.KIND, // 9735 — NIP-57 zap receipt OnchainZapEvent.KIND, // 8333 — onchain zap Bolt12ZapEvent.KIND, // 9736 — NIP-B1 BOLT12 zap // NIP-17 file-header messages (encrypted file DMs) @@ -111,10 +111,10 @@ object NotificationKinds { myPubKeyHex: HexKey, isTargetAuthoredByMe: (targetNoteId: HexKey) -> Boolean = { false }, ): Boolean { - // Own events never notify — except zap receipts (LnZap/Nutzap/Onchain) + // Own events never notify — except zap receipts (ZapReceipt/Nutzap/Onchain) // which are signed by the LNURL provider or the payer, not by us. if (event.pubKey == myPubKeyHex && - event !is LnZapEvent && + event !is ZapReceiptEvent && event !is NutzapEvent && event !is OnchainZapEvent && event !is Bolt12ZapEvent diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletIdentityWatch.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletIdentityWatch.kt similarity index 100% rename from commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletIdentityWatch.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletIdentityWatch.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletRelayCleartext.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletRelayCleartext.kt index 9cbee53bd1..8bd8d29085 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletRelayCleartext.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletRelayCleartext.kt @@ -24,7 +24,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip04Dm.crypto.EncryptedInfo -import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip04Dm.messages.EncryptedDmEvent import com.vitorpamplona.quartz.nip44Encryption.Nip44v2 /** NAP-RELAY read boundary: encrypted event content is decrypted or withheld, never exposed. */ @@ -56,7 +56,7 @@ object NappletRelayCleartext { } fun isEncrypted(event: Event): Boolean = - event is PrivateDmEvent || + event is EncryptedDmEvent || EncryptedInfo.isNIP04(event.content) || isNip44V2(event.content) diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletRequestRouter.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletRequestRouter.kt similarity index 100% rename from commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletRequestRouter.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletRequestRouter.kt diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletProtocolJson.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletProtocolJson.kt similarity index 96% rename from commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletProtocolJson.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletProtocolJson.kt index 027cae99df..c72f1bf825 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletProtocolJson.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletProtocolJson.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.commons.napplet.protocol import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonObject @@ -37,7 +38,7 @@ import kotlinx.serialization.json.long import kotlinx.serialization.json.put import kotlinx.serialization.json.putJsonArray import kotlinx.serialization.json.putJsonObject -import java.util.Base64 +import kotlin.io.encoding.Base64 /** * Marshals the KMP-pure [NappletRequest] / [NappletResponse] types to and from the wire the @@ -45,10 +46,10 @@ import java.util.Base64 * (`@napplet/shim` / `@napplet/nap`): requests are `{ "type": ".", "id", ...fields }` * and replies are `{ "type": "..result", "id", ...fields }`. * - * Lives in `commons/jvmAndroid` (not in any single front end) because the wire contract is identical + * Lives in `commons/commonMain` (not in any single front end) because the wire contract is identical * across hosts: the Android `:napplet` WebView host and a future desktop host both marshal through - * here. It depends only on `quartz` (Event/Filter), kotlinx.serialization, and `java.util.Base64` - * (available on Android API 26+ and the JVM) — no platform-UI or process APIs. + * here. It depends only on `quartz` (Event/Filter), kotlinx.serialization and `kotlin.io.encoding` + * — no platform-UI or process APIs. * * This is the only place the boundary parses untrusted applet input, so it is deliberately * strict: an unrecognized `type` decodes to `null` (the broker denies it) and a malformed/short @@ -58,6 +59,11 @@ import java.util.Base64 object NappletProtocolJson { private val json = Json { ignoreUnknownKeys = true } + // Accepts unpadded input, as java.util.Base64's decoder did before this moved to commonMain. + // One difference remains: non-zero pad bits ("SGl=") are rejected where Java ignored them. + // Browsers' btoa/readAsDataURL never produce those, so only a hand-crafted payload hits it. + private val lenientBase64 = Base64.withPadding(Base64.PaddingOption.PRESENT_OPTIONAL) + /** The `type` discriminant of a request envelope, used to build the matching `.result` type. */ fun readType(envelopeJson: String): String? = json.parseToJsonElement(envelopeJson).jsonObject.str("type") @@ -166,7 +172,7 @@ object NappletProtocolJson { kind = t.kindOf(), tags = decodeTags(t), content = t.str("content") ?: "", - createdAt = t["created_at"]?.jsonPrimitive?.long ?: (System.currentTimeMillis() / 1000), + createdAt = t["created_at"]?.jsonPrimitive?.long ?: TimeUtils.now(), ) } // NIP-07 nip44.encrypt/decrypt: crypto only, no publish. `peer` is the counterparty @@ -194,7 +200,7 @@ object NappletProtocolJson { // The Blob in `request.data` is inlined as base64 `request.dataBase64` by shell.html. val request = o.getValue("request").jsonObject NappletRequest.UploadBlob( - bytes = Base64.getDecoder().decode(request.req("dataBase64")), + bytes = lenientBase64.decode(request.req("dataBase64")), contentType = request.str("mimeType") ?: "application/octet-stream", filename = request.str("filename"), ) @@ -275,7 +281,7 @@ object NappletProtocolJson { } is NappletResponse.Bytes -> { put("ok", true) - put("bytes", Base64.getEncoder().encodeToString(response.bytes)) + put("bytes", Base64.encode(response.bytes)) put("mime", response.contentType) } is NappletResponse.ResourceInfo -> { @@ -301,7 +307,7 @@ object NappletProtocolJson { put("url", item.url) put("ok", item.resource != null) item.resource?.let { - put("bytes", Base64.getEncoder().encodeToString(it.bytes)) + put("bytes", Base64.encode(it.bytes)) put("mime", it.contentType) } item.error?.let { put("error", it) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessDismissedGamesStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessDismissedGamesStore.kt new file mode 100644 index 0000000000..1adbf9ab06 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessDismissedGamesStore.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.nip64Chess + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.stringSetPreferencesKey +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.first +import okio.IOException + +/** + * The chess games a user has dismissed from their completed list, per pubkey. + * + * Replaces the previous expect/actual trio. DataStore is multiplatform, so one + * implementation now serves every target — and iOS gains real persistence, + * where its actual had been an in-memory map standing in until an iosApp + * module existed. + * + * Nothing is carried over from the old per-platform stores: the dismissed list + * is a convenience, losing it costs a user one re-dismissal, and chess has few + * enough users that a migration is not worth the code that would carry it. + */ +class ChessDismissedGamesStore( + private val store: DataStore, +) { + companion object { + internal fun keyFor(userPubkey: String) = stringSetPreferencesKey("dismissed_$userPubkey") + } + + suspend fun load(userPubkey: String): Set = + store.data + .catch { e -> if (e is IOException) emit(emptyPreferences()) else throw e } + .first()[keyFor(userPubkey)] + ?: emptySet() + + /** An empty set removes the key rather than storing an empty one. */ + suspend fun save( + userPubkey: String, + ids: Set, + ) { + store.edit { prefs -> + if (ids.isEmpty()) prefs.remove(keyFor(userPubkey)) else prefs[keyFor(userPubkey)] = ids + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessLobbyLogic.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessLobbyLogic.kt index ee6e5604bd..eda246f63e 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessLobbyLogic.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessLobbyLogic.kt @@ -35,6 +35,7 @@ import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.cache.LargeCache import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -128,13 +129,60 @@ class ChessLobbyLogic( private val metadataProvider: IUserMetadataProvider, private val scope: CoroutineScope, pollingConfig: ChessPollingConfig = ChessPollingDefaults.android, - private val dismissedStorage: ChessDismissedGamesStorage? = null, + private val dismissedStorage: ChessDismissedGamesStore? = null, ) { val state = ChessLobbyState(userPubkey, scope) private val dismissedGameIdsLock = KmpLock() - private val dismissedGameIds: MutableSet = - (dismissedStorage?.load(userPubkey)?.toMutableSet() ?: mutableSetOf()) + + /** + * Seeded asynchronously: the store is DataStore-backed and reads suspend, + * so this starts empty and fills shortly after construction. Until it does, + * a previously dismissed game can appear in the completed list for a frame + * or two. + * + * The seed unions rather than replaces, so a dismissal the user makes + * before the read lands is not overwritten by it — and it asks for a write + * when it did union something in, because that earlier dismissal already + * persisted a snapshot that did not have the stored ids in it. + */ + private val dismissedGameIds: MutableSet = mutableSetOf() + + /** + * Serialises persistence, so a save cannot land out of order. + * + * Each dismissal used to launch its own `storage.save(snapshot)`. Two of + * them are unordered on the same dispatcher, so the first dismissal's + * smaller snapshot could be written *after* the second's and drop it — the + * game came back on the next launch. One consumer, writing whatever the set + * currently holds, cannot reorder; conflation is safe for the same reason, + * since a dropped signal is one whose contents the next write includes. + */ + private val persistRequests = Channel(Channel.CONFLATED) + + init { + dismissedStorage?.let { storage -> + scope.launch { + for (unused in persistRequests) { + storage.save(userPubkey, dismissedGameIdsLock.withLock { dismissedGameIds.toSet() }) + } + } + scope.launch { + val stored = storage.load(userPubkey) + if (stored.isNotEmpty()) { + val union = + dismissedGameIdsLock.withLock { + dismissedGameIds.addAll(stored) + dismissedGameIds.size + } + // Larger than what was on disk means a dismissal beat this + // read, and the snapshot it wrote is missing everything that + // was already stored. Write the union back. + if (union > stored.size) persistRequests.trySend(Unit) + } + } + } + } // Track when games were last loaded to prevent duplicate fetches // (e.g., discoverUserGames loads a game, then polling immediately re-fetches it). @@ -964,23 +1012,15 @@ class ChessLobbyLogic( fun dismissCompletedGame(gameId: String) { state.removeCompletedGame(gameId) - val snapshot = - dismissedGameIdsLock.withLock { - dismissedGameIds.add(gameId) - dismissedGameIds.toSet() - } - dismissedStorage?.save(userPubkey, snapshot) + dismissedGameIdsLock.withLock { dismissedGameIds.add(gameId) } + persistRequests.trySend(Unit) } fun dismissAllCompletedGames() { val allIds = state.completedGames.value.map { it.gameId } state.clearCompletedGames() - val snapshot = - dismissedGameIdsLock.withLock { - dismissedGameIds.addAll(allIds) - dismissedGameIds.toSet() - } - dismissedStorage?.save(userPubkey, snapshot) + dismissedGameIdsLock.withLock { dismissedGameIds.addAll(allIds) } + persistRequests.trySend(Unit) } /** diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/account/metadata/FilterBasicAccountInfoFromKeys.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/account/metadata/FilterBasicAccountInfoFromKeys.kt index 5244802c74..e03dbe41b1 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/account/metadata/FilterBasicAccountInfoFromKeys.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/account/metadata/FilterBasicAccountInfoFromKeys.kt @@ -28,16 +28,16 @@ import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent -import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.nip17Dm.settings.DmRelayListEvent import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent -import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent +import com.vitorpamplona.quartz.nip51Lists.interestList.InterestListEvent import com.vitorpamplona.quartz.nip51Lists.interestSet.InterestSetEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BroadcastRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.FavoriteRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent -import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.SimpleGroupListEvent import com.vitorpamplona.quartz.nip53LiveActivities.nestsServers.NestsServersEvent @@ -51,7 +51,7 @@ val BasicAccountInfoKinds = MetadataEvent.KIND, ContactListEvent.KIND, AdvertisedRelayListEvent.KIND, - ChatMessageRelayListEvent.KIND, + DmRelayListEvent.KIND, KeyPackageRelayListEvent.KIND, SearchRelayListEvent.KIND, FileServersEvent.KIND, @@ -66,10 +66,10 @@ val BasicAccountInfoKinds2 = BroadcastRelayListEvent.KIND, IndexerRelayListEvent.KIND, ProxyRelayListEvent.KIND, - HashtagListEvent.KIND, + InterestListEvent.KIND, GeohashListEvent.KIND, TrustProviderListEvent.KIND, - RelayFeedsListEvent.KIND, + FavoriteRelayListEvent.KIND, InterestSetEvent.KIND, // NIP-51 "simple groups" list (kind 10009), matching the sibling NIP-51 lists above. SimpleGroupListEvent.KIND, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/account/metadata/FilterBookmarksAndReportsFromKey.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/account/metadata/FilterBookmarksAndReportsFromKey.kt index 285fa83cb4..8b78c54e1c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/account/metadata/FilterBookmarksAndReportsFromKey.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/account/metadata/FilterBookmarksAndReportsFromKey.kt @@ -28,7 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip51Lists.PinListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent -import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.LabeledBookmarkListEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkSet.BookmarkSetEvent import com.vitorpamplona.quartz.nip56Reports.ReportEvent import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent @@ -37,7 +37,7 @@ val ReportsAndBookmarksFromKeyKinds = ReportEvent.KIND, BookmarkListEvent.KIND, OldBookmarkListEvent.KIND, - LabeledBookmarkListEvent.KIND, + BookmarkSetEvent.KIND, PinListEvent.KIND, RequestToVanishEvent.KIND, ) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/account/metadata/FilterFollowsAndMutesFromKey.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/account/metadata/FilterFollowsAndMutesFromKey.kt index 1e9840ef13..ce5bfa014e 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/account/metadata/FilterFollowsAndMutesFromKey.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/account/metadata/FilterFollowsAndMutesFromKey.kt @@ -26,24 +26,24 @@ import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEve import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent -import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEvent -import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import com.vitorpamplona.quartz.nip28PublicChat.list.PublicChatListEvent +import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiListEvent +import com.vitorpamplona.quartz.nip51Lists.followSet.FollowSetEvent import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent -import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent +import com.vitorpamplona.quartz.nip51Lists.starterPack.StarterPackEvent import com.vitorpamplona.quartz.nip58Badges.accepted.AcceptedBadgeSetEvent import com.vitorpamplona.quartz.nip58Badges.profile.ProfileBadgesEvent val FollowAndMutesFromKeyKinds = listOf( - PeopleListEvent.KIND, - FollowListEvent.KIND, + FollowSetEvent.KIND, + StarterPackEvent.KIND, MuteListEvent.KIND, AcceptedBadgeSetEvent.KIND, ProfileBadgesEvent.KIND, - EmojiPackSelectionEvent.KIND, + EmojiListEvent.KIND, EphemeralChatListEvent.KIND, - ChannelListEvent.KIND, + PublicChatListEvent.KIND, ) fun filterFollowsAndMutesFromKey( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/account/nip01Notifications/FilterNotificationsToPubkey.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/account/nip01Notifications/FilterNotificationsToPubkey.kt index 9cdd3a11c6..a341e4f78c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/account/nip01Notifications/FilterNotificationsToPubkey.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/account/nip01Notifications/FilterNotificationsToPubkey.kt @@ -48,12 +48,12 @@ import com.vitorpamplona.quartz.nip34Git.status.GitStatusAppliedEvent import com.vitorpamplona.quartz.nip34Git.status.GitStatusClosedEvent import com.vitorpamplona.quartz.nip34Git.status.GitStatusDraftEvent import com.vitorpamplona.quartz.nip34Git.status.GitStatusOpenEvent -import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcResponseEvent import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent import com.vitorpamplona.quartz.nip56Reports.ReportEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent import com.vitorpamplona.quartz.nip64Chess.challenge.accept.LiveChessGameAcceptEvent import com.vitorpamplona.quartz.nip64Chess.move.LiveChessMoveEvent @@ -79,7 +79,7 @@ val GroupNotificationKinds = PollResponseEvent.KIND, RepostEvent.KIND, GenericRepostEvent.KIND, - LnZapEvent.KIND, + ZapReceiptEvent.KIND, Bolt12ZapEvent.KIND, ReportEvent.KIND, ) @@ -90,7 +90,7 @@ val SummaryKinds = ReactionEvent.KIND, RepostEvent.KIND, GenericRepostEvent.KIND, - LnZapEvent.KIND, + ZapReceiptEvent.KIND, OnchainZapEvent.KIND, Bolt12ZapEvent.KIND, ) @@ -98,7 +98,7 @@ val SummaryKinds = val NotificationsPerKeyKinds = listOf( ReportEvent.KIND, - LnZapPaymentResponseEvent.KIND, + NwcResponseEvent.KIND, ChannelMessageEvent.KIND, EphemeralChatEvent.KIND, BadgeAwardEvent.KIND, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/ContactCardFilters.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/ContactCardFilters.kt index 87df5fba9c..837719a7ef 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/ContactCardFilters.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/ContactCardFilters.kt @@ -26,16 +26,16 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.dTag.DTag -import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent +import com.vitorpamplona.quartz.nip85TrustedAssertions.users.UserAssertionEvent -val ContactCardKindList = listOf(ContactCardEvent.KIND) +val UserAssertionKindList = listOf(UserAssertionEvent.KIND) /** * Kind:30382 cards *about* [targets], written by [trustedAccounts] (the account * itself plus its WoT trust providers). Fetches nicknames and scores for the * users currently on screen. */ -fun filterContactCardsToTargetKeysFromTrustedAccountsInTheRelay( +fun filterUserAssertionsToTargetKeysFromTrustedAccountsInTheRelay( targets: Set, trustedAccounts: List, relay: NormalizedRelayUrl, @@ -49,7 +49,7 @@ fun filterContactCardsToTargetKeysFromTrustedAccountsInTheRelay( ExplainedFilter( purpose = SubPurpose.PROFILE_METADATA, accountPubKeys = listOfNotNull(accountPubKey), - kinds = ContactCardKindList, + kinds = UserAssertionKindList, authors = trustedAccounts, // kind:30382 addresses the target user in the d-tag tags = mapOf(DTag.TAG_NAME to targets.sorted()), @@ -68,7 +68,7 @@ fun filterContactCardsToTargetKeysFromTrustedAccountsInTheRelay( * "Observing Profiles" — explained as *"profiles of the people currently on screen"* — made every * logged-in account look like it was watching somebody. */ -fun filterContactCardsByAuthorInTheRelay( +fun filterUserAssertionsByAuthorInTheRelay( relay: NormalizedRelayUrl, authors: List, since: Long?, @@ -81,7 +81,7 @@ fun filterContactCardsByAuthorInTheRelay( purpose = SubPurpose.ACCOUNT_DATA, // This variant fetches the accounts' OWN contact cards, so the authors are the owners. accountPubKeys = authors, - kinds = ContactCardKindList, + kinds = UserAssertionKindList, authors = authors, limit = limit, since = since, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/calendars/CalendarKinds.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/calendars/CalendarKinds.kt index 80846f47b5..9842410406 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/calendars/CalendarKinds.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/calendars/CalendarKinds.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.commons.relayClient.calendars import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent -import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent +import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarCollectionEvent import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent // Appointments are the only kinds shown in the main calendar feed/views. RSVPs and @@ -34,6 +34,6 @@ val AllCalendarKinds = listOf( CalendarTimeSlotEvent.KIND, CalendarDateSlotEvent.KIND, - CalendarEvent.KIND, + CalendarCollectionEvent.KIND, CalendarRSVPEvent.KIND, ) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/channel/FilterGoalForLiveActivity.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/channel/FilterGoalForLiveActivity.kt index 66f6929113..2468f528f6 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/channel/FilterGoalForLiveActivity.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/channel/FilterGoalForLiveActivity.kt @@ -25,8 +25,8 @@ import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFil import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent -import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent +import com.vitorpamplona.quartz.nip75ZapGoals.ZapGoalEvent import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent @@ -50,7 +50,7 @@ fun filterGoalForLiveActivities( filter = ExplainedFilter( purpose = SubPurpose.LIVE_CHAT, - kinds = listOf(GoalEvent.KIND), + kinds = listOf(ZapGoalEvent.KIND), ids = listOf(goalId), limit = 1, ), @@ -60,7 +60,7 @@ fun filterGoalForLiveActivities( filter = ExplainedFilter( purpose = SubPurpose.LIVE_CHAT, - kinds = listOf(LnZapEvent.KIND, OnchainZapEvent.KIND, Bolt12ZapEvent.KIND), + kinds = listOf(ZapReceiptEvent.KIND, OnchainZapEvent.KIND, Bolt12ZapEvent.KIND), tags = mapOf("e" to listOf(goalId)), limit = 200, since = since?.get(relay)?.time, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/channel/FilterMessagesToLiveStream.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/channel/FilterMessagesToLiveStream.kt index a065038175..5ae7424389 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/channel/FilterMessagesToLiveStream.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/channel/FilterMessagesToLiveStream.kt @@ -28,7 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent import com.vitorpamplona.quartz.nip53LiveActivities.raid.LiveActivitiesRaidEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent @@ -47,7 +47,7 @@ fun filterMessagesToLiveActivities( LiveActivitiesChatMessageEvent.KIND, LiveActivitiesRaidEvent.KIND, LiveActivitiesClipEvent.KIND, - LnZapEvent.KIND, + ZapReceiptEvent.KIND, OnchainZapEvent.KIND, Bolt12ZapEvent.KIND, ), diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/channel/relayGroup/RelayGroupFilterBuilders.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/channel/relayGroup/RelayGroupFilterBuilders.kt index 94135ea335..3b3b5226f2 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/channel/relayGroup/RelayGroupFilterBuilders.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/channel/relayGroup/RelayGroupFilterBuilders.kt @@ -55,7 +55,7 @@ import com.vitorpamplona.quartz.buzz.workflow.WorkflowTriggeredEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer -import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip29RelayGroups.GroupId @@ -63,8 +63,8 @@ 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 -import com.vitorpamplona.quartz.nip29RelayGroups.moderation.DeleteEventEvent +import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupRolesEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.GroupDeleteEventEvent import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.GroupTag import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent @@ -89,7 +89,7 @@ val RELAY_GROUP_METADATA_KINDS = GroupMetadataEvent.KIND, GroupAdminsEvent.KIND, GroupMembersEvent.KIND, - SupportedRolesEvent.KIND, + GroupRolesEvent.KIND, ) /** @@ -224,9 +224,9 @@ val RELAY_GROUP_ALL_TIMELINE_KINDS = RELAY_GROUP_TIMELINE_KINDS + BUZZ_RELAY_GRO */ val RELAY_GROUP_AUX_KINDS = listOf( - DeletionEvent.KIND, + DeletionRequestEvent.KIND, ReactionEvent.KIND, - DeleteEventEvent.KIND, + GroupDeleteEventEvent.KIND, ) /** How many aux events a channel replays on subscribe. Bounds the backfill; live delivery is unbounded. */ diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/communities/FilterCommunityPosts.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/communities/FilterCommunityPosts.kt index ef795c5880..fa00fd2524 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/communities/FilterCommunityPosts.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/communities/FilterCommunityPosts.kt @@ -30,7 +30,7 @@ import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent @@ -42,7 +42,7 @@ val CommunityPostKinds = RepostEvent.KIND, GenericRepostEvent.KIND, ClassifiedsEvent.KIND, - LongTextNoteEvent.KIND, + LongFormContentEvent.KIND, CommunityPostApprovalEvent.KIND, AttestationEvent.KIND, ) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/diagnostics/DmRelayDiagnosticsLogger.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/diagnostics/DmRelayDiagnosticsLogger.kt index f1ecab2c10..0eaa25ad91 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/diagnostics/DmRelayDiagnosticsLogger.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/diagnostics/DmRelayDiagnosticsLogger.kt @@ -31,7 +31,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.AuthCmd import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip04Dm.messages.EncryptedDmEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.EphemeralGiftWrapEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.utils.Log @@ -171,7 +171,7 @@ class DmRelayDiagnosticsLogger( // The kinds a DM-path REQ carries: NIP-17 gift wraps (1059 + 21059) and NIP-04 legacy DMs // (4). Matched exactly against the filter's "kinds" array — never as a substring of the whole // command, since a pubkey hex or timestamp can incidentally contain "1059" or "4". - private val DM_KINDS = setOf(GiftWrapEvent.KIND, EphemeralGiftWrapEvent.KIND, PrivateDmEvent.KIND) + private val DM_KINDS = setOf(GiftWrapEvent.KIND, EphemeralGiftWrapEvent.KIND, EncryptedDmEvent.KIND) private val KINDS_ARRAY = Regex("\"kinds\":\\[([0-9,\\s]*)]") diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/nip23LongForm/FilterLongFormByAllCommunities.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/nip23LongForm/FilterLongFormByAllCommunities.kt index ff5958375f..bd4233d7fa 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/nip23LongForm/FilterLongFormByAllCommunities.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/nip23LongForm/FilterLongFormByAllCommunities.kt @@ -26,7 +26,7 @@ import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent fun filterLongFormAllCommunities( @@ -47,7 +47,7 @@ fun filterLongFormAllCommunities( tags = mapOf( "a" to communityList, - "k" to listOf(LongTextNoteEvent.KIND.toString()), + "k" to listOf(LongFormContentEvent.KIND.toString()), ), limit = 30, since = since, @@ -60,7 +60,7 @@ fun filterLongFormAllCommunities( ExplainedFilter( purpose = SubPurpose.DISCOVER_FEED, tags = mapOf("k" to listOf("5300"), "a" to communityList), - kinds = listOf(LongTextNoteEvent.KIND), + kinds = listOf(LongFormContentEvent.KIND), limit = 30, since = since, ), diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/nip23LongForm/FilterLongFormByAuthors.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/nip23LongForm/FilterLongFormByAuthors.kt index e9b4eab6ae..14275756c6 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/nip23LongForm/FilterLongFormByAuthors.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/nip23LongForm/FilterLongFormByAuthors.kt @@ -28,7 +28,7 @@ import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent fun filterLongFormAuthors( relay: NormalizedRelayUrl, @@ -43,7 +43,7 @@ fun filterLongFormAuthors( ExplainedFilter( purpose = SubPurpose.DISCOVER_FEED, authors = authorList, - kinds = listOf(LongTextNoteEvent.KIND), + kinds = listOf(LongFormContentEvent.KIND), limit = 200, since = since, ), diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/nip23LongForm/FilterLongFormByCommunity.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/nip23LongForm/FilterLongFormByCommunity.kt index a93b476ab0..57a6f6a430 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/nip23LongForm/FilterLongFormByCommunity.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/nip23LongForm/FilterLongFormByCommunity.kt @@ -26,7 +26,7 @@ import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent fun filterLongFormByCommunity( @@ -48,7 +48,7 @@ fun filterLongFormByCommunity( tags = mapOf( "a" to listOf(community), - "k" to listOf(LongTextNoteEvent.KIND.toString()), + "k" to listOf(LongFormContentEvent.KIND.toString()), ), limit = 30, since = since, @@ -62,7 +62,7 @@ fun filterLongFormByCommunity( purpose = SubPurpose.DISCOVER_FEED, authors = authors, tags = mapOf("k" to listOf("5300"), "a" to listOf(community)), - kinds = listOf(LongTextNoteEvent.KIND), + kinds = listOf(LongFormContentEvent.KIND), limit = 30, since = since, ), diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/nip23LongForm/FilterLongFormByGeohash.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/nip23LongForm/FilterLongFormByGeohash.kt index cd35d75baa..5b86dbcb4e 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/nip23LongForm/FilterLongFormByGeohash.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/nip23LongForm/FilterLongFormByGeohash.kt @@ -27,7 +27,7 @@ import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent fun filterLongFormByGeohash( relay: NormalizedRelayUrl, @@ -44,7 +44,7 @@ fun filterLongFormByGeohash( filter = ExplainedFilter( purpose = SubPurpose.DISCOVER_FEED, - kinds = listOf(LongTextNoteEvent.KIND), + kinds = listOf(LongFormContentEvent.KIND), tags = mapOf("g" to geoHashes), limit = 100, since = since, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/nip23LongForm/FilterLongFormByHashtag.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/nip23LongForm/FilterLongFormByHashtag.kt index e857cd1b62..adefda859e 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/nip23LongForm/FilterLongFormByHashtag.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/nip23LongForm/FilterLongFormByHashtag.kt @@ -27,7 +27,7 @@ import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtagAlts -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent fun filterLongFormByHashtag( relay: NormalizedRelayUrl, @@ -44,7 +44,7 @@ fun filterLongFormByHashtag( filter = ExplainedFilter( purpose = SubPurpose.DISCOVER_FEED, - kinds = listOf(LongTextNoteEvent.KIND), + kinds = listOf(LongFormContentEvent.KIND), tags = mapOf("t" to hashtags), limit = 200, since = since, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/nip23LongForm/FilterLongFormGlobal.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/nip23LongForm/FilterLongFormGlobal.kt index 67b266a7ab..78219f6a18 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/nip23LongForm/FilterLongFormGlobal.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/nip23LongForm/FilterLongFormGlobal.kt @@ -25,7 +25,7 @@ import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFil import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent import com.vitorpamplona.quartz.utils.TimeUtils fun filterLongFormGlobal( @@ -42,7 +42,7 @@ fun filterLongFormGlobal( filter = ExplainedFilter( purpose = SubPurpose.DISCOVER_FEED, - kinds = listOf(LongTextNoteEvent.KIND), + kinds = listOf(LongFormContentEvent.KIND), limit = 100, since = since, ), diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/nip51FollowSets/FilterFollowSetsByAllCommunities.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/nip51FollowSets/FilterFollowSetsByAllCommunities.kt index b59a5bec9c..6492b4e3ee 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/nip51FollowSets/FilterFollowSetsByAllCommunities.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/nip51FollowSets/FilterFollowSetsByAllCommunities.kt @@ -27,7 +27,7 @@ import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import com.vitorpamplona.quartz.nip51Lists.starterPack.StarterPackEvent import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent fun filterFollowSetsAllCommunities( @@ -48,7 +48,7 @@ fun filterFollowSetsAllCommunities( tags = mapOf( "a" to communityList, - "k" to listOf(FollowListEvent.KIND.toString()), + "k" to listOf(StarterPackEvent.KIND.toString()), ), limit = 300, since = since, @@ -61,7 +61,7 @@ fun filterFollowSetsAllCommunities( ExplainedFilter( purpose = SubPurpose.FOLLOW_LISTS, tags = mapOf("k" to listOf("5300"), "a" to communityList), - kinds = listOf(FollowListEvent.KIND), + kinds = listOf(StarterPackEvent.KIND), limit = 300, since = since, ), diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/nip51FollowSets/FilterFollowSetsByAuthors.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/nip51FollowSets/FilterFollowSetsByAuthors.kt index 0fc3c986d5..86ea1eb063 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/nip51FollowSets/FilterFollowSetsByAuthors.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/nip51FollowSets/FilterFollowSetsByAuthors.kt @@ -28,7 +28,7 @@ import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import com.vitorpamplona.quartz.nip51Lists.starterPack.StarterPackEvent fun filterFollowSetsAuthors( relay: NormalizedRelayUrl, @@ -43,7 +43,7 @@ fun filterFollowSetsAuthors( ExplainedFilter( purpose = SubPurpose.FOLLOW_LISTS, authors = authorList, - kinds = listOf(FollowListEvent.KIND), + kinds = listOf(StarterPackEvent.KIND), limit = 300, since = since, ), diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/nip51FollowSets/FilterFollowSetsByCommunity.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/nip51FollowSets/FilterFollowSetsByCommunity.kt index 095ab751ed..b1589dc89b 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/nip51FollowSets/FilterFollowSetsByCommunity.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/nip51FollowSets/FilterFollowSetsByCommunity.kt @@ -26,7 +26,7 @@ import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import com.vitorpamplona.quartz.nip51Lists.starterPack.StarterPackEvent import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent fun filterFollowSetsByCommunity( @@ -48,7 +48,7 @@ fun filterFollowSetsByCommunity( tags = mapOf( "a" to listOf(community), - "k" to listOf(FollowListEvent.KIND.toString()), + "k" to listOf(StarterPackEvent.KIND.toString()), ), limit = 300, since = since, @@ -62,7 +62,7 @@ fun filterFollowSetsByCommunity( purpose = SubPurpose.FOLLOW_LISTS, authors = authors, tags = mapOf("k" to listOf("5300"), "a" to listOf(community)), - kinds = listOf(FollowListEvent.KIND), + kinds = listOf(StarterPackEvent.KIND), limit = 300, since = since, ), diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/nip51FollowSets/FilterFollowSetsByGeohash.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/nip51FollowSets/FilterFollowSetsByGeohash.kt index f671ba36b7..9b51e44710 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/nip51FollowSets/FilterFollowSetsByGeohash.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/nip51FollowSets/FilterFollowSetsByGeohash.kt @@ -26,7 +26,7 @@ import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import com.vitorpamplona.quartz.nip51Lists.starterPack.StarterPackEvent fun filterFollowSetsByGeohash( relay: NormalizedRelayUrl, @@ -43,7 +43,7 @@ fun filterFollowSetsByGeohash( filter = ExplainedFilter( purpose = SubPurpose.FOLLOW_LISTS, - kinds = listOf(FollowListEvent.KIND), + kinds = listOf(StarterPackEvent.KIND), tags = mapOf("g" to geoHashes), limit = 300, since = since, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/nip51FollowSets/FilterFollowSetsByHashtag.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/nip51FollowSets/FilterFollowSetsByHashtag.kt index bc53534f50..b228a62731 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/nip51FollowSets/FilterFollowSetsByHashtag.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/nip51FollowSets/FilterFollowSetsByHashtag.kt @@ -27,7 +27,7 @@ import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtagAlts -import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import com.vitorpamplona.quartz.nip51Lists.starterPack.StarterPackEvent fun filterFollowSetsByHashtag( relay: NormalizedRelayUrl, @@ -44,7 +44,7 @@ fun filterFollowSetsByHashtag( filter = ExplainedFilter( purpose = SubPurpose.FOLLOW_LISTS, - kinds = listOf(FollowListEvent.KIND), + kinds = listOf(StarterPackEvent.KIND), tags = mapOf("t" to hashtags), limit = 300, since = since, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/nip51FollowSets/FilterFollowSetsGlobal.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/nip51FollowSets/FilterFollowSetsGlobal.kt index a853b22722..b6b59bb3c4 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/nip51FollowSets/FilterFollowSetsGlobal.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/discover/nip51FollowSets/FilterFollowSetsGlobal.kt @@ -25,7 +25,7 @@ import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFil import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import com.vitorpamplona.quartz.nip51Lists.starterPack.StarterPackEvent fun filterFollowSetsGlobal( relays: GlobalTopNavPerRelayFilterSet, @@ -41,7 +41,7 @@ fun filterFollowSetsGlobal( filter = ExplainedFilter( purpose = SubPurpose.FOLLOW_LISTS, - kinds = listOf(FollowListEvent.KIND), + kinds = listOf(StarterPackEvent.KIND), limit = 100, since = since, ), diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/watchers/FilterRepliesAndReactionsToAddresses.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/watchers/FilterRepliesAndReactionsToAddresses.kt index 25ad0322a1..0aadb128d6 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/watchers/FilterRepliesAndReactionsToAddresses.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/watchers/FilterRepliesAndReactionsToAddresses.kt @@ -29,7 +29,7 @@ import com.vitorpamplona.quartz.experimental.videoCollaboration.VideoCollaborati import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent @@ -37,7 +37,7 @@ import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.nip56Reports.ReportEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent @@ -51,7 +51,7 @@ val RepliesAndReactionsToAddressesKinds1 = RepostEvent.KIND, GenericRepostEvent.KIND, ReportEvent.KIND, - LnZapEvent.KIND, + ZapReceiptEvent.KIND, Bolt12ZapEvent.KIND, ZapPollEvent.KIND, CommentEvent.KIND, @@ -88,7 +88,7 @@ val RootScopedRepliesToAddressesKinds = val DeletionKindList = listOf( - DeletionEvent.KIND, + DeletionRequestEvent.KIND, ) val TextNoteKindList = listOf(TextNoteEvent.KIND) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/watchers/FilterRepliesAndReactionsToNotes.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/watchers/FilterRepliesAndReactionsToNotes.kt index 11c723a879..ea59b16521 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/watchers/FilterRepliesAndReactionsToNotes.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/event/watchers/FilterRepliesAndReactionsToNotes.kt @@ -32,7 +32,7 @@ import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent -import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent @@ -46,11 +46,11 @@ import com.vitorpamplona.quartz.nip34Git.status.GitStatusDraftEvent import com.vitorpamplona.quartz.nip34Git.status.GitStatusOpenEvent import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent import com.vitorpamplona.quartz.nip56Reports.ReportEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent -import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent -import com.vitorpamplona.quartz.nip90Dvms.status.NIP90StatusEvent +import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.DvmContentDiscoveryResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.status.DvmStatusEvent import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent import com.vitorpamplona.quartz.utils.mapOfSet @@ -62,7 +62,7 @@ val RepliesAndReactionsKinds = RepostEvent.KIND, GenericRepostEvent.KIND, ReportEvent.KIND, - LnZapEvent.KIND, + ZapReceiptEvent.KIND, OnchainZapEvent.KIND, Bolt12ZapEvent.KIND, OtsEvent.KIND, @@ -89,9 +89,9 @@ val RootScopedRepliesKinds = val RepliesAndReactionsKinds2 = listOf( - DeletionEvent.KIND, - NIP90ContentDiscoveryResponseEvent.KIND, - NIP90StatusEvent.KIND, + DeletionRequestEvent.KIND, + DvmContentDiscoveryResponseEvent.KIND, + DvmStatusEvent.KIND, TorrentCommentEvent.KIND, GitReplyEvent.KIND, // NIP-34 status events (1630/1631/1632/1633). Rooted at the target diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/geohash/FilterPostsByGeohash.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/geohash/FilterPostsByGeohash.kt index 102d73791a..63bf3ed5bc 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/geohash/FilterPostsByGeohash.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/geohash/FilterPostsByGeohash.kt @@ -34,9 +34,9 @@ import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent -import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent +import com.vitorpamplona.quartz.nip54Wiki.WikiArticleEvent import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent @@ -49,7 +49,7 @@ val PostsByGeohashKinds = listOf( TextNoteEvent.KIND, ChannelMessageEvent.KIND, - LongTextNoteEvent.KIND, + LongFormContentEvent.KIND, PollEvent.KIND, ZapPollEvent.KIND, ClassifiedsEvent.KIND, @@ -60,7 +60,7 @@ val PostsByGeohashKinds = MusicPlaylistEvent.KIND, PodcastEpisodeEvent.KIND, PodcastMetadataEvent.KIND, - WikiNoteEvent.KIND, + WikiArticleEvent.KIND, RoadEventReportEvent.KIND, RoadEventConfirmationEvent.KIND, GeocacheListingEvent.KIND, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/home/nip01Core/FilterHomePostsByGeohashes.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/home/nip01Core/FilterHomePostsByGeohashes.kt index 90bdc83585..ed72bcacfb 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/home/nip01Core/FilterHomePostsByGeohashes.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/home/nip01Core/FilterHomePostsByGeohashes.kt @@ -33,13 +33,13 @@ import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent -import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent +import com.vitorpamplona.quartz.nip54Wiki.WikiArticleEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent -import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent +import com.vitorpamplona.quartz.nip71Video.AddressableNormalVideoEvent +import com.vitorpamplona.quartz.nip71Video.AddressableShortVideoEvent import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent import com.vitorpamplona.quartz.nip71Video.VideoShortEvent -import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent @@ -49,10 +49,10 @@ val HomePostsByGeohashKinds = TextNoteEvent.KIND, RepostEvent.KIND, GenericRepostEvent.KIND, - LongTextNoteEvent.KIND, + LongFormContentEvent.KIND, ClassifiedsEvent.KIND, HighlightEvent.KIND, - WikiNoteEvent.KIND, + WikiArticleEvent.KIND, CommentEvent.KIND, VoiceEvent.KIND, AttestationEvent.KIND, @@ -60,8 +60,8 @@ val HomePostsByGeohashKinds = PictureEvent.KIND, VideoNormalEvent.KIND, VideoShortEvent.KIND, - VideoHorizontalEvent.KIND, - VideoVerticalEvent.KIND, + AddressableNormalVideoEvent.KIND, + AddressableShortVideoEvent.KIND, ) fun filterHomePostsByGeohashes( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/home/nip01Core/FilterHomePostsByHashtags.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/home/nip01Core/FilterHomePostsByHashtags.kt index 6983457805..ee76335ea9 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/home/nip01Core/FilterHomePostsByHashtags.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/home/nip01Core/FilterHomePostsByHashtags.kt @@ -36,13 +36,13 @@ import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent -import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent +import com.vitorpamplona.quartz.nip54Wiki.WikiArticleEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent -import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent +import com.vitorpamplona.quartz.nip71Video.AddressableNormalVideoEvent +import com.vitorpamplona.quartz.nip71Video.AddressableShortVideoEvent import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent import com.vitorpamplona.quartz.nip71Video.VideoShortEvent -import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent @@ -52,12 +52,12 @@ val HomePostsBuHashtagsKinds = TextNoteEvent.KIND, RepostEvent.KIND, GenericRepostEvent.KIND, - LongTextNoteEvent.KIND, + LongFormContentEvent.KIND, ClassifiedsEvent.KIND, HighlightEvent.KIND, AudioHeaderEvent.KIND, CommentEvent.KIND, - WikiNoteEvent.KIND, + WikiArticleEvent.KIND, NipTextEvent.KIND, VoiceEvent.KIND, AttestationEvent.KIND, @@ -65,8 +65,8 @@ val HomePostsBuHashtagsKinds = PictureEvent.KIND, VideoNormalEvent.KIND, VideoShortEvent.KIND, - VideoHorizontalEvent.KIND, - VideoVerticalEvent.KIND, + AddressableNormalVideoEvent.KIND, + AddressableShortVideoEvent.KIND, ) fun filterHomePostsByHashtags( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/home/nip65Follows/FilterHomePostsByAuthors.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/home/nip65Follows/FilterHomePostsByAuthors.kt index d7daaa94c4..e9d27d4d9f 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/home/nip65Follows/FilterHomePostsByAuthors.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/home/nip65Follows/FilterHomePostsByAuthors.kt @@ -40,19 +40,19 @@ import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent -import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent +import com.vitorpamplona.quartz.nip54Wiki.WikiArticleEvent import com.vitorpamplona.quartz.nip64Chess.challenge.offer.LiveChessGameChallengeEvent import com.vitorpamplona.quartz.nip64Chess.end.LiveChessGameEndEvent import com.vitorpamplona.quartz.nip64Chess.game.ChessGameEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent -import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent +import com.vitorpamplona.quartz.nip71Video.AddressableNormalVideoEvent +import com.vitorpamplona.quartz.nip71Video.AddressableShortVideoEvent import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent import com.vitorpamplona.quartz.nip71Video.VideoShortEvent -import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent @@ -67,18 +67,18 @@ val HomePostsNewThreadKinds1 = RepostEvent.KIND, GenericRepostEvent.KIND, ClassifiedsEvent.KIND, - LongTextNoteEvent.KIND, + LongFormContentEvent.KIND, HighlightEvent.KIND, ZapPollEvent.KIND, PollEvent.KIND, - WikiNoteEvent.KIND, + WikiArticleEvent.KIND, AttestationEvent.KIND, NipTextEvent.KIND, PictureEvent.KIND, VideoNormalEvent.KIND, VideoShortEvent.KIND, - VideoHorizontalEvent.KIND, - VideoVerticalEvent.KIND, + AddressableNormalVideoEvent.KIND, + AddressableShortVideoEvent.KIND, ) val HomePostsNewThreadKinds2 = diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/home/nip72Communities/FilterHomePostsFromCommunities.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/home/nip72Communities/FilterHomePostsFromCommunities.kt index 096432af97..64e9a2f66d 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/home/nip72Communities/FilterHomePostsFromCommunities.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/home/nip72Communities/FilterHomePostsFromCommunities.kt @@ -30,13 +30,13 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent -import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent +import com.vitorpamplona.quartz.nip54Wiki.WikiArticleEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent -import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent +import com.vitorpamplona.quartz.nip71Video.AddressableNormalVideoEvent +import com.vitorpamplona.quartz.nip71Video.AddressableShortVideoEvent import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent import com.vitorpamplona.quartz.nip71Video.VideoShortEvent -import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent @@ -45,11 +45,11 @@ import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent val HomePostsFromCommunityKinds = listOf( TextNoteEvent.KIND, - LongTextNoteEvent.KIND, + LongFormContentEvent.KIND, ClassifiedsEvent.KIND, HighlightEvent.KIND, PollEvent.KIND, - WikiNoteEvent.KIND, + WikiArticleEvent.KIND, NipTextEvent.KIND, CommunityPostApprovalEvent.KIND, CommentEvent.KIND, @@ -57,8 +57,8 @@ val HomePostsFromCommunityKinds = PictureEvent.KIND, VideoNormalEvent.KIND, VideoShortEvent.KIND, - VideoHorizontalEvent.KIND, - VideoVerticalEvent.KIND, + AddressableNormalVideoEvent.KIND, + AddressableShortVideoEvent.KIND, ) /** The same set, as the `k` tag values a NIP-72 post approval carries. */ diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/home/nip90AlgoFeeds/FilterHomePostsByAlgoFeedIds.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/home/nip90AlgoFeeds/FilterHomePostsByAlgoFeedIds.kt index d8dbc554ab..6f575ea96d 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/home/nip90AlgoFeeds/FilterHomePostsByAlgoFeedIds.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/home/nip90AlgoFeeds/FilterHomePostsByAlgoFeedIds.kt @@ -28,8 +28,8 @@ import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent -import com.vitorpamplona.quartz.nip90Dvms.status.NIP90StatusEvent +import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.DvmContentDiscoveryResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.status.DvmStatusEvent /** * Builds relay REQ filters for a favorite-DVM home feed. @@ -112,8 +112,8 @@ private fun responseListenFilter( purpose = SubPurpose.HOME_FEED, kinds = listOf( - NIP90ContentDiscoveryResponseEvent.KIND, - NIP90StatusEvent.KIND, + DvmContentDiscoveryResponseEvent.KIND, + DvmStatusEvent.KIND, ), tags = mapOf("e" to requestIds), limit = 10 * requestIds.size, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/longs/FilterLongsByAllCommunities.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/longs/FilterLongsByAllCommunities.kt index c8b32bae95..f6461946c2 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/longs/FilterLongsByAllCommunities.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/longs/FilterLongsByAllCommunities.kt @@ -27,20 +27,20 @@ import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent +import com.vitorpamplona.quartz.nip71Video.AddressableNormalVideoEvent import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent val LongsFromCommunityKinds = listOf( VideoNormalEvent.KIND, - VideoHorizontalEvent.KIND, + AddressableNormalVideoEvent.KIND, ) val LongsFromCommunityKindsStr = listOf( VideoNormalEvent.KIND.toString(), - VideoHorizontalEvent.KIND.toString(), + AddressableNormalVideoEvent.KIND.toString(), ) fun filterLongsFromAllCommunities( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/longs/FilterLongsByAuthors.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/longs/FilterLongsByAuthors.kt index 2c365f4fbd..179349b97b 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/longs/FilterLongsByAuthors.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/longs/FilterLongsByAuthors.kt @@ -28,7 +28,7 @@ import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent +import com.vitorpamplona.quartz.nip71Video.AddressableNormalVideoEvent import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent fun filterLongsByAuthors( @@ -44,7 +44,7 @@ fun filterLongsByAuthors( ExplainedFilter( purpose = SubPurpose.MEDIA_FEED, authors = authorList, - kinds = listOf(VideoNormalEvent.KIND, VideoHorizontalEvent.KIND), + kinds = listOf(VideoNormalEvent.KIND, AddressableNormalVideoEvent.KIND), limit = 200, since = since, ), diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/longs/FilterLongsByGeohashes.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/longs/FilterLongsByGeohashes.kt index 8cf5096e02..9c0d605be8 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/longs/FilterLongsByGeohashes.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/longs/FilterLongsByGeohashes.kt @@ -26,7 +26,7 @@ import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent +import com.vitorpamplona.quartz.nip71Video.AddressableNormalVideoEvent import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent fun filterLongsByGeohashes( @@ -42,7 +42,7 @@ fun filterLongsByGeohashes( filter = ExplainedFilter( purpose = SubPurpose.MEDIA_FEED, - kinds = listOf(VideoNormalEvent.KIND, VideoHorizontalEvent.KIND), + kinds = listOf(VideoNormalEvent.KIND, AddressableNormalVideoEvent.KIND), tags = mapOf("g" to geotags.sorted()), limit = 100, since = since, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/longs/FilterLongsByHashtag.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/longs/FilterLongsByHashtag.kt index 85e2eee01b..956bd69436 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/longs/FilterLongsByHashtag.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/longs/FilterLongsByHashtag.kt @@ -26,7 +26,7 @@ import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent +import com.vitorpamplona.quartz.nip71Video.AddressableNormalVideoEvent import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent fun filterLongsByHashtag( @@ -40,7 +40,7 @@ fun filterLongsByHashtag( filter = ExplainedFilter( purpose = SubPurpose.MEDIA_FEED, - kinds = listOf(VideoNormalEvent.KIND, VideoHorizontalEvent.KIND), + kinds = listOf(VideoNormalEvent.KIND, AddressableNormalVideoEvent.KIND), tags = mapOf("t" to hashtags.toList()), limit = 200, since = since, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/longs/FilterLongsGlobal.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/longs/FilterLongsGlobal.kt index c538f86362..e9378496a7 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/longs/FilterLongsGlobal.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/longs/FilterLongsGlobal.kt @@ -25,7 +25,7 @@ import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFil import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent +import com.vitorpamplona.quartz.nip71Video.AddressableNormalVideoEvent import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent import com.vitorpamplona.quartz.utils.TimeUtils @@ -43,7 +43,7 @@ fun filterLongsGlobal( filter = ExplainedFilter( purpose = SubPurpose.MEDIA_FEED, - kinds = listOf(VideoNormalEvent.KIND, VideoHorizontalEvent.KIND), + kinds = listOf(VideoNormalEvent.KIND, AddressableNormalVideoEvent.KIND), limit = 200, since = since, ), diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/nip17Dm/GiftWrapDecryptor.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/nip17Dm/GiftWrapDecryptor.kt index cf19417ca5..4b9d7a8bc4 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/nip17Dm/GiftWrapDecryptor.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/nip17Dm/GiftWrapDecryptor.kt @@ -22,25 +22,25 @@ package com.vitorpamplona.amethyst.commons.relayClient.nip17Dm import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent +import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent /** * Fully decrypt a NIP-59 gift wrap down to the inner rumor. * * A gift wrap carries two encryption layers: - * kind:1059 [GiftWrapEvent] → kind:13 [SealedRumorEvent] → the rumor. + * kind:1059 [GiftWrapEvent] → kind:13 [SealEvent] → the rumor. * * [GiftWrapEvent.unwrapOrNull] peels only the outer layer. Every non-Android * consumer that looks at the rumor directly (the CLI's `dm list`, the desktop * chat receive path, the Marmot welcome ingest in commons) needs both peels. * - * If the inner content isn't a [SealedRumorEvent] (malformed, or a future + * If the inner content isn't a [SealEvent] (malformed, or a future * NIP-59 variant that wraps a rumor directly), the outer unwrap result is * returned as-is so callers can still route on kind. Either unwrap returning * null propagates as null. */ suspend fun GiftWrapEvent.unwrapAndUnsealOrNull(signer: NostrSigner): Event? { val inner = unwrapOrNull(signer) ?: return null - return if (inner is SealedRumorEvent) inner.unsealOrNull(signer) else inner + return if (inner is SealEvent) inner.unsealOrNull(signer) else inner } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/nip47WalletConnect/FilterNWCPaymentsFromRequests.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/nip47WalletConnect/FilterNWCPaymentsFromRequests.kt index 815746628b..b4a468d7b2 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/nip47WalletConnect/FilterNWCPaymentsFromRequests.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/nip47WalletConnect/FilterNWCPaymentsFromRequests.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.commons.relayClient.nip47WalletConnect import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter -import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcResponseEvent // The request event id (#e) is a unique 32-byte identifier — sufficient on // its own to match a spec-compliant kind-23195 response. We deliberately keep @@ -43,7 +43,7 @@ fun filterNWCPaymentsFromRequests( fromUsers: Set, ): Filter = Filter( - kinds = listOf(LnZapPaymentResponseEvent.KIND), + kinds = listOf(NwcResponseEvent.KIND), tags = mapOf( "e" to paymentRequests.sorted(), diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/profile/FilterUserProfileLists.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/profile/FilterUserProfileLists.kt index 022f5cb712..444f4e1695 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/profile/FilterUserProfileLists.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/profile/FilterUserProfileLists.kt @@ -29,9 +29,9 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip51Lists.PinListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent -import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent -import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent -import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent +import com.vitorpamplona.quartz.nip51Lists.followSet.FollowSetEvent +import com.vitorpamplona.quartz.nip51Lists.interestList.InterestListEvent +import com.vitorpamplona.quartz.nip51Lists.starterPack.StarterPackEvent import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.AppRecommendationEvent val UserProfileListKinds = @@ -39,9 +39,9 @@ val UserProfileListKinds = BookmarkListEvent.KIND, OldBookmarkListEvent.KIND, PinListEvent.KIND, - PeopleListEvent.KIND, - FollowListEvent.KIND, - HashtagListEvent.KIND, + FollowSetEvent.KIND, + StarterPackEvent.KIND, + InterestListEvent.KIND, AppRecommendationEvent.KIND, PaymentTargetsEvent.KIND, ) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/profile/FilterUserProfileMedia.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/profile/FilterUserProfileMedia.kt index 5d689fbb15..46d26ab82c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/profile/FilterUserProfileMedia.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/profile/FilterUserProfileMedia.kt @@ -29,17 +29,17 @@ import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryE import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent -import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent +import com.vitorpamplona.quartz.nip71Video.AddressableNormalVideoEvent +import com.vitorpamplona.quartz.nip71Video.AddressableShortVideoEvent import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent import com.vitorpamplona.quartz.nip71Video.VideoShortEvent -import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent val UserProfileMediaKinds = listOf( PictureEvent.KIND, ProfileGalleryEntryEvent.KIND, - VideoVerticalEvent.KIND, - VideoHorizontalEvent.KIND, + AddressableShortVideoEvent.KIND, + AddressableNormalVideoEvent.KIND, VideoNormalEvent.KIND, VideoShortEvent.KIND, ) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/profile/FilterUserProfilePosts.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/profile/FilterUserProfilePosts.kt index a00b67bce8..ef9fe01388 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/profile/FilterUserProfilePosts.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/profile/FilterUserProfilePosts.kt @@ -40,11 +40,11 @@ import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent import com.vitorpamplona.quartz.nip51Lists.PinListEvent -import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent +import com.vitorpamplona.quartz.nip54Wiki.WikiArticleEvent import com.vitorpamplona.quartz.nip5aStaticWebsites.NamedSiteEvent import com.vitorpamplona.quartz.nip5aStaticWebsites.RootSiteEvent import com.vitorpamplona.quartz.nip5dNapplets.NamedNappletEvent @@ -60,10 +60,10 @@ val UserProfilePostKinds1 = TextNoteEvent.KIND, GenericRepostEvent.KIND, RepostEvent.KIND, - LongTextNoteEvent.KIND, + LongFormContentEvent.KIND, PollEvent.KIND, HighlightEvent.KIND, - WikiNoteEvent.KIND, + WikiArticleEvent.KIND, VoiceEvent.KIND, PublicMessageEvent.KIND, ) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/profile/FilterUserProfileZapReceived.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/profile/FilterUserProfileZapReceived.kt index 8d5ab4790b..af26c39476 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/profile/FilterUserProfileZapReceived.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/profile/FilterUserProfileZapReceived.kt @@ -26,11 +26,11 @@ import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFil import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent -val UserProfileZapReceiverKinds = listOf(LnZapEvent.KIND, OnchainZapEvent.KIND, Bolt12ZapEvent.KIND) +val UserProfileZapReceiverKinds = listOf(ZapReceiptEvent.KIND, OnchainZapEvent.KIND, Bolt12ZapEvent.KIND) fun filterUserProfileZapsReceived( cache: ICacheProvider, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/relay/FilterPostsByRelay.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/relay/FilterPostsByRelay.kt index becd8b7779..a6fc642406 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/relay/FilterPostsByRelay.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/relay/FilterPostsByRelay.kt @@ -34,10 +34,10 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent -import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent +import com.vitorpamplona.quartz.nip54Wiki.WikiArticleEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent @@ -48,12 +48,12 @@ val PostsByRelayKinds = listOf( TextNoteEvent.KIND, ChannelMessageEvent.KIND, - LongTextNoteEvent.KIND, + LongFormContentEvent.KIND, PollEvent.KIND, LiveActivitiesChatMessageEvent.KIND, ClassifiedsEvent.KIND, HighlightEvent.KIND, - WikiNoteEvent.KIND, + WikiArticleEvent.KIND, CommentEvent.KIND, ) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/shorts/FilterShortsByAllCommunities.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/shorts/FilterShortsByAllCommunities.kt index 18bcd8b0fa..9d420b8e6d 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/shorts/FilterShortsByAllCommunities.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/shorts/FilterShortsByAllCommunities.kt @@ -27,20 +27,20 @@ import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip71Video.AddressableShortVideoEvent import com.vitorpamplona.quartz.nip71Video.VideoShortEvent -import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent val ShortsFromCommunityKinds = listOf( VideoShortEvent.KIND, - VideoVerticalEvent.KIND, + AddressableShortVideoEvent.KIND, ) val ShortsFromCommunityKindsStr = listOf( VideoShortEvent.KIND.toString(), - VideoVerticalEvent.KIND.toString(), + AddressableShortVideoEvent.KIND.toString(), ) fun filterShortsFromAllCommunities( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/shorts/FilterShortsByAuthors.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/shorts/FilterShortsByAuthors.kt index 24bca3c574..e671e6a37f 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/shorts/FilterShortsByAuthors.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/shorts/FilterShortsByAuthors.kt @@ -28,8 +28,8 @@ import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip71Video.AddressableShortVideoEvent import com.vitorpamplona.quartz.nip71Video.VideoShortEvent -import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent fun filterShortsByAuthors( relay: NormalizedRelayUrl, @@ -44,7 +44,7 @@ fun filterShortsByAuthors( ExplainedFilter( purpose = SubPurpose.MEDIA_FEED, authors = authorList, - kinds = listOf(VideoShortEvent.KIND, VideoVerticalEvent.KIND), + kinds = listOf(VideoShortEvent.KIND, AddressableShortVideoEvent.KIND), limit = 200, since = since, ), diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/shorts/FilterShortsByGeohashes.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/shorts/FilterShortsByGeohashes.kt index c9afffb742..36345aa08e 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/shorts/FilterShortsByGeohashes.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/shorts/FilterShortsByGeohashes.kt @@ -26,8 +26,8 @@ import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip71Video.AddressableShortVideoEvent import com.vitorpamplona.quartz.nip71Video.VideoShortEvent -import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent fun filterShortsByGeohashes( relay: NormalizedRelayUrl, @@ -42,7 +42,7 @@ fun filterShortsByGeohashes( filter = ExplainedFilter( purpose = SubPurpose.MEDIA_FEED, - kinds = listOf(VideoShortEvent.KIND, VideoVerticalEvent.KIND), + kinds = listOf(VideoShortEvent.KIND, AddressableShortVideoEvent.KIND), tags = mapOf("g" to geotags.sorted()), limit = 100, since = since, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/shorts/FilterShortsByHashtag.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/shorts/FilterShortsByHashtag.kt index dfe7382fe8..16cb6c6dcb 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/shorts/FilterShortsByHashtag.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/shorts/FilterShortsByHashtag.kt @@ -26,8 +26,8 @@ import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip71Video.AddressableShortVideoEvent import com.vitorpamplona.quartz.nip71Video.VideoShortEvent -import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent fun filterShortsByHashtag( relay: NormalizedRelayUrl, @@ -40,7 +40,7 @@ fun filterShortsByHashtag( filter = ExplainedFilter( purpose = SubPurpose.MEDIA_FEED, - kinds = listOf(VideoShortEvent.KIND, VideoVerticalEvent.KIND), + kinds = listOf(VideoShortEvent.KIND, AddressableShortVideoEvent.KIND), tags = mapOf("t" to hashtags.toList()), limit = 200, since = since, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/shorts/FilterShortsGlobal.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/shorts/FilterShortsGlobal.kt index ee0c14a2b1..ece2a170e2 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/shorts/FilterShortsGlobal.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/shorts/FilterShortsGlobal.kt @@ -25,8 +25,8 @@ import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFil import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip71Video.AddressableShortVideoEvent import com.vitorpamplona.quartz.nip71Video.VideoShortEvent -import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent import com.vitorpamplona.quartz.utils.TimeUtils fun filterShortsGlobal( @@ -43,7 +43,7 @@ fun filterShortsGlobal( filter = ExplainedFilter( purpose = SubPurpose.MEDIA_FEED, - kinds = listOf(VideoShortEvent.KIND, VideoVerticalEvent.KIND), + kinds = listOf(VideoShortEvent.KIND, AddressableShortVideoEvent.KIND), limit = 200, since = since, ), diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/softwareapps/FilterSoftwareAppsByAuthors.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/softwareapps/FilterSoftwareAppsByAuthors.kt index 5eda45cb9b..1483c10824 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/softwareapps/FilterSoftwareAppsByAuthors.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/softwareapps/FilterSoftwareAppsByAuthors.kt @@ -26,10 +26,10 @@ import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFil import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.SoftwareApplicationEvent -import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.SoftwareReleaseEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip51Lists.releaseArtifactSet.ReleaseArtifactSetEvent fun filterSoftwareAppsByAuthors( relay: NormalizedRelayUrl, @@ -44,7 +44,7 @@ fun filterSoftwareAppsByAuthors( ExplainedFilter( purpose = SubPurpose.ADD_ONS, authors = authorList, - kinds = listOf(SoftwareApplicationEvent.KIND, SoftwareReleaseEvent.KIND), + kinds = listOf(SoftwareApplicationEvent.KIND, ReleaseArtifactSetEvent.KIND), limit = 200, since = since, ), diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/softwareapps/FilterSoftwareAppsByHashtag.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/softwareapps/FilterSoftwareAppsByHashtag.kt index 638eee590e..00ff7c3723 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/softwareapps/FilterSoftwareAppsByHashtag.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/softwareapps/FilterSoftwareAppsByHashtag.kt @@ -25,10 +25,10 @@ import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFil import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.SoftwareApplicationEvent -import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.SoftwareReleaseEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtagAlts +import com.vitorpamplona.quartz.nip51Lists.releaseArtifactSet.ReleaseArtifactSetEvent fun filterSoftwareAppsByHashtag( relay: NormalizedRelayUrl, @@ -45,7 +45,7 @@ fun filterSoftwareAppsByHashtag( filter = ExplainedFilter( purpose = SubPurpose.TAG_FEED, - kinds = listOf(SoftwareApplicationEvent.KIND, SoftwareReleaseEvent.KIND), + kinds = listOf(SoftwareApplicationEvent.KIND, ReleaseArtifactSetEvent.KIND), tags = mapOf("t" to expanded), limit = 200, since = since, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/softwareapps/FilterSoftwareAppsGlobal.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/softwareapps/FilterSoftwareAppsGlobal.kt index ed54dbf12d..c72a5c6ac1 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/softwareapps/FilterSoftwareAppsGlobal.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/softwareapps/FilterSoftwareAppsGlobal.kt @@ -25,10 +25,10 @@ import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFil import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.SoftwareApplicationEvent -import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.SoftwareReleaseEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl +import com.vitorpamplona.quartz.nip51Lists.releaseArtifactSet.ReleaseArtifactSetEvent // zapstore's relay indexes the entire software-app catalog (NIP-82), so the // global Apps feed always queries it in addition to the user's own relays — @@ -57,7 +57,7 @@ fun filterSoftwareAppsGlobal( filter = ExplainedFilter( purpose = SubPurpose.ADD_ONS, - kinds = listOf(SoftwareApplicationEvent.KIND, SoftwareReleaseEvent.KIND), + kinds = listOf(SoftwareApplicationEvent.KIND, ReleaseArtifactSetEvent.KIND), // Keep the limit < 100. Relays such as zapstore's score filter // specificity and treat a limit >= 100 (or none) as too vague, // rejecting the whole REQ with "filters are too vague". diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/watchers/FilterUserMetadataForKey.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/watchers/FilterUserMetadataForKey.kt index db874ab832..0d1290113d 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/watchers/FilterUserMetadataForKey.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/watchers/FilterUserMetadataForKey.kt @@ -32,8 +32,8 @@ import com.vitorpamplona.quartz.nip01Core.hints.HintIndexer import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent -import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent +import com.vitorpamplona.quartz.nip17Dm.settings.DmRelayListEvent +import com.vitorpamplona.quartz.nip38UserStatus.UserStatusEvent import com.vitorpamplona.quartz.nip39ExtIdentities.ExternalIdentitiesEvent import com.vitorpamplona.quartz.nip61Nutzaps.info.NutzapInfoEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent @@ -44,9 +44,9 @@ val UserMetadataForKeyKinds = listOf( MetadataEvent.KIND, ExternalIdentitiesEvent.KIND, - StatusEvent.KIND, + UserStatusEvent.KIND, AdvertisedRelayListEvent.KIND, - ChatMessageRelayListEvent.KIND, + DmRelayListEvent.KIND, KeyPackageRelayListEvent.KIND, PaymentTargetsEvent.KIND, Bolt12OfferListEvent.KIND, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/watchers/UserCardsSubAssembler.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/watchers/UserCardsSubAssembler.kt index 9c4ffbc31e..bee7470979 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/watchers/UserCardsSubAssembler.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/user/watchers/UserCardsSubAssembler.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.commons.relayClient.user.watchers import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider import com.vitorpamplona.amethyst.commons.model.toHexSet -import com.vitorpamplona.amethyst.commons.relayClient.assemblers.filterContactCardsToTargetKeysFromTrustedAccountsInTheRelay +import com.vitorpamplona.amethyst.commons.relayClient.assemblers.filterUserAssertionsToTargetKeysFromTrustedAccountsInTheRelay import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubEoseManager import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderQueryState import com.vitorpamplona.amethyst.commons.relays.MutableTime @@ -103,14 +103,14 @@ class UserCardsSubAssembler( val groups = groupByRelayPresence(lastUsersOnFilter, relay) val trustedAccounts = trustedUsersInThisRelay.sorted() listOfNotNull( - filterContactCardsToTargetKeysFromTrustedAccountsInTheRelay( + filterUserAssertionsToTargetKeysFromTrustedAccountsInTheRelay( accountPubKey = soleAccountPubKey, targets = groups.usersWithoutEose.toHexSet(), trustedAccounts = trustedAccounts, relay = relay, since = null, ), - filterContactCardsToTargetKeysFromTrustedAccountsInTheRelay( + filterUserAssertionsToTargetKeysFromTrustedAccountsInTheRelay( accountPubKey = soleAccountPubKey, targets = groups.usersWithEose.toHexSet(), trustedAccounts = trustedAccounts, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/video/FeedBasis.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/video/FeedBasis.kt index a6d9bd3de3..17c4b31bdb 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/video/FeedBasis.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/video/FeedBasis.kt @@ -22,11 +22,11 @@ package com.vitorpamplona.amethyst.commons.relayClient.video import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent -import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent +import com.vitorpamplona.quartz.nip71Video.AddressableNormalVideoEvent +import com.vitorpamplona.quartz.nip71Video.AddressableShortVideoEvent import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent import com.vitorpamplona.quartz.nip71Video.VideoShortEvent -import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent -import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent +import com.vitorpamplona.quartz.nip94FileMetadata.FileMetadataEvent // The media types the video/shorts feeds will admit. HLS playlists are in here because a // blossom-hosted manifest is `https://host/` with no extension at all, so the MIME is the @@ -57,8 +57,8 @@ val SUPPORTED_VIDEO_FEED_MIME_TYPES_SET = SUPPORTED_VIDEO_FEED_MIME_TYPES.toSet( val PictureAndVideoKinds = listOf( PictureEvent.KIND, - VideoHorizontalEvent.KIND, - VideoVerticalEvent.KIND, + AddressableNormalVideoEvent.KIND, + AddressableShortVideoEvent.KIND, VideoNormalEvent.KIND, VideoShortEvent.KIND, ) @@ -66,12 +66,12 @@ val PictureAndVideoKinds = val PictureAndVideoKTags = listOf( PictureEvent.KIND.toString(), - VideoHorizontalEvent.KIND.toString(), - VideoVerticalEvent.KIND.toString(), + AddressableNormalVideoEvent.KIND.toString(), + AddressableShortVideoEvent.KIND.toString(), VideoNormalEvent.KIND.toString(), VideoShortEvent.KIND.toString(), ) -val PictureAndVideoLegacyKinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND) -val PictureAndVideoLegacyKTags = listOf(FileHeaderEvent.KIND.toString(), FileStorageHeaderEvent.KIND.toString()) +val PictureAndVideoLegacyKinds = listOf(FileMetadataEvent.KIND, FileStorageHeaderEvent.KIND) +val PictureAndVideoLegacyKTags = listOf(FileMetadataEvent.KIND.toString(), FileStorageHeaderEvent.KIND.toString()) val LegacyMimeTypes = SUPPORTED_VIDEO_FEED_MIME_TYPES val LegacyMimeTypeMap = mapOf("m" to LegacyMimeTypes) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/video/nip72Communities/FilterPictureAndVideoByAllCommunities.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/video/nip72Communities/FilterPictureAndVideoByAllCommunities.kt index 530b8a2d2c..81bebc33d5 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/video/nip72Communities/FilterPictureAndVideoByAllCommunities.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/video/nip72Communities/FilterPictureAndVideoByAllCommunities.kt @@ -32,7 +32,7 @@ import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent -import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent +import com.vitorpamplona.quartz.nip94FileMetadata.FileMetadataEvent fun filterPictureAndVideoAllCommunities( relay: NormalizedRelayUrl, @@ -80,7 +80,7 @@ fun filterPictureAndVideoAllCommunities( tags = mapOf( "a" to communityList, - "k" to listOf(FileHeaderEvent.KIND.toString(), FileStorageHeaderEvent.KIND.toString()), + "k" to listOf(FileMetadataEvent.KIND.toString(), FileStorageHeaderEvent.KIND.toString()), ), limit = 200, since = since, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/DataStoreRelayAuthPermissionStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayauth/DataStoreRelayAuthPermissionStore.kt similarity index 80% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/DataStoreRelayAuthPermissionStore.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayauth/DataStoreRelayAuthPermissionStore.kt index fe2e79fffd..20cd1aaca0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/DataStoreRelayAuthPermissionStore.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayauth/DataStoreRelayAuthPermissionStore.kt @@ -18,39 +18,34 @@ * 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.relayClient.authCommand.model +package com.vitorpamplona.amethyst.commons.relayauth -import android.content.Context import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.MutablePreferences -import androidx.datastore.preferences.core.PreferenceDataStoreFactory import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey -import com.vitorpamplona.amethyst.commons.relayauth.AuthPurposeKind -import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision -import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPermissionStore +import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.utils.TimeUtils +import com.vitorpamplona.quartz.utils.sha256.sha256 import kotlinx.coroutines.flow.first -import java.io.File -import java.security.MessageDigest -import java.util.concurrent.ConcurrentHashMap /** - * Single-file DataStore-backed [RelayAuthPermissionStore]. All per-relay ALLOW/DENY overrides - * live in one `datastore/relay_auth.preferences_pb` file; a SHA-256 prefix of the URL is the - * key so the URL itself is safe in the file (stored separately for reverse-lookup in [allDecisions]). + * Per-account NIP-42 ALLOW/DENY overrides, in one `relay_auth` store inside that + * account's own directory so a DENY for one account never leaks into another. + * + * A SHA-256 prefix of the relay URL is the key, so the URL itself is not a key + * in the file; it is stored separately under its own prefix for the reverse + * lookup [allDecisions] needs. + * + * Takes the store rather than the directory. DataStore throws if two instances + * are ever live on one file, and an account can be built more than once in a + * process (re-login, cache races), so the caller has to hand in a store it + * keeps — see AccountCacheState, which caches one holder per account. */ class DataStoreRelayAuthPermissionStore( - private val filesDir: File, + private val store: DataStore, ) : RelayAuthPermissionStore { - constructor(context: Context) : this(context.applicationContext.filesDir) - - // DataStore v1 throws if two instances are ever active on the same file. loadAccount can build - // this store more than once for the same account (re-login, cache races), so the underlying - // DataStore is shared per absolute file path across the process instead of created per instance. - private val store: DataStore get() = dataStoreFor(File(filesDir, "datastore/relay_auth.preferences_pb")) - override suspend fun loadDecision(relayUrl: String): RelayAuthDecision? { val raw = store.data.first()[decisionKey(relayUrl)] ?: return null return runCatching { RelayAuthDecision.valueOf(raw) }.getOrNull() @@ -198,14 +193,7 @@ class DataStoreRelayAuthPermissionStore( private fun lastUsedKey(relayUrl: String) = stringPreferencesKey("$LAST_USED_PREFIX${hash(relayUrl)}") companion object { - // One DataStore per file path, process-wide. computeIfAbsent runs the factory at most once - // per path, so concurrent constructions for the same account share a single active DataStore. - private val stores = ConcurrentHashMap>() - - private fun dataStoreFor(file: File): DataStore = - stores.computeIfAbsent(file.absolutePath) { - PreferenceDataStoreFactory.create(produceFile = { file }) - } + const val FILE_NAME = "relay_auth" private const val DECISION_PREFIX = "allow:" private const val URL_PREFIX = "url:" @@ -220,9 +208,15 @@ class DataStoreRelayAuthPermissionStore( /** Minimum seconds between last-used refreshes when no new counterparty appears. */ private const val LAST_USED_REFRESH_SECS = 300L - private fun hash(relayUrl: String): String { - val digest = MessageDigest.getInstance("SHA-256").digest(relayUrl.toByteArray()) - return digest.take(8).joinToString("") { "%02x".format(it) } - } + /** + * The first 8 bytes of the URL's SHA-256, lower-case hex. + * + * A stored key, so it has to keep producing exactly what + * `MessageDigest.getInstance("SHA-256")` plus `"%02x".format(byte)` did + * on Android: a different digest silently drops every decision the user + * has made rather than failing. Pinned in + * DataStoreRelayAuthPermissionStoreTest. + */ + internal fun hash(relayUrl: String): String = sha256(relayUrl.encodeToByteArray()).copyOfRange(0, 8).toHexKey() } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/nip11RelayInfo/RelaySupportsNip.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/nip11RelayInfo/RelaySupportsNip.kt new file mode 100644 index 0000000000..d59f7bd6c1 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/nip11RelayInfo/RelaySupportsNip.kt @@ -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.commons.relays.nip11RelayInfo + +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel +import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation + +/** + * Whether [relayInfo] affirmatively signals that its relay does NOT run NIP-29 groups: the doc + * resolved with an explicit `supported_nips` list that lacks "29" and no `self` key (the field + * NIP-29 relays publish so clients can verify their relay-signed group metadata — see + * [isRelaySignedRelayGroup]). A doc with a null `supported_nips` proves nothing (still loading, + * or the fetch failed), so it never triggers the warning. + * + * Takes the resolved document rather than a relay URL, which is what makes it shared: the cache + * lookup that produces one is the front end's business, the rule applied to it is not. The + * convenience forms that read Amethyst's in-memory cache stay in the app. + */ +fun looksLikeNonNip29Relay(relayInfo: Nip11RelayInformation): Boolean = relayInfo.supported_nips?.none { it == "29" } == true && relayInfo.self == null + +/** + * Whether [channel]'s relay-signed metadata is genuinely from its host relay, per NIP-29: + * "these are addressable events signed by the relay keypair directly … as stated by the NIP-11 + * `self` pubkey", and "relays shouldn't accept these events if they're signed by anyone else". + * + * So the authoritative check is `39000.author == relay.self`. When the relay publishes a `self` + * key we enforce that strictly — this rejects a stray user-published 39000 even on a real NIP-29 + * relay. When the relay does NOT advertise `self` at all (we can't verify cryptographically), we + * fall back to the weaker "advertises NIP-29" signal so a compliant relay that merely omits `self` + * still works. A relay with neither fails. Reads only the resolved doc ([relayInfo]); callers + * driving a live surface should warm it first and re-evaluate as it resolves. + */ +fun isRelaySignedRelayGroup( + channel: RelayGroupChannel, + relayInfo: Nip11RelayInformation, +): Boolean { + val self = relayInfo.self + return if (self != null) { + channel.event?.pubKey == self + } else { + relayInfo.supported_nips?.any { it == "29" } == true + } +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostNotifier.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostNotifier.kt similarity index 100% rename from commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostNotifier.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostNotifier.kt diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostPublisher.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostPublisher.kt similarity index 100% rename from commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostPublisher.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostPublisher.kt diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostStore.kt similarity index 84% rename from commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostStore.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostStore.kt index d19e8e7a6c..c3eb84304f 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostStore.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostStore.kt @@ -20,21 +20,31 @@ */ package com.vitorpamplona.amethyst.commons.scheduledposts +import com.vitorpamplona.amethyst.commons.util.platformFileSystem +import com.vitorpamplona.amethyst.commons.util.restrictFileToOwner import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.serialization.json.Json -import java.io.File -import java.nio.file.Files -import java.nio.file.attribute.PosixFilePermission +import okio.FileSystem +import okio.Path +import okio.Path.Companion.toPath class ScheduledPostStore( - private val storageFile: File, - private val nowSec: () -> Long = { System.currentTimeMillis() / 1000 }, + private val storageFile: Path, + private val nowSec: () -> Long = { TimeUtils.now() }, + private val fileSystem: FileSystem = platformFileSystem, ) { + /** Takes the platform's own path string, so callers need no okio of their own. */ + constructor( + storagePath: String, + nowSec: () -> Long = { TimeUtils.now() }, + ) : this(storagePath.toPath(), nowSec) + /** * `encodeDefaults = true` so this writes the same bytes Jackson did — kotlinx * omits a value equal to its default, which would silently drop `"version":1` @@ -214,7 +224,7 @@ class ScheduledPostStore( */ suspend fun publishNow( id: String, - nowSec: Long = System.currentTimeMillis() / 1000, + nowSec: Long = TimeUtils.now(), ): Boolean = mutex.withLock { ensureLoaded() @@ -336,8 +346,8 @@ class ScheduledPostStore( private fun reloadFromDiskLocked() { val fromDisk = try { - if (storageFile.exists() && storageFile.length() > 0) { - json.decodeFromString(storageFile.readText()).posts.toMutableList() + if (hasContent()) { + json.decodeFromString(readText()).posts.toMutableList() } else { // File vanished — treat as no external state; keep current in-memory. return @@ -354,8 +364,8 @@ class ScheduledPostStore( if (loaded) return posts = try { - if (storageFile.exists() && storageFile.length() > 0) { - json.decodeFromString(storageFile.readText()).posts.toMutableList() + if (hasContent()) { + json.decodeFromString(readText()).posts.toMutableList() } else { mutableListOf() } @@ -367,7 +377,7 @@ class ScheduledPostStore( // Secure a pre-existing file up front (an older build may have left it at the // 0644 umask default) so it's owner-only even if nothing mutates the store // this session. - if (storageFile.exists()) restrictToOwner(storageFile) + if (storeFileExists()) restrictToOwner(storageFile) var dirty = purgeStale(nowSec()) // Recover claims stranded by a crash-mid-publish so they aren't lost forever. if (recoverStuckClaimsLocked(nowSec())) dirty = true @@ -412,49 +422,58 @@ class ScheduledPostStore( private fun persist() { val snapshot = posts.toList() _flow.value = snapshot - storageFile.parentFile?.mkdirs() - val tmp = File(storageFile.parentFile, storageFile.name + ".tmp") + val tmp = "$storageFile.tmp".toPath() try { - tmp.writeText(json.encodeToString(ScheduledPostFile(version = 1, posts = snapshot))) - // Restrict to owner-only BEFORE the rename so the store is never briefly + storageFile.parent?.let { fileSystem.createDirectories(it) } + fileSystem.write(tmp) { writeUtf8(json.encodeToString(ScheduledPostFile(version = 1, posts = snapshot))) } + // Restrict to owner-only BEFORE the rename, so the store file itself is never // world-readable. It holds pre-signed events + the account's pubkey, which - // must not leak to other local users on a shared machine. + // must not leak to other local users on a shared machine. (The tmp file is + // created at the umask default and narrowed right after its write; the + // directory around it is owner-only on every host that uses this store.) restrictToOwner(tmp) - if (!tmp.renameTo(storageFile)) { - if (!storageFile.delete()) { - Log.w(TAG) { "Failed to delete existing $storageFile before rename retry" } - } - if (!tmp.renameTo(storageFile)) { - Log.e(TAG) { "Failed to rename $tmp to $storageFile" } - if (!tmp.delete()) { - Log.w(TAG) { "Failed to clean up temp file $tmp after rename failure" } - } - } - } + // Replaces an existing target, including on Windows, where a plain rename + // onto an existing file fails. + fileSystem.atomicMove(tmp, storageFile) } catch (e: Exception) { Log.e(TAG, "Failed to persist scheduled posts to $storageFile", e) - if (!tmp.delete()) { - Log.w(TAG) { "Failed to clean up temp file $tmp after persist exception" } + try { + fileSystem.delete(tmp) + } catch (cleanupError: Exception) { + Log.w(TAG, "Failed to clean up temp file $tmp after persist exception", cleanupError) } } } /** - * Best-effort chmod to `0600` (owner read/write only). No-op on filesystems - * without POSIX permissions (e.g. Windows), where confidentiality relies on the - * per-user home directory instead. Never throws. + * Permissions are a property of the real disk, so a caller that injects another + * [FileSystem] (an in-memory fake) gets no chmod aimed at a same-named real path. */ - private fun restrictToOwner(file: File) { - try { - Files.setPosixFilePermissions( - file.toPath(), - setOf(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE), - ) - } catch (e: Exception) { - Log.w(TAG) { "Could not restrict permissions on $file: ${e.message}" } - } + private fun restrictToOwner(path: Path) { + if (fileSystem === platformFileSystem) restrictFileToOwner(path, TAG) } + /** + * The store file's metadata, or null when there is none. A directory is not a store + * file. A symlink counts: okio's metadata does not follow links, so its size is the + * link's, and reading it (which does follow) decides whether there is anything in it. + * A stat that fails (EACCES on iOS, where File.exists() used to answer false) is "no + * file" rather than an exception out of the store. + */ + private fun storeFileMetadata() = + try { + fileSystem.metadataOrNull(storageFile)?.takeIf { it.isRegularFile || it.symlinkTarget != null } + } catch (e: Exception) { + Log.w(TAG, "Could not stat $storageFile", e) + null + } + + private fun storeFileExists(): Boolean = storeFileMetadata() != null + + private fun hasContent(): Boolean = (storeFileMetadata()?.size ?: 0L) > 0L + + private fun readText(): String = fileSystem.read(storageFile) { readUtf8() } + companion object { private const val TAG = "ScheduledPostStore" const val FILE_NAME = "scheduled_posts.json" diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostWorkGate.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostWorkGate.kt similarity index 100% rename from commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostWorkGate.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostWorkGate.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/DataStoreSearchHistoryStorage.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/DataStoreSearchHistoryStorage.kt similarity index 54% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/DataStoreSearchHistoryStorage.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/DataStoreSearchHistoryStorage.kt index 6820899ad4..80f9de7353 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/DataStoreSearchHistoryStorage.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/DataStoreSearchHistoryStorage.kt @@ -18,38 +18,27 @@ * 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.preferences +package com.vitorpamplona.amethyst.commons.search -import android.content.Context import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.PreferenceDataStoreFactory import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey -import com.vitorpamplona.amethyst.commons.search.SearchHistoryStorage import kotlinx.coroutines.flow.first -import java.io.File -import java.util.concurrent.ConcurrentHashMap /** - * Where Android keeps the search history: one `datastore/search_history.preferences_pb` file. + * [SearchHistoryStorage] on a DataStore of its own — `search_history` under the + * front end's datastore directory. * - * The history itself — what it holds, how much of it, in what order — is - * [com.vitorpamplona.amethyst.commons.search.SearchHistory] in commons, shared with Desktop. This - * is only the two strings and the file they live in. - * - * Device-global rather than per-account, like the drawer's collapse state beside it: what you - * searched for is a property of this phone, and it is never published to a relay. + * Takes the store rather than a directory: the search screen is rebuilt per + * seeded query, and DataStore refuses a second live instance on a path that + * already has one, so who owns the instance matters. Handing it in means the + * caller's store holder is the single registry rather than this class keeping a + * private one of its own. */ class DataStoreSearchHistoryStorage( - private val filesDir: File, + private val store: DataStore, ) : SearchHistoryStorage { - constructor(context: Context) : this(context.applicationContext.filesDir) - - // DataStore v1 throws if two instances are ever active on the same file, and the search screen - // is rebuilt per seeded query, so the store is shared per absolute path across the process. - private val store: DataStore get() = dataStoreFor(File(filesDir, "datastore/search_history.preferences_pb")) - override suspend fun read(key: String): String? = store.data.first()[stringPreferencesKey(key)] override suspend fun write( @@ -62,11 +51,6 @@ class DataStoreSearchHistoryStorage( } companion object { - private val stores = ConcurrentHashMap>() - - private fun dataStoreFor(file: File): DataStore = - stores.computeIfAbsent(file.absolutePath) { - PreferenceDataStoreFactory.create(produceFile = { file }) - } + const val FILE_NAME = "search_history" } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/KindRegistry.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/KindRegistry.kt index 6a61907b8c..fb790f0858 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/KindRegistry.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/KindRegistry.kt @@ -27,29 +27,29 @@ import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.SoftwareApplicationEvent import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent -import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import com.vitorpamplona.quartz.nip51Lists.starterPack.StarterPackEvent import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent -import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent +import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarCollectionEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent -import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent +import com.vitorpamplona.quartz.nip54Wiki.WikiArticleEvent import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent import com.vitorpamplona.quartz.nip5aStaticWebsites.NamedSiteEvent import com.vitorpamplona.quartz.nip5aStaticWebsites.RootSiteEvent import com.vitorpamplona.quartz.nip5dNapplets.NamedNappletEvent import com.vitorpamplona.quartz.nip5dNapplets.RootNappletEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent -import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent +import com.vitorpamplona.quartz.nip71Video.AddressableNormalVideoEvent +import com.vitorpamplona.quartz.nip71Video.AddressableShortVideoEvent import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent import com.vitorpamplona.quartz.nip71Video.VideoShortEvent -import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent @@ -93,7 +93,7 @@ object KindRegistry { val aliases: Map> = mapOf( "note" to listOf(TextNoteEvent.KIND), - "article" to listOf(LongTextNoteEvent.KIND), + "article" to listOf(LongFormContentEvent.KIND), // No `repost` or `profile` here on purpose: both name kinds in // [RenderableKinds.NEVER_IN_RESULTS], so the chip drew and the list stayed empty // forever. `kind:profile` was the worse of the two — it pinned the scope to Notes, @@ -101,7 +101,7 @@ object KindRegistry { "channel" to listOf(ChannelCreateEvent.KIND, ChannelMetadataEvent.KIND), "live" to listOf(LiveActivitiesEvent.KIND, MeetingSpaceEvent.KIND, MeetingRoomEvent.KIND), "community" to listOf(CommunityDefinitionEvent.KIND), - "wiki" to listOf(WikiNoteEvent.KIND), + "wiki" to listOf(WikiArticleEvent.KIND), "classified" to listOf(ClassifiedsEvent.KIND), "geocache" to listOf(GeocacheListingEvent.KIND), "highlight" to listOf(HighlightEvent.KIND), @@ -109,7 +109,7 @@ object KindRegistry { // The kinds the app's own feeds are windows onto, so a screen that seeds its search // gets a chip a reader recognises instead of a bare number. "picture" to listOf(PictureEvent.KIND), - "video" to listOf(VideoNormalEvent.KIND, VideoShortEvent.KIND, VideoHorizontalEvent.KIND, VideoVerticalEvent.KIND), + "video" to listOf(VideoNormalEvent.KIND, VideoShortEvent.KIND, AddressableNormalVideoEvent.KIND, AddressableShortVideoEvent.KIND), "nsite" to listOf(RootSiteEvent.KIND, NamedSiteEvent.KIND), "napplet" to listOf(RootNappletEvent.KIND, NamedNappletEvent.KIND), "emoji" to listOf(EmojiPackEvent.KIND), @@ -123,15 +123,15 @@ object KindRegistry { "podcast" to listOf(PodcastMetadataEvent.KIND), "episode" to listOf(Podcasting20EpisodeEvent.KIND), "software" to listOf(SoftwareApplicationEvent.KIND), - "followpack" to listOf(FollowListEvent.KIND), + "followpack" to listOf(StarterPackEvent.KIND), "zappoll" to listOf(ZapPollEvent.KIND), // `video` is every video kind; these name the two the app gives their own feeds. - "stories" to listOf(VideoHorizontalEvent.KIND, VideoVerticalEvent.KIND), - "short" to listOf(VideoVerticalEvent.KIND), - "longvideo" to listOf(VideoHorizontalEvent.KIND), + "stories" to listOf(AddressableNormalVideoEvent.KIND, AddressableShortVideoEvent.KIND), + "short" to listOf(AddressableShortVideoEvent.KIND), + "longvideo" to listOf(AddressableNormalVideoEvent.KIND), // The slots on a calendar, and the calendars that collect them. "calendar" to listOf(CalendarTimeSlotEvent.KIND, CalendarDateSlotEvent.KIND), - "calendarset" to listOf(CalendarEvent.KIND), + "calendarset" to listOf(CalendarCollectionEvent.KIND), ) val pseudoKinds: Set = setOf("reply", "media") @@ -148,11 +148,11 @@ object KindRegistry { val presets: Map = mapOf( "Notes" to ContentPreset(kinds = listOf(TextNoteEvent.KIND)), - "Articles" to ContentPreset(kinds = listOf(LongTextNoteEvent.KIND)), + "Articles" to ContentPreset(kinds = listOf(LongFormContentEvent.KIND)), "Media" to ContentPreset(pseudoKind = "media"), "Channels" to ContentPreset(kinds = listOf(ChannelCreateEvent.KIND, ChannelMetadataEvent.KIND)), "Communities" to ContentPreset(kinds = listOf(CommunityDefinitionEvent.KIND)), - "Wiki" to ContentPreset(kinds = listOf(WikiNoteEvent.KIND)), + "Wiki" to ContentPreset(kinds = listOf(WikiArticleEvent.KIND)), "Polls" to ContentPreset(kinds = listOf(PollEvent.KIND)), ) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/RenderableKinds.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/RenderableKinds.kt index b5be14e927..71636dfc37 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/RenderableKinds.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/RenderableKinds.kt @@ -33,12 +33,12 @@ import com.vitorpamplona.quartz.experimental.nns.NNSEvent import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent -import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent @@ -47,18 +47,18 @@ import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent import com.vitorpamplona.quartz.nip51Lists.PinListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent -import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent -import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent +import com.vitorpamplona.quartz.nip51Lists.followSet.FollowSetEvent +import com.vitorpamplona.quartz.nip51Lists.starterPack.StarterPackEvent import com.vitorpamplona.quartz.nip51Lists.videoCurationSet.VideoCurationSetEvent import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent -import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent +import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarCollectionEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent -import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import com.vitorpamplona.quartz.nip54Wiki.WikiArticleEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapRequestEvent import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent import com.vitorpamplona.quartz.nip5aStaticWebsites.NamedSiteEvent import com.vitorpamplona.quartz.nip5aStaticWebsites.RootSiteEvent @@ -66,17 +66,17 @@ import com.vitorpamplona.quartz.nip5dNapplets.NamedNappletEvent import com.vitorpamplona.quartz.nip5dNapplets.RootNappletEvent import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent -import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent +import com.vitorpamplona.quartz.nip71Video.AddressableNormalVideoEvent +import com.vitorpamplona.quartz.nip71Video.AddressableShortVideoEvent import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent import com.vitorpamplona.quartz.nip71Video.VideoShortEvent -import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent -import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent +import com.vitorpamplona.quartz.nip94FileMetadata.FileMetadataEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent @@ -141,15 +141,15 @@ object RenderableKinds { setOf( MetadataEvent.KIND, ContactListEvent.KIND, - DeletionEvent.KIND, + DeletionRequestEvent.KIND, RepostEvent.KIND, ReactionEvent.KIND, GenericRepostEvent.KIND, RequestToVanishEvent.KIND, - FileHeaderEvent.KIND, + FileMetadataEvent.KIND, CommunityPostApprovalEvent.KIND, - LnZapRequestEvent.KIND, - LnZapEvent.KIND, + ZapRequestEvent.KIND, + ZapReceiptEvent.KIND, AppSpecificDataEvent.KIND, ) @@ -166,8 +166,8 @@ object RenderableKinds { TextNoteEvent.KIND, CommentEvent.KIND, PublicMessageEvent.KIND, - LongTextNoteEvent.KIND, - WikiNoteEvent.KIND, + LongFormContentEvent.KIND, + WikiArticleEvent.KIND, HighlightEvent.KIND, CodeSnippetEvent.KIND, NipTextEvent.KIND, @@ -175,8 +175,8 @@ object RenderableKinds { PictureEvent.KIND, VideoNormalEvent.KIND, VideoShortEvent.KIND, - VideoHorizontalEvent.KIND, - VideoVerticalEvent.KIND, + AddressableNormalVideoEvent.KIND, + AddressableShortVideoEvent.KIND, // A video list has a card of its own, so by the rule the omission list states — in // ALL when Amethyst can render it — it belongs in the window. Its title and // description are what Quartz indexes, which is exactly what a reader would search. @@ -202,15 +202,15 @@ object RenderableKinds { MeetingRoomEvent.KIND, // communities and lists CommunityDefinitionEvent.KIND, - PeopleListEvent.KIND, - FollowListEvent.KIND, + FollowSetEvent.KIND, + StarterPackEvent.KIND, BookmarkListEvent.KIND, OldBookmarkListEvent.KIND, PinListEvent.KIND, // calendar CalendarDateSlotEvent.KIND, CalendarTimeSlotEvent.KIND, - CalendarEvent.KIND, + CalendarCollectionEvent.KIND, // marketplace, apps, code and sites ClassifiedsEvent.KIND, SoftwareApplicationEvent.KIND, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResultSorter.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResultSorter.kt index 0c1884976f..e1e4734441 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResultSorter.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResultSorter.kt @@ -25,7 +25,7 @@ import com.vitorpamplona.amethyst.commons.util.KmpLock import com.vitorpamplona.amethyst.commons.util.sortedBySnapshot import com.vitorpamplona.amethyst.commons.util.withLock import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent import com.vitorpamplona.quartz.utils.currentTimeSeconds object SearchResultSorter { @@ -66,7 +66,7 @@ object SearchResultSorter { } // Article title boost - if (event is LongTextNoteEvent) { + if (event is LongFormContentEvent) { val title = event.title()?.lowercase() if (title != null) { if (title.contains(query)) { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/nwc/NwcPaymentTracker.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/nwc/NwcPaymentTracker.kt index 94ba2218d7..697e71a1cd 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/nwc/NwcPaymentTracker.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/nwc/NwcPaymentTracker.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.commons.service.nwc import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcResponseEvent import com.vitorpamplona.quartz.utils.concurrent.ConcurrentMap import kotlin.concurrent.atomics.AtomicInt import kotlin.concurrent.atomics.ExperimentalAtomicApi @@ -60,7 +60,7 @@ class NwcPaymentTracker { class PendingRequest( val expectedServicePubkey: HexKey, val zappedNote: Note?, - val onResponse: suspend (LnZapPaymentResponseEvent) -> Unit, + val onResponse: suspend (NwcResponseEvent) -> Unit, ) { // Number of kind-23195 events that targeted this request id with the // right `e` tag but the wrong author. Surfaced to the UI when a @@ -74,7 +74,7 @@ class NwcPaymentTracker { /** * Registers a pending payment request. * - * @param requestId Event ID of the LnZapPaymentRequestEvent + * @param requestId Event ID of the NwcRequestEvent * @param expectedServicePubkey Wallet service pubkey from the request's `p` tag * @param zappedNote The note being zapped (null if not a zap payment) * @param onResponse Callback invoked when response arrives @@ -83,7 +83,7 @@ class NwcPaymentTracker { requestId: HexKey, expectedServicePubkey: HexKey, zappedNote: Note?, - onResponse: suspend (LnZapPaymentResponseEvent) -> Unit, + onResponse: suspend (NwcResponseEvent) -> Unit, ) { awaitingRequests[requestId] = PendingRequest(expectedServicePubkey, zappedNote, onResponse) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWPolicy.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWPolicy.kt index d4c0da8079..042127051c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWPolicy.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWPolicy.kt @@ -30,13 +30,13 @@ import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.nip56Reports.ReportEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapRequestEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent @@ -116,7 +116,7 @@ object PoWPolicy { setOf( MetadataEvent.KIND, ContactListEvent.KIND, - LnZapRequestEvent.KIND, // blocks the invoice fetch + ZapRequestEvent.KIND, // blocks the invoice fetch OtsEvent.KIND, // machine-generated companion events DraftWrapEvent.KIND, // re-signed on a 1s debounce while typing ) @@ -141,7 +141,7 @@ object PoWPolicy { TextNoteEvent.KIND -> PoWCategory.SHORT_NOTES CommentEvent.KIND -> PoWCategory.COMMENTS ReportEvent.KIND -> PoWCategory.REPORTS - LongTextNoteEvent.KIND, HighlightEvent.KIND -> PoWCategory.LONG_FORM + LongFormContentEvent.KIND, HighlightEvent.KIND -> PoWCategory.LONG_FORM VoiceEvent.KIND, VoiceReplyEvent.KIND -> PoWCategory.VOICE RepostEvent.KIND, GenericRepostEvent.KIND -> PoWCategory.REPOSTS ReactionEvent.KIND -> PoWCategory.REACTIONS diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorPreferencesPort.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/tor/TorPreferencesPort.kt similarity index 75% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorPreferencesPort.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/tor/TorPreferencesPort.kt index 3188f15f81..3710aec24e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorPreferencesPort.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/tor/TorPreferencesPort.kt @@ -18,15 +18,17 @@ * 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.tor +package com.vitorpamplona.amethyst.commons.tor -import com.vitorpamplona.amethyst.commons.tor.TorType import kotlinx.coroutines.flow.StateFlow /** - * The slice of `TorSharedPreferences` that [TorManager] depends on. Extracted so the - * manager can be unit-tested without an Android `Context` (and without DataStore). - * Production wires `TorSharedPreferences`; tests wire an in-memory fake. + * The slice of `TorSettingsStore` that each front end's Tor manager depends on. + * + * Extracted so a manager can be unit-tested without a real store behind it: + * production wires `TorSettingsStore`, tests wire an in-memory fake. It lives + * here rather than beside Android's `TorManager` because the store that + * satisfies it is shared, and a desktop or CLI manager needs the same slice. */ interface TorPreferencesPort { val torType: StateFlow diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorSettingsFlow.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/tor/TorSettingsFlow.kt similarity index 97% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorSettingsFlow.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/tor/TorSettingsFlow.kt index 89f297fedc..e7d3e43769 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorSettingsFlow.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/tor/TorSettingsFlow.kt @@ -18,11 +18,9 @@ * 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.tor +package com.vitorpamplona.amethyst.commons.tor import androidx.compose.runtime.Stable -import com.vitorpamplona.amethyst.commons.tor.TorSettings -import com.vitorpamplona.amethyst.commons.tor.TorType import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/OwnerOnlyFile.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/OwnerOnlyFile.kt new file mode 100644 index 0000000000..05934c55ca --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/OwnerOnlyFile.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.util + +import okio.Path + +/** + * Restricts the file at [path] to owner read/write (`0600`), best-effort. Never throws. + * + * Silent where the filesystem has no POSIX permissions (Windows), where the user profile's + * ACLs apply instead; logs under [tag] when a POSIX filesystem refuses. + */ +expect fun restrictFileToOwner( + path: Path, + tag: String, +) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/CordnGroupDraft.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/CordnGroupDraft.kt new file mode 100644 index 0000000000..da1f281e75 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/CordnGroupDraft.kt @@ -0,0 +1,148 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.viewmodels + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import com.vitorpamplona.amethyst.commons.cordn.CordnCoordinatorDiscovery +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** + * A cordn group being filled in, across the screens that fill it in. + * + * The form outgrew one screen. Choosing who is in the group is a search, a + * result list and a roster with a per-person admin toggle -- a screen's worth + * of surface, and the thing every later step depends on, because which + * coordinator can serve the group is decided by which of these people it holds + * a KeyPackage for. + * + * Held in a ViewModel rather than in `remember` because a Compose Navigation + * destination is disposed when you navigate off it: a half-typed name and a + * finished discovery run would both be gone on the way back from picking + * people. Scoped to the Activity by its caller (the same thing the chess lobby + * and board do) so both screens see one draft, keyed per account so switching + * accounts cannot inherit another one's roster. + * + * Deliberately headless and free of protocol: it holds what the user has typed + * and nothing derived from a coordinator. Coverage -- who a coordinator can + * actually reach -- is a live query each screen makes for itself, because + * caching a "cannot be reached" here would outlive the truth of it. + */ +class CordnGroupDraft : ViewModel() { + var name by mutableStateOf("") + var description by mutableStateOf("") + + /** + * Who the group is for, in the order they were added. + * + * Ordered rather than a set so the roster does not reshuffle under the + * user's finger as they build it, and so the invitations go out in the + * order they were asked for. + */ + var roster by mutableStateOf>(emptyList()) + private set + + /** + * Members who may also add and remove, NOT counting the creator. + * + * The creator is added back when the metadata is built: `spec/01.md` §5.3 + * makes a non-empty admin list permanent, so one that left out the person + * creating the group would produce a group nobody present could administer. + */ + var coAdmins by mutableStateOf>(emptySet()) + private set + + /** Empty `admin_pubkeys`, which §5.3 makes egalitarian permanently. */ + var egalitarian by mutableStateOf(false) + + /** The chosen coordinator, or null while the manual fields are in use. */ + var selected by mutableStateOf(null) + + /** + * Whether the user chose the coordinator themselves. + * + * Once they have, the best-covering one stops being offered: a selection + * that moved on its own after the person had made one would be the screen + * overruling them. + */ + var userPicked by mutableStateOf(false) + + var pubKeyInput by mutableStateOf("") + var relaysInput by mutableStateOf("") + + /** + * How the coordinator section was left. + * + * Here rather than in the screen because the screen does not survive a + * trip to a profile, and the coordinator rows are the one place on this + * form you can tap through to one. Coming back to a collapsed picker with + * the discovery thrown away reads as having lost your work, and the + * discovery is a ~30-second round trip to every relay -- the most + * expensive thing on the page to have to do twice. + */ + var coordinatorOpen by mutableStateOf(false) + + /** The last discovery run, kept so returning does not mean running it again. */ + var discovered by mutableStateOf(null) + + var showStale by mutableStateOf(false) + var showAllLive by mutableStateOf(false) + + fun add(pubKey: HexKey) { + if (pubKey !in roster) roster = roster + pubKey + } + + fun remove(pubKey: HexKey) { + roster = roster - pubKey + coAdmins = coAdmins - pubKey + } + + fun toggleAdmin(pubKey: HexKey) { + coAdmins = if (pubKey in coAdmins) coAdmins - pubKey else coAdmins + pubKey + } + + /** + * `admin_pubkeys` for the group about to be created. + * + * Empty in egalitarian mode and the creator plus their choices otherwise -- + * never the choices alone, for the reason on [coAdmins]. + */ + fun adminPubKeys(creator: HexKey): List = if (egalitarian) emptyList() else listOf(creator) + coAdmins.filterNot { it == creator } + + /** Forgets the draft, so the next new group does not start as this one. */ + fun clear() { + name = "" + description = "" + roster = emptyList() + coAdmins = emptySet() + egalitarian = false + selected = null + userPicked = false + pubKeyInput = "" + relaysInput = "" + coordinatorOpen = false + discovered = null + showStale = false + showAllLive = false + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/LiveStreamTopZappersViewModel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/LiveStreamTopZappersViewModel.kt index 9c698f36d9..17e06618e1 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/LiveStreamTopZappersViewModel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/LiveStreamTopZappersViewModel.kt @@ -30,8 +30,8 @@ import com.vitorpamplona.amethyst.commons.nip53LiveActivities.LiveActivityTopZap import com.vitorpamplona.amethyst.commons.nip53LiveActivities.TopZapperEntry import com.vitorpamplona.amethyst.commons.nip53LiveActivities.ZapContribution import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapRequestEvent import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent import com.vitorpamplona.quartz.utils.toLongValue import kotlinx.coroutines.Dispatchers @@ -163,7 +163,7 @@ class LiveStreamTopZappersViewModel( private fun contributionFromStreamZap(note: Note): ZapContribution? = when (val ev = note.event) { - is LnZapEvent -> { + is ZapReceiptEvent -> { val request = ev.zapRequest ?: return null val sats = ev.amount()?.toLongValue() ?: return null ZapContribution(note.idHex, request.pubKey, request.isAnonTagged(), sats) @@ -179,12 +179,12 @@ class LiveStreamTopZappersViewModel( zapRequestNote: Note, receiptNote: Note?, ): ZapContribution? { - val receiptEv = receiptNote?.event as? LnZapEvent ?: return null - val request = zapRequestNote.event as? LnZapRequestEvent ?: return null + val receiptEv = receiptNote?.event as? ZapReceiptEvent ?: return null + val request = zapRequestNote.event as? ZapRequestEvent ?: return null val sats = receiptEv.amount()?.toLongValue() ?: return null return ZapContribution(receiptNote.idHex, request.pubKey, request.isAnonTagged(), sats) } } /** True for both public anonymous and NIP-57 private zaps (any `anon` tag, empty or encrypted). */ -private fun LnZapRequestEvent.isAnonTagged(): Boolean = tags.any { it.isNotEmpty() && it[0] == "anon" } +private fun ZapRequestEvent.isAnonTagged(): Boolean = tags.any { it.isNotEmpty() && it[0] == "anon" } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomZapsState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomZapsState.kt index ce0267fb9e..09af0fb088 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomZapsState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomZapsState.kt @@ -21,7 +21,7 @@ package com.vitorpamplona.amethyst.commons.viewmodels import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent import com.vitorpamplona.quartz.utils.toLongValue @@ -49,13 +49,13 @@ data class RoomZap( ) { companion object { /** - * Project a kind-9735 [LnZapEvent] into a [RoomZap]. The + * Project a kind-9735 [ZapReceiptEvent] into a [RoomZap]. The * source pubkey is the embedded kind-9734 request author — * the actual zapper — NOT the receipt's own `pubKey`, which * is the lnurl service provider's signing key. Falls back to * the receipt issuer only when the request can't be parsed. */ - fun from(event: LnZapEvent): RoomZap = + fun from(event: ZapReceiptEvent): RoomZap = RoomZap( eventId = event.id, sourcePubkey = event.zapRequest?.pubKey ?: event.pubKey, @@ -95,7 +95,7 @@ class RoomZapsAggregator { /** Apply one zap and return the post-evict snapshot. */ fun apply( - event: LnZapEvent, + event: ZapReceiptEvent, nowSec: Long, windowSec: Long, ): Map> = apply(RoomZap.from(event), nowSec, windowSec) diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/PrivateNoteFactoryTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/PrivateNoteFactoryTest.kt index 29e8d2617e..b18e2da18e 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/PrivateNoteFactoryTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/PrivateNoteFactoryTest.kt @@ -26,7 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip01Core.tags.people.pTags -import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip17Dm.NIP17Factory import kotlinx.coroutines.test.runTest @@ -85,12 +85,12 @@ class PrivateNoteFactoryTest { val result = NIP17Factory().createDeletionNIP17( - template = DeletionEvent.build(listOf(reaction)), + template = DeletionRequestEvent.build(listOf(reaction)), to = listOf(bobSigner.pubKey), signer = aliceSigner, ) - assertEquals(DeletionEvent.KIND, result.msg.kind) + assertEquals(DeletionRequestEvent.KIND, result.msg.kind) assertEquals( setOf(aliceSigner.pubKey, bobSigner.pubKey), result.wraps.mapNotNull { it.recipientPubKey() }.toSet(), @@ -101,7 +101,7 @@ class PrivateNoteFactoryTest { val rumor = bobWrap.unwrapAndUnsealOrNull(bobSigner) assertNotNull(rumor, "recipient must be able to unwrap and unseal the deletion") - assertEquals(DeletionEvent.KIND, rumor.kind) + assertEquals(DeletionRequestEvent.KIND, rumor.kind) assertEquals(aliceSigner.pubKey, rumor.pubKey, "deletions only apply when the author matches") assertTrue(rumor.sig.isEmpty(), "the deletion travels as an unsigned rumor") assertTrue( diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ZapActionsTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ZapActionsTest.kt index eaa21809b1..e738de8961 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ZapActionsTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ZapActionsTest.kt @@ -27,7 +27,7 @@ import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent import com.vitorpamplona.quartz.utils.Secp256k1Instance import kotlinx.coroutines.test.runTest import kotlin.test.Test @@ -108,7 +108,7 @@ class ZapActionsTest { amountMillisats = 21_000L, inboxRelays = setOf(relay), comment = "thanks!", - zapType = LnZapEvent.ZapType.PUBLIC, + zapType = ZapReceiptEvent.ZapType.PUBLIC, lnurl = "lnurl1example", ) @@ -133,7 +133,7 @@ class ZapActionsTest { recipientPubkey = recipientPubkey, amountMillisats = 1_000L, inboxRelays = setOf(relay), - zapType = LnZapEvent.ZapType.ANONYMOUS, + zapType = ZapReceiptEvent.ZapType.ANONYMOUS, ) assertTrue( @@ -152,7 +152,7 @@ class ZapActionsTest { recipientPubkey = recipientPubkey, amountMillisats = 1_000L, inboxRelays = setOf(relay), - zapType = LnZapEvent.ZapType.PRIVATE, + zapType = ZapReceiptEvent.ZapType.PRIVATE, ) // NIP-57 PRIVATE zaps use an ephemeral key derived from diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/KeyedCordnBlobCipherTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/KeyedCordnBlobCipherTest.kt new file mode 100644 index 0000000000..cb4449fc72 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/KeyedCordnBlobCipherTest.kt @@ -0,0 +1,136 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn + +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertFails +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +/** + * The blob cipher every platform without an OS key store uses. + * + * What is worth asserting is not that ChaCha20-Poly1305 works — quartz tests + * that — but the three things this wrapper decides: that the nonce is fresh + * per blob, that a blob is authenticated rather than merely scrambled, and + * that the plaintext is nowhere in the output. The first matters most: one key + * covers every blob, and a repeated nonce leaks the XOR of two `MlsGroupState` + * serialisations, which is epoch secrets. + */ +class KeyedCordnBlobCipherTest { + private val key = ByteArray(KeyedCordnBlobCipher.KEY_LENGTH) { it.toByte() } + private val cipher = KeyedCordnBlobCipher(key) + private val blob = "ratchet tree and epoch secrets".encodeToByteArray() + + @Test + fun `a blob round-trips`() { + assertContentEquals(blob, cipher.decrypt(cipher.encrypt(blob))) + } + + @Test + fun `an empty blob round-trips`() { + // A group with nothing saved yet writes one, and a frame that is all + // nonce and tag has to survive the trip. + assertContentEquals(ByteArray(0), cipher.decrypt(cipher.encrypt(ByteArray(0)))) + } + + @Test + fun `the same blob encrypted twice never repeats a nonce`() { + val first = cipher.encrypt(blob) + val second = cipher.encrypt(blob) + + val nonceA = first.copyOfRange(0, KeyedCordnBlobCipher.NONCE_LENGTH) + val nonceB = second.copyOfRange(0, KeyedCordnBlobCipher.NONCE_LENGTH) + + assertFalse(nonceA.contentEquals(nonceB), "a repeated nonce leaks the XOR of two group states") + assertFalse(first.contentEquals(second)) + } + + @Test + fun `the plaintext does not appear in the output`() { + val sealed = cipher.encrypt(blob) + + assertFalse(sealed.decodeToString().contains("ratchet tree")) + assertNotEquals(blob.size, sealed.size) + } + + @Test + fun `a flipped byte is refused rather than decrypted`() { + val sealed = cipher.encrypt(blob) + sealed[sealed.size - 1] = (sealed[sealed.size - 1].toInt() xor 0x01).toByte() + + // An MlsGroupState cannot be re-derived from anywhere, so a silently + // corrupted one is worse than a loud failure. + assertFails { cipher.decrypt(sealed) } + } + + @Test + fun `a flipped nonce byte is refused`() { + val sealed = cipher.encrypt(blob) + sealed[0] = (sealed[0].toInt() xor 0x01).toByte() + + assertFails { cipher.decrypt(sealed) } + } + + @Test + fun `another key cannot open it`() { + val sealed = cipher.encrypt(blob) + val other = KeyedCordnBlobCipher(ByteArray(KeyedCordnBlobCipher.KEY_LENGTH) { (it + 1).toByte() }) + + assertFails { other.decrypt(sealed) } + } + + @Test + fun `a truncated blob is refused with a message that says so`() { + // A short blob throws either way: the AEAD underneath rejects a + // ciphertext shorter than its tag, and slicing 12 nonce bytes out of a + // 0-byte array is an illegal range. So the guard buys no new failure — + // what it buys is the reason. Asserting the message is therefore the + // only way to pin it, and the reason is worth pinning: "fromIndex(12) + // > toIndex(0)" from inside a copyOfRange reads as a bug in Amethyst, + // while the size of the file that is not a blob points at the file. + val short = assertFailsWith { cipher.decrypt(ByteArray(KeyedCordnBlobCipher.NONCE_LENGTH)) } + val empty = assertFailsWith { cipher.decrypt(ByteArray(0)) } + + assertTrue(short.message!!.contains("not a cordn blob"), "unhelpful: ${short.message}") + assertTrue(empty.message!!.contains("not a cordn blob"), "unhelpful: ${empty.message}") + } + + @Test + fun `a key of the wrong length is refused at construction`() { + // Failing here rather than at the first encrypt means a misconfigured + // front end cannot get as far as writing blobs under a 16-byte key. + assertFails { KeyedCordnBlobCipher(ByteArray(16)) } + assertFails { KeyedCordnBlobCipher(ByteArray(0)) } + } + + @Test + fun `a fresh key is the right length and not a constant`() { + val a = KeyedCordnBlobCipher.newKey() + val b = KeyedCordnBlobCipher.newKey() + + assertTrue(a.size == KeyedCordnBlobCipher.KEY_LENGTH) + assertFalse(a.contentEquals(b)) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/ThreadAssemblerTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/ThreadAssemblerTest.kt index 5bed98dc6f..04c4a0cacd 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/ThreadAssemblerTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/ThreadAssemblerTest.kt @@ -29,7 +29,7 @@ import com.vitorpamplona.quartz.nip01Core.hints.HintIndexer import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -75,7 +75,7 @@ class ThreadAssemblerTest { private val zap = Note(zapId).apply { - event = LnZapEvent(zapId, authorKey, 1001, arrayOf(arrayOf("e", originalId), arrayOf("p", authorKey)), "", sig) + event = ZapReceiptEvent(zapId, authorKey, 1001, arrayOf(arrayOf("e", originalId), arrayOf("p", authorKey)), "", sig) replyTo = listOf(original) } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/ChatPreviewTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/ChatPreviewTest.kt index 35c6013608..b831f2d452 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/ChatPreviewTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/ChatPreviewTest.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.commons.model.privateChats -import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip04Dm.messages.EncryptedDmEvent import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent import com.vitorpamplona.quartz.nip51Lists.videoCurationSet.VideoCurationSetEvent @@ -37,10 +37,10 @@ class ChatPreviewTest { private val ciphertext = "0tyoSVovKSK9uDKLUVMs137TD0b+vz+fjoJN+gG3Bk4=?iv=6r6yPQ==" - private fun privateDm( + private fun encryptedDm( author: String = other, recipient: String = me, - ) = PrivateDmEvent( + ) = EncryptedDmEvent( id = "dm".padEnd(64, '0'), pubKey = author, createdAt = 1_000, @@ -63,7 +63,7 @@ class ChatPreviewTest { @Test fun nip04DmCarriesCiphertext() { - assertTrue(privateDm().hasEncryptedContent()) + assertTrue(encryptedDm().hasEncryptedContent()) } @Test @@ -129,14 +129,14 @@ class ChatPreviewTest { fun decryptedDmRendersThePlaintext() { assertEquals( ChatPreview.Body("hi!"), - chatPreviewOf(privateDm(), decrypted = "hi!", myPubKey = me, canDecrypt = true), + chatPreviewOf(encryptedDm(), decrypted = "hi!", myPubKey = me, canDecrypt = true), ) } /** The regression under test: the raw base64 blob must never become the preview. */ @Test fun pendingDmNeverFallsThroughToCiphertext() { - val preview = chatPreviewOf(privateDm(), decrypted = null, myPubKey = me, canDecrypt = true) + val preview = chatPreviewOf(encryptedDm(), decrypted = null, myPubKey = me, canDecrypt = true) assertEquals(ChatPreview.Decrypting, preview) assertFalse(preview is ChatPreview.Body) @@ -146,7 +146,7 @@ class ChatPreviewTest { fun readOnlyAccountCannotEverDecrypt() { assertEquals( ChatPreview.Undecryptable, - chatPreviewOf(privateDm(), decrypted = null, myPubKey = me, canDecrypt = false), + chatPreviewOf(encryptedDm(), decrypted = null, myPubKey = me, canDecrypt = false), ) } @@ -155,7 +155,7 @@ class ChatPreviewTest { assertEquals( ChatPreview.Undecryptable, chatPreviewOf( - privateDm(author = other, recipient = stranger), + encryptedDm(author = other, recipient = stranger), decrypted = null, myPubKey = me, canDecrypt = true, @@ -168,7 +168,7 @@ class ChatPreviewTest { assertEquals( ChatPreview.Decrypting, chatPreviewOf( - privateDm(author = me, recipient = other), + encryptedDm(author = me, recipient = other), decrypted = null, myPubKey = me, canDecrypt = true, diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/moderation/notifications/NotificationKindsTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/moderation/notifications/NotificationKindsTest.kt index 52ce93b5e1..e472f7c54e 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/moderation/notifications/NotificationKindsTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/moderation/notifications/NotificationKindsTest.kt @@ -21,14 +21,14 @@ package com.vitorpamplona.amethyst.commons.moderation.notifications import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip04Dm.messages.EncryptedDmEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent import kotlin.test.Test @@ -116,8 +116,8 @@ class NotificationKindsTest { ) } - private fun zapReceipt(pTag: String = me): LnZapEvent = - LnZapEvent( + private fun zapReceipt(pTag: String = me): ZapReceiptEvent = + ZapReceiptEvent( id = "zap".padEnd(64, '0'), pubKey = "lnurl_provider".padEnd(64, '0'), createdAt = 1_000, @@ -139,11 +139,11 @@ class NotificationKindsTest { sig = someSig, ) - private fun privateDm( + private fun encryptedDm( author: String, pTag: String = me, - ): PrivateDmEvent = - PrivateDmEvent( + ): EncryptedDmEvent = + EncryptedDmEvent( id = "dm".padEnd(64, '0'), pubKey = author, createdAt = 1_000, @@ -266,9 +266,9 @@ class NotificationKindsTest { @Test fun myOwnZapReceiptStillAccepted() { - // LnZap receipts are signed by the LNURL provider, not by us, + // ZapReceipt receipts are signed by the LNURL provider, not by us, // so `pubKey == me` never applies — but even if it did the - // helper explicitly allows LnZap/Nutzap/OnchainZap own-events. + // helper explicitly allows ZapReceipt/Nutzap/OnchainZap own-events. assertTrue(acceptsFor(zapReceipt(pTag = me))) } @@ -280,8 +280,8 @@ class NotificationKindsTest { // DMs -------------------------------------------------------------------- @Test - fun privateDmAccepted() { - assertTrue(acceptsFor(privateDm(author = alice))) + fun encryptedDmAccepted() { + assertTrue(acceptsFor(encryptedDm(author = alice))) } @Test @@ -303,7 +303,7 @@ class NotificationKindsTest { val expected = setOf( TextNoteEvent.KIND, - PrivateDmEvent.KIND, + EncryptedDmEvent.KIND, RepostEvent.KIND, ReactionEvent.KIND, GenericRepostEvent.KIND, @@ -311,7 +311,7 @@ class NotificationKindsTest { CommentEvent.KIND, GiftWrapEvent.KIND, NutzapEvent.KIND, - LnZapEvent.KIND, + ZapReceiptEvent.KIND, ) val actual = NotificationKinds.SUBSCRIPTION_KINDS.toSet() assertTrue(actual.containsAll(expected), "expected subset missing from SUBSCRIPTION_KINDS") diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerTest.kt index a5d33e5a23..586a158653 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerTest.kt @@ -42,8 +42,8 @@ import com.vitorpamplona.quartz.nip01Core.crypto.verify import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal -import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import com.vitorpamplona.quartz.nip57Zaps.PrivateZapEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapRequestEvent import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll @@ -137,7 +137,7 @@ class NappletBrokerTest { fromPublicKey: HexKey, ): String = throw NotImplementedError() - override suspend fun decryptZapEvent(event: LnZapRequestEvent): LnZapPrivateEvent = throw NotImplementedError() + override suspend fun decryptZapEvent(event: ZapRequestEvent): PrivateZapEvent = throw NotImplementedError() override suspend fun deriveKey(nonce: HexKey): HexKey = throw NotImplementedError() diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/onchain/OnchainZapSenderTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/onchain/OnchainZapSenderTest.kt index 8f439de480..f8937547c1 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/onchain/OnchainZapSenderTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/onchain/OnchainZapSenderTest.kt @@ -27,8 +27,8 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal -import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import com.vitorpamplona.quartz.nip57Zaps.PrivateZapEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapRequestEvent import com.vitorpamplona.quartz.nipBCOnchainZaps.builder.OnchainZapBuilder import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.BitcoinAddressTx import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.BitcoinTx @@ -228,7 +228,7 @@ class OnchainZapSenderTest { fromPublicKey: HexKey, ) = inner.nip44Decrypt(ciphertext, fromPublicKey) - override suspend fun decryptZapEvent(event: LnZapRequestEvent): LnZapPrivateEvent = inner.decryptZapEvent(event) + override suspend fun decryptZapEvent(event: ZapRequestEvent): PrivateZapEvent = inner.decryptZapEvent(event) override suspend fun deriveKey(nonce: HexKey): HexKey = inner.deriveKey(nonce) diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/channel/FilterRelayGroupStateTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/channel/FilterRelayGroupStateTest.kt index d0ef97fdf9..5a410492c9 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/channel/FilterRelayGroupStateTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/channel/FilterRelayGroupStateTest.kt @@ -27,7 +27,7 @@ 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 +import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupRolesEvent import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -51,7 +51,7 @@ class FilterRelayGroupStateTest { GroupMetadataEvent.KIND, GroupAdminsEvent.KIND, GroupMembersEvent.KIND, - SupportedRolesEvent.KIND, + GroupRolesEvent.KIND, ) private val pinKinds = listOf(GroupPinnedEvent.KIND) diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/channel/relayGroup/RelayGroupFilterBuildersTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/channel/relayGroup/RelayGroupFilterBuildersTest.kt index f7f85e742f..56c53c075d 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/channel/relayGroup/RelayGroupFilterBuildersTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/channel/relayGroup/RelayGroupFilterBuildersTest.kt @@ -24,7 +24,7 @@ import com.vitorpamplona.quartz.buzz.forum.ForumCommentEvent import com.vitorpamplona.quartz.buzz.forum.ForumPostEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.FiltersChanged import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer -import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip29RelayGroups.GroupId @@ -32,8 +32,8 @@ 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 -import com.vitorpamplona.quartz.nip29RelayGroups.moderation.DeleteEventEvent +import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupRolesEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.GroupDeleteEventEvent import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.GroupTag import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent @@ -187,7 +187,7 @@ class RelayGroupFilterBuildersTest { val filter = buildRelayGroupAuxFilter(GroupId("g1", relayA), 42L) assertEquals(relayA, filter.relay) - assertEquals(listOf(DeletionEvent.KIND, ReactionEvent.KIND, DeleteEventEvent.KIND), filter.filter.kinds) + assertEquals(listOf(DeletionRequestEvent.KIND, ReactionEvent.KIND, GroupDeleteEventEvent.KIND), filter.filter.kinds) assertEquals(listOf("g1"), filter.filter.tags!!["h"]) assertEquals(42L, filter.filter.since) assertEquals(RELAY_GROUP_AUX_LIMIT, filter.filter.limit) @@ -293,7 +293,7 @@ class RelayGroupFilterBuildersTest { @Test fun `directory kinds are 39000-39003 and exclude the pin list`() { assertEquals( - listOf(GroupMetadataEvent.KIND, GroupAdminsEvent.KIND, GroupMembersEvent.KIND, SupportedRolesEvent.KIND), + listOf(GroupMetadataEvent.KIND, GroupAdminsEvent.KIND, GroupMembersEvent.KIND, GroupRolesEvent.KIND), RELAY_GROUP_DIRECTORY_KINDS, ) assertFalse(RELAY_GROUP_DIRECTORY_KINDS.contains(GroupPinnedEvent.KIND), "the directory doesn't fetch each group's pins") diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/home/HomeMediaKindCoverageTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/home/HomeMediaKindCoverageTest.kt index c51e367053..813879a187 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/home/HomeMediaKindCoverageTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/home/HomeMediaKindCoverageTest.kt @@ -26,10 +26,10 @@ import com.vitorpamplona.amethyst.commons.relayClient.home.nip65Follows.HomePost import com.vitorpamplona.amethyst.commons.relayClient.home.nip72Communities.HomePostsFromCommunityKinds import com.vitorpamplona.amethyst.commons.relayClient.home.nip72Communities.HomePostsFromCommunityKindsStr import com.vitorpamplona.quartz.nip68Picture.PictureEvent -import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent +import com.vitorpamplona.quartz.nip71Video.AddressableNormalVideoEvent +import com.vitorpamplona.quartz.nip71Video.AddressableShortVideoEvent import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent import com.vitorpamplona.quartz.nip71Video.VideoShortEvent -import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue @@ -46,8 +46,8 @@ class HomeMediaKindCoverageTest { PictureEvent.KIND, VideoNormalEvent.KIND, VideoShortEvent.KIND, - VideoHorizontalEvent.KIND, - VideoVerticalEvent.KIND, + AddressableNormalVideoEvent.KIND, + AddressableShortVideoEvent.KIND, ) private fun assertCarriesMedia( diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/home/nip90AlgoFeeds/FilterHomePostsByAlgoFeedIdsTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/home/nip90AlgoFeeds/FilterHomePostsByAlgoFeedIdsTest.kt index fd94ee87ee..5376b735b3 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/home/nip90AlgoFeeds/FilterHomePostsByAlgoFeedIdsTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/home/nip90AlgoFeeds/FilterHomePostsByAlgoFeedIdsTest.kt @@ -23,8 +23,8 @@ package com.vitorpamplona.amethyst.commons.relayClient.home.nip90AlgoFeeds import com.vitorpamplona.amethyst.commons.model.topNavFeeds.favoriteAlgoFeeds.FavoriteAlgoFeedTopNavPerRelayFilter import com.vitorpamplona.amethyst.commons.model.topNavFeeds.favoriteAlgoFeeds.FavoriteAlgoFeedTopNavPerRelayFilterSet import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer -import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent -import com.vitorpamplona.quartz.nip90Dvms.status.NIP90StatusEvent +import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.DvmContentDiscoveryResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.status.DvmStatusEvent import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull @@ -83,7 +83,7 @@ class FilterHomePostsByAlgoFeedIdsTest { val listen = filters.single() assertEquals(dvmRelay, listen.relay) assertEquals( - listOf(NIP90ContentDiscoveryResponseEvent.KIND, NIP90StatusEvent.KIND), + listOf(DvmContentDiscoveryResponseEvent.KIND, DvmStatusEvent.KIND), listen.filter.kinds, ) val eTag = listen.filter.tags?.get("e") diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResultSorterTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResultSorterTest.kt index 96217a9904..0afcf06b9e 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResultSorterTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResultSorterTest.kt @@ -25,7 +25,7 @@ import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue @@ -51,14 +51,14 @@ class SearchResultSorterTest { createdAt: Long, content: String = "", title: String? = null, - ): LongTextNoteEvent { + ): LongFormContentEvent { val tags = if (title != null) { arrayOf(arrayOf("title", title)) } else { emptyArray() } - return LongTextNoteEvent( + return LongFormContentEvent( id = id, pubKey = "abc123def456abc123def456abc123def456abc123def456abc123def456abcd", createdAt = createdAt, diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/SearchSeedTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/SearchSeedTest.kt index 2d1ed9f50f..5cc5a7bf18 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/SearchSeedTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/SearchSeedTest.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.commons.search import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter import com.vitorpamplona.quartz.nip01Core.core.Address -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent import kotlinx.collections.immutable.persistentListOf import kotlin.test.Test @@ -46,7 +46,7 @@ class SearchSeedTest { @Test fun aKindWindowArrivesAsTheSameKinds() { - val seed = SearchSeed.ofKinds(LongTextNoteEvent.KIND) + val seed = SearchSeed.ofKinds(LongFormContentEvent.KIND) assertEquals("kind:article", QuerySerializer.serialize(seed)) assertEquals(seed, throughTheField(seed)) } @@ -196,7 +196,7 @@ class SearchSeedTest { assertTrue(SearchQuery(scopes = persistentListOf(ExternalScope("isbn", "123"))).pinsToNotes) assertTrue(SearchQuery(groups = persistentListOf("abc")).pinsToNotes) // Every seeded feed window pins it too. - assertTrue(SearchSeed.ofKinds(LongTextNoteEvent.KIND).pinsToNotes) + assertTrue(SearchSeed.ofKinds(LongFormContentEvent.KIND).pinsToNotes) } @Test diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/tor/TorSettingsTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/tor/TorSettingsTest.kt index 1dfb214a3b..a96731c21f 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/tor/TorSettingsTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/tor/TorSettingsTest.kt @@ -179,4 +179,60 @@ class TorSettingsTest { assertEquals(TorType.OFF, modified.torType) assertNotEquals(original, modified) } + + @Test + fun isPreset_torTypeDifference_ignored() { + val withOff = torDefaultPreset.copy(torType = TorType.OFF) + assertTrue(isPreset(withOff, torDefaultPreset)) + } + + @Test + fun presets_areIncreasing_defaultSupersetOfOnlyWhenNeeded() { + // Default enables DM + new relays on top of onlyWhenNeeded + assertTrue(torDefaultPreset.dmRelaysViaTor) + assertTrue(torDefaultPreset.newRelaysViaTor) + assertFalse(torOnlyWhenNeededPreset.dmRelaysViaTor) + assertFalse(torOnlyWhenNeededPreset.newRelaysViaTor) + } + + @Test + fun presets_areIncreasing_fullPrivacySupersetOfSmallPayloads() { + // Full privacy adds images, videos, media uploads + assertTrue(torFullyPrivate.imagesViaTor) + assertTrue(torFullyPrivate.videosViaTor) + assertTrue(torFullyPrivate.mediaUploadsViaTor) + assertFalse(torSmallPayloadsPreset.imagesViaTor) + assertFalse(torSmallPayloadsPreset.videosViaTor) + assertFalse(torSmallPayloadsPreset.mediaUploadsViaTor) + } + + @Test + fun smallPayloadsPreset_addsPreviewsNip05Money() { + assertTrue(torSmallPayloadsPreset.onionRelaysViaTor) + assertTrue(torSmallPayloadsPreset.dmRelaysViaTor) + assertTrue(torSmallPayloadsPreset.newRelaysViaTor) + assertTrue(torSmallPayloadsPreset.trustedRelaysViaTor) + assertTrue(torSmallPayloadsPreset.urlPreviewsViaTor) + assertTrue(torSmallPayloadsPreset.profilePicsViaTor) + assertFalse(torSmallPayloadsPreset.imagesViaTor) + assertFalse(torSmallPayloadsPreset.videosViaTor) + assertTrue(torSmallPayloadsPreset.moneyOperationsViaTor) + assertTrue(torSmallPayloadsPreset.nip05VerificationsViaTor) + assertFalse(torSmallPayloadsPreset.mediaUploadsViaTor) + } + + @Test + fun torSettings_equality_worksForDistinctUntilChanged() { + val a = TorSettings(torType = TorType.INTERNAL, externalSocksPort = 9050) + val b = TorSettings(torType = TorType.INTERNAL, externalSocksPort = 9050) + assertEquals(a, b) + assertEquals(a.hashCode(), b.hashCode()) + } + + @Test + fun whichPreset_ignoresTorTypeAndPort() { + // whichPreset only compares boolean flags, not torType/port + val withExternal = torDefaultPreset.copy(torType = TorType.EXTERNAL, externalSocksPort = 1234) + assertEquals(TorPresetType.DEFAULT, whichPreset(withExternal)) + } } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomZapsStateTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomZapsStateTest.kt index bc67bc0981..6ac9dc3d7d 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomZapsStateTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomZapsStateTest.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.commons.viewmodels -import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull @@ -46,13 +46,13 @@ class RoomZapsStateTest { to: String?, createdAt: Long, id: String = (nextEventId++).toString(16).padStart(64, '0'), - ): LnZapEvent { + ): ZapReceiptEvent { val tags = buildList> { add(arrayOf("a", "30312:host:room")) if (to != null) add(arrayOf("p", to)) }.toTypedArray() - return LnZapEvent( + return ZapReceiptEvent( id = id, pubKey = from, createdAt = createdAt, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessDismissedGamesStorage.kt b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/util/OwnerOnlyFile.ios.kt similarity index 65% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessDismissedGamesStorage.kt rename to commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/util/OwnerOnlyFile.ios.kt index 3c950e3509..ebd18c664a 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessDismissedGamesStorage.kt +++ b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/util/OwnerOnlyFile.ios.kt @@ -18,21 +18,24 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.commons.nip64Chess +package com.vitorpamplona.amethyst.commons.util -/** - * Persists dismissed chess game IDs locally per user. - * Uses expect/actual for platform-specific storage. - */ -expect class ChessDismissedGamesStorage private constructor() { - companion object { - fun create(context: Any? = null): ChessDismissedGamesStorage +import com.vitorpamplona.quartz.utils.Log +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.convert +import okio.Path +import platform.posix.S_IRUSR +import platform.posix.S_IWUSR +import platform.posix.chmod +import platform.posix.errno + +@OptIn(ExperimentalForeignApi::class) +actual fun restrictFileToOwner( + path: Path, + tag: String, +) { + if (chmod(path.toString(), (S_IRUSR or S_IWUSR).convert()) != 0) { + val error = errno + Log.w(tag) { "Could not restrict permissions on $path: errno $error" } } - - fun load(userPubkey: String): Set - - fun save( - userPubkey: String, - ids: Set, - ) } diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cashu/ops/CashuWalletOps.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cashu/ops/CashuWalletOps.kt index 99def91987..f40c21c608 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cashu/ops/CashuWalletOps.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cashu/ops/CashuWalletOps.kt @@ -29,7 +29,7 @@ import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.tags.aTag.aTag -import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent import com.vitorpamplona.quartz.nip60Cashu.bdhke.Bdhke import com.vitorpamplona.quartz.nip60Cashu.history.CashuSpendingHistoryEvent import com.vitorpamplona.quartz.nip60Cashu.history.SpendingDirection @@ -57,6 +57,7 @@ import com.vitorpamplona.quartz.nip60Cashu.wallet.CashuWalletEvent import com.vitorpamplona.quartz.nip61Nutzaps.info.NutzapInfoEvent import com.vitorpamplona.quartz.nip61Nutzaps.info.tags.NutzapMintTag import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent +import com.vitorpamplona.quartz.nip61Nutzaps.redemption.notifySender import com.vitorpamplona.quartz.nip87Ecash.cashu.CashuMintEvent import com.vitorpamplona.quartz.nip87Ecash.recommendation.MintRecommendationEvent import com.vitorpamplona.quartz.utils.Log @@ -119,14 +120,14 @@ class CashuWalletOps( * it. Used to rewind the restore window in [completeMintFromLightning] * recovery. Default is 0 (no persistent counter store). */ - private val peekCashuCounter: (keysetId: String) -> Long = { 0L }, + private val peekCashuCounter: suspend (keysetId: String) -> Long = { 0L }, /** * Atomically reserve [count] consecutive NUT-13 counters and * return the first reserved index. Used by the recovery path to * advance past slots the mint confirmed in use. Default is a * no-op for tests / random-only callers. */ - private val reserveCashuCounters: (keysetId: String, count: Int) -> Long = { _, _ -> 0L }, + private val reserveCashuCounters: suspend (keysetId: String, count: Int) -> Long = { _, _ -> 0L }, ) { private val opsCache = ConcurrentHashMap() @@ -195,7 +196,7 @@ class CashuWalletOps( * bails when it has no shared mint or no P2PK pubkey. This is the * durable signal. * 2. NIP-09 delete the now-empty kind:10019 by its address coordinate - * (DeletionEvent.build adds the `a` tag automatically because the + * (DeletionRequestEvent.build adds the `a` tag automatically because the * event is replaceable) so relays that DO honor deletions drop it * entirely. Deletions are optional on Nostr, which is why step 1 * runs first as the fallback. @@ -209,7 +210,7 @@ class CashuWalletOps( val emptyEvent = signer.sign(emptyTemplate) publish(emptyEvent) - val delTemplate = DeletionEvent.build(listOf(emptyEvent)) + val delTemplate = DeletionRequestEvent.build(listOf(emptyEvent)) val delEvent = signer.sign(delTemplate) publish(delEvent) } @@ -230,7 +231,7 @@ class CashuWalletOps( suspend fun deleteWallet(walletEvent: CashuWalletEvent) { stopNutzaps() - val delTemplate = DeletionEvent.build(listOf(walletEvent)) + val delTemplate = DeletionRequestEvent.build(listOf(walletEvent)) val delEvent = signer.sign(delTemplate) publish(delEvent) } @@ -286,7 +287,7 @@ class CashuWalletOps( * fired anyway; running it twice on a missing event is harmless. */ suspend fun cancelMintQuote(quoteEvent: CashuMintQuoteEvent) { - val delTemplate = DeletionEvent.build(listOf(quoteEvent)) + val delTemplate = DeletionRequestEvent.build(listOf(quoteEvent)) val delEvent = signer.sign(delTemplate) publish(delEvent) } @@ -363,7 +364,7 @@ class CashuWalletOps( publish(historyEvent) // NIP-09 delete the now-fulfilled quote event. - val delTemplate = DeletionEvent.build(listOf(quoteEvent)) + val delTemplate = DeletionRequestEvent.build(listOf(quoteEvent)) val delEvent = signer.sign(delTemplate) publish(delEvent) @@ -527,11 +528,11 @@ class CashuWalletOps( } // NIP-09 delete the source token events. Per spec, also need k=7375 tag, - // which DeletionEvent.build adds automatically from the source event kind. + // which DeletionRequestEvent.build adds automatically from the source event kind. val deleteEvent = run { val toDelete = selected.map { it.event } + listOfNotNull(prePaidChangeEvent.takeIf { finalChangeEvent != null }) - val delTemplate = DeletionEvent.build(toDelete) + val delTemplate = DeletionRequestEvent.build(toDelete) signer.sign(delTemplate).also { publish(it) } } @@ -598,7 +599,7 @@ class CashuWalletOps( val deleteEvent = run { - val template = DeletionEvent.build(selected.map { it.event }) + val template = DeletionRequestEvent.build(selected.map { it.event }) signer.sign(template).also { publish(it) } } @@ -769,7 +770,7 @@ class CashuWalletOps( // NIP-09 delete the source token events. val deleteEvent = run { - val template = DeletionEvent.build(selected.map { it.event }) + val template = DeletionRequestEvent.build(selected.map { it.event }) signer.sign(template).also { publish(it) } @@ -914,7 +915,10 @@ class CashuWalletOps( TokenReference(nutzap.id, null, TokenReference.MARKER_REDEEMED), ), signer = signer, - ) + ) { + // NIP-61: tag the nutzap sender so their client learns it was redeemed. + notifySender(EventHintBundle(nutzap)) + } val historyEvent = signer.sign(historyTemplate) publish(historyEvent) @@ -1001,7 +1005,7 @@ class CashuWalletOps( entry.content.proofs.any { states[it.secret] == ProofState.SPENT } } if (stale.isEmpty()) return emptyList() - val delTemplate = DeletionEvent.build(stale.map { it.event }) + val delTemplate = DeletionRequestEvent.build(stale.map { it.event }) publish(signer.sign(delTemplate)) return stale.map { it.event } } @@ -1046,7 +1050,7 @@ class CashuWalletOps( val signed = signer.sign(template) publish(signed) - val delTemplate = DeletionEvent.build(entries.map { it.event }) + val delTemplate = DeletionRequestEvent.build(entries.map { it.event }) val delEvent = signer.sign(delTemplate) publish(delEvent) @@ -1088,7 +1092,7 @@ class CashuWalletOps( * kind:38000 is parameterized-replaceable — a delete with an `a` tag * pointing at the address coordinate (kind:pubkey:dTag) drops all * versions on compliant relays. We also include the original event id - * via DeletionEvent.build so relays that only track by event id still + * via DeletionRequestEvent.build so relays that only track by event id still * remove it. MintRecommendationEvent doesn't extend AddressableEvent * today, so we compute and add the `a` tag ourselves. */ @@ -1099,7 +1103,7 @@ class CashuWalletOps( // d-tag still get a NIP-09 `e`-only delete (the default build path). val dTag = event.dTag() val template = - DeletionEvent.build(listOf(event)) { + DeletionRequestEvent.build(listOf(event)) { if (dTag != null) aTag(Address(event.kind, event.pubKey, dTag)) } val delEvent = signer.sign(template) @@ -1497,14 +1501,14 @@ data class MeltCompleted( val paidAmount: Long, val fees: Long, val historyEvent: CashuSpendingHistoryEvent, - val deleteEvent: DeletionEvent, + val deleteEvent: DeletionRequestEvent, val newTokenEvent: CashuTokenEvent?, ) data class SendTokenCompleted( val cashuToken: String, val amount: Long, - val deleteEvent: DeletionEvent, + val deleteEvent: DeletionRequestEvent, val keepEvent: CashuTokenEvent?, val historyEvent: CashuSpendingHistoryEvent, ) @@ -1519,7 +1523,7 @@ data class RedeemCompleted( data class NutzapSent( val nutzapEvent: NutzapEvent, val keepEvent: CashuTokenEvent?, - val deleteEvent: DeletionEvent, + val deleteEvent: DeletionRequestEvent, val historyEvent: CashuSpendingHistoryEvent, val amount: Long, ) diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnMigrationStores.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnMigrationStores.kt new file mode 100644 index 0000000000..d48d7eebbc --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnMigrationStores.kt @@ -0,0 +1,182 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn + +import com.vitorpamplona.quartz.cordn.appMultiDevice.CordnCarriedKeyPackage +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnDeliveredMessageCodec +import com.vitorpamplona.quartz.cordn.sync.GroupCursor +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import java.io.File +import java.util.Base64 + +/** + * Reading a handoff snapshot off the cordn stores, and writing one back. + * + * Here rather than in the Android runtime because `amy` needs exactly the same + * two operations, and duplicating them would let the CLI and the app disagree + * about what a migration carries — which the user would discover as a + * conversation that arrived on one path and not the other. + */ +object CordnMigrationStores { + /** + * Collects every group on disk for [configs]. + * + * Reads the stores rather than any in-memory state: a coordinator whose + * session failed to open today is still migrated, because a handoff that + * silently omitted the groups the app could not reach would be wrong + * precisely when it matters. + */ + suspend fun read( + root: File, + accountPubKey: HexKey, + cipher: CordnBlobCipher, + configs: List, + ): CordnMigrationSnapshot { + val groups = mutableListOf() + val keyPackages = mutableListOf() + + configs.forEach { config -> + val dir = CordnStorageLayout.directoryFor(root, accountPubKey, config.pubKey) + val groupStore = FileCordnGroupStore(dir, cipher) + val keyPackageStore = FileCordnKeyPackageStore(dir, cipher) + + groupStore.listGroups().forEach { gid -> + val state = groupStore.loadGroup(gid) ?: return@forEach + groups += + CordnMigrationGroup( + coordinatorPubKey = config.pubKey, + coordinatorRelays = config.relays.map { it.url }, + gid = gid, + clientStateBase64 = state.toBase64(), + cursor = groupStore.loadCursor(gid)?.fetchCursor ?: 0L, + roomStateBase64 = CordnRoomStateCodec.encode(groupStore.loadRoomState(gid)).toBase64(), + echoStateBase64 = EchoStateCodec.encode(groupStore.loadEchoState(gid)).toBase64(), + joinedViaRequest = groupStore.loadJoinedViaRequest(gid), + messages = carriedMessages(groupStore, gid), + ) + } + + keyPackageStore.list().forEach { ref -> + val bundle = keyPackageStore.load(ref) ?: return@forEach + keyPackages += CordnCarriedKeyPackage(config.pubKey, ref, bundle.toBase64()) + } + } + + return CordnMigrationSnapshot(accountPubKey, groups, keyPackages = keyPackages) + } + + /** + * Roughly how much conversation one group contributes to a handoff. + * + * A migration document is sealed and uploaded to blob hosts whose limits we + * do not know, and a handoff that fails because one group is chatty is a + * worse outcome than one that carries a deep but bounded history. Budgeted + * in bytes rather than messages because a single long message can cost as + * much as a hundred short ones. + */ + private const val MESSAGE_BUDGET_BYTES = 512 * 1024 + + /** + * The newest messages that fit the budget, back in oldest-first order. + * + * Newest-first while accumulating: if something has to be left behind it + * should be the oldest part of the conversation, which is the part least + * likely to be missed and the part a reader scrolls to last. + */ + private suspend fun carriedMessages( + store: FileCordnGroupStore, + gid: String, + ): List { + var budget = MESSAGE_BUDGET_BYTES + return store + .loadMessages(gid) + .asReversed() + .map { CordnDeliveredMessageCodec.encode(it) } + .takeWhile { entry -> + budget -= entry.length + budget > 0 + }.asReversed() + } + + /** + * Replaces this device's cordn tree with [snapshot]'s, returning the + * coordinator list to start. + * + * **Replaces, and is not a merge.** Two devices holding one group's state + * and both committing fork the ratchet tree, and MLS does not recover — + * merging would produce that on purpose. The old tree goes first, so a + * `gid` present in both cannot end up half from each. + */ + suspend fun write( + root: File, + accountPubKey: HexKey, + cipher: CordnBlobCipher, + snapshot: CordnMigrationSnapshot, + ): List { + require(snapshot.accountPubKey == accountPubKey) { + "this migration belongs to a different account" + } + + File(root, "cordn/$accountPubKey").deleteRecursively() + + snapshot.groups.forEach { group -> + val store = FileCordnGroupStore(CordnStorageLayout.directoryFor(root, accountPubKey, group.coordinatorPubKey), cipher) + store.saveGroup(group.gid, group.clientStateBase64.fromBase64()) + // Before the cursor, for the same reason the live path writes them in + // that order: a seeding that wrote the cursor and then failed would + // leave a device holding a cursor past a conversation it never + // wrote, with no way to ask for it again. + group.messages.forEach { entry -> + CordnDeliveredMessageCodec.decodeOrNull(entry)?.let { store.appendMessage(group.gid, it) } + } + // Both halves of the cursor: the writer's snapshot was consistent at + // fetchCursor (§4.1), and starting behind it would re-fetch + // messages the state has already advanced past. + store.saveCursor(group.gid, GroupCursor(fetchCursor = group.cursor, lastCursor = group.cursor)) + group.roomStateBase64?.let { store.saveRoomState(group.gid, CordnRoomStateCodec.decode(it.fromBase64())) } + group.echoStateBase64?.let { store.saveEchoState(group.gid, EchoStateCodec.decode(it.fromBase64())) } + if (group.joinedViaRequest) store.saveJoinedViaRequest(group.gid) + } + + snapshot.keyPackages.forEach { keyPackage -> + FileCordnKeyPackageStore(CordnStorageLayout.directoryFor(root, accountPubKey, keyPackage.coordinatorPubKey), cipher) + .save(keyPackage.keyPackageRef, keyPackage.bundle.fromBase64()) + } + + return snapshot.groups + .groupBy { it.coordinatorPubKey } + .mapNotNull { (pubKey, groups) -> + val relays = + groups + .flatMap { it.coordinatorRelays } + .distinct() + .mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } + // A coordinator with no reachable relay cannot be talked to, and + // CoordinatorConfig refuses to be built without one. + if (relays.isEmpty()) null else CoordinatorConfig(pubKey, relays, CoordinatorConfig.Origin.MANUAL) + } + } + + private fun ByteArray.toBase64() = Base64.getEncoder().encodeToString(this) + + private fun String.fromBase64(): ByteArray = Base64.getDecoder().decode(this) +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cordn/FileBackedCordnScopeFactory.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cordn/FileBackedCordnScopeFactory.kt new file mode 100644 index 0000000000..6a4dec3613 --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cordn/FileBackedCordnScopeFactory.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import java.io.File + +/** + * The production [CordnCoordinatorScopeFactory]: encrypted files on disk, plus + * whatever transport [links] opens. + * + * Splitting it this way is what lets the storage half be tested. The transport + * half needs a relay and an account; the storage half needs a directory, and + * everything that can go wrong with it — the wrong account reading another's + * groups, two coordinators colliding on one `gid`, a `gid` that walks out of + * the directory — goes wrong silently and is worth a test. See + * `FileCordnStoresTest`. + * + * @param root the app's private files directory. Everything lands under + * `/cordn//`; see [CordnStorageLayout]. + */ +class FileBackedCordnScopeFactory( + private val root: File, + private val cipher: CordnBlobCipher, + private val links: CordnCoordinatorLinkFactory, +) : CordnCoordinatorScopeFactory { + override suspend fun open( + accountPubKey: HexKey, + config: CoordinatorConfig, + ): CordnCoordinatorScope { + val dir = CordnStorageLayout.directoryFor(root, accountPubKey, config.pubKey) + val link = links.connect(accountPubKey, config) + + return object : CordnCoordinatorScope { + override val coordinator = link.coordinator + + override suspend fun serverInfo() = link.serverInfo() + + override val groupStore = FileCordnGroupStore(dir, cipher) + override val keyPackageStore = FileCordnKeyPackageStore(dir, cipher) + + // Only the transport closes. The files outlive the session by + // design — closing a coordinator is not leaving its groups, and + // the registry's `forget` says the same thing. + override suspend fun close() = link.close() + } + } +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cordn/FileCordnStores.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cordn/FileCordnStores.kt new file mode 100644 index 0000000000..914fb8c555 --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cordn/FileCordnStores.kt @@ -0,0 +1,496 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn + +import com.vitorpamplona.amethyst.commons.storage.EncryptedAppendLog +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnDeliveredMessage +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnDeliveredMessageCodec +import com.vitorpamplona.quartz.cordn.sync.EchoState +import com.vitorpamplona.quartz.cordn.sync.GroupCursor +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import java.io.File +import java.nio.ByteBuffer +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +/** + * Where one account's state for one coordinator lives on disk. + * + * Both halves of the key matter and neither is optional. Per account, because + * two accounts on one device must not read each other's groups. Per + * coordinator, because a `gid` is unique only within one (`spec/00.md` §4) — + * two coordinators can both serve `gid = "abc"` as unrelated groups, and a + * layout that ignored the coordinator would have one silently overwrite the + * other's ratchet tree. + */ +@OptIn(ExperimentalEncodingApi::class) +object CordnStorageLayout { + /** + * `/cordn//`. + * + * Both keys are validated as hex before they reach a path. They always are + * — they are Nostr pubkeys — so this costs nothing and closes the one place + * a caller could smuggle `..` into a directory name. + */ + fun directoryFor( + root: File, + accountPubKey: HexKey, + coordinatorPubKey: HexKey, + ): File { + require(accountPubKey.matches(HEX)) { "account pubkey must be hex" } + require(coordinatorPubKey.matches(HEX)) { "coordinator pubkey must be hex" } + return File(root, "cordn/$accountPubKey/$coordinatorPubKey") + } + + /** + * `/cordn/` — the account's own directory, above any + * coordinator's. + * + * The coordinator list lives here rather than inside a coordinator's + * directory, because it is the list OF them: storing it under one would + * make that coordinator's removal delete the record of the others. + */ + fun accountDirectoryFor( + root: File, + accountPubKey: HexKey, + ): File { + require(accountPubKey.matches(HEX)) { "account pubkey must be hex" } + return File(root, "cordn/$accountPubKey") + } + + /** + * A filename for an arbitrary caller-chosen key. + * + * Base64url rather than the key itself, and deliberately NOT the hex + * validation Marmot's store uses. A Marmot group id is a hash and is + * always hex; a cordn `gid` is whatever the group's creator picked (§4 — + * the coordinator never interprets it, and the reference client happens to + * use a UUID). So a `gid` can contain `/`, `..`, a NUL, or a name that is + * special on some filesystem, and rejecting those would refuse groups the + * protocol allows. Encoding accepts every one of them and can still be + * reversed, which is what lets [FileCordnGroupStore.listGroups] give the + * real `gid` back rather than an opaque hash. + */ + fun encodeKey(key: String): String { + val encoded = Base64.UrlSafe.encode(key.encodeToByteArray()).trimEnd('=') + require(encoded.length <= MAX_NAME) { "key is too long to store: ${key.length} chars" } + return encoded + } + + /** The inverse of [encodeKey]; null when the name was not written by us. */ + fun decodeKey(name: String): String? = + try { + Base64.UrlSafe.decode(name.padEnd((name.length + 3) / 4 * 4, '=')).decodeToString() + } catch (e: IllegalArgumentException) { + // A stray file in our directory. Ignoring it is better than failing + // the whole listing and hiding every real group behind it. + null + } + + private val HEX = Regex("^[0-9a-fA-F]{64}$") + + /** Comfortably under the 255-byte limit every filesystem we target has. */ + private const val MAX_NAME = 200 +} + +private const val TAG = "CordnStores" + +/** + * Writes a blob to [file] so that a crash leaves either the old bytes or the + * new ones, never half of each. + * + * MLS makes this sharper than ordinary durability: a truncated `MlsGroupState` + * is not a stale group, it is an unreadable one, and the group cannot be + * re-derived from anywhere else on this device. + */ +private fun atomicWrite( + file: File, + data: ByteArray, +) { + file.parentFile?.mkdirs() + val temp = File(file.parentFile, "${file.name}.tmp") + temp.writeBytes(data) + if (!temp.renameTo(file)) { + temp.copyTo(file, overwrite = true) + temp.delete() + } +} + +/** + * A [CordnGroupStore] on the filesystem, encrypted through [cipher]. + * + * ``` + * /groups//state — encrypted MlsGroupState + * /groups//cursor — encrypted GroupCursor + * /groups//via-request — present iff admitted by request + * /groups//room — encrypted draft + read position + * ``` + * + * Scope [dir] with [CordnStorageLayout.directoryFor]; this class trusts that it + * already belongs to exactly one (account, coordinator) pair. + * + * Not a refactor of Marmot's `AndroidMlsGroupStateStore` and not shareable with + * it: that one keys by a hex Nostr group id and persists retained epoch secrets + * for Marmot's own rotation, neither of which exists here. The resemblance is + * that both encrypt blobs, which is not an abstraction worth having. + */ +class FileCordnGroupStore( + private val dir: File, + private val cipher: CordnBlobCipher, +) : CordnGroupStore { + private fun groupDir(gid: String) = File(dir, "groups/${CordnStorageLayout.encodeKey(gid)}") + + private fun stateFile(gid: String) = File(groupDir(gid), "state") + + private fun cursorFile(gid: String) = File(groupDir(gid), "cursor") + + private fun joinOriginFile(gid: String) = File(groupDir(gid), "via-request") + + private fun roomStateFile(gid: String) = File(groupDir(gid), "room") + + private fun echoStateFile(gid: String) = File(groupDir(gid), "echoes") + + /** + * Inside [groupDir] so [deleteGroup]'s recursive delete already covers it: + * a group that left its history behind would keep the plaintext of an + * end-to-end encrypted conversation after the key that read it was gone. + */ + private fun messagesFile(gid: String) = File(groupDir(gid), "messages") + + private fun summaryFile(gid: String) = File(groupDir(gid), "newest") + + /** + * Appending a segment rather than rewriting the conversation, so the cost + * of receiving a message does not grow with how much has been said. + */ + private val messageLog = + EncryptedAppendLog( + encrypt = cipher::encrypt, + // The log treats null as "this segment is unreadable" and carries on + // with the rest, which is what one corrupt segment should cost. + decrypt = { runCatching { cipher.decrypt(it) }.getOrNull() }, + ) + + private val messageLock = Mutex() + + /** + * Envelope ids already in a group's log. + * + * Dedup cannot be the log's own whole-entry comparison: the same message + * re-delivered after a crash carries the same envelope but not necessarily + * the same cursor, so the entries differ as strings while naming one + * message. Built once per group from the log the first time it is touched. + */ + private val seenIds = mutableMapOf>() + + private fun idsFor(gid: String): MutableSet = + seenIds.getOrPut(gid) { + messageLog + .readAll(messagesFile(gid)) + .mapNotNullTo(mutableSetOf()) { CordnDeliveredMessageCodec.decodeOrNull(it)?.envelope?.id } + } + + override suspend fun saveGroup( + gid: String, + state: ByteArray, + ) = withContext(Dispatchers.IO) { + atomicWrite(stateFile(gid), cipher.encrypt(state)) + } + + override suspend fun loadGroup(gid: String): ByteArray? = + withContext(Dispatchers.IO) { + val file = stateFile(gid) + if (!file.exists()) null else cipher.decrypt(file.readBytes()) + } + + override suspend fun deleteGroup(gid: String) { + withContext(Dispatchers.IO) { + messageLock.withLock { + // The log caches a file's entries by path, so dropping the + // directory alone would leave a re-join of the same gid reading + // the previous membership's messages out of memory. + messageLog.forget(messagesFile(gid)) + seenIds.remove(gid) + } + // The cursor goes with it. Leaving one behind would mean a later + // re-join of the same gid resumes from a cursor belonging to a + // group it is no longer in, skipping everything before it. + groupDir(gid).deleteRecursively() + } + } + + override suspend fun listGroups(): List = + withContext(Dispatchers.IO) { + File(dir, "groups") + .listFiles() + .orEmpty() + .filter { it.isDirectory && File(it, "state").exists() } + .mapNotNull { CordnStorageLayout.decodeKey(it.name) } + } + + override suspend fun saveCursor( + gid: String, + cursor: GroupCursor, + ) = withContext(Dispatchers.IO) { + val buffer = ByteBuffer.allocate(16).putLong(cursor.fetchCursor).putLong(cursor.lastCursor) + atomicWrite(cursorFile(gid), cipher.encrypt(buffer.array())) + } + + override suspend fun loadCursor(gid: String): GroupCursor? = + withContext(Dispatchers.IO) { + val file = cursorFile(gid) + if (!file.exists()) return@withContext null + val bytes = cipher.decrypt(file.readBytes()) + if (bytes.size < 16) return@withContext null + val buffer = ByteBuffer.wrap(bytes) + GroupCursor(fetchCursor = buffer.long, lastCursor = buffer.long) + } + + // Existence IS the flag, so there is nothing to encrypt and nothing to + // read back wrong. It only ever goes from absent to present -- a join + // request cannot be unsent -- and it is removed with the group because a + // later re-join of the same gid is a different admission. + override suspend fun saveJoinedViaRequest(gid: String) { + withContext(Dispatchers.IO) { + atomicWrite(joinOriginFile(gid), ByteArray(0)) + } + } + + override suspend fun loadJoinedViaRequest(gid: String): Boolean = withContext(Dispatchers.IO) { joinOriginFile(gid).exists() } + + override suspend fun saveRoomState( + gid: String, + state: CordnRoomState, + ) = withContext(Dispatchers.IO) { + // Deleted rather than blanked when there is nothing to remember, so an + // emptied draft leaves no plaintext behind in an old file. + if (state.isBlank) { + roomStateFile(gid).delete() + return@withContext + } + atomicWrite(roomStateFile(gid), cipher.encrypt(CordnRoomStateCodec.encode(state))) + } + + override suspend fun loadRoomState(gid: String): CordnRoomState = + withContext(Dispatchers.IO) { + val file = roomStateFile(gid) + if (!file.exists()) return@withContext CordnRoomState() + try { + CordnRoomStateCodec.decode(cipher.decrypt(file.readBytes())) + } catch (e: Exception) { + CordnRoomState() + } + } + + override suspend fun appendMessage( + gid: String, + message: CordnDeliveredMessage, + ) = withContext(Dispatchers.IO) { + messageLock.withLock { + val ids = idsFor(gid) + // Idempotent on the envelope id. The crash window between this and + // saveCursor is deliberate — see the store interface — and it is + // this check that makes re-delivery free rather than duplicating. + if (!ids.add(message.envelope.id)) return@withContext + + groupDir(gid).mkdirs() + messageLog.append(messagesFile(gid), CordnDeliveredMessageCodec.encode(message)) + // Written on the same beat, so the inbox preview cannot disagree + // with the room. Whole-blob rather than appended: it is one entry + // that is always overwritten. + atomicWrite(summaryFile(gid), cipher.encrypt(CordnMessageSummaryCodec.encode(message, ids.size))) + } + } + + override suspend fun loadMessages(gid: String): List = + withContext(Dispatchers.IO) { + messageLock.withLock { + // One unreadable entry costs that message, not the conversation + // behind it in the file. + messageLog.readAll(messagesFile(gid)).mapNotNull { CordnDeliveredMessageCodec.decodeOrNull(it) } + } + } + + override suspend fun loadMessageSummary(gid: String): CordnMessageSummary? = + withContext(Dispatchers.IO) { + val file = summaryFile(gid) + if (!file.exists()) return@withContext null + try { + CordnMessageSummaryCodec.decode(cipher.decrypt(file.readBytes())) + } catch (e: Exception) { + // A summary is a derived convenience; losing one costs a preview + // line until the next message, not the history it summarises. + Log.w("FileCordnGroupStore", "unreadable message summary for $gid: ${e.message}", e) + null + } + } + + override suspend fun saveEchoState( + gid: String, + state: EchoState, + ) = withContext(Dispatchers.IO) { + // Deleted when there is nothing pending, so the common steady state is + // no file rather than an empty one. + if (state.isEmpty) { + echoStateFile(gid).delete() + return@withContext + } + atomicWrite(echoStateFile(gid), cipher.encrypt(EchoStateCodec.encode(state))) + } + + override suspend fun loadEchoState(gid: String): EchoState = + withContext(Dispatchers.IO) { + val file = echoStateFile(gid) + if (!file.exists()) return@withContext EchoState() + try { + EchoStateCodec.decode(cipher.decrypt(file.readBytes())) + } catch (e: Exception) { + EchoState() + } + } +} + +/** + * A [CordnKeyPackageStore] on the filesystem, encrypted through [cipher]. + * + * ``` + * /keypackages/ — encrypted KeyPackageBundle + * ``` + * + * Scope [dir] per coordinator like the group store: a `kp_ref` is the + * coordinator's primary key for a KeyPackage (§4.2) and the same account + * publishes different ones to different coordinators. + */ +class FileCordnKeyPackageStore( + private val dir: File, + private val cipher: CordnBlobCipher, +) : CordnKeyPackageStore { + private fun bundleFile(keyPackageRef: String) = File(dir, "keypackages/${CordnStorageLayout.encodeKey(keyPackageRef)}") + + override suspend fun save( + keyPackageRef: String, + bundle: ByteArray, + ) = withContext(Dispatchers.IO) { + atomicWrite(bundleFile(keyPackageRef), cipher.encrypt(bundle)) + } + + override suspend fun load(keyPackageRef: String): ByteArray? = + withContext(Dispatchers.IO) { + val file = bundleFile(keyPackageRef) + if (!file.exists()) null else cipher.decrypt(file.readBytes()) + } + + override suspend fun delete(keyPackageRef: String) { + withContext(Dispatchers.IO) { + bundleFile(keyPackageRef).delete() + } + } + + override suspend fun list(): List = + withContext(Dispatchers.IO) { + File(dir, "keypackages") + .listFiles() + .orEmpty() + .filter { it.isFile } + // No explicit ".tmp" exclusion, because the encoding already + // is one: '.' is not in the base64url alphabet, so a temp file + // left behind by a crashed write cannot decode to a ref and + // [CordnStorageLayout.decodeKey] drops it. A second filter + // saying the same thing would be a branch no test can reach. + .mapNotNull { CordnStorageLayout.decodeKey(it.name) } + } +} + +/** + * A [CordnCoordinatorStore] on the filesystem, encrypted through [cipher]. + * + * ``` + * /coordinators — encrypted CoordinatorListCodec blob + * ``` + * + * Scope [dir] with [CordnStorageLayout.accountDirectoryFor]: one file per + * account, sitting above the per-coordinator directories it names. + */ +class FileCordnCoordinatorStore( + private val dir: File, + private val cipher: CordnBlobCipher, +) : CordnCoordinatorStore { + private val file get() = File(dir, "coordinators") + + override suspend fun save(configs: List) = + withContext(Dispatchers.IO) { + atomicWrite(file, cipher.encrypt(CoordinatorListCodec.encode(configs))) + } + + override suspend fun load(): List = + withContext(Dispatchers.IO) { + val stored = file + if (!stored.exists()) return@withContext emptyList() + try { + CoordinatorListCodec.decode(cipher.decrypt(stored.readBytes())) + } catch (e: Exception) { + // A list written by a future build, or one the keystore can no + // longer decrypt. Returning nothing loses the coordinators but + // keeps the account usable; throwing here would fail login. + // + // Say so, though. Losing this list loses every cordn group with + // it — the MLS state stays on disk but a gid whose coordinator + // is unknown is not a group anyone can open — and silently it + // looks exactly like an account that never had a coordinator: + // the rooms are gone from the inbox and the invitations screen + // says there is nowhere to look. One line is the difference + // between a diagnosable fault and a mystery. + Log.w(TAG, "could not read ${stored.length()} bytes of coordinators, losing them: ${e.message}", e) + emptyList() + } + } +} + +/** + * The handoff flag, as the presence of a file. + * + * `/handed-off`. A zero-byte marker rather than an encrypted blob: + * it carries no secret, and a flag that failed to decrypt would fail open — + * which here means a device that quietly resumes committing after handing its + * groups to another one, the exact fork the flag exists to prevent. + */ +class FileCordnHandoffStore( + private val accountDir: File, +) : CordnHandoffStore { + override suspend fun load(): Boolean = marker().exists() + + override suspend fun save(handedOff: Boolean) { + val file = marker() + if (handedOff) { + file.parentFile?.mkdirs() + file.writeBytes(ByteArray(0)) + } else { + file.delete() + } + } + + private fun marker() = File(accountDir, "handed-off") +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidKeyPackageBundleStore.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/marmot/EncryptedKeyPackageBundleStore.kt similarity index 87% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidKeyPackageBundleStore.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/marmot/EncryptedKeyPackageBundleStore.kt index 7a99f80347..4a67d695ec 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidKeyPackageBundleStore.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/marmot/EncryptedKeyPackageBundleStore.kt @@ -18,9 +18,9 @@ * 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.marmot +package com.vitorpamplona.amethyst.commons.marmot -import com.vitorpamplona.amethyst.model.preferences.KeyStoreEncryption +import com.vitorpamplona.amethyst.commons.model.preferences.SecretEncryption import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.Dispatchers @@ -30,7 +30,7 @@ import kotlinx.coroutines.withContext import java.io.File /** - * Android implementation of [KeyPackageBundleStore] using file-based encrypted storage. + * File-backed [KeyPackageBundleStore], encrypted at rest. * * Storage layout: * ``` @@ -40,18 +40,18 @@ import java.io.File * The blob contains private key material — init keys, encryption keys, * signature keys — that the MLS engine needs to process Welcome events * received days or weeks after the corresponding KeyPackage was published. - * It is encrypted at rest with [KeyStoreEncryption] (AES/GCM via Android - * KeyStore), the same primitive used by [AndroidMlsGroupStateStore]. + * It is encrypted at rest with [SecretEncryption] (AES-256-GCM, keyed by the platform's + * keystore), the same primitive used by [EncryptedMlsGroupStateStore]. */ -class AndroidKeyPackageBundleStore( +class EncryptedKeyPackageBundleStore( private val rootDir: File, - private val encryption: KeyStoreEncryption = KeyStoreEncryption(), + private val encryption: SecretEncryption = SecretEncryption(), ) : KeyPackageBundleStore { private val mutex = Mutex() init { Log.d(TAG) { - "Initialized AndroidKeyPackageBundleStore: rootDir=${rootDir.absolutePath}" + "Initialized EncryptedKeyPackageBundleStore: rootDir=${rootDir.absolutePath}" } } @@ -121,6 +121,6 @@ class AndroidKeyPackageBundleStore( } companion object { - private const val TAG = "AndroidKeyPackageBundleStore" + private const val TAG = "EncryptedKeyPackageBundleStore" } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidMarmotMessageStore.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/marmot/EncryptedMarmotMessageStore.kt similarity index 95% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidMarmotMessageStore.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/marmot/EncryptedMarmotMessageStore.kt index e7641cb3bf..0c0cc10d4b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidMarmotMessageStore.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/marmot/EncryptedMarmotMessageStore.kt @@ -18,11 +18,11 @@ * 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.marmot +package com.vitorpamplona.amethyst.commons.marmot -import com.vitorpamplona.amethyst.commons.marmot.EncryptedAppendLog -import com.vitorpamplona.amethyst.model.preferences.KeyStoreEncryption -import com.vitorpamplona.quartz.marmot.mls.group.MarmotMessageStore +import com.vitorpamplona.amethyst.commons.model.preferences.SecretEncryption +import com.vitorpamplona.amethyst.commons.storage.EncryptedAppendLog +import com.vitorpamplona.quartz.marmot.groups.MarmotMessageStore import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.Dispatchers @@ -32,9 +32,9 @@ import kotlinx.coroutines.withContext import java.io.File /** - * Android implementation of [MarmotMessageStore] using file-based encrypted storage. + * File-backed [MarmotMessageStore], encrypted at rest. * - * Stored alongside the [AndroidMlsGroupStateStore] data: + * Stored alongside the [EncryptedMlsGroupStateStore] data: * ``` * /mls_groups//messages — encrypted message log * ``` @@ -44,15 +44,15 @@ import java.io.File * small encrypted segment instead of rewriting the conversation, which is what * keeps the cost of a send flat as the history grows. */ -class AndroidMarmotMessageStore( +class EncryptedMarmotMessageStore( private val rootDir: File, - private val encryption: KeyStoreEncryption = KeyStoreEncryption(), + private val encryption: SecretEncryption = SecretEncryption(), ) : MarmotMessageStore { private val logMutex = Mutex() init { Log.d(TAG) { - "Initialized AndroidMarmotMessageStore: rootDir=${rootDir.absolutePath}" + "Initialized EncryptedMarmotMessageStore: rootDir=${rootDir.absolutePath}" } } @@ -324,7 +324,7 @@ class AndroidMarmotMessageStore( EncryptedAppendLog( encrypt = { encryption.encrypt(it) }, // EncryptedAppendLog requires null, not a throw, for a segment it - // cannot open — KeyStoreEncryption.decrypt rethrows. Without this + // cannot open — SecretEncryption.decrypt rethrows. Without this // one bad segment would abort the whole read, and a caller that // then sees an empty log can overwrite a history that was merely // unreadable. @@ -353,7 +353,7 @@ class AndroidMarmotMessageStore( ) = log.rewrite(file, messages) companion object { - private const val TAG = "AndroidMarmotMessageStore" + private const val TAG = "EncryptedMarmotMessageStore" private val HEX_PATTERN = Regex("^[0-9a-fA-F]+$") } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidMlsGroupStateStore.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/marmot/EncryptedMlsGroupStateStore.kt similarity index 93% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidMlsGroupStateStore.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/marmot/EncryptedMlsGroupStateStore.kt index 504037d630..c7e2927b9c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidMlsGroupStateStore.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/marmot/EncryptedMlsGroupStateStore.kt @@ -18,10 +18,10 @@ * 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.marmot +package com.vitorpamplona.amethyst.commons.marmot -import com.vitorpamplona.amethyst.model.preferences.KeyStoreEncryption -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore +import com.vitorpamplona.amethyst.commons.model.preferences.SecretEncryption +import com.vitorpamplona.quartz.marmot.groups.MlsGroupStateStore import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -29,10 +29,10 @@ import java.io.File import java.io.FileOutputStream /** - * Android implementation of [MlsGroupStateStore] using file-based encrypted storage. + * File-backed [MlsGroupStateStore], encrypted at rest. * * All MLS group state (containing private keys and epoch secrets) is encrypted - * at rest using [KeyStoreEncryption] (AES/GCM backed by Android KeyStore). + * at rest using [SecretEncryption] (AES-256-GCM, keyed by the platform's keystore). * * Storage layout: * ``` @@ -41,13 +41,13 @@ import java.io.FileOutputStream * /mls_groups//ratchet — encrypted OwnSenderRatchet * ``` */ -class AndroidMlsGroupStateStore( +class EncryptedMlsGroupStateStore( private val rootDir: File, - private val encryption: KeyStoreEncryption = KeyStoreEncryption(), + private val encryption: SecretEncryption = SecretEncryption(), ) : MlsGroupStateStore { init { Log.d(TAG) { - "Initialized AndroidMlsGroupStateStore: rootDir=${rootDir.absolutePath}, " + + "Initialized EncryptedMlsGroupStateStore: rootDir=${rootDir.absolutePath}, " + "mls_groups exists=${File(rootDir, "mls_groups").exists()}" } } @@ -61,7 +61,7 @@ class AndroidMlsGroupStateStore( } companion object { - private const val TAG = "AndroidMlsGroupStateStore" + private const val TAG = "EncryptedMlsGroupStateStore" private val HEX_PATTERN = Regex("^[0-9a-fA-F]+$") } @@ -274,7 +274,7 @@ class AndroidMlsGroupStateStore( // Fallback: if rename fails (e.g., cross-filesystem), copy and delete tempFile.copyTo(target, overwrite = true) if (!tempFile.delete()) { - Log.w("AndroidMlsGroupStateStore") { "Failed to delete temp file after copy fallback: ${tempFile.absolutePath}" } + Log.w("EncryptedMlsGroupStateStore") { "Failed to delete temp file after copy fallback: ${tempFile.absolutePath}" } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidPublishObligationStore.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/marmot/EncryptedPublishObligationStore.kt similarity index 94% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidPublishObligationStore.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/marmot/EncryptedPublishObligationStore.kt index 445e91ee91..054ec0d035 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidPublishObligationStore.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/marmot/EncryptedPublishObligationStore.kt @@ -18,9 +18,9 @@ * 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.marmot +package com.vitorpamplona.amethyst.commons.marmot -import com.vitorpamplona.amethyst.model.preferences.KeyStoreEncryption +import com.vitorpamplona.amethyst.commons.model.preferences.SecretEncryption import com.vitorpamplona.quartz.marmot.protocolCore.MarmotPublishObligationStore import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.utils.Log @@ -31,8 +31,8 @@ import kotlinx.coroutines.withContext import java.io.File /** - * Android implementation of [MarmotPublishObligationStore], encrypted at rest - * with [KeyStoreEncryption] like the group-state and KeyPackage stores. + * File-backed [MarmotPublishObligationStore], encrypted at rest + * with [SecretEncryption] like the group-state and KeyPackage stores. * * ``` * /marmot_obligations/.obligation @@ -49,9 +49,9 @@ import java.io.File * concurrently and resolve out of order, so removing one record must not * rewrite another's. */ -class AndroidPublishObligationStore( +class EncryptedPublishObligationStore( private val rootDir: File, - private val encryption: KeyStoreEncryption = KeyStoreEncryption(), + private val encryption: SecretEncryption = SecretEncryption(), ) : MarmotPublishObligationStore { private val mutex = Mutex() @@ -184,6 +184,6 @@ class AndroidPublishObligationStore( } companion object { - private const val TAG = "AndroidPublishObligationStore" + private const val TAG = "EncryptedPublishObligationStore" } } diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/nip52Calendar/IcsExport.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/nip52Calendar/IcsExport.kt index ecf32fda47..2c5e7b579b 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/nip52Calendar/IcsExport.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/nip52Calendar/IcsExport.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.commons.model.nip52Calendar import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent -import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent +import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarCollectionEvent import java.time.Instant import java.time.LocalDate import java.time.ZoneOffset @@ -71,7 +71,7 @@ object IcsExport { * haven't been fetched from relays yet. */ fun calendarToIcs( - calendar: CalendarEvent, + calendar: CalendarCollectionEvent, memberEvents: List>, nowSeconds: Long, ): String { @@ -110,7 +110,7 @@ object IcsExport { return safeFilename(title ?: address.dTag) + ".ics" } - fun calendarFilename(calendar: CalendarEvent): String = safeFilename(calendar.title() ?: "calendar") + ".ics" + fun calendarFilename(calendar: CalendarCollectionEvent): String = safeFilename(calendar.title() ?: "calendar") + ".ics" private fun safeFilename(raw: String): String = raw diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/nip57Zaps/ZapAction.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/nip57Zaps/ZapAction.kt index 34e116b43b..0066a7ed89 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/nip57Zaps/ZapAction.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/nip57Zaps/ZapAction.kt @@ -24,8 +24,8 @@ import com.vitorpamplona.amethyst.commons.service.lnurl.LightningAddressResolver import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapRequestEvent /** * Handles NIP-57 zap requests and invoice fetching. @@ -66,7 +66,7 @@ object ZapAction { relays: Set, signer: NostrSigner, resolver: LightningAddressResolver, - zapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PUBLIC, + zapType: ZapReceiptEvent.ZapType = ZapReceiptEvent.ZapType.PUBLIC, onProgress: (Float) -> Unit = {}, ): ZapResult { if (!signer.isWriteable()) { @@ -76,7 +76,7 @@ object ZapAction { // Create zap request using quartz factory val zapRequest = try { - LnZapRequestEvent.create( + ZapRequestEvent.create( zappedEvent = targetEvent, relays = relays, signer = signer, @@ -120,7 +120,7 @@ object ZapAction { relays: Set, signer: NostrSigner, resolver: LightningAddressResolver, - zapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PUBLIC, + zapType: ZapReceiptEvent.ZapType = ZapReceiptEvent.ZapType.PUBLIC, onProgress: (Float) -> Unit = {}, ): ZapResult { if (!signer.isWriteable()) { @@ -130,7 +130,7 @@ object ZapAction { // Create zap request for user val zapRequest = try { - LnZapRequestEvent.create( + ZapRequestEvent.create( userHex = userPubHex, relays = relays, signer = signer, diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountRosterStore.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountRosterStore.kt new file mode 100644 index 0000000000..2090fa600f --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountRosterStore.kt @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.preferences + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.stringPreferencesKey +import kotlinx.coroutines.CoroutineScope + +/** + * Which accounts this installation holds, and which one is in front. + * + * Not per-account — this is the index that has to be read before any account + * can be. Losing it is the worst failure in the preference layer: every key + * survives untouched, and the app still opens as if freshly installed. + * + * Encrypted because it was encrypted before. The contents are npubs and two + * booleans rather than key material, but they are the list of identities on + * this device, and moving them to a plain store would be a quiet downgrade. + * + * Accessors are per-field rather than one group: the app writes the current + * account and the account list on separate paths, and a group save would make + * each one clobber the other's value. + */ +class AccountRosterStore( + store: DataStore, + encryption: SecretEncryption, + scope: CoroutineScope, +) { + companion object { + private val currentAccountKey = stringPreferencesKey("currently_logged_in_account") + private val allAccountInfoKey = stringPreferencesKey("all_saved_accounts_info") + private val migratedKey = stringPreferencesKey("migrated.roster") + } + + private val encrypted = EncryptedDataStore(store, encryption, scope) + + /** True once the one-off copy out of the legacy encrypted file has run. */ + suspend fun hasMigrated(): Boolean = encrypted.get(migratedKey) != null + + suspend fun markMigrated() { + encrypted.save(migratedKey, "true") + } + + suspend fun currentAccount(): String? = encrypted.get(currentAccountKey) + + suspend fun setCurrentAccount(npub: String?) { + if (npub != null) encrypted.save(currentAccountKey, npub) else encrypted.remove(currentAccountKey) + } + + /** The account list as the JSON the app already stores; parsing stays at the call site. */ + suspend fun allAccountInfoJson(): String? = encrypted.get(allAccountInfoKey) + + suspend fun setAllAccountInfoJson(json: String?) { + if (json != null) encrypted.save(allAccountInfoKey, json) else encrypted.remove(allAccountInfoKey) + } + + /** + * Wipes the roster, matching the legacy `clear()` on the global file when + * the last account is removed. + * + * The migrated marker goes too: with the legacy file cleared as well, there + * is nothing left to copy, and leaving the marker set would be a claim + * about a migration whose source no longer exists. + */ + suspend fun clear() { + encrypted.remove(currentAccountKey) + encrypted.remove(allAccountInfoKey) + encrypted.remove(migratedKey) + } +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountSecrets.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountSecrets.kt new file mode 100644 index 0000000000..46769ead3d --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountSecrets.kt @@ -0,0 +1,202 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.preferences + +import androidx.datastore.preferences.core.stringPreferencesKey + +/** + * The per-account values that used to live in the encrypted + * `secret_keeper_` file, other than the private key itself. + * + * Everything here stays encrypted, including the parts that are not obviously + * secret. `defaultPaymentSourceId` is only an identifier and + * `nip46SeenRequestIds` only request ids, but both were encrypted before and + * both say something about what the account does — moving them to a plain + * store would be a quiet downgrade, so they keep the protection they had. + * + * The two `legacy` fields are read-only leftovers the app still migrates from; + * they are carried across so an upgrade does not strand a wallet that only + * exists in the old shape. + */ +data class AccountSecrets( + val nip46SignerEnabled: Boolean = false, + val nip46BunkerSecret: String = "", + val nip46TransportKey: String = "", + val nip46SeenRequestIds: Set = emptySet(), + val nwcWalletsJson: String? = null, + val clinkDebitWalletsJson: String? = null, + val defaultPaymentSourceId: String? = null, + val legacyDefaultNwcWalletId: String? = null, + val legacyZapPaymentRequestServer: String? = null, +) + +/** + * Keys for [AccountSecrets] inside an [EncryptedDataStore]. + * + * All of them are string keys: the store encrypts strings, so a boolean and a + * set are encoded here rather than stored in typed keys that would sit in + * cleartext beside the encrypted values. + */ +internal object AccountSecretKeys { + val nip46SignerEnabled = stringPreferencesKey("nip46SignerEnabled") + val nip46BunkerSecret = stringPreferencesKey("nip46BunkerSecret") + val nip46TransportKey = stringPreferencesKey("nip46TransportKey") + val nip46SeenRequestIds = stringPreferencesKey("nip46SeenRequestIds") + val nwcWallets = stringPreferencesKey("nwcWallets") + val clinkDebitWallets = stringPreferencesKey("clinkDebitWallets") + val defaultPaymentSourceId = stringPreferencesKey("defaultPaymentSourceId") + val legacyDefaultNwcWalletId = stringPreferencesKey("defaultNwcWalletId") + val legacyZapPaymentRequestServer = stringPreferencesKey("zapPaymentServer") + + /** Records that the one-off copy out of the legacy file has run for this account. */ + val migrated = stringPreferencesKey("migrated.accountSecrets") + + /** + * Sets are stored newline-joined rather than as JSON. + * + * The members are nostr event ids — hex, so they cannot contain a newline — + * which makes the round trip exact without pulling a serializer into the + * encryption path. + */ + const val SET_SEPARATOR = "\n" +} + +/** + * The names [AccountSecrets] had in the `secret_keeper_` file, and how to + * read a set of them back out. + * + * Unlike the plain-store groups, these are still written to both stores on + * every save, so this is not only a migration source: it is what lets a check + * read the legacy file and the current one and assert they agree before the + * legacy file is deleted. + */ +object LegacyAccountSecretNames { + const val NIP46_SIGNER_ENABLED = "nip46SignerEnabled" + const val NIP46_BUNKER_SECRET = "nip46BunkerSecret" + const val NIP46_TRANSPORT_KEY = "nip46TransportKey" + const val NIP46_SEEN_IDS = "nip46SeenRequestIds" + const val NWC_WALLETS = "nwcWallets" + const val CLINK_DEBIT_WALLETS = "clinkDebitWallets" + const val DEFAULT_PAYMENT_SOURCE_ID = "defaultPaymentSourceId" + const val DEFAULT_NWC_WALLET_ID = "defaultNwcWalletId" + const val ZAP_PAYMENT_REQUEST_SERVER = "zapPaymentServer" + + /** The private key, which lives in its own store rather than in [AccountSecrets]. */ + const val NOSTR_PRIVKEY = "nostr_privkey" + + /** + * The location-chat identity, which is [GeohashIdentitySecrets] rather than + * part of [AccountSecrets] — see that class for why it is its own group. + * + * These two sit in `secret_keeper_`, not `secret_keeper_`: + * the writer passed `signer.pubKey`, which is hex, where every other caller + * passes an npub. They are therefore in a *different file* from everything + * else named here, which is why they are deliberately **not** in [all]: + * [all] is what `LegacyPreferenceCleanup` treats as claimed in the npub + * file, and these never appear in it. That file's deletion cannot lose + * them, and cannot clean them up either — retiring the hex file is its own + * job, once these writes stop. + */ + const val GEOHASH_DEVICE_SEED = "geohash_chat_device_seed" + const val GEOHASH_NICKNAME = "geohash_chat_nickname" + + val all = + setOf( + NIP46_SIGNER_ENABLED, + NIP46_BUNKER_SECRET, + NIP46_TRANSPORT_KEY, + NIP46_SEEN_IDS, + NWC_WALLETS, + CLINK_DEBIT_WALLETS, + DEFAULT_PAYMENT_SOURCE_ID, + DEFAULT_NWC_WALLET_ID, + ZAP_PAYMENT_REQUEST_SERVER, + NOSTR_PRIVKEY, + ) +} + +/** + * The secrets as the legacy file holds them. + * + * Absent keys become the same defaults the loader has always applied, so this + * is directly comparable with what the current store returns. + */ +fun readLegacyAccountSecrets(source: LegacyPreferenceSource) = + AccountSecrets( + nip46SignerEnabled = source.getBoolean(LegacyAccountSecretNames.NIP46_SIGNER_ENABLED) ?: false, + nip46BunkerSecret = source.getString(LegacyAccountSecretNames.NIP46_BUNKER_SECRET) ?: "", + nip46TransportKey = source.getString(LegacyAccountSecretNames.NIP46_TRANSPORT_KEY) ?: "", + nip46SeenRequestIds = source.getStringSet(LegacyAccountSecretNames.NIP46_SEEN_IDS) ?: emptySet(), + nwcWalletsJson = source.getString(LegacyAccountSecretNames.NWC_WALLETS), + clinkDebitWalletsJson = source.getString(LegacyAccountSecretNames.CLINK_DEBIT_WALLETS), + defaultPaymentSourceId = source.getString(LegacyAccountSecretNames.DEFAULT_PAYMENT_SOURCE_ID), + legacyDefaultNwcWalletId = source.getString(LegacyAccountSecretNames.DEFAULT_NWC_WALLET_ID), + legacyZapPaymentRequestServer = source.getString(LegacyAccountSecretNames.ZAP_PAYMENT_REQUEST_SERVER), + ) + +/** + * The account's location-chat identity: the seed its per-geohash throwaway keys + * come from, and the handle it posts under. + * + * # Why this is not two more fields on [AccountSecrets] + * + * Every account save mirrors a whole [AccountSecrets], built field by field from + * `AccountSettings` — which does not hold these, because they are owned by + * `GeohashChatIdentityState` rather than by the settings object. Folding them in + * would make each save write null over them, and the group save uses + * `putOrRemove`, so null *deletes*. The seed would vanish on the next unrelated + * save and every geohash identity the user has would silently change. A separate + * group with its own save path cannot be wiped by a save that does not know + * about it. + * + * # Why encrypted + * + * The whole point of the seed is that the identities derived from it are + * unlinkable to the npub. Anyone who can read it can link every cell the user + * has ever posted in, to each other and to the device, which is exactly what the + * feature exists to prevent. It was in an encrypted file before; it stays in one. + */ +data class GeohashIdentitySecrets( + val deviceSeed: String? = null, + val nickname: String? = null, +) + +/** Keys for [GeohashIdentitySecrets] inside an [EncryptedDataStore]. */ +internal object GeohashIdentityKeys { + val deviceSeed = stringPreferencesKey(LegacyAccountSecretNames.GEOHASH_DEVICE_SEED) + val nickname = stringPreferencesKey(LegacyAccountSecretNames.GEOHASH_NICKNAME) + + /** Records that the one-off copy out of the legacy file has run for this account. */ + val migrated = stringPreferencesKey("migrated.geohashIdentity") +} + +/** + * The location-chat identity as the legacy file holds it. + * + * Both absent is a real answer — an account that never opened a location chat — + * and is why the caller compares against [GeohashIdentitySecrets] rather than + * treating null as "not migrated". + */ +fun readLegacyGeohashIdentity(source: LegacyPreferenceSource) = + GeohashIdentitySecrets( + deviceSeed = source.getString(LegacyAccountSecretNames.GEOHASH_DEVICE_SEED), + nickname = source.getString(LegacyAccountSecretNames.GEOHASH_NICKNAME), + ) diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountSecretsEncryptedStores.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountSecretsEncryptedStores.kt new file mode 100644 index 0000000000..90d4a323e3 --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountSecretsEncryptedStores.kt @@ -0,0 +1,224 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.preferences + +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.stringPreferencesKey +import com.vitorpamplona.amethyst.commons.util.platformFileSystem +import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect +import com.vitorpamplona.quartz.utils.cache.LargeCache +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.job +import okio.Path + +/** + * The per-account secret store: one encrypted DataStore per npub, at + * `/datastore/.secrets_pb`, for secrets that are not the identity + * key — wallet connection strings, NIP-46 bunker material. + * + * **Private keys do not belong here.** They live in + * [com.vitorpamplona.amethyst.commons.keystorage.SecureKeyStorage], which backs + * them with the OS credential manager (Keychain, Credential Manager, Secret + * Service) rather than a file this process can read, and which desktop already + * uses. Two stores for one identity key would be one store too many, and the + * weaker one would win by being convenient. + * + * Separate from [AccountPreferenceStores] so ordinary settings stay cheap to + * read: every value here pays an encrypt/decrypt, which on a StrongBox-backed + * device runs at roughly 68 KB/s. + */ +class AccountSecretsEncryptedStores( + val rootFilesDir: () -> Path, + val scope: CoroutineScope, + private val encryption: SecretEncryption = SecretEncryption(), +) { + companion object { + val nwc = stringPreferencesKey("nwc") + } + + /** + * One store per account, each on its own child scope. + * + * The scope matters: DataStore keeps a process-wide registry keyed by file + * path and only releases an entry when the owning scope is cancelled. On a + * shared scope, deleting an account and re-adding it in the same session + * would throw "multiple DataStores active for the same file". + */ + private class Entry( + val scope: CoroutineScope, + val store: EncryptedDataStore, + ) + + private val storeCache = LargeCache() + + /** + * The `.preferences_pb` suffix is required, not decorative: DataStore's + * Preferences factory rejects any other extension at open time. The + * `.secrets` part is what keeps this file distinct from the account's + * plain preference store. + */ + fun file(npub: String): Path = rootFilesDir() / "datastore" / "$npub.secrets.preferences_pb" + + fun getDataStore(npub: String): EncryptedDataStore = + storeCache + .getOrCreate(npub) { + val child = CoroutineScope(scope.coroutineContext + SupervisorJob(scope.coroutineContext[Job])) + Entry( + child, + EncryptedDataStore( + PreferenceDataStoreFactory.createWithPath(scope = child, produceFile = { file(npub) }), + encryption, + scope = child, + ), + ) + }.store + + fun nwc(npub: String): UpdatablePropertyFlow = + getDataStore(npub).getProperty( + key = nwc, + parser = Nip47WalletConnect.Nip47URI::parser, + serializer = Nip47WalletConnect.Nip47URI::serializer, + ) + + /** + * Drops the account's secrets. + * + * Cancels the store's scope and waits for it, so DataStore releases the + * path and the same account can be added again in this session. The wait + * is the point: `cancel()` only asks, and the path stays registered until + * the job completes — [AccountPreferenceStores.removeAccount] has the + * longer note. + */ + suspend fun removeAccount(npub: String): Boolean { + storeCache.get(npub)?.scope?.let { + it.cancel() + it.coroutineContext.job.join() + } + storeCache.remove(npub) + val path = file(npub) + if (!platformFileSystem.exists(path)) return false + platformFileSystem.delete(path) + return true + } + + // ── the per-account secret group ────────────────────────────────── + + /** + * Reads [AccountSecrets], or null when this account has not been migrated + * out of the legacy encrypted file yet. + * + * Null and "all defaults" are deliberately different answers: the caller + * uses null to decide whether to run the one-off copy, and an account that + * genuinely holds no secrets must not trigger it forever. + */ + suspend fun loadSecrets(npub: String): AccountSecrets? { + // One snapshot for the whole group rather than ten flow collections. + val stored = getDataStore(npub).snapshot() + if (stored[AccountSecretKeys.migrated] == null) return null + + return AccountSecrets( + nip46SignerEnabled = stored[AccountSecretKeys.nip46SignerEnabled].toBoolean(), + nip46BunkerSecret = stored[AccountSecretKeys.nip46BunkerSecret] ?: "", + nip46TransportKey = stored[AccountSecretKeys.nip46TransportKey] ?: "", + nip46SeenRequestIds = decodeSet(stored[AccountSecretKeys.nip46SeenRequestIds]), + nwcWalletsJson = stored[AccountSecretKeys.nwcWallets], + clinkDebitWalletsJson = stored[AccountSecretKeys.clinkDebitWallets], + defaultPaymentSourceId = stored[AccountSecretKeys.defaultPaymentSourceId], + legacyDefaultNwcWalletId = stored[AccountSecretKeys.legacyDefaultNwcWalletId], + legacyZapPaymentRequestServer = stored[AccountSecretKeys.legacyZapPaymentRequestServer], + ) + } + + /** + * Writes the group and its marker as one edit. + * + * One edit, not ten. Every account save runs this, and a key at a time cost + * ten encrypted-file rewrites — none of which DataStore could skip, because + * AES-GCM re-randomises the IV so the ciphertext differs even when the value + * does not. + * + * It also makes the marker meaningful. Written in its own transaction after + * the others it merely *tended* to be last; in the same one it cannot exist + * without them, so a marker found on disk proves a complete group — which is + * what `LegacyPreferenceCleanup` reads it as before deleting the legacy file. + */ + suspend fun saveSecrets( + npub: String, + value: AccountSecrets, + ) { + getDataStore(npub).edit { + put(AccountSecretKeys.nip46SignerEnabled, value.nip46SignerEnabled.toString()) + put(AccountSecretKeys.nip46BunkerSecret, value.nip46BunkerSecret) + put(AccountSecretKeys.nip46TransportKey, value.nip46TransportKey) + put(AccountSecretKeys.nip46SeenRequestIds, value.nip46SeenRequestIds.joinToString(AccountSecretKeys.SET_SEPARATOR)) + putOrRemove(AccountSecretKeys.nwcWallets, value.nwcWalletsJson) + putOrRemove(AccountSecretKeys.clinkDebitWallets, value.clinkDebitWalletsJson) + putOrRemove(AccountSecretKeys.defaultPaymentSourceId, value.defaultPaymentSourceId) + putOrRemove(AccountSecretKeys.legacyDefaultNwcWalletId, value.legacyDefaultNwcWalletId) + putOrRemove(AccountSecretKeys.legacyZapPaymentRequestServer, value.legacyZapPaymentRequestServer) + + put(AccountSecretKeys.migrated, "true") + } + } + + // ── the location-chat identity ──────────────────────────────────── + + /** + * Reads [GeohashIdentitySecrets], or null when this account has not been + * copied out of the legacy encrypted file yet. + * + * Its own marker, not [AccountSecretKeys.migrated]: the two groups migrate + * from *different files* (this one from `secret_keeper_`, the + * secrets from `secret_keeper_`), so one marker cannot speak for both. + */ + suspend fun loadGeohashIdentity(npub: String): GeohashIdentitySecrets? { + val stored = getDataStore(npub).snapshot() + if (stored[GeohashIdentityKeys.migrated] == null) return null + + return GeohashIdentitySecrets( + deviceSeed = stored[GeohashIdentityKeys.deviceSeed], + nickname = stored[GeohashIdentityKeys.nickname], + ) + } + + /** Writes the group and its marker as one edit, for the reasons [saveSecrets] gives. */ + suspend fun saveGeohashIdentity( + npub: String, + value: GeohashIdentitySecrets, + ) { + getDataStore(npub).edit { + putOrRemove(GeohashIdentityKeys.deviceSeed, value.deviceSeed) + putOrRemove(GeohashIdentityKeys.nickname, value.nickname) + + put(GeohashIdentityKeys.migrated, "true") + } + } + + private fun decodeSet(raw: String?): Set = + raw + ?.split(AccountSecretKeys.SET_SEPARATOR) + ?.filter { it.isNotEmpty() } + ?.toSet() + ?: emptySet() +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/EncryptedDataStore.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/EncryptedDataStore.kt new file mode 100644 index 0000000000..477e50fe0b --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/EncryptedDataStore.kt @@ -0,0 +1,194 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.preferences + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.MutablePreferences +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.map +import okio.IOException +import kotlin.io.encoding.Base64 + +/** + * A DataStore whose values are encrypted with [SecretEncryption] and stored + * Base64-encoded, for anything that must not sit in plaintext on disk. + * + * Keys stay in the clear — only values are encrypted — so the set of keys an + * account has is visible even though their contents are not. + */ +class EncryptedDataStore( + private val store: DataStore, + private val encryption: SecretEncryption = sharedSecretEncryption, + private val scope: CoroutineScope, +) { + private fun encrypt(value: String): String = Base64.encode(encryption.encrypt(value.encodeToByteArray())) + + private fun decrypt(value: String): String? = encryption.decrypt(Base64.decode(value))?.decodeToString() + + suspend fun remove(key: Preferences.Key) { + store.edit { prefs -> prefs.remove(key) } + } + + suspend fun save( + key: Preferences.Key, + value: String, + ) { + store.edit { prefs -> prefs[key] = encrypt(value) } + } + + /** + * The value, or null when the key is absent **or unreadable**. + * + * A read error is reported as absence, which is what most callers want. + * Anything that must not mistake a failure for an empty store — a probe + * deciding whether to create a replacement key, say — needs [getOrThrow]. + */ + suspend fun get(key: Preferences.Key): String? = + store.data + .catch { e -> + if (e is IOException) emit(emptyPreferences()) else throw e + }.firstOrNull() + ?.get(key) + ?.let { decrypt(it) } + + /** + * Whether the key is present, without decrypting it. + * + * For callers that only need presence — deleting, say. [get] would report a + * value it cannot decrypt as absent, which is the wrong answer when the + * decision being made is whether to remove it. + */ + suspend fun contains(key: Preferences.Key): Boolean = + store.data + .catch { e -> if (e is IOException) emit(emptyPreferences()) else throw e } + .firstOrNull() + ?.contains(key) == true + + /** + * The value, or null only when the key is genuinely absent. + * + * Unlike [get], a failure to read propagates rather than being flattened + * into null. The difference matters wherever null means "nothing was ever + * stored" and the caller acts on that — overwriting a key that is present + * but temporarily unreadable is not recoverable. + */ + suspend fun getOrThrow(key: Preferences.Key): String? = store.data.first()[key]?.let { decrypt(it) } + + /** + * Reads or writes several keys against one snapshot of the store. + * + * A key at a time costs a full DataStore round trip each — a transform, a + * serialize, a temp-file write, an fsync and a rename to save; a fresh flow + * collection to read. Worse for writes, AES-GCM re-randomises the IV, so the + * ciphertext differs every time and DataStore's "value unchanged, skip the + * write" shortcut never fires: all of them always reach disk. + * + * One [edit] is also a single transaction, which is what lets a group be + * written with its own migration marker and never be seen half-applied. + */ + suspend fun edit(block: Editor.() -> Unit) { + store.edit { prefs -> Editor(prefs, ::encrypt).block() } + } + + class Editor internal constructor( + private val prefs: MutablePreferences, + private val encrypt: (String) -> String, + ) { + fun put( + key: Preferences.Key, + value: String, + ) { + prefs[key] = encrypt(value) + } + + fun remove(key: Preferences.Key) { + prefs.remove(key) + } + + fun putOrRemove( + key: Preferences.Key, + value: String?, + ) { + if (value != null) put(key, value) else remove(key) + } + } + + /** + * One snapshot of the store, decrypting on access. + * + * Reads every key of a group against the same collection, and — since the + * values are one atomic write — against the same version of it. + */ + suspend fun snapshot(): Snapshot = + Snapshot( + store.data + .catch { e -> if (e is IOException) emit(emptyPreferences()) else throw e } + .firstOrNull() ?: emptyPreferences(), + ::decrypt, + ) + + class Snapshot internal constructor( + private val prefs: Preferences, + private val decrypt: (String) -> String?, + ) { + operator fun get(key: Preferences.Key): String? = prefs[key]?.let(decrypt) + } + + fun getProperty( + key: Preferences.Key, + parser: (String) -> T, + serializer: (T) -> String, + ): UpdatablePropertyFlow = + UpdatablePropertyFlow( + flow = + store.data + .catch { e -> + if (e is IOException) emit(emptyPreferences()) else throw e + }.map { prefs -> + prefs[key]?.let { decrypt(it) }?.takeIf { it.isNotBlank() }?.let(parser) + }, + update = { newValue -> + val serialized = newValue?.let(serializer) + if (serialized != null && serialized.isNotBlank()) { + save(key, serialized) + } else { + remove(key) + } + }, + scope = scope, + ) +} + +/** + * The one [SecretEncryption] every encrypted store shares. + * + * Its constructor loads the AndroidKeyStore and its first use probes the key's + * security level, and each instance keeps its own per-thread Cipher cache — all + * for a single key alias. There were eight instances doing that independently. + * Both actuals are documented as safe for concurrent use, so one will do. + */ +internal val sharedSecretEncryption: SecretEncryption by lazy { SecretEncryption() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/OtsSharedPreferences.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/OtsSettingsStore.kt similarity index 60% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/OtsSharedPreferences.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/OtsSettingsStore.kt index 9063633321..b3e7815201 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/OtsSharedPreferences.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/OtsSettingsStore.kt @@ -18,51 +18,62 @@ * 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.preferences +package com.vitorpamplona.amethyst.commons.model.preferences -import android.content.Context import androidx.compose.runtime.Stable +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey import com.vitorpamplona.amethyst.commons.model.nip03Timestamp.OtsSettings import com.vitorpamplona.quartz.utils.Log -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.first -import kotlinx.coroutines.runBlocking import kotlin.coroutines.cancellation.CancellationException /** - * Persistent storage for [OtsSettings], following the same pattern as - * [NamecoinSharedPreferences]. + * Persistent storage for [OtsSettings] — which blockchain explorer the user + * has pointed OpenTimestamps at. * - * Uses the app-wide [sharedPreferencesDataStore] so OTS explorer settings - * are global — not per-account. + * App-wide, not per-account, and shares [AppPreferenceStores.SHARED_SETTINGS] + * with the other global settings groups under its own `ots.` key prefix. + * + * Lives in `jvmAndroid` rather than `commonMain` only because [OtsSettings] + * does: it names OkHttp's explorer constants, and OkHttp is JVM-bound. Android + * and Desktop still share it. + * + * [initial] is taken rather than read here because the current value has to be + * available synchronously from [current] — a resolver builder reads it from a + * non-suspending lambda. The caller loads it with [load] and decides how to + * wait; commonMain has no `runBlocking` to hide that decision behind. */ @Stable -class OtsSharedPreferences( - private val context: Context, - private val scope: CoroutineScope, +class OtsSettingsStore( + private val store: DataStore, + initial: OtsSettings, ) { companion object { val KEY_CUSTOM_EXPLORER_URL = stringPreferencesKey("ots.customExplorerUrl") + + /** The stored settings, or [OtsSettings.DEFAULT] if unset or unreadable. */ + suspend fun load(store: DataStore): OtsSettings = + try { + val url = store.data.first()[KEY_CUSTOM_EXPLORER_URL]?.takeIf { it.isNotBlank() } + OtsSettings(customExplorerUrl = url) + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("OtsSettingsStore") { "Error reading DataStore: ${e.message}" } + OtsSettings.DEFAULT + } } - /** - * Current settings, loaded synchronously at init to avoid races. - */ - private val _settings = - MutableStateFlow( - runBlocking { loadFromDisk() ?: OtsSettings.DEFAULT }, - ) + private val _settings = MutableStateFlow(initial) val settings: StateFlow = _settings /** Synchronous snapshot — safe to call from resolver builder lambdas. */ val current: OtsSettings get() = _settings.value - // ── Mutators ─────────────────────────────────────────────────────── - suspend fun setCustomExplorerUrl(url: String?) { val normalized = url?.trim()?.takeIf { it.isNotBlank() } persist(current.copy(customExplorerUrl = normalized)) @@ -72,12 +83,10 @@ class OtsSharedPreferences( persist(OtsSettings.DEFAULT) } - // ── Internal ─────────────────────────────────────────────────────── - private suspend fun persist(settings: OtsSettings) { _settings.value = settings try { - context.sharedPreferencesDataStore.edit { prefs -> + store.edit { prefs -> val customExplorerUrl = settings.customExplorerUrl if (customExplorerUrl != null) { prefs[KEY_CUSTOM_EXPLORER_URL] = customExplorerUrl @@ -87,18 +96,7 @@ class OtsSharedPreferences( } } catch (e: Exception) { if (e is CancellationException) throw e - Log.e("OtsPrefs") { "Error writing DataStore: ${e.message}" } + Log.e("OtsSettingsStore") { "Error writing DataStore: ${e.message}" } } } - - private suspend fun loadFromDisk(): OtsSettings? = - try { - val prefs = context.sharedPreferencesDataStore.data.first() - val url = prefs[KEY_CUSTOM_EXPLORER_URL]?.takeIf { it.isNotBlank() } - OtsSettings(customExplorerUrl = url) - } catch (e: Exception) { - if (e is CancellationException) throw e - Log.e("OtsPrefs") { "Error reading DataStore: ${e.message}" } - null - } } diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/SecretEncryption.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/SecretEncryption.kt new file mode 100644 index 0000000000..22ad8f450e --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/SecretEncryption.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.preferences + +/** + * Symmetric encryption for data this app stores at rest — account secrets, the + * Marmot group state, message bodies. + * + * Declared for `jvmAndroid` rather than `commonMain` on purpose: Android backs + * it with the hardware-held AndroidKeyStore and desktop with a key file the OS + * user owns, and those are the two targets that store secrets today. An Apple + * actual belongs with the first iOS build that needs one, written against the + * Keychain — not stubbed here, where nothing would exercise it. + * + * Implementations must be safe to call from several coroutines at once. + */ +expect class SecretEncryption() { + /** Returns the ciphertext with whatever nonce/IV the implementation needs prefixed. */ + fun encrypt(bytes: ByteArray): ByteArray + + /** Inverse of [encrypt]. Throws if the input is not what [encrypt] produced. */ + fun decrypt(bytes: ByteArray): ByteArray? +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlPreview.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlPreview.kt index a3e3676e3f..78a918541f 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlPreview.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlPreview.kt @@ -125,4 +125,9 @@ private fun ResponseBody.readPrefix(max: Long): ByteArray = src.buffer.readByteArray(minOf(src.buffer.size, max)) } -private const val MAX_PREVIEW_BYTES = 512L * 1024L +// Large enough for real ``s, not just typical ones. YouTube's watch page front-loads its +// head with inline script: youtu.be/fwcKTYvupJw (2026-09) is 1.29 MB, and its og: block starts +// 708,776 bytes in, before `` at 716,956. The earlier 512 KB cut it off, so every YouTube +// link lost its card. 2 MB leaves headroom for that head to grow while still bounding a body +// that never ends. +private const val MAX_PREVIEW_BYTES = 2L * 1024L * 1024L diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/Nip11CachedRetriever.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relays/nip11RelayInfo/Nip11CachedRetriever.kt similarity index 92% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/Nip11CachedRetriever.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relays/nip11RelayInfo/Nip11CachedRetriever.kt index e97b3a6270..b6fc1b1e38 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/Nip11CachedRetriever.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relays/nip11RelayInfo/Nip11CachedRetriever.kt @@ -18,9 +18,9 @@ * 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.nip11RelayInfo +package com.vitorpamplona.amethyst.commons.relays.nip11RelayInfo -import android.util.LruCache +import androidx.collection.LruCache import androidx.compose.runtime.Stable import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl @@ -33,7 +33,12 @@ class Nip11CachedRetriever( val okHttpClient: (NormalizedRelayUrl) -> OkHttpClient, ) { private val relayInformationEmptyCache = LruCache(1000) - private val relayInformationDocumentCache = LruCache(1000) + + // Value type is non-null: androidx.collection.LruCache bounds V to Any, and every put here + // stores a concrete RetrieveResult. The old android.util.LruCache was a Java platform type, so + // the nullable argument compiled but never meant anything — get() returns null on a miss either + // way, which is what the readers below already branch on. + private val relayInformationDocumentCache = LruCache(1000) private val retriever = Nip11Retriever(okHttpClient) /** diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/Nip11Retriever.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relays/nip11RelayInfo/Nip11Retriever.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/Nip11Retriever.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relays/nip11RelayInfo/Nip11Retriever.kt index 10118b09ac..a8ca53429d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/Nip11Retriever.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relays/nip11RelayInfo/Nip11Retriever.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.model.nip11RelayInfo +package com.vitorpamplona.amethyst.commons.relays.nip11RelayInfo import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.toHttp diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/RetrieveResult.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relays/nip11RelayInfo/RetrieveResult.kt similarity index 96% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/RetrieveResult.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relays/nip11RelayInfo/RetrieveResult.kt index 7f749ae12b..bb44f63cb7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/RetrieveResult.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relays/nip11RelayInfo/RetrieveResult.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.model.nip11RelayInfo +package com.vitorpamplona.amethyst.commons.relays.nip11RelayInfo import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation import com.vitorpamplona.quartz.utils.TimeUtils diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/OnlineCheck.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/OnlineCheck.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/OnlineCheck.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/OnlineCheck.kt index 4f1aa7271b..834d011380 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/OnlineCheck.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/OnlineCheck.kt @@ -18,9 +18,9 @@ * 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 +package com.vitorpamplona.amethyst.commons.service -import android.util.LruCache +import androidx.collection.LruCache import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.RandomInstance diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpWebSocket.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/http/OkHttpWebSocket.kt similarity index 99% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpWebSocket.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/http/OkHttpWebSocket.kt index e90277072f..60591cc64c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpWebSocket.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/http/OkHttpWebSocket.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.service.okhttp +package com.vitorpamplona.amethyst.commons.service.http import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocket diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/lnurl/LightningAddressResolver.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/lnurl/LightningAddressResolver.kt index 5acca96399..a675147433 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/lnurl/LightningAddressResolver.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/lnurl/LightningAddressResolver.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.commons.service.lnurl import com.fasterxml.jackson.databind.ObjectMapper import com.vitorpamplona.quartz.lightning.LnInvoiceUtil import com.vitorpamplona.quartz.lightning.Lud06 -import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapRequestEvent import com.vitorpamplona.quartz.nip57Zaps.validate.LnurlEndpointCache import com.vitorpamplona.quartz.nip57Zaps.validate.LnurlEndpointInfo import kotlinx.coroutines.Dispatchers @@ -94,7 +94,7 @@ class LightningAddressResolver( lnAddress: String, milliSats: Long, message: String = "", - zapRequest: LnZapRequestEvent? = null, + zapRequest: ZapRequestEvent? = null, onProgress: (Float) -> Unit = {}, ): Result = withContext(Dispatchers.IO) { @@ -209,7 +209,7 @@ class LightningAddressResolver( callbackUrl: String, milliSats: Long, message: String, - zapRequest: LnZapRequestEvent?, + zapRequest: ZapRequestEvent?, ): String? = withContext(Dispatchers.IO) { try { diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/lnurl/OkHttpLnurlEndpointResolver.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/lnurl/OkHttpLnurlEndpointResolver.kt index dd162f881c..2755642c0f 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/lnurl/OkHttpLnurlEndpointResolver.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/lnurl/OkHttpLnurlEndpointResolver.kt @@ -32,7 +32,7 @@ import okhttp3.coroutines.executeAsync import kotlin.coroutines.cancellation.CancellationException /** - * OkHttp-backed [LnurlEndpointResolver]. Used by `LocalCache.consume(LnZapEvent)` + * OkHttp-backed [LnurlEndpointResolver]. Used by `LocalCache.consume(ZapReceiptEvent)` * to look up a recipient's LNURL provider's `nostrPubkey` when validating an * incoming zap receipt (NIP-57 Appendix F). * diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowJobStore.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWJobStore.kt similarity index 93% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowJobStore.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWJobStore.kt index 97e68b8b19..7ee8316c18 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowJobStore.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWJobStore.kt @@ -18,10 +18,8 @@ * 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.pow +package com.vitorpamplona.amethyst.commons.service.pow -import com.vitorpamplona.amethyst.commons.service.pow.PersistedPoWJob -import com.vitorpamplona.amethyst.commons.service.pow.PoWJobPersistence import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -41,7 +39,7 @@ import java.io.File * [save]/[remove] are the queue-facing fire-and-forget hooks; they serialize * onto a single-lane dispatcher so writes land in call order. */ -class PowJobStore( +class PoWJobStore( private val storageFile: File, scope: CoroutineScope, ) : PoWJobPersistence { @@ -111,7 +109,7 @@ class PowJobStore( jobs = try { if (storageFile.exists() && storageFile.length() > 0) { - json.decodeFromString(storageFile.readText()).jobs.toMutableList() + json.decodeFromString(storageFile.readText()).jobs.toMutableList() } else { mutableListOf() } @@ -128,7 +126,7 @@ class PowJobStore( storageFile.parentFile?.mkdirs() val tmp = File(storageFile.parentFile, storageFile.name + ".tmp") try { - tmp.writeText(json.encodeToString(PowJobsFile(version = 1, jobs = jobs.toList()))) + tmp.writeText(json.encodeToString(PoWJobsFile(version = 1, jobs = jobs.toList()))) if (!tmp.renameTo(storageFile)) { if (!storageFile.delete() || !tmp.renameTo(storageFile)) { Log.e(TAG) { "Failed to rename $tmp to $storageFile" } @@ -146,7 +144,7 @@ class PowJobStore( } companion object { - private const val TAG = "PowJobStore" + private const val TAG = "PoWJobStore" const val FILE_NAME = "pending_pow_jobs.json" // a job this stale is a post the user has long forgotten; publishing @@ -156,7 +154,7 @@ class PowJobStore( } @Serializable -data class PowJobsFile( +data class PoWJobsFile( val version: Int = 1, val jobs: List = emptyList(), ) diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/marmot/EncryptedAppendLog.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/storage/EncryptedAppendLog.kt similarity index 96% rename from commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/marmot/EncryptedAppendLog.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/storage/EncryptedAppendLog.kt index b8ef668cb8..0573a8bc96 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/marmot/EncryptedAppendLog.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/storage/EncryptedAppendLog.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.commons.marmot +package com.vitorpamplona.amethyst.commons.storage import java.io.File import java.io.FileOutputStream @@ -34,10 +34,17 @@ import java.io.RandomAccessFile * plain := uint32 count, (uint32 len, byte[len])* * ``` * + * Protocol-neutral on purpose: it takes [encrypt]/[decrypt] lambdas and stores + * opaque strings, so both group-chat implementations keep their own ciphers and + * their own formats on top of one file layout. It used to live in the `marmot` + * package, which made it unreachable from cordn — the independence guard + * forbids either feature importing the other by name — for no reason other + * than where it happened to be written first. + * * Segments are the whole point. The format this replaces was a single blob * covering the entire history, so recording one line meant pushing every line * ever written back through the cipher and out to disk again — work that grew - * with the log and, for Marmot's message log, was paid on the send path. A + * with the log and was paid on the send path. A * conversation a few thousand messages long was moving hundreds of KB through * a hardware-backed cipher to append a couple of hundred bytes. * @@ -58,7 +65,7 @@ import java.io.RandomAccessFile * * **Not thread-safe.** Entries are cached in memory so an append never has to * read the log back, and that cache assumes one owner. Callers hold their own - * lock around every method (see `AndroidMarmotMessageStore`), and one instance + * lock around every method (see `EncryptedMarmotMessageStore`), and one instance * must own any given file. * * @param encrypt must produce a self-describing blob — it carries its own IV / diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/TorRelayState.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/tor/TorRelayState.kt similarity index 95% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/TorRelayState.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/tor/TorRelayState.kt index b2d4c32286..0c00f35f06 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/torState/TorRelayState.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/tor/TorRelayState.kt @@ -18,15 +18,10 @@ * 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.torState +package com.vitorpamplona.amethyst.commons.tor import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.service.http.DualHttpClientManager -import com.vitorpamplona.amethyst.commons.tor.RelayClassification -import com.vitorpamplona.amethyst.commons.tor.TorRelayEvaluation -import com.vitorpamplona.amethyst.commons.tor.TorRelaySettings -import com.vitorpamplona.amethyst.commons.tor.TorType -import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/OwnerOnlyFile.jvmAndroid.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/OwnerOnlyFile.jvmAndroid.kt new file mode 100644 index 0000000000..cbcfe50038 --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/OwnerOnlyFile.jvmAndroid.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.util + +import com.vitorpamplona.quartz.utils.Log +import okio.Path +import java.nio.file.Files +import java.nio.file.attribute.PosixFilePermission + +actual fun restrictFileToOwner( + path: Path, + tag: String, +) { + try { + Files.setPosixFilePermissions( + path.toNioPath(), + setOf(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE), + ) + } catch (_: UnsupportedOperationException) { + // Windows: no POSIX permissions; the user profile's NTFS ACLs apply instead. + } catch (e: Exception) { + Log.w(tag, "Could not restrict permissions on $path", e) + } +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModel.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModel.kt index 80ad79160f..e642ed3aa5 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModel.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModel.kt @@ -45,6 +45,7 @@ import com.vitorpamplona.nestsclient.connectReconnectingNestsSpeaker import com.vitorpamplona.nestsclient.transport.WebTransportFactory import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent import com.vitorpamplona.quartz.utils.Log import kotlinx.collections.immutable.ImmutableSet @@ -629,7 +630,7 @@ class NestViewModel( * same window so the visual cadence matches the React button. */ fun onZapEvent( - event: com.vitorpamplona.quartz.nip57Zaps.LnZapEvent, + event: ZapReceiptEvent, nowSec: Long, windowSec: Long = REACTION_WINDOW_SEC, ) { diff --git a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/SecretEncryption.jvm.kt b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/SecretEncryption.jvm.kt new file mode 100644 index 0000000000..c11b1505da --- /dev/null +++ b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/SecretEncryption.jvm.kt @@ -0,0 +1,133 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.preferences + +import com.vitorpamplona.amethyst.commons.util.appDataDir +import com.vitorpamplona.amethyst.commons.util.restrictToOwner +import com.vitorpamplona.quartz.utils.Log +import java.io.File +import java.security.SecureRandom +import javax.crypto.Cipher +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.SecretKeySpec + +/** + * Desktop [SecretEncryption]: the same AES-256-GCM as Android, but with the key + * held in a file this OS user owns instead of in hardware. + * + * That difference is real and worth stating plainly. On Android the key lives + * in the AndroidKeyStore and never enters app memory, so a copy of the data + * files is useless without the device. Here the key sits next to the data, + * readable by anything running as this user — encryption at rest that survives + * a stolen disk or a careless backup, not a compromised account. The JVM has no + * portable hardware-backed keystore to do better with; a per-OS keyring binding + * (as `cli`'s SecretStore does for credentials) is the upgrade path. + */ +actual class SecretEncryption internal constructor( + private val keyFile: File, +) { + /** Production entry point: the key file this OS user owns. */ + actual constructor() : this(defaultKeyFile()) + + companion object { + private const val TAG = "SecretEncryption" + private const val TRANSFORMATION = "AES/GCM/NoPadding" + private const val ALGORITHM = "AES" + private const val KEY_SIZE_BYTES = 32 + private const val GCM_IV_LENGTH = 12 + private const val GCM_TAG_LENGTH_BITS = 128 + private const val KEY_FILE_NAME = "secret.key" + + internal fun defaultKeyFile(): File = File(appDataDir, KEY_FILE_NAME) + } + + // A Cipher holds the state of the operation in progress, so two coroutines + // encrypting through one instance would corrupt each other's output. One + // per thread, matching the Android actual. + private val ciphers = ThreadLocal.withInitial { Cipher.getInstance(TRANSFORMATION) } + + @Volatile + private var cachedKey: SecretKey? = null + + private fun getKey(): SecretKey = + cachedKey ?: synchronized(this) { + cachedKey ?: loadOrCreateKey().also { cachedKey = it } + } + + private fun loadOrCreateKey(): SecretKey { + if (keyFile.exists()) { + val bytes = keyFile.readBytes() + if (bytes.size == KEY_SIZE_BYTES) return SecretKeySpec(bytes, ALGORITHM) + // A truncated or padded key file cannot decrypt anything already + // written; replacing it silently would strand that data under a key + // nobody holds. Fail loudly instead. + throw IllegalStateException( + "Key file ${keyFile.absolutePath} is ${bytes.size} bytes, expected $KEY_SIZE_BYTES. " + + "Refusing to overwrite it — move it aside to start fresh.", + ) + } + return createKey() + } + + private fun createKey(): SecretKey { + Log.d(TAG) { "Creating a new AES key at ${keyFile.absolutePath}" } + val bytes = ByteArray(KEY_SIZE_BYTES).also { SecureRandom().nextBytes(it) } + + keyFile.parentFile?.let { parent -> + parent.mkdirs() + parent.restrictToOwner(TAG) + } + // Narrow the file before the key goes in: created at the default umask + // and chmodded afterwards, the key would be world-readable in between. + keyFile.createNewFile() + keyFile.restrictToOwner(TAG) + keyFile.writeBytes(bytes) + + return SecretKeySpec(bytes, ALGORITHM) + } + + actual fun encrypt(bytes: ByteArray): ByteArray { + try { + val cipher = ciphers.get() + cipher.init(Cipher.ENCRYPT_MODE, getKey()) + return cipher.iv + cipher.doFinal(bytes) + } catch (e: Exception) { + cachedKey = null + Log.e(TAG, "encrypt() failed: ${e.message}", e) + throw e + } + } + + actual fun decrypt(bytes: ByteArray): ByteArray? { + try { + val iv = bytes.copyOfRange(0, GCM_IV_LENGTH) + val data = bytes.copyOfRange(GCM_IV_LENGTH, bytes.size) + val cipher = ciphers.get() + cipher.init(Cipher.DECRYPT_MODE, getKey(), GCMParameterSpec(GCM_TAG_LENGTH_BITS, iv)) + return cipher.doFinal(data) + } catch (e: Exception) { + cachedKey = null + Log.e(TAG, "decrypt() failed (input ${bytes.size} bytes): ${e.message}", e) + throw e + } + } +} diff --git a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessDismissedGamesStoreJvm.kt b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessDismissedGamesStoreJvm.kt new file mode 100644 index 0000000000..436f56a2a9 --- /dev/null +++ b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessDismissedGamesStoreJvm.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.nip64Chess + +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import com.vitorpamplona.amethyst.commons.util.appDataDir +import okio.Path.Companion.toOkioPath +import java.io.File + +/** + * The desktop dismissed-games store, in the shared app data directory. + * + * Built here rather than in desktopApp so a front end does not need DataStore + * on its own classpath to get one. Replaces a `java.util.prefs` node without + * carrying it over — the dismissed list is a convenience, and chess has few + * enough users that a migration is not worth the code. + */ +fun desktopChessDismissedGamesStore(): ChessDismissedGamesStore = sharedStore + +/** + * One store for the process. + * + * DataStore keeps a process-wide registry keyed by file path and only releases + * an entry when the owning scope ends; the factory's own scope never does. So + * building a fresh store per call — and the chess view model builds one in its + * constructor, under a `remember(account)` — made the second one throw + * "multiple DataStores active for the same file" on its first read. That + * surfaced from a `scope.launch` with no handler, taking the screen's whole + * scope down with it. The `java.util.prefs` node this replaced was safe to + * construct repeatedly, so nothing here used to need a singleton. + */ +private val sharedStore: ChessDismissedGamesStore by lazy { + val file = File(appDataDir, "chess_dismissed_games.preferences_pb") + file.parentFile?.mkdirs() + ChessDismissedGamesStore(PreferenceDataStoreFactory.createWithPath(produceFile = { file.toOkioPath() })) +} diff --git a/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessDismissedGamesStorage.ios.kt b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/util/AppDataDir.kt similarity index 57% rename from commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessDismissedGamesStorage.ios.kt rename to commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/util/AppDataDir.kt index 9148daed9e..c589ceb54c 100644 --- a/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessDismissedGamesStorage.ios.kt +++ b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/util/AppDataDir.kt @@ -18,27 +18,26 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.commons.nip64Chess +package com.vitorpamplona.amethyst.commons.util -// Phase 2 compile-only iOS actual. In-memory only; persistence via -// NSUserDefaults arrives with the iosApp module in Phase 3. -actual class ChessDismissedGamesStorage private actual constructor() { - private val dismissed = mutableMapOf>() +import java.io.File - actual companion object { - actual fun create(context: Any?): ChessDismissedGamesStorage = ChessDismissedGamesStorage() - } - - actual fun load(userPubkey: String): Set = dismissed[userPubkey] ?: emptySet() - - actual fun save( - userPubkey: String, - ids: Set, - ) { - if (ids.isEmpty()) { - dismissed.remove(userPubkey) - } else { - dismissed[userPubkey] = ids +/** + * Where this desktop OS keeps per-user application data. + * + * One definition, so the key file, the preference stores and anything else + * persistent land together rather than each picking their own convention. + */ +val appDataDir: File + get() { + val home = System.getProperty("user.home") ?: "." + val os = System.getProperty("os.name").orEmpty().lowercase() + return when { + os.contains("mac") || os.contains("darwin") -> + File(home, "Library/Application Support/Amethyst") + os.contains("win") -> + File(System.getenv("APPDATA") ?: "$home\\AppData\\Roaming", "Amethyst") + else -> + File(System.getenv("XDG_DATA_HOME")?.takeIf { it.isNotBlank() } ?: "$home/.local/share", "amethyst") } } -} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserHistoryRegistryTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserHistoryRegistryTest.kt new file mode 100644 index 0000000000..108389ccfb --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserHistoryRegistryTest.kt @@ -0,0 +1,218 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.browser + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import okio.Path.Companion.toOkioPath +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +/** + * The visit history's ranking inputs and its bound, neither of which had coverage while this was an + * Android-side `object`. The omnibox ranks on [BrowserHistoryEntry.visitCount] and + * [BrowserHistoryEntry.lastVisitedAt], so a bump that does not bump is a silently wrong suggestion + * order rather than a crash. + */ +class BrowserHistoryRegistryTest { + @get:Rule + val folder = TemporaryFolder() + + private var seq = 0 + + private fun newFile() = File(folder.root, "browser_history_${seq++}.preferences_pb") + + private fun store( + scope: CoroutineScope, + file: File, + ): DataStore = PreferenceDataStoreFactory.createWithPath(scope = scope, produceFile = { file.toOkioPath() }) + + /** + * One app "session" over [file]. Each gets its own [Job] so the DataStore it opened is released + * when the session is cancelled: DataStore refuses a second live instance on a path that already + * has one, which is exactly why production keeps a single instance per file in the store holder. + * A restart test that skipped this would read an empty store and look like data loss. + */ + private fun TestScope.session(file: File): Pair { + val scope = CoroutineScope(coroutineContext + Job()) + return BrowserHistoryRegistry(store(scope, file), scope) to scope + } + + /** A revisit is a bump, not a second row — this is what makes frecency mean anything. */ + @Test + fun revisitingAUrlBumpsTheCountInsteadOfAddingARow() = + runTest { + val (registry, _) = session(newFile()) + registry.init() + advanceUntilIdle() + + registry.record("https://example.com/a", "A") + registry.record("https://example.com/a", "A again") + + assertEquals("one row for one url", 1, registry.history.value.size) + assertEquals( + "visit count bumped", + 2, + registry.history.value + .single() + .visitCount, + ) + assertEquals( + "title refreshed", + "A again", + registry.history.value + .single() + .title, + ) + } + + /** A page that finishes loading with no must not blank out the name the user recognises. */ + @Test + fun aBlankTitleOnARevisitKeepsTheOldOne() = + runTest { + val (registry, _) = session(newFile()) + registry.init() + advanceUntilIdle() + + registry.record("https://example.com/a", "Real Title") + registry.record("https://example.com/a", " ") + + assertEquals( + "the blank did not overwrite it", + "Real Title", + registry.history.value + .single() + .title, + ) + } + + /** Most recent first, because that is the order the omnibox shows them in. */ + @Test + fun theMostRecentVisitLeads() = + runTest { + val (registry, _) = session(newFile()) + registry.init() + advanceUntilIdle() + + registry.record("https://first.example", "First") + registry.record("https://second.example", "Second") + + assertEquals( + "newest at the front", + listOf("https://second.example", "https://first.example"), + registry.history.value.map { it.url }, + ) + } + + /** The bound is what stops an unbounded JSON blob being rewritten on every page load. */ + @Test + fun historyIsCappedAtFiveHundredEntries() = + runTest { + val (registry, _) = session(newFile()) + registry.init() + advanceUntilIdle() + + repeat(505) { registry.record("https://example.com/page$it", "Page $it") } + + assertEquals("capped", 500, registry.history.value.size) + assertEquals( + "and it is the oldest that fell off", + "https://example.com/page504", + registry.history.value + .first() + .url, + ) + assertTrue("page0 is gone", registry.history.value.none { it.url == "https://example.com/page0" }) + } + + @Test + fun historySurvivesARestart() = + runTest { + val file = newFile() + + val (first, firstScope) = session(file) + first.init() + advanceUntilIdle() + first.record("https://example.com/a", "A") + advanceUntilIdle() + firstScope.cancel() + + val (second, _) = session(file) + second.init() + advanceUntilIdle() + + assertEquals("hydrated from disk", listOf("https://example.com/a"), second.history.value.map { it.url }) + assertEquals( + "with its count", + 1, + second.history.value + .single() + .visitCount, + ) + } + + /** Clearing is a privacy action: it has to reach disk, not just the in-memory flow. */ + @Test + fun clearingEmptiesTheStoredHistoryToo() = + runTest { + val file = newFile() + + val (first, firstScope) = session(file) + first.init() + advanceUntilIdle() + first.record("https://example.com/a", "A") + advanceUntilIdle() + first.clear() + advanceUntilIdle() + firstScope.cancel() + + val (second, _) = session(file) + second.init() + advanceUntilIdle() + + assertTrue("nothing came back", second.history.value.isEmpty()) + } + + @Test + fun removingOneUrlLeavesTheRest() = + runTest { + val (registry, _) = session(newFile()) + registry.init() + advanceUntilIdle() + + registry.record("https://keep.example", "Keep") + registry.record("https://drop.example", "Drop") + registry.remove("https://drop.example") + + assertEquals("only the one", listOf("https://keep.example"), registry.history.value.map { it.url }) + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserIconRegistryTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserIconRegistryTest.kt new file mode 100644 index 0000000000..1e5593e921 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserIconRegistryTest.kt @@ -0,0 +1,197 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.browser + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import okio.Path.Companion.toOkioPath +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +/** + * The favicon store's disk behaviour. Untestable while it was an Android `object` taking a `Context` + * for its `filesDir`; taking `iconDir: () -> Path` is what opens it up. + */ +class BrowserIconRegistryTest { + @get:Rule + val folder = TemporaryFolder() + + private var seq = 0 + + private fun newDir(): File = folder.newFolder("icons_${seq++}") + + private val png = byteArrayOf(0x89.toByte(), 0x50, 0x4E, 0x47) + + /** One app "session" over [dir], on the test scheduler so [advanceUntilIdle] drives its disk work. */ + private fun TestScope.registryOver(dir: File) = BrowserIconRegistry({ dir.toOkioPath() }, CoroutineScope(coroutineContext + Job())) + + @Test + fun aRecordedIconBecomesAvailableAndReachesDisk() = + runTest { + val dir = newDir() + val registry = registryOver(dir) + registry.init() + advanceUntilIdle() + + registry.record("example.com", png) + advanceUntilIdle() + + assertEquals("the host is announced", setOf("example.com"), registry.keys.value) + assertEquals( + "and the model points at the file", + "file://" + File(dir, "example.com.png").absolutePath, + registry.iconModelFor("example.com"), + ) + assertTrue("which exists", File(dir, "example.com.png").exists()) + assertEquals("with the bytes given", png.toList(), File(dir, "example.com.png").readBytes().toList()) + } + + /** A cold start has to find what earlier sessions stored, or every icon redownloads on first paint. */ + @Test + fun iconsAlreadyOnDiskAreIndexedByInit() = + runTest { + val dir = newDir() + File(dir, "already.example.png").writeBytes(png) + + val registry = registryOver(dir) + assertTrue("nothing is known before init", registry.keys.value.isEmpty()) + + registry.init() + advanceUntilIdle() + + assertEquals("the stored icon is indexed", setOf("already.example"), registry.keys.value) + } + + /** + * [BrowserIconRegistry.iconModelFor] is read from composition, so it must answer from [keys] rather + * than touch the filesystem — a host with no icon is null, not a path to a file that is not there. + */ + @Test + fun aHostWithNoStoredIconHasNoModel() = + runTest { + val registry = registryOver(newDir()) + registry.init() + advanceUntilIdle() + + assertNull(registry.iconModelFor("never-visited.example")) + } + + /** Host keys become one flat filename, so a port or an uppercase host cannot escape the directory. */ + @Test + fun hostsAreSanitizedIntoASingleFlatFilename() = + runTest { + val dir = newDir() + val registry = registryOver(dir) + registry.init() + advanceUntilIdle() + + registry.record("Example.COM:8080/../etc", png) + advanceUntilIdle() + + assertEquals( + "lowercased, and everything but letters/digits/dot/dash replaced", + setOf("example.com_8080_.._etc"), + registry.keys.value, + ) + assertEquals( + "one file, directly in the icon dir", + listOf("example.com_8080_.._etc.png"), + dir.listFiles()?.map { it.name }, + ) + } + + /** Lookups are sanitized the same way, so the caller passes the raw host and still finds it. */ + @Test + fun aLookupSanitizesTheHostTheSameWay() = + runTest { + val dir = newDir() + val registry = registryOver(dir) + registry.init() + advanceUntilIdle() + + registry.record("Example.COM", png) + advanceUntilIdle() + + assertEquals( + "the raw host resolves to the sanitized file", + "file://" + File(dir, "example.com.png").absolutePath, + registry.iconModelFor("Example.COM"), + ) + } + + @Test + fun aBlankHostOrEmptyBytesAreIgnored() = + runTest { + val dir = newDir() + val registry = registryOver(dir) + registry.init() + advanceUntilIdle() + + registry.record(" ", png) + registry.record("example.com", ByteArray(0)) + advanceUntilIdle() + + assertTrue("nothing announced", registry.keys.value.isEmpty()) + assertEquals("nothing written", emptyList<String>(), dir.listFiles()?.map { it.name }) + } + + @Test + fun aRecordedIconSurvivesARestart() = + runTest { + val dir = newDir() + + val first = registryOver(dir) + first.init() + advanceUntilIdle() + first.record("example.com", png) + advanceUntilIdle() + + val second = registryOver(dir) + second.init() + advanceUntilIdle() + + assertEquals("indexed again from disk", setOf("example.com"), second.keys.value) + } + + /** The icon dir need not exist yet: a first run must create it rather than drop the icon. */ + @Test + fun aMissingIconDirectoryIsCreated() = + runTest { + val dir = File(folder.root, "not_yet_${seq++}") + val registry = registryOver(dir) + registry.init() + advanceUntilIdle() + + registry.record("example.com", png) + advanceUntilIdle() + + assertTrue("the directory was created", dir.isDirectory) + assertEquals("and the icon landed in it", setOf("example.com"), registry.keys.value) + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cashu/DataStoreCashuCounterStoreTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cashu/DataStoreCashuCounterStoreTest.kt new file mode 100644 index 0000000000..e1485b0698 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cashu/DataStoreCashuCounterStoreTest.kt @@ -0,0 +1,254 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cashu + +import androidx.datastore.core.DataMigration +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.longPreferencesKey +import com.vitorpamplona.amethyst.commons.model.preferences.CopyOnceMigration +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.job +import kotlinx.coroutines.test.runTest +import okio.Path.Companion.toOkioPath +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +/** + * NUT-13 counters decide whether ecash is spendable. + * + * A counter handed out twice under the same (seed, keyset) derives the same + * blinded secret twice; the mint answers `outputs already signed` and the + * proofs are stranded. These tests exist because that path had no coverage at + * all while it was backed by SharedPreferences. + */ +class DataStoreCashuCounterStoreTest { + @get:Rule + val folder = TemporaryFolder() + + private var seq = 0 + + /** + * Closes a store's scope and waits for it. + * + * DataStore refuses two live instances over one file and only releases it + * once the owning job has actually finished, so a bare cancel() races the + * next open. + */ + private suspend fun CoroutineScope.release() { + coroutineContext.job.cancelAndJoin() + } + + private fun raw( + file: File = File(folder.root, "cashu_${seq++}.preferences_pb"), + scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()), + migrations: List<DataMigration<Preferences>> = emptyList(), + ): DataStore<Preferences> = + PreferenceDataStoreFactory.createWithPath( + scope = scope, + migrations = migrations, + produceFile = { file.toOkioPath() }, + ) + + @Test + fun anUnseenKeysetStartsAtZero() = + runTest { + assertEquals(0L, DataStoreCashuCounterStore(raw()).peek("keyset1")) + } + + @Test + fun peekDoesNotAdvance() = + runTest { + val store = DataStoreCashuCounterStore(raw()) + + store.peek("keyset1") + store.peek("keyset1") + + assertEquals(0L, store.reserve("keyset1", 1)) + } + + @Test + fun reserveReturnsTheFirstIndexAndAdvancesByCount() = + runTest { + val store = DataStoreCashuCounterStore(raw()) + + assertEquals(0L, store.reserve("keyset1", 3)) + assertEquals(3L, store.peek("keyset1")) + assertEquals(3L, store.reserve("keyset1", 2)) + assertEquals(5L, store.peek("keyset1")) + } + + @Test + fun keysetsAdvanceIndependently() = + runTest { + val store = DataStoreCashuCounterStore(raw()) + + store.reserve("keyset1", 5) + + assertEquals(0L, store.reserve("keyset2", 1)) + } + + @Test + fun aNonPositiveReservationIsRejected() = + runTest { + val store = DataStoreCashuCounterStore(raw()) + + // Not assertThrows: a nested runTest would wrap the failure. + val thrown = runCatching { store.reserve("keyset1", 0) }.exceptionOrNull() + + assertTrue("expected IllegalArgumentException, got $thrown", thrown is IllegalArgumentException) + } + + /** + * The property everything else rests on: no index is ever handed out twice. + * Concurrent reservations must carve up disjoint ranges. + */ + @Test + fun concurrentReservationsNeverOverlap() = + runTest { + val store = DataStoreCashuCounterStore(raw()) + val batch = 4 + val workers = 25 + + val firsts = + (1..workers) + .map { async(Dispatchers.IO) { store.reserve("keyset1", batch) } } + .awaitAll() + + val handedOut = firsts.flatMap { first -> (0 until batch).map { first + it } } + assertEquals("every index handed out exactly once", handedOut.size, handedOut.toSet().size) + assertEquals("the counter accounts for all of them", (workers * batch).toLong(), store.peek("keyset1")) + } + + /** A reserved counter must survive the process that reserved it. */ + @Test + fun reservationsSurviveAReopen() = + runTest { + val file = File(folder.root, "persist.preferences_pb") + + val firstScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + DataStoreCashuCounterStore(raw(file, firstScope)).reserve("keyset1", 7) + firstScope.release() + + val secondScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + assertEquals(7L, DataStoreCashuCounterStore(raw(file, secondScope)).peek("keyset1")) + secondScope.release() + } + + // ── seeding from older stores ───────────────────────────────────── + + @Test + fun seedIfMissingCarriesALegacyValueForward() = + runTest { + val store = DataStoreCashuCounterStore(raw()) + + store.seedIfMissing("keyset1", 42L) + + assertEquals(42L, store.peek("keyset1")) + } + + /** Never backwards: a legacy value below the current one must be ignored. */ + @Test + fun seedIfMissingNeverMovesACounterBackwards() = + runTest { + val store = DataStoreCashuCounterStore(raw()) + store.reserve("keyset1", 100) + + store.seedIfMissing("keyset1", 5L) + + assertEquals(100L, store.peek("keyset1")) + } + + @Test + fun seedIfMissingIgnoresNonPositiveValues() = + runTest { + val store = DataStoreCashuCounterStore(raw()) + store.reserve("keyset1", 3) + + store.seedIfMissing("keyset1", 0L) + store.seedIfMissing("keyset1", -1L) + + assertEquals(3L, store.peek("keyset1")) + } + + /** + * The SharedPreferences -> DataStore migration. A counter lost here + * restarts a keyset at zero and reuses every index it already spent. + */ + @Test + fun theLegacyMigrationCarriesEveryCounter() = + runTest { + val legacy = + mapOf( + "counter_keysetA" to 17L, + "counter_keysetB" to 4L, + ) + val migration = + CopyOnceMigration("migrated.cashuCounters") { out -> + legacy.forEach { (key, value) -> out[longPreferencesKey(key)] = value } + } + + val store = DataStoreCashuCounterStore(raw(migrations = listOf(migration))) + + assertEquals(17L, store.peek("keysetA")) + assertEquals(4L, store.peek("keysetB")) + assertEquals("the next reservation continues, never replays", 17L, store.reserve("keysetA", 1)) + } + + /** The migration must not re-run and rewind counters spent since it ran. */ + @Test + fun theLegacyMigrationDoesNotRewindLaterReservations() = + runTest { + val file = File(folder.root, "once.preferences_pb") + val migration = { CopyOnceMigration("migrated.cashuCounters") { out -> out[longPreferencesKey("counter_keysetA")] = 10L } } + + val firstScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + DataStoreCashuCounterStore(raw(file, firstScope, listOf(migration()))).reserve("keysetA", 5) + firstScope.release() + + val secondScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + val reopened = DataStoreCashuCounterStore(raw(file, secondScope, listOf(migration()))) + assertEquals(15L, reopened.peek("keysetA")) + assertTrue("a later reservation is past everything spent", reopened.reserve("keysetA", 1) >= 15L) + secondScope.release() + } + + /** A host that wired no store must fail loudly rather than answer 0. */ + @Test + fun theUnavailableStoreRefusesToAnswer() = + runTest { + val onPeek = runCatching { UnavailableCashuKeysetCounterStore.peek("keyset1") }.exceptionOrNull() + val onReserve = runCatching { UnavailableCashuKeysetCounterStore.reserve("keyset1", 1) }.exceptionOrNull() + + assertTrue("peek must refuse, got $onPeek", onPeek is IllegalStateException) + assertTrue("reserve must refuse, got $onReserve", onReserve is IllegalStateException) + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/DataStoreNostrSignerPermissionStoreTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/DataStoreNostrSignerPermissionStoreTest.kt new file mode 100644 index 0000000000..bb6135bf88 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/DataStoreNostrSignerPermissionStoreTest.kt @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.connectedApps + +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import com.vitorpamplona.amethyst.commons.model.preferences.AppPreferenceStores +import kotlinx.coroutines.test.runTest +import okio.Path.Companion.toOkioPath +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class DataStoreNostrSignerPermissionStoreTest { + @get:Rule + val folder = TemporaryFolder() + + private fun stores() = AppPreferenceStores(rootFilesDir = { folder.root.toOkioPath() }) + + /** + * The hash is half the file name, so it has to keep producing exactly what + * `MessageDigest.getInstance("SHA-256")` truncated to 8 bytes and formatted + * with `"%02x"` produced on Android. Get it wrong and the old file is simply + * never opened again — every permission the user granted that app is gone, + * silently. The expected values are SHA-256 prefixes computed outside this + * codebase. + */ + @Test + fun theFileNameMatchesTheAndroidImplementation() { + assertEquals("nsp_12bafbcaa8bb08b6", DataStoreNostrSignerPermissionStore.nameFor("31990:abc:def")) + assertEquals("nsp_12f134c5dae480dc", DataStoreNostrSignerPermissionStore.nameFor("wss://relay.example.com")) + assertEquals("nsp_e3b0c44298fc1c14", DataStoreNostrSignerPermissionStore.nameFor("")) + } + + /** + * allPolicies enumerates the datastore directory, which used to be + * `File.listFiles` and is now AppPreferenceStores.names. It must see only + * the signer files — the shared settings and every other store live in the + * same directory. + */ + @Test + fun namesSeesOnlyTheSignerFilesAndSurvivesAnEmptyDirectory() = + runTest { + val subject = stores() + + assertTrue("nothing written yet", subject.names("nsp_").isEmpty()) + + // a read does not create the file, only a write does + listOf("shared_settings", "search_history", "nsp_deadbeefdeadbeef", "nsp_0011223344556677").forEach { + subject.getDataStore(it).edit { prefs -> prefs[booleanPreferencesKey("touch")] = true } + } + + assertEquals( + listOf("nsp_0011223344556677", "nsp_deadbeefdeadbeef"), + subject.names("nsp_").sorted(), + ) + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/DataStoreNip46ClientStoreTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/DataStoreNip46ClientStoreTest.kt new file mode 100644 index 0000000000..c98604a1cc --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/DataStoreNip46ClientStoreTest.kt @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.connectedApps.nip46 + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import kotlinx.coroutines.test.runTest +import okio.Path.Companion.toOkioPath +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class DataStoreNip46ClientStoreTest { + @get:Rule + val folder = TemporaryFolder() + + private fun store(name: String): DataStore<Preferences> = + PreferenceDataStoreFactory.createWithPath( + produceFile = { folder.root.toOkioPath() / "$name.preferences_pb" }, + ) + + /** + * The coordinate hash is a stored key, so it has to keep producing exactly + * what the Android implementation did — `MessageDigest.getInstance("SHA-256")` + * truncated to 8 bytes and formatted with `"%02x"`. A different digest would + * not fail; it would quietly orphan every client the user has authorized. + * + * The expected values are SHA-256 prefixes computed outside this codebase, so + * this is a cross-check rather than a restatement of the implementation. + */ + @Test + fun theCoordinateHashMatchesTheAndroidImplementation() { + assertEquals("12bafbcaa8bb08b6", DataStoreNip46ClientStore.hash("31990:abc:def")) + assertEquals("12f134c5dae480dc", DataStoreNip46ClientStore.hash("wss://relay.example.com")) + assertEquals("e3b0c44298fc1c14", DataStoreNip46ClientStore.hash("")) + } + + /** 16 lower-case hex characters, always — it is half a key name. */ + @Test + fun theHashIsAlwaysSixteenLowerCaseHexChars() { + listOf("a", "a longer coordinate with spaces", "ünïcödé", "31990:".repeat(50)).forEach { + val h = DataStoreNip46ClientStore.hash(it) + assertEquals("wrong length for '$it'", 16, h.length) + assertEquals("not lower-case hex for '$it'", h, h.lowercase().filter { c -> c in "0123456789abcdef" }) + } + } + + @Test + fun aStoredClientComesBack() = + runTest { + val subject = DataStoreNip46ClientStore(store("clients")) + val coordinate = "31990:pubkeyhex:handler" + + assertNull(subject.load(coordinate)) + + subject.store( + coordinate, + Nip46ClientInfo(name = "Test App", url = "https://x", image = "https://x/y.png", relays = setOf("wss://a", "wss://b")), + ) + + val loaded = subject.load(coordinate)!! + assertEquals("Test App", loaded.name) + assertEquals("https://x", loaded.url) + assertEquals("https://x/y.png", loaded.image) + assertEquals(setOf("wss://a", "wss://b"), loaded.relays) + + assertEquals(mapOf(coordinate to loaded), subject.all()) + + subject.remove(coordinate) + assertNull(subject.load(coordinate)) + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnBackupTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnBackupTest.kt new file mode 100644 index 0000000000..bcaafed52a --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnBackupTest.kt @@ -0,0 +1,206 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn + +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnDeliveredMessage +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnEnvelope +import com.vitorpamplona.quartz.cordn.sync.GroupCursor +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class CordnBackupTest { + private val account = "a".repeat(64) + private val coordinator = "b".repeat(64) + + /** A cheap cost: these tests exercise the format, not the KDF's strength. */ + private val cheap = 10 + + private val archive = + CordnBackup.Archive( + accountPubKey = account, + coordinators = + listOf( + CoordinatorConfig( + coordinator, + listOf(RelayUrlNormalizer.normalizeOrNull("wss://one.example.com")!!), + CoordinatorConfig.Origin.MANUAL, + "Work", + ), + ), + groups = + listOf( + CordnBackup.Archive.Group( + coordinatorPubKey = coordinator, + gid = "room-1", + state = byteArrayOf(1, 2, 3, 4), + cursor = GroupCursor(fetchCursor = 7, lastCursor = 9), + joinedViaRequest = true, + messages = listOf(delivered("hello", 7), delivered("again", 8)), + ), + CordnBackup.Archive.Group( + coordinatorPubKey = coordinator, + gid = "room-2", + state = byteArrayOf(5, 6), + cursor = null, + joinedViaRequest = false, + ), + ), + keyPackages = + listOf( + CordnBackup.Archive.KeyPackage(coordinator, "kp-ref", byteArrayOf(9, 9, 9)), + ), + ) + + @Test + fun `an archive round-trips`() { + val opened = CordnBackup.open(CordnBackup.seal(archive, "correct horse", cheap), "correct horse") + + assertEquals(archive, opened) + } + + @Test + fun `a group with no cursor stays a group with no cursor`() { + // The optional field is the one a length-prefixed format gets wrong by + // writing a zero and reading it back as a real position, which would + // make a restored room resume from the start of its history. + val opened = CordnBackup.open(CordnBackup.seal(archive, "pw", cheap), "pw") + + assertEquals(null, opened.groups.single { it.gid == "room-2" }.cursor) + assertEquals( + 7L, + opened.groups + .single { it.gid == "room-1" } + .cursor + ?.fetchCursor, + ) + } + + @Test + fun `the wrong passphrase fails, and says nothing about how wrong`() { + val sealed = CordnBackup.seal(archive, "correct horse", cheap) + + assertFailsWith<Exception> { CordnBackup.open(sealed, "correct hors") } + } + + @Test + fun `rewriting the KDF cost down does not weaken the file`() { + // The attack this refuses: edit logN to something trivially cheap and + // brute-force against that instead of the real key. It fails because + // logN feeds the derivation, so a changed cost is a changed key -- + // not because the header is under the AEAD, which is defence in depth + // for header fields this format does not have yet. + val sealed = CordnBackup.seal(archive, "pw", cheap) + val weakened = sealed.copyOf().also { it[MAGIC_LENGTH + 2] = 1 } + + assertFailsWith<Exception> { CordnBackup.open(weakened, "pw") } + } + + @Test + fun `a salt swapped between two files does not open either`() { + val mine = CordnBackup.seal(archive, "pw", cheap) + val theirs = CordnBackup.seal(archive, "pw", cheap) + val spliced = mine.copyOf().also { theirs.copyOfRange(SALT_AT, SALT_AT + 16).copyInto(it, SALT_AT) } + + assertFailsWith<Exception> { CordnBackup.open(spliced, "pw") } + } + + @Test + fun `a truncated file fails rather than restoring half a group`() { + val sealed = CordnBackup.seal(archive, "pw", cheap) + + assertFailsWith<Exception> { CordnBackup.open(sealed.copyOf(sealed.size - 8), "pw") } + } + + @Test + fun `something that is not a backup is refused by name`() { + val notABackup = ByteArray(200) { it.toByte() } + + val failure = assertFailsWith<IllegalArgumentException> { CordnBackup.open(notABackup, "pw") } + assertTrue(failure.message!!.contains("not a cordn backup")) + } + + @Test + fun `MLS state and key material never appear in the clear`() { + // The state blob is a ratchet tree and the bundle is private key + // material. A backup that leaked either is worse than no backup. + val sealed = CordnBackup.seal(archive, "pw", cheap) + + assertFalse(sealed.asList().windowed(4).any { it == listOf<Byte>(1, 2, 3, 4) }, "group state in the clear") + assertFalse(sealed.asList().windowed(3).any { it == listOf<Byte>(9, 9, 9) }, "key material in the clear") + assertFalse(sealed.decodeToString().contains("room-1"), "a gid in the clear") + } + + @Test + fun `two seals of one archive differ, so a file never reveals a repeat`() { + val first = CordnBackup.seal(archive, "pw", cheap) + val second = CordnBackup.seal(archive, "pw", cheap) + + assertFalse(first.contentEquals(second), "a fresh salt and nonce per seal") + } + + @Test + fun `an unreasonable cost is refused instead of hanging the device`() { + assertFailsWith<IllegalArgumentException> { CordnBackup.seal(archive, "pw", logN = 40) } + } + + /** + * The id has to hash the contents: `CordnEnvelope.fromJsonObject` checks + * it, so an envelope with a made-up id decodes to null and the archive + * appears to have lost the message. + */ + private fun delivered( + content: String, + cursor: Long, + ): CordnDeliveredMessage { + val unsigned = + CordnEnvelope( + id = "", + pubKey = account, + createdAt = 1_700_000_000L + cursor, + kind = 9, + tags = arrayOf(arrayOf("h", "room-1")), + content = content, + ) + return CordnDeliveredMessage(unsigned.copy(id = unsigned.computedId()), cursor) + } + + @Test + fun `messages survive the round trip`() { + val opened = CordnBackup.open(CordnBackup.seal(archive, "pw", cheap), "pw") + + val room1 = opened.groups.first { it.gid == "room-1" } + assertEquals(listOf("hello", "again"), room1.messages.map { it.envelope.content }) + assertEquals(listOf(7L, 8L), room1.messages.map { it.cursor }) + // The group carrying none still round-trips as none. + assertEquals(emptyList(), opened.groups.first { it.gid == "room-2" }.messages) + } + + private companion object { + const val MAGIC_LENGTH = 8 + + /** magic(8) + version(2) + logN(1). */ + const val SALT_AT = 11 + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnCoordinatorDiscoveryTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnCoordinatorDiscoveryTest.kt new file mode 100644 index 0000000000..a10658e78e --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnCoordinatorDiscoveryTest.kt @@ -0,0 +1,331 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn + +import com.vitorpamplona.quartz.contextvm.core.CvmKinds +import com.vitorpamplona.quartz.cordn.spec00Coordinator.CoordinatorAdvertisement +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Tag +import com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Finding coordinators in a relay's worth of MCP announcements. + * + * Three things here fail as a plausible-looking list rather than as an error, + * which is why they are tested: an unrelated MCP server presented as a + * coordinator, a coordinator credited with only one of the relays that carry + * it, and a lagging relay's older announcement overwriting a newer one. + */ +class CordnCoordinatorDiscoveryTest { + private val relayA = relay("wss://a.example") + private val relayB = relay("wss://b.example") + + @Test + fun `a server serving the eleven tools is a coordinator`() { + val found = discovery().coordinatorsIn(announcementPair(relayA, COORDINATOR, toolNames = CORDN_TOOLS)) + + assertEquals(1, found.size) + assertEquals(COORDINATOR, found[0].pubKey) + assertEquals(listOf(relayA), found[0].relays) + assertEquals(CoordinatorConfig.Origin.ANNOUNCEMENT, found[0].toConfig().origin) + } + + @Test + fun `an unrelated MCP server is not`() { + // The case this whole filter exists for: the public relays carry far + // more non-cordn MCP servers than coordinators, and nothing on the + // announcement distinguishes them except the tool names. + val found = + discovery().coordinatorsIn( + announcementPair(relayA, OTHER_SERVER, toolNames = listOf("search", "fetch_page")), + ) + + assertTrue(found.isEmpty()) + } + + @Test + fun `a server missing one of the eleven is not`() { + val found = + discovery().coordinatorsIn( + announcementPair(relayA, OTHER_SERVER, toolNames = CORDN_TOOLS - "msg_sub_many"), + ) + + assertTrue(found.isEmpty()) + } + + @Test + fun `serving more than the eleven still counts`() { + val found = + discovery().coordinatorsIn( + announcementPair(relayA, COORDINATOR, toolNames = CORDN_TOOLS + "vendor_extra"), + ) + + assertEquals(1, found.size) + assertTrue("vendor_extra" in found[0].tools) + } + + @Test + fun `a bare server announcement with no tool list is not a coordinator`() { + // A kind 11316 says who a server claims to be, never what it serves. + val found = discovery().coordinatorsIn(listOf(relayA to serverAnnouncement(COORDINATOR, name = "looks real"))) + + assertTrue(found.isEmpty()) + } + + @Test + fun `every relay that carried an announcement is kept`() { + // The reachable relay set is the one thing a CEP-6 announcement does + // not carry, so losing a relay here is losing the only routing + // information discovery produces. + val found = + discovery().coordinatorsIn( + announcementPair(relayA, COORDINATOR, toolNames = CORDN_TOOLS) + + announcementPair(relayB, COORDINATOR, toolNames = CORDN_TOOLS), + ) + + assertEquals(1, found.size) + assertEquals(listOf(relayA, relayB), found[0].relays) + } + + @Test + fun `a lagging relay's older copy does not win`() { + val newest = + announcementPair(relayA, COORDINATOR, toolNames = CORDN_TOOLS, createdAt = 2_000, name = "current") + val stale = + announcementPair(relayB, COORDINATOR, toolNames = CORDN_TOOLS, createdAt = 1_000, name = "outdated") + + val found = discovery().coordinatorsIn(stale + newest) + + assertEquals("current", found[0].surface.name) + assertEquals(2_000L, found[0].announcedAt) + } + + @Test + fun `the announced name is never copied into the user's label`() { + // A label is what the user calls a coordinator. The announced name is + // what the coordinator calls itself, and a coordinator cannot prove a + // name (spec/00.md §8.5). + val found = discovery().coordinatorsIn(announcementPair(relayA, COORDINATOR, toolNames = CORDN_TOOLS, name = "Official")) + + assertEquals("Official", found[0].surface.name) + assertNull(found[0].toConfig().label) + } + + @Test + fun `results are newest first`() { + val found = + discovery().coordinatorsIn( + announcementPair(relayA, COORDINATOR, toolNames = CORDN_TOOLS, createdAt = 100) + + announcementPair(relayA, SECOND_COORDINATOR, toolNames = CORDN_TOOLS, createdAt = 900), + ) + + assertEquals(listOf(SECOND_COORDINATOR, COORDINATOR), found.map { it.pubKey }) + } + + @Test + fun `each relay is queried in its own call`() = + runTest { + // fetchAllWithHooks deduplicates by event id across the relays in + // one call, so a single multi-relay call would credit a shared + // announcement to whichever relay answered first and drop the + // others. One call per relay scopes that dedup. + val client = ServingClient(this) + client.serve(relayA, announcementPair(relayA, COORDINATOR, toolNames = CORDN_TOOLS).map { it.second }) + client.serve(relayB, announcementPair(relayB, COORDINATOR, toolNames = CORDN_TOOLS).map { it.second }) + + val result = CordnCoordinatorDiscovery(client).discover(setOf(relayA, relayB)) + + assertEquals(setOf(setOf(relayA), setOf(relayB)), client.requestedRelaySets.toSet()) + assertEquals(1, result.coordinators.size) + assertEquals(setOf(relayA, relayB), result.coordinators[0].relays.toSet()) + assertTrue(result.unreachable.isEmpty()) + } + + @Test + fun `a relay that never answers is reported, not silently empty`() = + runTest { + // An empty list means "nobody is announcing" only when every relay + // answered. Conflating the two would present a failed scan as a + // network with no coordinators on it. + val client = ServingClient(this) + client.serve(relayA, emptyList()) + + val result = CordnCoordinatorDiscovery(client).discover(setOf(relayA, relayB), idleTimeoutMs = 50) + + assertTrue(result.coordinators.isEmpty()) + assertEquals(setOf(relayB), result.unreachable) + } + + @Test + fun `no relays is not a query`() = + runTest { + val client = ServingClient(this) + + val result = CordnCoordinatorDiscovery(client).discover(emptySet()) + + assertTrue(result.coordinators.isEmpty()) + assertTrue(client.requestedRelaySets.isEmpty()) + } + + @Test + fun `the required toolset is derived from the protocol, not retyped`() { + // If a twelfth tool is ever added to CoordinatorMethod, this predicate + // must tighten with it rather than keep matching an older server. + assertEquals(11, CoordinatorAdvertisement.REQUIRED_TOOLS.size) + assertEquals(CORDN_TOOLS.toSet(), CoordinatorAdvertisement.REQUIRED_TOOLS) + assertFalse("kp_publish" in CoordinatorAdvertisement.missingFrom(tools(CORDN_TOOLS))) + } + + private fun discovery() = CordnCoordinatorDiscovery(EmptyNostrClient()) + + private fun tools(names: List<String>) = + com.vitorpamplona.quartz.contextvm.cep06Announcements.AnnouncedTools + .parseOrNull(toolsJson(names))!! + + /** Serves a fixed set of events per relay, then EOSEs. */ + private class ServingClient( + private val scope: CoroutineScope, + ) : INostrClient by EmptyNostrClient() { + private val byRelay = mutableMapOf<NormalizedRelayUrl, List<Event>>() + val requestedRelaySets = mutableListOf<Set<NormalizedRelayUrl>>() + + fun serve( + relay: NormalizedRelayUrl, + events: List<Event>, + ) { + byRelay[relay] = events + } + + override fun subscribe( + subId: String, + filters: Map<NormalizedRelayUrl, List<Filter>>, + listener: SubscriptionListener?, + ) { + requestedRelaySets += filters.keys + val target = listener ?: return + scope.launch(Dispatchers.Unconfined) { + filters.keys.forEach { relay -> + // A relay with nothing registered never answers at all, + // which is the "stalled" case discover must report. + val events = byRelay[relay] ?: return@forEach + events.forEach { target.onEvent(it, false, relay, null) } + target.onEose(relay, null) + } + } + } + + override fun unsubscribe(subId: String) = Unit + } + + companion object { + private val COORDINATOR = "aa".repeat(32) + private val SECOND_COORDINATOR = "bb".repeat(32) + private val OTHER_SERVER = "cc".repeat(32) + + private val CORDN_TOOLS = + listOf( + "kp_publish", + "kp_remove", + "kp_list", + "kp_take", + "welcome_store", + "welcome_take", + "join_request_store", + "join_request_take_many", + "msg_post", + "msg_fetch_many", + "msg_sub_many", + ) + + private fun relay(url: String) = RelayUrlNormalizer.normalize(url) + + private fun toolsJson(names: List<String>) = + names.joinToString( + prefix = """{"tools":[""", + postfix = "]}", + ) { """{"name":"$it","inputSchema":{"type":"object"}}""" } + + /** The 11316 + 11317 pair a real coordinator publishes. */ + private fun announcementPair( + relay: NormalizedRelayUrl, + pubKey: HexKey, + toolNames: List<String>, + createdAt: Long = 1_000, + name: String = "a coordinator", + ) = listOf( + relay to serverAnnouncement(pubKey, name, createdAt), + relay to + event( + pubKey = pubKey, + kind = CvmKinds.TOOLS_LIST, + createdAt = createdAt, + content = toolsJson(toolNames), + ), + ) + + private fun serverAnnouncement( + pubKey: HexKey, + name: String, + createdAt: Long = 1_000, + ) = event( + pubKey = pubKey, + kind = CvmKinds.SERVER_ANNOUNCEMENT, + createdAt = createdAt, + content = """{"protocolVersion":"2025-11-25"}""", + tags = arrayOf(arrayOf("name", name)), + ) + + /** + * Unsigned: nothing in discovery verifies a signature, because the + * relay already did and an announcement proves nothing either way. + */ + private fun event( + pubKey: HexKey, + kind: Int, + createdAt: Long, + content: String, + tags: Array<Tag> = emptyArray(), + ) = Event( + id = "${kind}_${pubKey.take(4)}_$createdAt", + pubKey = pubKey, + createdAt = createdAt, + kind = kind, + tags = tags, + content = content, + sig = "00".repeat(32), + ) + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnCoordinatorRegistryTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnCoordinatorRegistryTest.kt new file mode 100644 index 0000000000..6557f632c9 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnCoordinatorRegistryTest.kt @@ -0,0 +1,333 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn + +import com.vitorpamplona.quartz.cordn.spec00Coordinator.CoordinatorServerInfo +import com.vitorpamplona.quartz.cordn.spec00Coordinator.ICoordinator +import com.vitorpamplona.quartz.cordn.spec01GroupMetadata.CordnGroupMetadata +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.yield +import kotlin.io.encoding.ExperimentalEncodingApi +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +/** + * One session per coordinator, for one account. + * + * The rules under test are all consequences of one fact: a `gid`, a `kp_ref` + * and a cursor mean nothing outside the coordinator that issued them. Break + * that and the failures are not loud — one group answers for another, or two + * managers fork the same ratchet tree — so each rule gets a test that fails + * for the right reason. + */ +@OptIn(ExperimentalEncodingApi::class) +class CordnCoordinatorRegistryTest : CordnTransportHarness() { + private val gid = "1c4d3e2f-5a6b-4c7d-8e9f-0a1b2c3d4e5f" + + /** One identity across every coordinator, as an account really is. */ + private val account = Account() + + /** Counts opens, so "the same session" can be told from "an equal one". */ + private inner class CountingFactory : CordnCoordinatorScopeFactory { + var opened = 0 + var closed = 0 + private val groupStores = mutableMapOf<HexKey, CordnGroupStore>() + private val keyStores = mutableMapOf<HexKey, CordnKeyPackageStore>() + + override suspend fun open( + accountPubKey: HexKey, + config: CoordinatorConfig, + ): CordnCoordinatorScope { + // Opening really suspends: production reaches a relay and an + // encrypted store. Without this the in-memory fixture runs to + // completion atomically and no concurrency test can observe + // anything — a second caller never gets to look at the map. + yield() + opened++ + return object : CordnCoordinatorScope { + override val coordinator: ICoordinator = account.clientFor(config.pubKey) + + // Keyed by coordinator, and kept across a reopen: the stores are + // what survives a transport change, which is the whole reason a + // relay edit does not cost the user their groups. + override val groupStore = groupStores.getOrPut(config.pubKey) { InMemoryCordnGroupStore() } + override val keyPackageStore = keyStores.getOrPut(config.pubKey) { InMemoryCordnKeyPackageStore() } + + override suspend fun close() { + closed++ + } + } + } + } + + private fun registry(factory: CordnCoordinatorScopeFactory) = CordnCoordinatorRegistry(account.pubKey, factory) + + @Test + fun `asking twice for one coordinator returns the same session`() = + runTest { + // Not a cache: a second CordnGroupManager would deserialise its own + // copy of every MlsGroup from the same store, and both would commit + // against the same epoch. MLS does not recover from a forked + // ratchet tree — every later message simply fails to decrypt. + val factory = CountingFactory() + val registry = registry(factory) + + val first = driving { registry.session(config) } + val second = driving { registry.session(config) } + + assertSame(first, second) + assertSame(first.manager, second.manager) + assertEquals(1, factory.opened, "the transport must not be opened twice") + } + + @Test + fun `concurrent callers do not get two sessions`() = + runTest { + // The ordinary case, not a race worth dismissing: the group list and + // the sync loop both reach for a session at launch. + val factory = CountingFactory() + val registry = registry(factory) + + val sessions = + driving { + coroutineScope { + List(8) { async { registry.session(config) } }.awaitAll() + } + } + + sessions.forEach { assertSame(sessions.first(), it) } + assertEquals(1, factory.opened) + } + + @Test + fun `the same gid on two coordinators is two groups`() = + runTest { + // spec/00.md §4: a gid is the caller's to choose and unique only + // within one coordinator. Two of them picking "abc" is expected, and + // they are unrelated groups. A registry keyed by gid instead of by + // coordinator would let one answer for the other. + val factory = CountingFactory() + val registry = registry(factory) + val other = config.copy(pubKey = secondCoordinatorPubKey) + + val here = driving { registry.session(config) } + val there = driving { registry.session(other) } + + driving { here.manager.createGroup(gid, CordnGroupMetadata(name = "Ours", adminPubkeys = listOf(registry.accountPubKey))) } + + assertEquals(setOf(gid), here.manager.gids.value) + assertTrue( + there.manager.gids.value + .isEmpty(), + "the other coordinator's session must not see it", + ) + assertNull(there.manager.group(gid)) + assertEquals(2, factory.opened) + } + + @Test + fun `editing a coordinator's relays keeps its groups`() = + runTest { + // A user correcting a relay or renaming a coordinator has not made + // it a different coordinator. The transport is bound to the relays + // so it reopens; the stores are not, so the groups come back. + val factory = CountingFactory() + val registry = registry(factory) + + val before = driving { registry.session(config) } + driving { before.manager.createGroup(gid, CordnGroupMetadata(name = "Kept", adminPubkeys = listOf(registry.accountPubKey))) } + + val moved = config.copy(relays = listOf(RelayUrlNormalizer.normalizeOrNull("wss://other.example.com")!!), label = "Renamed") + val after = driving { registry.session(moved) } + + assertEquals(2, factory.opened, "the transport is bound to the relays, so it reopens") + assertEquals(1, factory.closed, "and the old one is released, not leaked") + assertEquals(setOf(gid), after.manager.gids.value, "but the group state survived") + assertNotNull(after.manager.group(gid)) + assertEquals( + "Renamed", + registry.coordinators.value + .single() + .label, + ) + } + + @Test + fun `forgetting a coordinator closes it and keeps its state`() = + runTest { + // cordn-web separates removing a coordinator from purging it. Wiping + // the stores here would make the undo impossible, so forget only + // closes the wire. + val factory = CountingFactory() + val registry = registry(factory) + + val session = driving { registry.session(config) } + driving { session.manager.createGroup(gid, CordnGroupMetadata(name = "Undo me", adminPubkeys = listOf(registry.accountPubKey))) } + + registry.forget(config.pubKey) + + assertEquals(1, factory.closed) + assertNull(registry.sessionOrNull(config.pubKey)) + assertTrue(registry.coordinators.value.isEmpty()) + + val readded = driving { registry.session(config) } + assertEquals(setOf(gid), readded.manager.gids.value, "re-adding must find the groups again") + } + + @Test + fun `restore closes the coordinators that are no longer configured`() = + runTest { + // Otherwise a coordinator the user removed on another launch comes + // back to life because its session happened to still be open. + val factory = CountingFactory() + val registry = registry(factory) + val other = config.copy(pubKey = secondCoordinatorPubKey) + + driving { registry.restore(listOf(config, other)) } + assertEquals(2, registry.coordinators.value.size) + + driving { registry.restore(listOf(other)) } + + assertEquals(listOf(other.pubKey), registry.coordinators.value.map { it.pubKey }) + assertNull(registry.sessionOrNull(config.pubKey)) + assertEquals(1, factory.closed) + } + + @Test + fun `a session knows its groups without being told to restore`() = + runTest { + // A session that reports no groups is indistinguishable from a + // failure, and the first commit it makes for a group it forgot + // starts from epoch zero. Leaving restore() to the caller makes + // that a one-line omission away. + val factory = CountingFactory() + val first = registry(factory) + val second = CordnCoordinatorRegistry(first.accountPubKey, factory) + + val opened = driving { first.session(config) } + driving { opened.manager.createGroup(gid, CordnGroupMetadata(name = "Found", adminPubkeys = listOf(first.accountPubKey))) } + driving { opened.keyPackages.publishNew() } + val refs = opened.keyPackages.published.value + + // A fresh registry over the same stores — what a relaunch looks like. + val relaunched = driving { second.session(config) } + + assertEquals(setOf(gid), relaunched.manager.gids.value) + assertEquals(refs, relaunched.keyPackages.published.value) + } + + @Test + fun `logging out closes every coordinator`() = + runTest { + val factory = CountingFactory() + val registry = registry(factory) + + driving { registry.restore(listOf(config, config.copy(pubKey = secondCoordinatorPubKey))) } + registry.close() + + assertEquals(2, factory.closed) + assertTrue(registry.coordinators.value.isEmpty()) + assertNull(registry.sessionOrNull(config.pubKey)) + } + + /** + * A scope whose handshake the test decides: an answer, silence, or a throw. + */ + private inner class InfoFactory( + private val info: CoordinatorServerInfo?, + private val blowUp: Boolean = false, + ) : CordnCoordinatorScopeFactory { + override suspend fun open( + accountPubKey: HexKey, + config: CoordinatorConfig, + ): CordnCoordinatorScope = + object : CordnCoordinatorScope { + override val coordinator: ICoordinator = account.clientFor(config.pubKey) + override val groupStore: CordnGroupStore = InMemoryCordnGroupStore() + override val keyPackageStore: CordnKeyPackageStore = InMemoryCordnKeyPackageStore() + + override suspend fun serverInfo(): CoordinatorServerInfo? { + if (blowUp) throw IllegalStateException("coordinator unreachable") + return info + } + + override suspend fun close() = Unit + } + } + + private val anInfo = CoordinatorServerInfo(name = "cordn-server", version = "0.1.0", protocolVersion = "2025-11-25", capabilities = null) + + /** + * The settings screen offers "ask who it is" so someone can find out + * whether a coordinator answers. Before this, a successful handshake left + * the health line reading "Nothing asked of it yet" directly above the + * answer it had just printed. + */ + @Test + fun `a handshake that answers counts as a healthy call`() = + runTest { + val registry = registry(InfoFactory(anInfo)) + val session = driving { registry.session(config) } + + assertTrue(session.health.state.value.isUnknown, "nothing has been asked yet") + + assertNotNull(driving { session.serverInfo() }) + + assertTrue(!session.health.state.value.isUnknown, "the handshake should have been observed") + assertNotNull(session.health.state.value.lastSuccessAt) + } + + @Test + fun `a handshake that throws counts against health`() = + runTest { + val registry = registry(InfoFactory(null, blowUp = true)) + val session = driving { registry.session(config) } + + runCatching { driving { session.serverInfo() } } + + assertEquals(1, session.health.state.value.consecutiveFailures) + assertEquals("coordinator unreachable", session.health.state.value.lastFailure) + } + + /** + * A scope that did no handshake returns null, and a call that never left + * the device must not report the coordinator as reachable. + */ + @Test + fun `silence is neither a success nor a failure`() = + runTest { + val registry = registry(InfoFactory(null)) + val session = driving { registry.session(config) } + + assertNull(driving { session.serverInfo() }) + + assertTrue(session.health.state.value.isUnknown, "silence should leave health unknown") + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnGroupExceptionReasonTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnGroupExceptionReasonTest.kt new file mode 100644 index 0000000000..ccfa485e68 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnGroupExceptionReasonTest.kt @@ -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.commons.cordn + +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * The reason a screen branches on. + * + * `CordnGroupException`'s message is written for a log: it names pubkeys and + * gids in full, which is what you want when reading one and never what you + * want in a chat. A UI that rendered it answered somebody who had just tapped + * a face with 64 hex characters. [CordnGroupException.Reason] is how the + * screen says it in its own words instead, so it has to survive as a value + * rather than as English anyone is free to reword. + */ +class CordnGroupExceptionReasonTest { + @Test + fun `the reason travels with the exception`() { + val e = CordnGroupException("the coordinator holds no KeyPackage for abc", CordnGroupException.Reason.NO_KEY_PACKAGE) + + assertEquals(CordnGroupException.Reason.NO_KEY_PACKAGE, e.reason) + } + + @Test + fun `an exception raised without one is OTHER, not a crash`() { + // Most throw sites have no better wording than their own message, and + // must keep compiling and keep rendering that message. + val e = CordnGroupException("something else went wrong") + + assertEquals(CordnGroupException.Reason.OTHER, e.reason) + } + + @Test + fun `the message is left alone for the log`() { + // The reason is additive. Whatever a maintainer reads in logcat must + // still be the precise thing, pubkey and all. + val e = CordnGroupException("abc is not a member of gid-1", CordnGroupException.Reason.NOT_A_MEMBER) + + assertEquals("abc is not a member of gid-1", e.message) + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnGroupManagerTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnGroupManagerTest.kt new file mode 100644 index 0000000000..bbee86ce3a --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnGroupManagerTest.kt @@ -0,0 +1,1050 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn + +import com.vitorpamplona.quartz.cordn.groups.CordnCredential +import com.vitorpamplona.quartz.cordn.groups.CordnGroupPolicy +import com.vitorpamplona.quartz.cordn.spec00Coordinator.GroupMessage +import com.vitorpamplona.quartz.cordn.spec00Coordinator.ICoordinator +import com.vitorpamplona.quartz.cordn.spec01GroupMetadata.CordnGroupMetadata +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnAnnotationIndex +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnDeliveredMessage +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnEnvelope +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnMessageReferences +import com.vitorpamplona.quartz.cordn.sync.GroupCursor +import com.vitorpamplona.quartz.mls.group.MlsGroup +import com.vitorpamplona.quartz.mls.messages.KeyPackageBundle +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.yield +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * [CordnGroupManager] over a full two-party lifecycle. + * + * Alice creates a group, takes Bob's published KeyPackage off the coordinator, + * adds him, and sends a message; Bob joins from the Welcome the coordinator + * held for him, catches up, and reads it. Both managers run against one + * [FakeCoordinator], so the only thing joining them is the wire — which is the + * point: a cursor or epoch mistake shows up as Bob failing to read, not as an + * assertion about internals. + */ +@OptIn(ExperimentalEncodingApi::class) +class CordnGroupManagerTest { + private val gid = "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21" + private val coordinatorKey = "cc".repeat(32) + + private val aliceSigner = NostrSignerSync(KeyPair()) + private val bobSigner = NostrSignerSync(KeyPair()) + private val alice: HexKey get() = aliceSigner.pubKey + private val bob: HexKey get() = bobSigner.pubKey + + private val config = + CoordinatorConfig( + pubKey = coordinatorKey, + relays = listOf(RelayUrlNormalizer.normalizeOrNull("wss://relay.example.com")!!), + origin = CoordinatorConfig.Origin.DEFAULT, + ) + + private fun manager( + account: HexKey, + coordinator: FakeCoordinator, + store: CordnGroupStore = InMemoryCordnGroupStore(), + ) = CordnGroupManager( + accountPubKey = account, + config = config, + coordinator = coordinator, + store = store, + clock = { 1_757_000_000L }, + ) + + /** + * Bob's KeyPackage plus the signed `kp_publish` request event that binds it + * to him — `spec/00.md` §7's "signed publication payload", which for cordn + * *is* the ContextVM request event. Built here rather than faked because + * `invite` verifies it, and a fake would verify nothing. + */ + private fun bobsPublication(): Pair<KeyPackageBundle, FakeCoordinator.StoredKeyPackage> { + val scratch = MlsGroup.create(CordnCredential.of(bob).identity, policy = CordnGroupPolicy) + val bundle = scratch.createKeyPackage(CordnCredential.of(bob).identity, ByteArray(0)) + val bytes = bundle.keyPackage.toTlsBytes() + val base64 = Base64.encode(bytes) + val ref = + bundle.keyPackage + .toTlsBytes() + .take(16) + .joinToString("") { "%02x".format(it) } + + val content = + """{"jsonrpc":"2.0","id":1,"method":"tools/call",""" + + """"params":{"name":"kp_publish","arguments":{"kp_ref":"$ref","kp_64":"$base64"}}}""" + val event: Event = + bobSigner.sign( + EventTemplate( + createdAt = 1_757_000_000L, + kind = 25910, + tags = arrayOf(arrayOf("p", coordinatorKey)), + content = content, + ), + ) + + return bundle to FakeCoordinator.StoredKeyPackage(bob, ref, base64, event) + } + + /** As [bobsPublication], for a third member. */ + private fun carolsPublication(): Pair<KeyPackageBundle, FakeCoordinator.StoredKeyPackage> { + val carolSigner = NostrSignerSync(KeyPair()) + val carol = carolSigner.pubKey + val scratch = MlsGroup.create(CordnCredential.of(carol).identity, policy = CordnGroupPolicy) + val bundle = scratch.createKeyPackage(CordnCredential.of(carol).identity, ByteArray(0)) + val base64 = Base64.encode(bundle.keyPackage.toTlsBytes()) + val ref = "ca".repeat(16) + val content = + """{"jsonrpc":"2.0","id":1,"method":"tools/call",""" + + """"params":{"name":"kp_publish","arguments":{"kp_ref":"$ref","kp_64":"$base64"}}}""" + val event: Event = + carolSigner.sign( + EventTemplate( + createdAt = 1_757_000_000L, + kind = 25910, + tags = arrayOf(arrayOf("p", coordinatorKey)), + content = content, + ), + ) + return bundle to FakeCoordinator.StoredKeyPackage(carol, ref, base64, event) + } + + @Test + fun `alice creates, invites, sends, and bob joins and reads`() = + runTest { + val coordinator = FakeCoordinator(callerPubKey = alice) + val aliceManager = manager(alice, coordinator) + val (bobBundle, stored) = bobsPublication() + coordinator.seedKeyPackage(stored) + + aliceManager.createGroup(gid, CordnGroupMetadata(name = "Stage 4", adminPubkeys = listOf(alice))) + assertEquals(setOf(gid), aliceManager.gids.value) + + val invite = aliceManager.invite(gid, bob) + assertEquals(bob, invite.invited) + assertEquals(1L, aliceManager.group(gid)!!.epoch, "adding a member advances the epoch") + + aliceManager.send(gid, "hello bob") + + // Bob's side, from nothing but what the coordinator holds. + val bobCoordinator = FakeCoordinator(callerPubKey = bob) + bobCoordinator.welcomes.putAll(coordinator.welcomes) + bobCoordinator.streams.putAll(coordinator.streams) + val bobManager = manager(bob, bobCoordinator) + + val results = bobManager.joinPendingWelcomes({ ref -> bobBundle.takeIf { ref == stored.keyPackageRef } }) + assertEquals(listOf(gid), results.joined, "Bob recovers the gid from the group id") + assertTrue(results.skipped.isEmpty()) + + val delivered = mutableListOf<CordnGroupManager.Delivery>() + bobManager.catchUp { delivered += it } + + val messages = delivered.filterIsInstance<CordnGroupManager.Delivery.Message>() + assertEquals(1, messages.size, "got ${delivered.map { it::class.simpleName }}") + assertEquals("hello bob", messages[0].received.envelope.content) + assertEquals(alice, messages[0].received.sender, "the sender is MLS-authenticated, not claimed") + assertEquals(CordnGroupManager.CHAT_KIND, messages[0].received.envelope.kind) + } + + @Test + fun `an admin removes a member and the group advances past them`() = + runTest { + val coordinator = FakeCoordinator(callerPubKey = alice) + val aliceManager = manager(alice, coordinator) + val (_, stored) = bobsPublication() + coordinator.seedKeyPackage(stored) + + aliceManager.createGroup(gid, CordnGroupMetadata(name = "Removal", adminPubkeys = listOf(alice))) + aliceManager.invite(gid, bob) + val epochAfterInvite = aliceManager.group(gid)!!.epoch + + val removal = aliceManager.removeMember(gid, bob) + + assertEquals(bob, removal.removed) + assertEquals(epochAfterInvite + 1, aliceManager.group(gid)!!.epoch, "a Remove is its own epoch") + assertTrue( + bob !in CordnCredential.membersOf(aliceManager.group(gid)!!).values, + "bob must be gone from the tree, not merely marked", + ) + } + + @Test + fun `removing yourself is refused`() = + runTest { + val coordinator = FakeCoordinator(callerPubKey = alice) + val aliceManager = manager(alice, coordinator) + aliceManager.createGroup(gid, CordnGroupMetadata(name = "Self", adminPubkeys = listOf(alice))) + + // Committing your own Remove advances the group into an epoch whose + // keys you no longer hold, so the reference client refuses it too. + assertFailsWith<IllegalArgumentException> { aliceManager.removeMember(gid, alice) } + } + + @Test + fun `removing someone who is not a member fails loudly`() = + runTest { + val coordinator = FakeCoordinator(callerPubKey = alice) + val aliceManager = manager(alice, coordinator) + aliceManager.createGroup(gid, CordnGroupMetadata(name = "Absent", adminPubkeys = listOf(alice))) + + assertFailsWith<CordnGroupException> { aliceManager.removeMember(gid, bob) } + } + + @Test + fun `a non-admin cannot remove anyone`() = + runTest { + val coordinator = FakeCoordinator(callerPubKey = alice) + val aliceManager = manager(alice, coordinator) + val (_, stored) = bobsPublication() + coordinator.seedKeyPackage(stored) + + // Egalitarian first, so alice can invite; then she hands admin to + // bob alone, which is a real sequence and the only one that leaves + // her a member who may not remove anyone. Creating the group with + // admins = [bob] would have failed at the invite instead — the gate + // firing there is correct, but it tests the wrong call. + aliceManager.createGroup(gid, CordnGroupMetadata(name = "Handover")) + aliceManager.invite(gid, bob) + aliceManager.updateGroupMetadata(gid, CordnGroupMetadata(name = "Handover", adminPubkeys = listOf(bob))) + + assertFailsWith<IllegalStateException> { aliceManager.removeMember(gid, bob) } + } + + @Test + fun `updating metadata keeps every other extension`() = + runTest { + val coordinator = FakeCoordinator(callerPubKey = alice) + val aliceManager = manager(alice, coordinator) + aliceManager.createGroup(gid, CordnGroupMetadata(name = "Before", adminPubkeys = listOf(alice))) + + val before = + aliceManager + .group(gid)!! + .extensions + .map { it.extensionType } + .toSet() + + aliceManager.updateGroupMetadata(gid, CordnGroupMetadata(name = "After", adminPubkeys = listOf(alice, bob))) + + val group = aliceManager.group(gid)!! + val metadata = CordnGroupMetadata.fromExtensions(group.extensions) + assertEquals("After", metadata?.name) + assertEquals(listOf(alice, bob), metadata?.adminPubkeys, "the admin list travels with the metadata") + + // A GroupContextExtensions proposal replaces the WHOLE list, so + // required_capabilities would vanish if only the metadata were sent + // — and a group that lost it is one whose next commit peers reject. + assertEquals(before, group.extensions.map { it.extensionType }.toSet()) + } + + @Test + fun `a non-admin cannot rewrite metadata`() = + runTest { + val coordinator = FakeCoordinator(callerPubKey = alice) + val aliceManager = manager(alice, coordinator) + aliceManager.createGroup(gid, CordnGroupMetadata(name = "Gated", adminPubkeys = listOf(bob))) + + assertFailsWith<IllegalStateException> { + aliceManager.updateGroupMetadata(gid, CordnGroupMetadata(name = "Hijacked", adminPubkeys = listOf(alice))) + } + } + + @Test + fun `an egalitarian group lets any member do both`() = + runTest { + val coordinator = FakeCoordinator(callerPubKey = alice) + val aliceManager = manager(alice, coordinator) + val (_, stored) = bobsPublication() + coordinator.seedKeyPackage(stored) + + // No admins named: spec/01.md §5.3 egalitarian mode, where the gate + // opens rather than closes. + aliceManager.createGroup(gid, CordnGroupMetadata(name = "Flat")) + aliceManager.invite(gid, bob) + + aliceManager.updateGroupMetadata(gid, CordnGroupMetadata(name = "Still flat")) + aliceManager.removeMember(gid, bob) + + assertEquals("Still flat", CordnGroupMetadata.fromExtensions(aliceManager.group(gid)!!.extensions)?.name) + } + + @Test + fun `join requests are only asked for where they could be accepted`() = + runTest { + val coordinator = FakeCoordinator(callerPubKey = alice) + val aliceManager = manager(alice, coordinator) + + // Alice is a member but not an admin, so accepting a request here + // would be an Add commit the policy refuses. Asking for the list at + // all would only offer her something that cannot work. + aliceManager.createGroup(gid, CordnGroupMetadata(name = "Someone else's", adminPubkeys = listOf(bob))) + + assertTrue(aliceManager.pendingJoinRequests().isEmpty()) + } + + @Test + fun `the welcome cursor keeps bob from replaying epochs he cannot read`() = + runTest { + // Without `after`, catch-up starts at 0 and hands Bob the Commit + // that created him plus everything before it -- all Undecryptable, + // and all indistinguishable from real loss. + val coordinator = FakeCoordinator(callerPubKey = alice) + val aliceManager = manager(alice, coordinator) + val (bobBundle, stored) = bobsPublication() + coordinator.seedKeyPackage(stored) + + aliceManager.createGroup(gid, CordnGroupMetadata(name = "Stage 4")) + aliceManager.send(gid, "before bob existed") + aliceManager.invite(gid, bob) + aliceManager.send(gid, "after bob joined") + + val bobCoordinator = FakeCoordinator(callerPubKey = bob) + bobCoordinator.welcomes.putAll(coordinator.welcomes) + bobCoordinator.streams.putAll(coordinator.streams) + val bobManager = manager(bob, bobCoordinator) + bobManager.joinPendingWelcomes({ bobBundle.takeIf { _ -> true } }) + + val delivered = mutableListOf<CordnGroupManager.Delivery>() + bobManager.catchUp { delivered += it } + + val messages = delivered.filterIsInstance<CordnGroupManager.Delivery.Message>() + assertEquals(listOf("after bob joined"), messages.map { it.received.envelope.content }) + assertTrue( + delivered.none { it is CordnGroupManager.Delivery.Undecryptable }, + "nothing from before Bob's epoch should have been offered: ${delivered.map { it::class.simpleName }}", + ) + } + + @Test + fun `an existing member applies a later commit, sealed under the pre-commit epoch`() = + runTest { + // `spec/03.md` §5: a Commit is sealed with the exporter of the + // epoch it LEAVES, because that is the only key its recipients + // have. Sealing under the epoch it creates produces a payload the + // sender can read and nobody else can -- and it cannot be caught + // by the join path, where the Welcome's cursor skips the Commit + // that created the joiner. It needs a member already in the group + // when a later Commit lands, which is what this is. + val coordinator = FakeCoordinator(callerPubKey = alice) + val aliceManager = manager(alice, coordinator) + val (bobBundle, stored) = bobsPublication() + coordinator.seedKeyPackage(stored) + + aliceManager.createGroup(gid, CordnGroupMetadata(name = "Three")) + aliceManager.invite(gid, bob) + + val bobCoordinator = FakeCoordinator(callerPubKey = bob) + bobCoordinator.welcomes.putAll(coordinator.welcomes) + bobCoordinator.streams.putAll(coordinator.streams) + val bobManager = manager(bob, bobCoordinator) + bobManager.joinPendingWelcomes({ bobBundle }) + bobManager.catchUp { } + assertEquals(1L, bobManager.group(gid)!!.epoch) + + // Carol arrives. Bob was already here, so the Commit is his to apply. + val (_, carolStored) = carolsPublication() + coordinator.seedKeyPackage(carolStored) + aliceManager.invite(gid, carolStored.pubKey) + aliceManager.send(gid, "carol is in") + + bobCoordinator.streams.clear() + bobCoordinator.streams.putAll(coordinator.streams) + val delivered = mutableListOf<CordnGroupManager.Delivery>() + bobManager.catchUp { delivered += it } + + assertTrue( + delivered.none { it is CordnGroupManager.Delivery.Undecryptable }, + "Bob could not open a Commit addressed to his own epoch: " + + delivered.filterIsInstance<CordnGroupManager.Delivery.Undecryptable>().map { it.reason }, + ) + assertEquals(2L, bobManager.group(gid)!!.epoch, "the Commit must have advanced Bob's epoch") + assertEquals( + listOf("carol is in"), + delivered.filterIsInstance<CordnGroupManager.Delivery.Message>().map { it.received.envelope.content }, + "and the message sent at the new epoch must still open", + ) + } + + @Test + fun `a group survives a restart`() = + runTest { + val coordinator = FakeCoordinator(callerPubKey = alice) + val store = InMemoryCordnGroupStore() + val first = manager(alice, coordinator, store) + first.createGroup(gid, CordnGroupMetadata(name = "Persisted")) + first.send(gid, "one") + + val restarted = manager(alice, coordinator, store) + restarted.restore() + + assertEquals(setOf(gid), restarted.gids.value) + assertEquals("Persisted", CordnGroupMetadata.fromExtensions(restarted.group(gid)!!.extensions)?.name) + + // Our own message comes back, because posting deliberately does + // not advance the cursor — a lower cursor may still hold somebody + // else's unprocessed message. So it must come back recognised. + // + // This assertion used to be `none { it is Message }`, and it + // passed while the delivery was `Undecryptable`: not a Message, so + // not a failure, so nobody noticed that a sender reported a gap in + // its own conversation. Only a run against a real coordinator + // made it visible. Assert what it IS, not what it is not. + val delivered = mutableListOf<CordnGroupManager.Delivery>() + restarted.catchUp { delivered += it } + assertEquals( + listOf("Echo"), + delivered.map { it::class.simpleName }, + "our own message must come back as an echo, not as a gap", + ) + } + + @Test + fun `a message is stored before the cursor that would skip it`() = + runTest { + // The ordering IS the correctness property. Write the cursor first + // and a crash in between loses the message permanently: both cordn + // seal keys are epoch-derived, so the copy the coordinator still + // holds can no longer be opened, and a cursor past it means it is + // never offered again. Write the message first and the same crash + // costs nothing — the message is on disk, the cursor still points + // before it, and the re-delivery dedups. + val coordinator = FakeCoordinator(callerPubKey = alice) + val store = OrderRecordingStore() + val m = manager(alice, coordinator, store) + m.createGroup(gid, CordnGroupMetadata(name = "Order")) + m.send(gid, "one") + + val append = store.calls.indexOf("appendMessage") + val cursor = store.calls.indexOf("saveCursor") + assertTrue(append >= 0, "the message was never stored at all: ${store.calls}") + assertTrue(cursor >= 0, "the cursor was never stored at all: ${store.calls}") + assertTrue( + append < cursor, + "the cursor was written before the message, so a crash between them loses it: ${store.calls}", + ) + } + + @Test + fun `the same message delivered twice is stored once`() = + runTest { + // The crash window above lands here: a re-fetch after a restart + // re-delivers a message that is already on disk, and it arrives + // carrying a cursor of its own. Dedup on the whole stored entry + // would not catch that — same envelope, different cursor, different + // bytes — so it has to key on the envelope id. + val store = InMemoryCordnGroupStore() + val envelope = + CordnEnvelope.build( + pubKey = alice, + createdAt = 1_757_000_000L, + kind = 9, + content = "once", + ) + + store.appendMessage(gid, CordnDeliveredMessage(envelope, cursor = 7)) + store.appendMessage(gid, CordnDeliveredMessage(envelope, cursor = 9)) + + assertEquals(1, store.loadMessages(gid).size, "the same envelope was stored twice") + } + + @Test + fun `a stored conversation reloads as the one that was ingested`() = + runTest { + // Equality of the raw list is not enough: what the room shows is + // the annotation fold — edits applied, deletions withdrawn, + // reactions counted. A reload that produced the same messages but a + // different fold would look right in a list assertion and wrong on + // screen. + val coordinator = FakeCoordinator(callerPubKey = alice) + val store = InMemoryCordnGroupStore() + val m = manager(alice, coordinator, store) + m.createGroup(gid, CordnGroupMetadata(name = "Fold")) + + val first = m.send(gid, "original") + m.post( + gid = gid, + content = "edited", + editTo = + CordnMessageReferences.Target( + id = first.envelope.id, + pubKey = first.envelope.pubKey, + kind = first.envelope.kind, + tags = first.envelope.tags, + ), + ) + m.post(gid, "\uD83D\uDC4D", reactionTo = CordnMessageReferences.Target(first.envelope.id, first.envelope.pubKey, first.envelope.kind, first.envelope.tags)) + + val live = CordnAnnotationIndex.of(listOf(first) + m.storedMessages(gid).drop(1)) + val reloaded = CordnAnnotationIndex.of(m.storedMessages(gid)) + + assertEquals("edited", reloaded.contentOf(first.envelope.id), "the edit did not survive the reload") + assertTrue(reloaded.isEdited(first.envelope.id)) + assertEquals( + live.reactions[first.envelope.id]?.keys, + reloaded.reactions[first.envelope.id]?.keys, + "the reaction fold differs after a reload", + ) + } + + @Test + fun `the summary names the newest message without loading the log`() = + runTest { + val coordinator = FakeCoordinator(callerPubKey = alice) + val m = manager(alice, coordinator) + m.createGroup(gid, CordnGroupMetadata(name = "Summary")) + m.send(gid, "first") + val last = m.send(gid, "last") + + val summary = m.storedMessageSummary(gid) + assertEquals(last.envelope.id, summary?.newest?.envelope?.id) + assertEquals(2, summary?.count) + } + + @Test + fun `leaving a group takes its messages with it`() = + runTest { + // A group whose history outlived it would keep the plaintext of an + // end-to-end encrypted conversation on disk after the MLS state + // that could read it was thrown away. + val store = InMemoryCordnGroupStore() + val envelope = CordnEnvelope.build(alice, 1_757_000_000L, 9, content = "secret") + store.saveGroup(gid, ByteArray(1)) + store.appendMessage(gid, CordnDeliveredMessage(envelope, cursor = 1)) + + store.deleteGroup(gid) + + assertEquals(emptyList<CordnDeliveredMessage>(), store.loadMessages(gid)) + assertEquals(null, store.loadMessageSummary(gid)) + } + + /** Records the order writes arrive in, so an ordering invariant can be asserted. */ + private class OrderRecordingStore( + private val inner: CordnGroupStore = InMemoryCordnGroupStore(), + ) : CordnGroupStore by inner { + val calls = mutableListOf<String>() + + override suspend fun appendMessage( + gid: String, + message: CordnDeliveredMessage, + ) { + calls += "appendMessage" + inner.appendMessage(gid, message) + } + + override suspend fun saveCursor( + gid: String, + cursor: GroupCursor, + ) { + calls += "saveCursor" + inner.saveCursor(gid, cursor) + } + } + + @Test + fun `a posted commit is still recognised as ours after a restart`() = + runTest { + // The same bug, one layer worse. A Commit is sealed under the + // PRE-commit epoch key (spec/03.md §5), which the poster no longer + // has once it has advanced — so when the echo arrives it cannot be + // opened at all, and without the record that it is ours it is + // indistinguishable from a payload from an epoch we never had. + // + // Feeding it back through the engine instead would be worse than a + // gap: it would advance the epoch twice and desynchronise us from + // every other member, with the failure surfacing epochs later. + val coordinator = FakeCoordinator(callerPubKey = alice) + val store = InMemoryCordnGroupStore() + val (bundle, stored) = bobsPublication() + coordinator.seedKeyPackage(stored) + + val first = manager(alice, coordinator, store) + first.createGroup(gid, CordnGroupMetadata(name = "Restart")) + first.invite(gid, bob, stored.keyPackageRef) + assertEquals(1, first.group(gid)!!.epoch) + + val restarted = manager(alice, coordinator, store) + restarted.restore() + + val delivered = mutableListOf<CordnGroupManager.Delivery>() + restarted.catchUp { delivered += it } + + assertEquals( + listOf("Echo"), + delivered.map { it::class.simpleName }, + "a restarted client must recognise the Commit it posted: ${delivered.map { it::class.simpleName }}", + ) + assertEquals(1, restarted.group(gid)!!.epoch, "and must not apply it a second time") + assertTrue(bundle.keyPackage.toTlsBytes().isNotEmpty()) + } + + @Test + fun `inviteAll adds everybody in one epoch, not one each`() = + runTest { + // The reason inviteAll exists. A loop of single invites reaches + // epoch 2 for two people; more importantly, each Add is applied + // locally BEFORE its Commit is posted, so one failed post left the + // group forked and every later invite in the loop building on it. + val coordinator = FakeCoordinator(callerPubKey = alice) + val aliceManager = manager(alice, coordinator) + val (_, bobStored) = bobsPublication() + val (_, carolStored) = carolsPublication() + coordinator.seedKeyPackage(bobStored) + coordinator.seedKeyPackage(carolStored) + + aliceManager.createGroup(gid, CordnGroupMetadata(name = "Batch", adminPubkeys = listOf(alice))) + val result = aliceManager.inviteAll(gid, listOf(bob, carolStored.pubKey)) + + assertEquals(2, result.invited.size, "both were invited") + assertTrue(result.refused.isEmpty(), "nobody was refused: ${result.refused}") + assertTrue(result.undelivered.isEmpty(), "every Welcome was stored: ${result.undelivered}") + assertEquals(1L, aliceManager.group(gid)!!.epoch, "one commit, so one epoch") + } + + @Test + fun `inviteAll refuses the one with no KeyPackage and still adds the rest`() = + runTest { + // The refusal happens before the commit, so it costs the group + // nothing: the others are added in the same single epoch they would + // have reached on their own. + val coordinator = FakeCoordinator(callerPubKey = alice) + val aliceManager = manager(alice, coordinator) + val (_, bobStored) = bobsPublication() + val (_, carolStored) = carolsPublication() + coordinator.seedKeyPackage(bobStored) + + aliceManager.createGroup(gid, CordnGroupMetadata(name = "Batch", adminPubkeys = listOf(alice))) + val result = aliceManager.inviteAll(gid, listOf(bob, carolStored.pubKey)) + + assertEquals(listOf(bob), result.invited.map { it.invited }) + assertEquals( + CordnGroupException.Reason.NO_KEY_PACKAGE, + result.refused[carolStored.pubKey]?.reason, + "the one with nothing published is named, not the whole batch", + ) + assertEquals(1L, aliceManager.group(gid)!!.epoch) + } + + @Test + fun `inviteAll with nobody to invite leaves the group where it was`() = + runTest { + val coordinator = FakeCoordinator(callerPubKey = alice) + val aliceManager = manager(alice, coordinator) + aliceManager.createGroup(gid, CordnGroupMetadata(name = "Batch")) + + val result = aliceManager.inviteAll(gid, emptyList()) + + assertTrue(result.invited.isEmpty()) + assertEquals(0L, aliceManager.group(gid)!!.epoch, "an empty batch is not a commit") + } + + @Test + fun `inviting someone the coordinator has no KeyPackage for fails loudly`() = + runTest { + val coordinator = FakeCoordinator(callerPubKey = alice) + val aliceManager = manager(alice, coordinator) + aliceManager.createGroup(gid, CordnGroupMetadata(name = "Stage 4")) + + val failure = assertFailsWith<CordnGroupException> { aliceManager.invite(gid, bob) } + assertTrue(failure.message!!.contains("holds no KeyPackage")) + assertEquals(0L, aliceManager.group(gid)!!.epoch, "a failed invite must not advance the group") + } + + @Test + fun `a KeyPackage published under someone else's name is refused`() = + runTest { + // §9/§10: the coordinator checks this too, and we check it anyway. + // A coordinator that skipped the check could otherwise hand us any + // account's name over any account's key material. + val coordinator = FakeCoordinator(callerPubKey = alice) + val aliceManager = manager(alice, coordinator) + aliceManager.createGroup(gid, CordnGroupMetadata(name = "Stage 4")) + + val (_, stored) = bobsPublication() + val impostor = "ee".repeat(32) + coordinator.seedKeyPackage( + FakeCoordinator.StoredKeyPackage(impostor, stored.keyPackageRef, stored.base64, stored.publicationEvent), + ) + + assertFailsWith<Exception> { aliceManager.invite(gid, impostor) } + assertEquals(0L, aliceManager.group(gid)!!.epoch) + } + + @Test + fun `health tracks the coordinator without polling it`() = + runTest { + val coordinator = FakeCoordinator(callerPubKey = alice) + val aliceManager = manager(alice, coordinator) + aliceManager.createGroup(gid, CordnGroupMetadata(name = "Stage 4")) + + assertTrue(aliceManager.health.state.value.isUnknown, "no call yet is not the same as down") + + aliceManager.send(gid, "works") + assertEquals(0, aliceManager.health.state.value.consecutiveFailures) + + coordinator.failNext = CoordinatorHealth.DOWN_AFTER + repeat(CoordinatorHealth.DOWN_AFTER) { + runCatching { aliceManager.send(gid, "fails") } + } + assertTrue(aliceManager.health.state.value.isDown) + + aliceManager.send(gid, "works again") + assertEquals(0, aliceManager.health.state.value.consecutiveFailures, "a success clears the streak") + assertTrue(!aliceManager.health.state.value.isDown) + } + + @Test + fun `exposure reports the real linked-group count`() = + runTest { + // Section 8.2 is the one a user cannot guess: one ephemeral session + // per coordinator means every group on it is linked to the others. + val coordinator = FakeCoordinator(callerPubKey = alice) + val aliceManager = manager(alice, coordinator) + aliceManager.createGroup(gid, CordnGroupMetadata(name = "One")) + + val alone = aliceManager.exposure(gid) + assertEquals(1, alone.linkedGroupCount) + assertTrue(ExposureNote.GROUPS_LINKED_BY_SESSION !in alone.notes()) + assertEquals(ExposureLevel.NONE, alone.content) + assertEquals(ExposureLevel.IDENTIFIED, alone.membership) + assertTrue(ExposureNote.ENCRYPTION_NOT_PINNED !in alone.notes(), "our transport pins REQUIRED (§8.6)") + + aliceManager.createGroup("second-gid", CordnGroupMetadata(name = "Two")) + val linked = aliceManager.exposure(gid) + assertEquals(2, linked.linkedGroupCount) + assertTrue(ExposureNote.GROUPS_LINKED_BY_SESSION in linked.notes()) + + assertTrue(ExposureNote.PUBLICATION_IS_A_SIGNED_RECORD !in linked.notes()) + aliceManager.publishKeyPackage("ref", "a2s=") + assertTrue(ExposureNote.PUBLICATION_IS_A_SIGNED_RECORD in aliceManager.exposure(gid).notes(), "§8.4") + } + + @Test + fun `a share ref round-trips through this coordinator`() = + runTest { + val coordinator = FakeCoordinator(callerPubKey = alice) + val aliceManager = manager(alice, coordinator) + aliceManager.createGroup(gid, CordnGroupMetadata(name = "Shared")) + + val ref = aliceManager.shareRef(gid) + assertEquals(gid, ref.gid) + assertEquals(coordinatorKey, ref.coordinatorPubKey) + assertEquals(CoordinatorConfig.from(ref)?.pubKey, coordinatorKey) + assertNull(aliceManager.group("nope")) + } + + @Test + fun `a second drain does not roll a live group back to its welcome epoch`() = + runTest { + val coordinator = FakeCoordinator(callerPubKey = alice) + val aliceManager = manager(alice, coordinator) + val (bobBundle, stored) = bobsPublication() + coordinator.seedKeyPackage(stored) + + aliceManager.createGroup(gid, CordnGroupMetadata(name = "Rollback")) + aliceManager.invite(gid, bob) + + val bobCoordinator = FakeCoordinator(callerPubKey = bob) + bobCoordinator.welcomes.putAll(coordinator.welcomes) + bobCoordinator.streams.putAll(coordinator.streams) + val bobManager = manager(bob, bobCoordinator) + bobManager.joinPendingWelcomes({ bobBundle }) + bobManager.catchUp { } + val epochAfterJoin = bobManager.group(gid)!!.epoch + + // Carol arrives; Bob applies the Commit and advances. + val (_, carolStored) = carolsPublication() + coordinator.seedKeyPackage(carolStored) + aliceManager.invite(gid, carolStored.pubKey) + bobCoordinator.streams.putAll(coordinator.streams) + bobManager.catchUp { } + val epochAfterCommit = bobManager.group(gid)!!.epoch + assertTrue(epochAfterCommit > epochAfterJoin, "precondition: Bob advanced") + + // A second drain, which is what a relaunch or a retry does. + bobManager.joinPendingWelcomes({ bobBundle }) + + // Before the accept/decline split this replaced Bob's live group + // with the one the Welcome was issued at, and every message after + // that epoch stopped decrypting -- silently, and for good. + assertEquals(epochAfterCommit, bobManager.group(gid)!!.epoch, "a second drain rolled Bob's group back") + } + + @Test + fun `a welcome can be read before it is answered`() = + runTest { + val coordinator = FakeCoordinator(callerPubKey = alice) + val aliceManager = manager(alice, coordinator) + val (bobBundle, stored) = bobsPublication() + coordinator.seedKeyPackage(stored) + + aliceManager.createGroup(gid, CordnGroupMetadata(name = "Book club", description = "Thursdays")) + aliceManager.invite(gid, bob) + + val bobManager = manager(bob, bobsCoordinatorOver(coordinator)) + val inbox = bobManager.pendingWelcomes({ bobBundle }) + + val welcome = inbox.pending.single() + assertEquals(gid, welcome.gid) + assertEquals("Book club", welcome.metadata?.name) + // Who is already in it -- deliberately not "who invited you", + // which a Welcome does not carry. + assertEquals(setOf(alice, bob), welcome.members) + assertTrue(bobManager.gids.value.isEmpty(), "reading an invitation must not join it") + } + + @Test + fun `accepting joins the group and stops the coordinator serving the welcome`() = + runTest { + val coordinator = FakeCoordinator(callerPubKey = alice) + val aliceManager = manager(alice, coordinator) + val (bobBundle, stored) = bobsPublication() + coordinator.seedKeyPackage(stored) + aliceManager.createGroup(gid, CordnGroupMetadata(name = "Accepted")) + aliceManager.invite(gid, bob) + + val bobCoordinator = bobsCoordinatorOver(coordinator) + val bobManager = manager(bob, bobCoordinator) + val welcome = bobManager.pendingWelcomes({ bobBundle }).pending.single() + assertEquals(gid, bobManager.accept(welcome)) + + assertEquals(setOf(gid), bobManager.gids.value) + // Retired at the coordinator, not merely filtered out on the way + // back: an un-retired welcome is served to every future session of + // this account forever, and each one has to re-open it to find out + // it is stale. + assertTrue(bobCoordinator.welcomes[bob].isNullOrEmpty(), "the welcome was not retired") + } + + @Test + fun `declining retires the welcome without joining anything`() = + runTest { + val coordinator = FakeCoordinator(callerPubKey = alice) + val aliceManager = manager(alice, coordinator) + val (bobBundle, stored) = bobsPublication() + coordinator.seedKeyPackage(stored) + aliceManager.createGroup(gid, CordnGroupMetadata(name = "Declined")) + aliceManager.invite(gid, bob) + + val bobManager = manager(bob, bobsCoordinatorOver(coordinator)) + bobManager.decline(bobManager.pendingWelcomes({ bobBundle }).pending.single()) + + assertTrue(bobManager.gids.value.isEmpty()) + assertTrue( + bobManager.pendingWelcomes({ bobBundle }).pending.isEmpty(), + "a declined welcome must not be offered again", + ) + } + + @Test + fun `a welcome this device has no key package for is left for the device that does`() = + runTest { + val coordinator = FakeCoordinator(callerPubKey = alice) + val aliceManager = manager(alice, coordinator) + val (_, stored) = bobsPublication() + coordinator.seedKeyPackage(stored) + aliceManager.createGroup(gid, CordnGroupMetadata(name = "Other device")) + aliceManager.invite(gid, bob) + + val bobManager = manager(bob, bobsCoordinatorOver(coordinator)) + val first = bobManager.pendingWelcomes({ null }) + assertEquals(CordnGroupManager.NO_PRIVATE_HALF, first.skipped.single().reason) + + // Still there: retiring it would destroy the other device's only + // copy of an invitation it can actually open. + assertEquals(1, bobManager.pendingWelcomes({ null }).skipped.size) + } + + @Test + fun `a welcome for a group we are already in is retired rather than offered as a choice`() = + runTest { + val coordinator = FakeCoordinator(callerPubKey = alice) + val aliceManager = manager(alice, coordinator) + val (bobBundle, stored) = bobsPublication() + coordinator.seedKeyPackage(stored) + aliceManager.createGroup(gid, CordnGroupMetadata(name = "Stale")) + aliceManager.invite(gid, bob) + + val bobCoordinator = bobsCoordinatorOver(coordinator) + val bobManager = manager(bob, bobCoordinator) + // Joined, but the acknowledgement never landed -- a dropped + // connection between accepting and retiring leaves exactly this + // record still being served. + val served = bobCoordinator.welcomes[bob]!!.toList() + bobManager.accept(bobManager.pendingWelcomes({ bobBundle }).pending.single()) + bobCoordinator.welcomes[bob] = served.toMutableList() + + val inbox = bobManager.pendingWelcomes({ bobBundle }) + + assertTrue(inbox.pending.isEmpty(), "nobody should be asked about a group they are in") + assertEquals(CordnGroupManager.ALREADY_A_MEMBER, inbox.skipped.single().reason) + } + + @Test + fun `asking to join is remembered across a restart, because the coordinator remembers`() = + runTest { + val coordinator = FakeCoordinator(callerPubKey = alice) + val store = InMemoryCordnGroupStore() + val first = manager(alice, coordinator, store) + first.createGroup(gid, CordnGroupMetadata(name = "Asked")) + + assertFalse(first.exposure(gid).joinedFromShareLink) + first.requestToJoin(gid, "kp-ref") + assertTrue(first.exposure(gid).joinedFromShareLink) + + // A new manager over the same store, which is what a relaunch is. + // The coordinator still has the npub tied to this group, so an + // exposure card that came back clean here would be lying. + val second = manager(alice, coordinator, store) + second.restore() + assertTrue(second.exposure(gid).joinedFromShareLink) + } + + @Test + fun `a failed join request still counts as asked`() = + runTest { + val coordinator = FakeCoordinator(callerPubKey = alice) + val aliceManager = manager(alice, coordinator) + aliceManager.createGroup(gid, CordnGroupMetadata(name = "Asked")) + + aliceManager.requestToJoin(gid, "kp-ref") + // Nobody answers. The exposure is the asking, not the joining. + assertTrue(aliceManager.exposure(gid).joinedFromShareLink) + assertTrue(aliceManager.group(gid)!!.memberCount == 1) + } + + @Test + fun `a cancelled subscription still saves what it ingested`() = + runTest { + // The case the `finally` exists for, and the one it could not + // serve: persistAll suspends, and a suspend call inside a + // cancelled coroutine throws before writing anything. Cancelling + // is also the normal way this ends — CordnSyncLoop cancels the + // subscription whenever the group set changes. + val aliceCoordinator = FakeCoordinator(callerPubKey = alice) + val aliceManager = manager(alice, aliceCoordinator) + val (bundle, stored) = bobsPublication() + aliceCoordinator.keyPackages[stored.keyPackageRef] = stored + aliceManager.createGroup(gid, CordnGroupMetadata(name = "Cancelled")) + aliceManager.invite(gid, bob, stored.keyPackageRef) + aliceManager.send(gid, "a message bob has not seen") + + // Bob joins with an empty store, so any cursor in it afterwards + // can only have come from the subscription. + val bobStore = SuspendingStore() + val bobManager = + CordnGroupManager( + accountPubKey = bob, + config = config, + coordinator = HangingCoordinator(bobsCoordinatorOver(aliceCoordinator)), + store = bobStore, + clock = { 1_757_000_000L }, + ) + bobManager.pendingWelcomes({ bundle }).pending.forEach { bobManager.accept(it) } + val before = bobStore.loadCursor(gid) + + // Delivers, then hangs — so the cancellation lands while the + // subscription is open, which is the whole point. A fake that + // returns normally leaves the `finally` running in a live + // coroutine and proves nothing. + val job = launch { bobManager.subscribe(timeoutMs = 60_000) { } } + advanceUntilIdle() + job.cancelAndJoin() + + assertNotEquals( + before, + bobStore.loadCursor(gid), + "a cursor the subscription advanced must reach the store even when cancelled", + ) + } + + /** + * A store whose writes actually suspend, like the file-backed one. + * + * [InMemoryCordnGroupStore]'s methods are `suspend` but never reach a + * suspension point, and cancellation is only observed at one — so against + * it a cancelled `finally` writes happily and proves nothing. The real + * store goes through `Dispatchers.IO`, where the same code throws before + * it writes. One `yield()` is the difference. + */ + private class SuspendingStore( + private val inner: CordnGroupStore = InMemoryCordnGroupStore(), + ) : CordnGroupStore by inner { + override suspend fun saveCursor( + gid: String, + cursor: GroupCursor, + ) { + yield() + inner.saveCursor(gid, cursor) + } + + override suspend fun saveGroup( + gid: String, + state: ByteArray, + ) { + yield() + inner.saveGroup(gid, state) + } + } + + /** Delivers whatever it is wrapping, then never returns. */ + private class HangingCoordinator( + private val inner: FakeCoordinator, + ) : ICoordinator by inner { + override suspend fun subscribeMessages( + cursors: Map<String, Long?>, + timeoutMs: Long, + onMessage: (GroupMessage) -> Unit, + ) { + inner.subscribeMessages(cursors, timeoutMs, onMessage) + awaitCancellation() + } + } + + /** Bob's own view of the coordinator, carrying whatever Alice's has stored. */ + private fun bobsCoordinatorOver(alices: FakeCoordinator): FakeCoordinator { + val bobCoordinator = FakeCoordinator(callerPubKey = bob) + bobCoordinator.welcomes.putAll(alices.welcomes) + bobCoordinator.streams.putAll(alices.streams) + return bobCoordinator + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnHandoffStateTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnHandoffStateTest.kt new file mode 100644 index 0000000000..f41b719896 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnHandoffStateTest.kt @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn + +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertFalse +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +/** + * The flag the whole migration design rests on. + * + * Two phones holding one MLS leaf and both committing fork the ratchet tree, + * and MLS does not recover: after the fork every message silently fails to + * decrypt for somebody. Migration is only safe because the old phone stops — + * so the cases that matter are the ones where it fails to stop, or stops and + * cannot be brought back. + */ +class CordnHandoffStateTest { + @get:Rule val folder = TemporaryFolder() + + @Test + fun `a fresh device has not handed off`() = + runTest { + val state = state() + state.restore() + + assertFalse(state.handedOff.value) + state.requireNotHandedOff() + } + + @Test + fun `a handed-off device refuses to write`() = + runTest { + val state = state() + state.markHandedOff() + + assertTrue(state.handedOff.value) + val thrown = assertThrows(CordnHandedOffException::class.java) { state.requireNotHandedOff() } + assertTrue(thrown.message!!.contains("fork")) + } + + @Test + fun `the flag survives a restart`() = + runTest { + // The case that matters: a device that forgot it had handed off + // would resume committing on the next launch, which is the fork. + state().markHandedOff() + + val afterRestart = state() + afterRestart.restore() + + assertTrue(afterRestart.handedOff.value) + } + + @Test + fun `a handoff can be undone, and the undo survives a restart`() = + runTest { + // A migration can fail after the export — a flat battery, a QR that + // will not scan. Locking irreversibly would strand the account on + // the device that still holds the only copy of its state. + val state = state() + state.markHandedOff() + + state.resume() + + assertFalse(state.handedOff.value) + state.requireNotHandedOff() + + val afterRestart = state() + afterRestart.restore() + assertFalse(afterRestart.handedOff.value) + } + + @Test + fun `restore reflects what is on disk, not what this instance did`() = + runTest { + val first = state() + first.restore() + assertFalse(first.handedOff.value) + + state().markHandedOff() + first.restore() + + assertTrue(first.handedOff.value) + } + + private fun state() = CordnHandoffState(FileCordnHandoffStore(folder.root)) +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnIndependenceTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnIndependenceTest.kt new file mode 100644 index 0000000000..df0b4ccfb6 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnIndependenceTest.kt @@ -0,0 +1,141 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertTrue +import kotlin.test.fail + +/** + * The app half of the Marmot/cordn boundary. + * + * `quartz`'s `BindingIsolationTest` holds the protocol layer apart. This holds + * the layer above it apart, and the rule is the same and deliberate: **Marmot is + * frozen, and neither binding may acquire a reason to change when the other + * does.** The two protocols do not interoperate and neither is a layer of the + * other, so a shared model, state holder or chatroom type would be a coupling + * with nothing to justify it. + * + * What cordn *may* share is what any feature shares: the design system, the + * theme, and generic components — buttons, avatars, text fields, QR, media + * viewers. Those are app furniture, not group chat. The line this test draws is + * `marmot`-named code, because that is where the coupling would actually land. + */ +class CordnIndependenceTest { + private val commonsRoot: File by lazy { + // Resolved rather than assumed: a wrong root makes an architecture test + // pass for the wrong reason, which is worse than not having one. + generateSequence(File(".").absoluteFile) { it.parentFile } + .map { if (it.name == "commons") it else File(it, "commons") } + .firstOrNull { File(it, "src/commonMain/kotlin/com/vitorpamplona/amethyst/commons").isDirectory } + ?: fail("cannot locate the commons module from ${File(".").absolutePath}") + } + + /** Main source sets only; interop tests may legitimately drive both. */ + private fun kotlinFilesIn(pkg: String): List<File> = + File(commonsRoot, "src") + .listFiles() + .orEmpty() + .filter { it.isDirectory && !it.name.endsWith("Test") } + .map { File(it, "kotlin/com/vitorpamplona/amethyst/commons/$pkg") } + .filter { it.isDirectory } + .flatMap { it.walkTopDown().filter { f -> f.isFile && f.extension == "kt" }.toList() } + + private fun importsMatching( + pkg: String, + forbidden: Regex, + ): List<String> = + kotlinFilesIn(pkg).flatMap { file -> + file.readLines().withIndex().mapNotNull { (i, line) -> + val trimmed = line.trimStart() + if (trimmed.startsWith("import ") && forbidden.containsMatchIn(trimmed)) { + "${file.relativeTo(commonsRoot)}:${i + 1} $trimmed" + } else { + null + } + } + } + + /** + * Plain substring, NOT `\bmarmot\b`. + * + * The word-boundary form cannot match the imports that matter: in + * `…model.marmotGroups.MarmotGroupChatroom` the character after `marmot` + * is a word character both times, so `\b` fails and the guard waves + * through exactly the dependency it exists to stop. This guard shipped + * with that bug and was only caught by trying to break it on purpose — + * which is why [the matcher itself is tested] below. + * + * A substring is safe here because only `import` lines are scanned: the + * word "marmot" inside an import IS a Marmot dependency. + */ + private val marmotImport = Regex("marmot", RegexOption.IGNORE_CASE) + + private val cordnImport = Regex("cordn", RegexOption.IGNORE_CASE) + + @Test + fun `the matcher catches the import it exists to catch`() { + // The meta-test. Without it, a guard that can never fire looks exactly + // like a codebase that never violates the rule. + val realImport = "import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom" + assertTrue( + marmotImport.containsMatchIn(realImport), + "the Marmot matcher would not flag $realImport — the guard cannot fire", + ) + assertTrue( + cordnImport.containsMatchIn("import com.vitorpamplona.amethyst.commons.cordn.CordnGroupManager"), + "the cordn matcher cannot fire either", + ) + } + + @Test + fun `cordn does not reach into Marmot`() { + val offences = importsMatching("cordn", marmotImport) + assertTrue( + offences.isEmpty(), + "cordn must not import Marmot — the two are independent and Marmot is frozen\n " + + offences.joinToString("\n "), + ) + } + + @Test + fun `Marmot does not reach into cordn`() { + // The direction that protects the shipped feature: Marmot must not gain + // a dependency on the newer, less settled binding. + val offences = importsMatching("marmot", cordnImport) + assertTrue( + offences.isEmpty(), + "Marmot must not import cordn — Marmot is frozen and cordn moves\n " + offences.joinToString("\n "), + ) + } + + @Test + fun `the scan sees real files and real imports`() { + listOf("cordn", "marmot").forEach { + assertTrue(kotlinFilesIn(it).size > 3, "found ${kotlinFilesIn(it).size} files under $it — the scan is broken, not the code") + } + assertTrue( + importsMatching("cordn", Regex("quartz", RegexOption.IGNORE_CASE)).isNotEmpty(), + "the scanner found no quartz imports in cordn, so it would not find a marmot one either", + ) + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnJoinRequestTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnJoinRequestTest.kt new file mode 100644 index 0000000000..6651a26c3f --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnJoinRequestTest.kt @@ -0,0 +1,206 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn + +import com.vitorpamplona.quartz.cordn.groups.CordnCredential +import com.vitorpamplona.quartz.cordn.groups.CordnGroupPolicy +import com.vitorpamplona.quartz.cordn.spec01GroupMetadata.CordnGroupMetadata +import com.vitorpamplona.quartz.mls.group.MlsGroup +import com.vitorpamplona.quartz.mls.messages.KeyPackageBundle +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import kotlinx.coroutines.test.runTest +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Stage C's stated verification: **link → request → accept → first message**. + * + * The four steps are one flow and only mean anything together. A share ref is + * useless if the `gid` it names cannot be requested; a request is useless if + * nobody can turn it into a Welcome; a Welcome is useless if the joiner cannot + * then read the room. Each half has its own unit tests elsewhere — this is the + * seam between them, which is where a protocol built out of four independent + * coordinator calls actually breaks. + */ +@OptIn(ExperimentalEncodingApi::class) +class CordnJoinRequestTest { + private val coordinatorKey = "d".repeat(64) + private val aliceSigner = NostrSignerSync(KeyPair()) + private val bobSigner = NostrSignerSync(KeyPair()) + private val alice: HexKey get() = aliceSigner.pubKey + private val bob: HexKey get() = bobSigner.pubKey + + private val config = + CoordinatorConfig( + pubKey = coordinatorKey, + relays = listOf(RelayUrlNormalizer.normalizeOrNull("wss://relay.example.com")!!), + origin = CoordinatorConfig.Origin.DEFAULT, + ) + + private fun manager( + account: HexKey, + coordinator: FakeCoordinator, + ) = CordnGroupManager( + accountPubKey = account, + config = config, + coordinator = coordinator, + store = InMemoryCordnGroupStore(), + clock = { 1_757_000_000L }, + ) + + @Test + fun `a share ref becomes a request, an invite, and a readable room`() = + runTest { + // One coordinator, two callers. Each manager sees it as its own + // account, which is what makes the Welcome routing meaningful. + val alicesView = FakeCoordinator(callerPubKey = alice) + val bobsView = alicesView.viewAs(bob) + + val aliceManager = manager(alice, alicesView) + val bobManager = manager(bob, bobsView) + + // 1. Alice makes a group and shares its ref. + val gid = "stage-c-room" + aliceManager.createGroup(gid, CordnGroupMetadata(name = "Stage C")) + val ref = aliceManager.shareRef(gid) + val fromLink = CordnLinkInspection.of(ref.encode()) + assertTrue(fromLink is CordnLinkInspection.Valid, "the ref Alice shares must parse as a link") + assertEquals(gid, fromLink.ref.gid) + + // 2. Bob publishes a KeyPackage to that coordinator and asks in. + val (bobBundle, bobsKeyPackage) = publicationFor(bobSigner) + bobsView.seedKeyPackage(bobsKeyPackage) + bobManager.requestToJoin(fromLink.ref.gid, bobsKeyPackage.keyPackageRef) + + // 3. Alice sees the request and accepts it. + val request = aliceManager.pendingJoinRequests().single() + assertEquals(bob, request.pubKey) + assertEquals(bobsKeyPackage.keyPackageRef, request.keyPackageRef) + aliceManager.acceptJoinRequest(request) + + assertTrue( + aliceManager.pendingJoinRequests().isEmpty(), + "an accepted request must not be offered a second time", + ) + + // 4. Bob opens the Welcome and reads what Alice says next. + aliceManager.send(gid, "welcome in") + val welcome = bobManager.pendingWelcomes({ bobBundle }).pending.single() + assertEquals(gid, welcome.gid) + assertEquals(gid, bobManager.accept(welcome)) + + val delivered = mutableListOf<CordnGroupManager.Delivery>() + bobManager.catchUp { delivered += it } + val messages = delivered.filterIsInstance<CordnGroupManager.Delivery.Message>() + + assertEquals(listOf("welcome in"), messages.map { it.received.envelope.content }) + assertEquals(alice, messages.single().received.sender, "the sender is MLS-authenticated, not claimed") + } + + @Test + fun `declining a request adds nobody and does not offer it again`() = + runTest { + val alicesView = FakeCoordinator(callerPubKey = alice) + val bobsView = alicesView.viewAs(bob) + val aliceManager = manager(alice, alicesView) + val bobManager = manager(bob, bobsView) + + val gid = "closed-room" + aliceManager.createGroup(gid, CordnGroupMetadata(name = "Closed")) + val (bobBundle, bobsKeyPackage) = publicationFor(bobSigner) + bobsView.seedKeyPackage(bobsKeyPackage) + bobManager.requestToJoin(gid, bobsKeyPackage.keyPackageRef) + + aliceManager.declineJoinRequest(aliceManager.pendingJoinRequests().single()) + + assertTrue(aliceManager.pendingJoinRequests().isEmpty()) + assertEquals(0L, aliceManager.group(gid)!!.epoch, "declining must not commit anything") + assertTrue( + bobManager.pendingWelcomes({ bobBundle }).pending.isEmpty(), + "a declined request must not produce a Welcome", + ) + } + + @Test + fun `a request is answered with the key package it named`() = + runTest { + // Bob has two packages published. The request names one; taking the + // other would spend a package he meant for a different inviter, and + // the Welcome would be addressed to a private half he still holds + // but did not expect to use here. + val alicesView = FakeCoordinator(callerPubKey = alice) + val bobsView = alicesView.viewAs(bob) + val aliceManager = manager(alice, alicesView) + val bobManager = manager(bob, bobsView) + + val gid = "two-packages" + aliceManager.createGroup(gid, CordnGroupMetadata(name = "Two")) + + val (_, spare) = publicationFor(bobSigner, ref = "aa".repeat(16)) + val (named, asked) = publicationFor(bobSigner, ref = "bb".repeat(16)) + bobsView.seedKeyPackage(spare) + bobsView.seedKeyPackage(asked) + + bobManager.requestToJoin(gid, asked.keyPackageRef) + aliceManager.acceptJoinRequest(aliceManager.pendingJoinRequests().single()) + + val welcome = bobManager.pendingWelcomes({ ref -> named.takeIf { ref == asked.keyPackageRef } }).pending.single() + assertEquals(gid, welcome.gid) + } + + /** A KeyPackage plus the signed `kp_publish` event that binds it to [signer]. */ + private fun publicationFor( + signer: NostrSignerSync, + ref: String? = null, + ): Pair<KeyPackageBundle, FakeCoordinator.StoredKeyPackage> { + val identity = CordnCredential.of(signer.pubKey).identity + val scratch = MlsGroup.create(identity, policy = CordnGroupPolicy) + val bundle = scratch.createKeyPackage(identity, ByteArray(0)) + val base64 = Base64.encode(bundle.keyPackage.toTlsBytes()) + val keyPackageRef = + ref ?: bundle.keyPackage + .toTlsBytes() + .take(16) + .joinToString("") { "%02x".format(it) } + + val content = + """{"jsonrpc":"2.0","id":1,"method":"tools/call",""" + + """"params":{"name":"kp_publish","arguments":{"kp_ref":"$keyPackageRef","kp_64":"$base64"}}}""" + val event: Event = + signer.sign( + EventTemplate( + createdAt = 1_757_000_000L, + kind = 25910, + tags = arrayOf(arrayOf("p", coordinatorKey)), + content = content, + ), + ) + return bundle to FakeCoordinator.StoredKeyPackage(signer.pubKey, keyPackageRef, base64, event) + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnKeyPackagesTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnKeyPackagesTest.kt new file mode 100644 index 0000000000..c4553df8a5 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnKeyPackagesTest.kt @@ -0,0 +1,398 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn + +import com.vitorpamplona.quartz.cordn.groups.CordnCredential +import com.vitorpamplona.quartz.cordn.spec00Coordinator.AvailableKeyPackage +import com.vitorpamplona.quartz.cordn.spec00Coordinator.ICoordinator +import com.vitorpamplona.quartz.cordn.spec00Coordinator.KeyPackagePublication +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.messages.KeyPackageBundleCodec +import com.vitorpamplona.quartz.mls.messages.MlsKeyPackage +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import kotlinx.coroutines.test.runTest +import kotlin.io.encoding.ExperimentalEncodingApi +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Publishing KeyPackages so other people can invite this account. + * + * Driven over the real transport rather than a fake coordinator, because two of + * the things worth checking only exist on the wire: that `kp_ref` is the RFC + * 9420 KeyPackageRef the coordinator will key on, and that the publication + * payload a `kp_take` serves back verifies — which it can only do if the signed + * request event round-tripped (`spec/00.md` §7). + */ +@OptIn(ExperimentalEncodingApi::class) +class CordnKeyPackagesTest : CordnTransportHarness() { + @Test + fun `a published ref is the RFC 9420 KeyPackageRef`() = + runTest { + // The coordinator's primary key and the address a Welcome is + // delivered to. Computing it differently from everyone else fails + // silently: every kp_take misses and no group ever forms. + val alice = Account() + val published = driving { alice.keyPackages.publishNew() } + + val bundle = assertNotNull(alice.keyPackages.bundleFor(published.keyPackageRef)) + assertEquals(bundle.keyPackage.reference().toHexKey(), published.keyPackageRef) + } + + @Test + fun `what the coordinator serves back verifies as ours`() = + runTest { + // §9: the client re-runs every check even though §8 makes the + // coordinator run them too. That is only meaningful if the signed + // request event really is what comes back. + val alice = Account() + val published = driving { alice.keyPackages.publishNew() } + + val taken = assertNotNull(driving { alice.coordinatorClient.takeKeyPackage(published.keyPackageRef) }) + val verified = KeyPackagePublication.verify(taken.publicationEvent) + + assertEquals(alice.pubKey, verified.pubKey) + assertEquals( + published.keyPackageRef, + MlsKeyPackage.decodeTls(TlsReader(verified.bytes)).reference().toHexKey(), + ) + assertEquals(alice.pubKey, CordnCredential.identityOrNull(verified.keyPackage.leafNode)) + } + + @Test + fun `the private half survives a restart`() = + runTest { + val alice = Account() + val published = driving { alice.keyPackages.publishNew() } + val before = assertNotNull(alice.keyPackages.bundleFor(published.keyPackageRef)) + + // Same store, new manager — what a process restart looks like. + val restarted = CordnKeyPackages(alice.pubKey, alice.coordinatorClient, alice.keyPackageStore) + restarted.restore() + + assertEquals(setOf(published.keyPackageRef), restarted.published.value) + val after = assertNotNull(restarted.bundleFor(published.keyPackageRef)) + assertContentEquals(before.initPrivateKey, after.initPrivateKey) + assertContentEquals(before.signaturePrivateKey, after.signaturePrivateKey) + assertContentEquals(before.encryptionPrivateKey, after.encryptionPrivateKey) + } + + @Test + fun `a failed publish leaves nothing behind`() = + runTest { + // The half that matters: a bundle we kept for a package the + // coordinator never took is dead weight, and accumulating one per + // failed attempt is how a key store grows without bound. + val alice = Account() + coordinator.rejectPublication = true + + assertFailsWith<Exception> { driving { alice.keyPackages.publishNew() } } + + assertTrue( + alice.keyPackages.published.value + .isEmpty(), + ) + assertTrue(alice.keyPackageStore.list().isEmpty()) + } + + @Test + fun `a last-resort package is published once and reused`() = + runTest { + val alice = Account() + val first = driving { alice.keyPackages.ensureLastResort() } + val second = driving { alice.keyPackages.ensureLastResort() } + + assertEquals(first, second, "a second call must not publish another reusable package") + + val bundle = assertNotNull(alice.keyPackages.bundleFor(assertNotNull(first))) + assertTrue(bundle.keyPackage.isLastResort(), "it must actually carry the marker") + } + + @Test + fun `a second device publishes its own last-resort package`() = + runTest { + // kp_list shows the account's packages, not this device's, so a + // second device sees the first device's reusable package and could + // mistake it for its own. Reusing it would send every fallback + // Welcome to a KeyPackage only the other device can open — the + // invite looks accepted and the group never forms here. + val alice = Account() + val first = assertNotNull(driving { alice.keyPackages.ensureLastResort() }) + + // Same account and coordinator, empty key store: a new install. + val secondDevice = CordnKeyPackages(alice.pubKey, alice.coordinatorClient, InMemoryCordnKeyPackageStore()) + val second = assertNotNull(driving { secondDevice.ensureLastResort() }) + + assertTrue(first != second, "it must not adopt a key it has no private half for") + assertNotNull(secondDevice.bundleFor(second)) + + // And it settles: a second call on that device reuses its own. + assertEquals(second, driving { secondDevice.ensureLastResort() }) + } + + @Test + fun `an ordinary package is not marked last resort`() = + runTest { + val alice = Account() + val published = driving { alice.keyPackages.publishNew() } + val bundle = assertNotNull(alice.keyPackages.bundleFor(published.keyPackageRef)) + assertTrue(!bundle.keyPackage.isLastResort()) + } + + @Test + fun `top-up counts what the coordinator holds, not what we published`() = + runTest { + // kp_take consumes a single-use package, so the coordinator's count + // falls as people invite us while ours does not. Counting ours + // would let the pool silently empty. + val alice = Account() + driving { alice.keyPackages.topUp(minimum = 3) } + assertEquals(3, coordinator.availableCount(alice.pubKey)) + + // Already at the floor: nothing more to do. + val none = driving { alice.keyPackages.topUp(minimum = 3) } + assertTrue(none.isEmpty()) + + // Somebody invites us, consuming one. + driving { alice.coordinatorClient.takeKeyPackage(alice.pubKey) } + val refilled = driving { alice.keyPackages.topUp(minimum = 3) } + assertEquals(1, refilled.size, "the pool must refill to the floor") + } + + @Test + fun `the last-resort package does not count toward the single-use floor`() = + runTest { + // Startup calls both, so this is the ordinary state, not a corner. + // The reusable package is the fallback for when the pool is drained; + // letting it fill a slot in the pool means every account ships one + // fewer single-use package than it asked for, and the fallback gets + // used a round earlier than it should. + val alice = Account() + driving { alice.keyPackages.ensureLastResort() } + driving { alice.keyPackages.topUp(minimum = 3) } + + assertEquals(3, coordinator.availableCount(alice.pubKey), "the pool is the single-use packages alone") + assertEquals(4, coordinator.keyPackagesOf(alice.pubKey).size, "plus the one reusable package") + } + + @Test + fun `withdrawing tells the coordinator before forgetting the key`() = + runTest { + // Order matters: dropping the private half while the coordinator + // still serves the package means somebody invites us and we cannot + // open the Welcome. + val alice = Account() + val published = driving { alice.keyPackages.publishNew() } + + val removed = driving { alice.keyPackages.withdraw(listOf(published.keyPackageRef)) } + + assertEquals(listOf(published.keyPackageRef), removed) + assertNull(alice.keyPackages.bundleFor(published.keyPackageRef)) + assertTrue( + alice.keyPackages.published.value + .isEmpty(), + ) + assertNull(driving { alice.coordinatorClient.takeKeyPackage(published.keyPackageRef) }) + } + + @Test + fun `a withdrawal the coordinator refuses keeps the key we still need`() = + runTest { + // The other half of the ordering rule, and the only half a working + // coordinator can show. While the coordinator still serves the + // package, somebody can still invite us with it — so the private + // half has to survive a removal that did not happen. Forgetting it + // first would leave an invite we can never open. + val alice = Account() + val published = driving { alice.keyPackages.publishNew() } + coordinator.rejectRemoval = true + + assertFailsWith<Exception> { driving { alice.keyPackages.withdraw(listOf(published.keyPackageRef)) } } + + assertNotNull(alice.keyPackages.bundleFor(published.keyPackageRef)) + assertEquals(setOf(published.keyPackageRef), alice.keyPackages.published.value) + + // And the invite it can still back really does open. + coordinator.rejectRemoval = false + val taken = assertNotNull(driving { alice.coordinatorClient.takeKeyPackage(published.keyPackageRef) }) + assertEquals(published.keyPackageRef, taken.keyPackageRef) + } + + @Test + fun `a bundle we cannot decode sends the Welcome back to the inbox`() = + runTest { + // Rather than throwing: another device of the same account, or a + // later version of this one, may be able to handle it. + val alice = Account() + alice.keyPackageStore.save("corrupt", byteArrayOf(9, 9, 9)) + assertNull(alice.keyPackages.bundleFor("corrupt")) + } + + @Test + fun `the bundle codec round-trips every private half`() = + runTest { + val alice = Account() + val published = driving { alice.keyPackages.publishNew() } + val bundle = assertNotNull(alice.keyPackages.bundleFor(published.keyPackageRef)) + + val again = KeyPackageBundleCodec.decode(KeyPackageBundleCodec.encode(bundle)) + + assertContentEquals(bundle.keyPackage.toTlsBytes(), again.keyPackage.toTlsBytes()) + assertContentEquals(bundle.initPrivateKey, again.initPrivateKey) + assertContentEquals(bundle.encryptionPrivateKey, again.encryptionPrivateKey) + assertContentEquals(bundle.signaturePrivateKey, again.signaturePrivateKey) + } + + @Test + fun `an unknown bundle layout is refused, not guessed at`() { + // A misread bundle yields key material that is silently wrong, which + // surfaces much later as a Welcome that will not open. + val forwardVersion = byteArrayOf(0x00, 0x63) + ByteArray(32) + assertFailsWith<IllegalArgumentException> { KeyPackageBundleCodec.decode(forwardVersion) } + } + + @Test + fun `publishing names the account, by design`() = + runTest { + // §8.4: kp_publish rides the stable identity and the coordinator + // keeps the signed event to re-serve. That is the disclosure the + // exposure card reports; it must not quietly become ephemeral. + val alice = Account() + driving { alice.keyPackages.publishNew() } + + val publish = assertNotNull(coordinator.calls.lastOrNull { it.method == "kp_publish" }) + assertEquals(alice.pubKey, publish.callerPubKey) + } + + @Test + fun `whether a key package is published is read from the store, not from this session`() = + runTest { + val alice = Account() + val store = InMemoryCordnKeyPackageStore() + val first = CordnKeyPackages(alice.pubKey, alice.coordinatorClient, store) + assertFalse(first.hasPublished(), "nothing published yet") + + driving { first.publishNew() } + + // A second instance over the same store -- which is what a relaunch + // is. The coordinator still holds the KeyPackage, so an exposure + // card reporting "not published" here would understate what it + // knows, and would do it on every launch after the first. + val second = CordnKeyPackages(alice.pubKey, alice.coordinatorClient, store) + assertTrue(second.hasPublished(), "before restore()") + second.restore() + assertTrue(second.hasPublished(), "after restore()") + } + + @Test + fun `listPublished reports what the coordinator serves, not what we think we sent`() = + runTest { + val alice = Account() + val first = driving { alice.keyPackages.publishNew() } + driving { alice.keyPackages.publishNew() } + + // Somebody invites Alice: kp_take consumes the single-use package. + // Our own record still has two; the coordinator has one, and it is + // the coordinator's count that decides whether the next invitation + // can happen at all. + driving { alice.coordinatorClient.takeKeyPackage(first.keyPackageRef) } + + val listed = driving { alice.keyPackages.listPublished() } + assertEquals(2, alice.keyPackages.published.value.size, "our local record is unchanged") + assertEquals(1, listed.size, "the coordinator has one left") + assertFalse(listed.any { it.keyPackageRef == first.keyPackageRef }) + } + + @Test + fun `listPublished is scoped to this account`() = + runTest { + val alice = Account() + val bob = Account() + driving { alice.keyPackages.publishNew() } + driving { bob.keyPackages.publishNew() } + + assertTrue(driving { alice.keyPackages.listPublished() }.all { it.pubKey == alice.pubKey }) + } + + @Test + fun `the identity set spans everybody, where listPublished is scoped to us`() = + runTest { + // The two read one `kp_list` response and keep different halves of + // it. Scoping the identity set the way listPublished is scoped + // would answer "can I add Bob" with our own packages, which is + // always no. + val alice = Account() + val bob = Account() + driving { alice.keyPackages.publishNew() } + driving { bob.keyPackages.publishNew() } + + val reachable = assertNotNull(driving { alice.keyPackages.identitiesWithKeyPackages() }) + assertTrue(alice.pubKey in reachable, "ourselves") + assertTrue(bob.pubKey in reachable, "somebody else") + } + + @Test + fun `publishing shows up straight away rather than at the end of the snapshot's life`() = + runTest { + // The snapshot is reused for a minute, so our own publish has to + // drop it. Otherwise the account that just published a package is + // told for the next minute that it has none. + val alice = Account() + assertTrue(driving { alice.keyPackages.identitiesWithKeyPackages() }?.contains(alice.pubKey) == false) + + driving { alice.keyPackages.publishNew() } + + assertTrue(driving { alice.keyPackages.identitiesWithKeyPackages() }?.contains(alice.pubKey) == true) + } + + @Test + fun `a coordinator that cannot answer is unknown, not nobody`() = + runTest { + // The distinction the whole return type exists for. An empty set + // says the coordinator holds nothing for anyone; null says we never + // found out. A caller that folds them together renders a network + // failure as a claim that somebody cannot be added. + val alice = Account() + driving { alice.keyPackages.publishNew() } + + val blind = + CordnKeyPackages( + accountPubKey = alice.pubKey, + coordinator = UnreachableList(alice.coordinatorClient), + store = InMemoryCordnKeyPackageStore(), + ) + + assertNull(driving { blind.identitiesWithKeyPackages() }) + } + + /** A coordinator whose `kp_list` is the one call that fails. */ + private class UnreachableList( + delegate: ICoordinator, + ) : ICoordinator by delegate { + override suspend fun listKeyPackages(): List<AvailableKeyPackage> = throw IllegalStateException("kp_list unreachable") + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnLinkInspectionTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnLinkInspectionTest.kt new file mode 100644 index 0000000000..2da3dfc0cc --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnLinkInspectionTest.kt @@ -0,0 +1,127 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn + +import com.vitorpamplona.quartz.cordn.appGroupRef.CordnGroupRef +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The parse-and-disclose step behind the "someone sent me a cordn link" screen. + * + * Headless on purpose: the screen draws whatever this returns, so the rules + * that matter — what counts as a link, what a link without a coordinator means, + * and what §8 says about joining through one — are checkable without a device. + */ +class CordnLinkInspectionTest { + private val gid = "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21" + private val coordinator = "cc".repeat(32) + + private fun link( + withCoordinator: Boolean = true, + relays: List<String> = listOf("wss://relay.example.com/"), + ) = CordnGroupRef( + gid = gid, + coordinatorPubKey = if (withCoordinator) coordinator else null, + relays = if (withCoordinator) relays else emptyList(), + ).encode() + + @Test + fun `a full link resolves to a coordinator and a disclosure`() { + val result = CordnLinkInspection.of(link()) + assertTrue(result is CordnLinkInspection.Valid, "got $result") + assertEquals(gid, result.ref.gid) + assertTrue(result.isFollowable) + + val config = assertNotNull(result.coordinator) + assertEquals(coordinator, config.pubKey) + assertEquals(CoordinatorConfig.Origin.GROUP_REF, config.origin, "a pasted link is not a coordinator the user chose") + + val exposure = assertNotNull(result.exposure) + assertEquals(ExposureLevel.NONE, exposure.content) + assertEquals(ExposureLevel.IDENTIFIED, exposure.membership) + assertTrue(exposure.joinedFromShareLink, "joining by link is the §8.1 path that names you") + } + + @Test + fun `a link naming no coordinator is valid but not followable`() { + // spec/applications/group-ref.md §2 makes the coordinator optional. The + // ref still identifies a group; it just does not say who serves it, and + // there is nobody to disclose anything about. + val result = CordnLinkInspection.of(link(withCoordinator = false)) + assertTrue(result is CordnLinkInspection.Valid) + assertEquals(gid, result.ref.gid) + assertTrue(!result.isFollowable) + assertNull(result.coordinator) + assertNull(result.exposure, "inventing a threat model for an unnamed server would be worse than saying nothing") + } + + @Test + fun `the linkage count describes what joining would create`() { + // §8.2: one throwaway key covers every group on a coordinator, so the + // warning is only true once there is a second group to link to. The + // count has to include the group being joined. + val alone = CordnLinkInspection.of(link()) as CordnLinkInspection.Valid + assertEquals(1, alone.exposure!!.linkedGroupCount) + assertTrue(ExposureNote.GROUPS_LINKED_BY_SESSION !in alone.exposure!!.notes()) + + val joining = CordnLinkInspection.of(link(), existingGroupsOnCoordinator = 2) as CordnLinkInspection.Valid + assertEquals(3, joining.exposure!!.linkedGroupCount) + assertTrue(ExposureNote.GROUPS_LINKED_BY_SESSION in joining.exposure!!.notes()) + } + + @Test + fun `a published key package is disclosed, because the coordinator can re-serve it`() { + val quiet = CordnLinkInspection.of(link()) as CordnLinkInspection.Valid + assertTrue(ExposureNote.PUBLICATION_IS_A_SIGNED_RECORD !in quiet.exposure!!.notes()) + + val published = CordnLinkInspection.of(link(), publishedKeyPackage = true) as CordnLinkInspection.Valid + assertTrue(ExposureNote.PUBLICATION_IS_A_SIGNED_RECORD in published.exposure!!.notes(), "§8.4") + } + + @Test + fun `pasted whitespace and uppercase still resolve`() { + // What actually arrives from a clipboard: a trailing newline from a + // chat app, or a ref someone typed in caps. Bech32 permits the + // uppercase form and cordn's own decoder accepts it. + assertTrue(CordnLinkInspection.of(" ${link()}\n") is CordnLinkInspection.Valid) + assertTrue(CordnLinkInspection.of(link().uppercase()) is CordnLinkInspection.Valid) + } + + @Test + fun `everything else is refused, with the reason the decoder gave`() { + listOf( + "", + " ", + "nostr1qqqqq", + "cordn1qqqqq", + "npub1xxxx", + "https://cordn.net/g/abc", + ).forEach { + val result = CordnLinkInspection.of(it) + assertTrue(result is CordnLinkInspection.Invalid, "must refuse '$it', got $result") + assertTrue(result.reason.isNotEmpty(), "a refusal with no reason is a dead end for the user") + } + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnMentionsTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnMentionsTest.kt new file mode 100644 index 0000000000..53cb043bb4 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnMentionsTest.kt @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn + +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip19Bech32.entities.NPub +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class CordnMentionsTest { + private val alice = KeyPair() + private val aliceHex = alice.pubKey.toHexKey() + private val aliceNpub = NPub.create(aliceHex) + + /** Every segment's original text, concatenated. */ + private fun rebuild(segments: List<CordnMentions.Segment>) = + segments.joinToString("") { + when (it) { + is CordnMentions.Segment.Text -> it.value + is CordnMentions.Segment.Mention -> it.raw + } + } + + @Test + fun `a message with no mentions is one run of text`() { + val segments = CordnMentions.segment("just talking") + + assertEquals(listOf(CordnMentions.Segment.Text("just talking")), segments) + } + + @Test + fun `a mention is split out of the text around it`() { + val content = "hey nostr:$aliceNpub what do you think" + val segments = CordnMentions.segment(content) + + assertEquals(3, segments.size, "text, mention, text") + assertEquals(aliceHex, (segments[1] as CordnMentions.Segment.Mention).pubKey) + assertEquals(content, rebuild(segments), "segmenting must not lose a character") + } + + @Test + fun `a message that is only a mention has no empty text runs around it`() { + val segments = CordnMentions.segment("nostr:$aliceNpub") + + assertEquals(1, segments.size) + assertTrue(segments.single() is CordnMentions.Segment.Mention) + } + + @Test + fun `a bare npub is left as text`() { + // Without `nostr:` this is as likely to be someone quoting a key as + // mentioning a person, and turning it into a name changes what they + // wrote. + val content = "my key is $aliceNpub, check it" + val segments = CordnMentions.segment(content) + + assertEquals(listOf(CordnMentions.Segment.Text(content)), segments) + } + + @Test + fun `an unparseable nostr uri stays text rather than vanishing`() { + // The regex can match something bech32 cannot decode. Dropping it + // would delete part of the message on screen. + val content = "look at nostr:npub1qqqqqqqqqq here" + val segments = CordnMentions.segment(content) + + assertEquals(content, rebuild(segments)) + } + + @Test + fun `punctuation right after a mention is not eaten`() { + val content = "thanks nostr:$aliceNpub!" + val segments = CordnMentions.segment(content) + + assertEquals(content, rebuild(segments)) + assertEquals(CordnMentions.Segment.Text("!"), segments.last()) + } + + @Test + fun `the same person mentioned twice is listed once`() { + val mentioned = CordnMentions.mentioned("nostr:$aliceNpub and again nostr:$aliceNpub") + + assertEquals(listOf(aliceHex), mentioned) + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnMessageActionsTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnMessageActionsTest.kt new file mode 100644 index 0000000000..4148e8d558 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnMessageActionsTest.kt @@ -0,0 +1,146 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn + +import com.vitorpamplona.quartz.cordn.spec01GroupMetadata.CordnGroupMetadata +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnMessageKinds +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnMessageReferences +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** + * The send side of cordn's annotation kinds. + * + * The read side has been able to fold reactions, edits, deletions and pins + * since the protocol work landed; until now nothing could produce them. What + * is worth testing here is not the tag shapes — `CordnMessageReferences` owns + * those and has its own round-trip tests — but that the manager refuses the + * sends whose results would be silently discarded by the very fold that + * receives them. + */ +class CordnMessageActionsTest { + private val coordinatorKey = "d".repeat(64) + private val aliceSigner = NostrSignerSync(KeyPair()) + private val alice: HexKey get() = aliceSigner.pubKey + private val bob: HexKey = "b".repeat(64) + private val gid = "actions" + + private fun manager(coordinator: FakeCoordinator) = + CordnGroupManager( + accountPubKey = alice, + config = + CoordinatorConfig( + pubKey = coordinatorKey, + relays = listOf(RelayUrlNormalizer.normalizeOrNull("wss://relay.example.com")!!), + origin = CoordinatorConfig.Origin.DEFAULT, + ), + coordinator = coordinator, + store = InMemoryCordnGroupStore(), + clock = { 1_757_000_000L }, + ) + + private fun target( + author: HexKey, + id: String = "aa".repeat(32), + ) = CordnMessageReferences.Target(id = id, pubKey = author, kind = CordnMessageKinds.TEXT) + + @Test + fun `a reply goes out as a NIP-22 thread reply`() = + runTest { + val manager = manager(FakeCoordinator(callerPubKey = alice)) + manager.createGroup(gid, CordnGroupMetadata(name = "Actions")) + + val sent = manager.post(gid, "in reply", replyTo = target(bob)).envelope + + assertEquals(CordnMessageKinds.THREAD_REPLY, sent.kind) + assertEquals("in reply", sent.content) + assertTrue(sent.tags.any { it.firstOrNull() == "E" }, "a reply carries a thread root") + } + + @Test + fun `a reaction is anyone's to send`() = + runTest { + val manager = manager(FakeCoordinator(callerPubKey = alice)) + manager.createGroup(gid, CordnGroupMetadata(name = "Actions")) + + val sent = manager.post(gid, "👍", reactionTo = target(bob)).envelope + + assertEquals(CordnMessageKinds.REACTION, sent.kind) + assertEquals("👍", sent.content) + } + + @Test + fun `editing someone else's message fails at the call instead of on delivery`() = + runTest { + // CordnAnnotationIndex is author-only for edits, so this would be + // accepted by the coordinator, stored, delivered -- and then + // dropped by every client including the sender's. A send nobody + // can see fail is worse than one that throws. + val manager = manager(FakeCoordinator(callerPubKey = alice)) + manager.createGroup(gid, CordnGroupMetadata(name = "Actions")) + + assertFailsWith<IllegalArgumentException> { + manager.post(gid, "not mine to change", editTo = target(bob)) + } + } + + @Test + fun `deleting someone else's message fails at the call`() = + runTest { + val manager = manager(FakeCoordinator(callerPubKey = alice)) + manager.createGroup(gid, CordnGroupMetadata(name = "Actions")) + + assertFailsWith<IllegalArgumentException> { + manager.post(gid, deleteTo = target(bob)) + } + } + + @Test + fun `editing and deleting your own message is allowed`() = + runTest { + val manager = manager(FakeCoordinator(callerPubKey = alice)) + manager.createGroup(gid, CordnGroupMetadata(name = "Actions")) + + assertEquals(CordnMessageKinds.EDIT, manager.post(gid, "fixed", editTo = target(alice)).envelope.kind) + assertEquals(CordnMessageKinds.DELETION, manager.post(gid, deleteTo = target(alice)).envelope.kind) + } + + @Test + fun `pinning someone else's message is allowed, because a pin is any member's`() = + runTest { + // §5.1: reaction is anyone's, pin is any member's, only edit and + // deletion are author-only. Checking all five the same way would + // invent a rule cordn does not have. + val manager = manager(FakeCoordinator(callerPubKey = alice)) + manager.createGroup(gid, CordnGroupMetadata(name = "Actions")) + + val sent = manager.post(gid, pinTo = target(bob)).envelope + + assertEquals(CordnMessageKinds.PIN, sent.kind) + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnMigrationTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnMigrationTest.kt new file mode 100644 index 0000000000..76ffe85e4f --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnMigrationTest.kt @@ -0,0 +1,531 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn + +import com.vitorpamplona.quartz.cordn.appMultiDevice.CordnCarriedKeyPackage +import com.vitorpamplona.quartz.cordn.appMultiDevice.CordnDeviceTip +import com.vitorpamplona.quartz.cordn.appMultiDevice.CordnDocumentSeal +import com.vitorpamplona.quartz.cordn.appMultiDevice.CordnGroupDocument +import com.vitorpamplona.quartz.cordn.appMultiDevice.CordnHandoffCode +import com.vitorpamplona.quartz.cordn.appMultiDevice.CordnLastResortKeyPackage +import com.vitorpamplona.quartz.cordn.appMultiDevice.CordnTipEntry +import com.vitorpamplona.quartz.cordn.appMultiDevice.CordnTipInventory +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnDeliveredMessage +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnDeliveredMessageCodec +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnEnvelope +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper +import com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.utils.TimeUtils +import com.vitorpamplona.quartz.utils.sha256.sha256 +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The whole handoff, old phone to new phone, without a phone. + * + * The cases here are the ones where a migration fails as *plausible data* + * rather than as an error: a group silently missing, state that decrypts but + * was written by a stranger, or bytes that are not the ones the tip named. + */ +class CordnMigrationTest { + private val account = NostrSignerInternal(KeyPair()) + + /** Real codec output, so this travels the same bytes the store writes. */ + private val entryOne = + CordnDeliveredMessageCodec.encode( + CordnDeliveredMessage(CordnEnvelope.build("aa".repeat(32), 1_757_000_000L, 9, content = "first"), cursor = 1), + ) + + private val entryTwo = + CordnDeliveredMessageCodec.encode( + CordnDeliveredMessage(CordnEnvelope.build("aa".repeat(32), 1_757_000_060L, 9, content = "second"), cursor = 2), + ) + private val relay = RelayUrlNormalizer.normalize("wss://tip.example") + + private val snapshot = + CordnMigrationSnapshot( + accountPubKey = account.pubKey, + groups = + listOf( + group("gid-1", cursor = 7), + group("gid-2", cursor = 0, joinedViaRequest = true), + ), + lastResortKeyPackage = CordnLastResortKeyPackage("a2s=", "cHJpdg=="), + keyPackages = listOf(CordnCarriedKeyPackage("cc".repeat(32), "ref-1", "YnVuZGxl")), + ) + + @Test + fun `a snapshot survives the round trip`() = + runTest { + val world = World(this) + + val code = world.old().publish(snapshot, setOf(relay)) + val received = world.new(account).fetch(code) + + assertEquals(snapshot, received) + } + + @Test + fun `every group makes it, with its cursor and its flags`() = + runTest { + val world = World(this) + + val received = world.new(account).fetch(world.old().publish(snapshot, setOf(relay))) + + assertEquals(listOf("gid-1", "gid-2"), received.groups.map { it.gid }) + assertEquals(7L, received.groups[0].cursor) + assertTrue(received.groups[1].joinedViaRequest) + } + + @Test + fun `the draft, read position and coordinator relays survive`() = + runTest { + val world = World(this) + + val received = world.new(account).fetch(world.old().publish(snapshot, setOf(relay))) + + assertEquals("cm9vbQ==", received.groups[0].roomStateBase64) + assertEquals("ZWNobw==", received.groups[0].echoStateBase64) + assertEquals(listOf("wss://coord.example"), received.groups[0].coordinatorRelays) + } + + @Test + fun `the conversation travels, because nothing else can carry it`() = + runTest { + // The one piece of a handoff that has no second source. A group's + // MLS state can be re-derived from the coordinator's stream and a + // KeyPackage can be republished, but a cordn message is readable + // exactly once — at ingest — and the cursor in this very document + // has already moved past it. A device seeded without the messages + // arrives holding every group and no conversation, for good. + val world = World(this) + val withHistory = + snapshot.copy( + groups = listOf(group("gid-1", cursor = 7, messages = listOf(entryOne, entryTwo))), + ) + + val received = world.new(account).fetch(world.old().publish(withHistory, setOf(relay))) + + assertEquals(listOf(entryOne, entryTwo), received.groups[0].messages) + } + + @Test + fun `a group with no history carries no messages field`() = + runTest { + // The field is additive, so an empty list must not become an empty + // array in the document and come back as something other than what + // went in — the round-trip equality above depends on it. + val world = World(this) + + val received = world.new(account).fetch(world.old().publish(snapshot, setOf(relay))) + + assertEquals(emptyList<String>(), received.groups[0].messages) + } + + @Test + fun `the key packages travel, so a Welcome in flight is not lost`() = + runTest { + val world = World(this) + + val received = world.new(account).fetch(world.old().publish(snapshot, setOf(relay))) + + assertEquals(snapshot.keyPackages, received.keyPackages) + assertEquals(snapshot.lastResortKeyPackage, received.lastResortKeyPackage) + } + + @Test + fun `an account that is not the writer cannot read the tip`() = + runTest { + // The tip is NIP-44 sealed to the owner, so a different account + // fails at the decrypt before any signature check. + val world = World(this) + val code = world.old().publish(snapshot, setOf(relay)) + + val thrown = + runCatching { world.new(NostrSignerInternal(KeyPair())).fetch(code) }.exceptionOrNull() + + assertTrue(thrown is CordnMigrationException) + } + + @Test + fun `a blob that does not match its address is refused`() = + runTest { + // The §6 check. Without it a storage server could substitute MLS + // state of its choosing and the new phone would adopt it. + val world = World(this) + val code = world.old().publish(snapshot, setOf(relay)) + world.store.corruptOne() + + val thrown = runCatching { world.new(account).fetch(code) }.exceptionOrNull() + + assertTrue(thrown is CordnMigrationException) + assertTrue(thrown!!.message!!.contains("does not hash")) + } + + @Test + fun `a missing blob fails loudly rather than migrating a subset`() = + runTest { + // A partial migration is the worst outcome: the user arrives on the + // new phone with some conversations and no indication any are gone. + val world = World(this) + val code = world.old().publish(snapshot, setOf(relay)) + world.store.dropOne() + + val thrown = runCatching { world.new(account).fetch(code) }.exceptionOrNull() + + assertTrue(thrown is CordnMigrationException) + assertTrue(thrown!!.message!!.contains("no server served")) + } + + @Test + fun `a migration written by another MLS engine is refused with a reason`() = + runTest { + // The cordn-web case. §4.2 leaves clientState library-private, so + // their ClientState is unreadable here — and it arrives at a valid + // address under a correctly owner-signed tip, so every other check + // passes and only the format marker catches it. Built by hand + // because our own publish() can never produce it. + val world = World(this) + world.publishForeignTip("ts-mls") + + val thrown = + runCatching { + world.new(account).fetch( + CordnHandoffCode( + ephemeralPubKey = world.published.last().pubKey, + dTag = + world.published + .last() + .tags + .first { it[0] == "d" }[1], + relays = listOf(relay.url), + ), + ) + }.exceptionOrNull() + + assertTrue(thrown is CordnMigrationException) + assertTrue(thrown!!.message!!.contains("different MLS engine")) + } + + @Test + fun `a tip whose inner event was signed by someone else is refused`() = + runTest { + // Not reachable by an outsider — the outer content is a self-seal, + // so only the owner can produce something the owner will decrypt. + // It IS reachable by a bug on the writing side, and adopting MLS + // state under a credential naming someone else would make every + // Commit the new phone sent get rejected by the whole group. + val world = World(this) + world.publishTipSignedBy(NostrSignerInternal(KeyPair())) + + val thrown = runCatching { world.new(account).fetch(world.lastCode()) }.exceptionOrNull() + + assertTrue(thrown is CordnMigrationException) + assertTrue(thrown!!.message!!.contains("different account")) + } + + @Test + fun `a code with no relays cannot be published`() = + runTest { + val world = World(this) + + val thrown = runCatching { world.old().publish(snapshot, emptySet()) }.exceptionOrNull() + + assertTrue(thrown is IllegalArgumentException) + } + + @Test + fun `nothing stored means nothing advertised`() = + runTest { + // Publishing a tip that names blobs no server holds would hand the + // user a code that fails on the other phone, after the old one is + // already locked. + val world = World(this, acceptUploads = false) + + val thrown = runCatching { world.old().publish(snapshot, setOf(relay)) }.exceptionOrNull() + + assertTrue(thrown is CordnMigrationException) + } + + @Test + fun `the code grants no write and names no owner key`() = + runTest { + val world = World(this) + + val code = world.old().publish(snapshot, setOf(relay)) + + assertTrue(!code.grantsWrite) + assertTrue(code.ephemeralPubKey != account.pubKey) + } + + @Test + fun `the tip event leaks neither the owner nor cordn`() = + runTest { + val world = World(this) + world.old().publish(snapshot, setOf(relay)) + + val tip = world.published.single() + + assertTrue(tip.pubKey != account.pubKey) + assertEquals(CordnDeviceTip.OUTER_KIND, tip.kind) + assertTrue(!tip.content.contains(account.pubKey)) + assertTrue(!tip.content.contains("gid-1")) + assertEquals(listOf("d"), tip.tags.map { it[0] }) + } + + private fun group( + gid: String, + cursor: Long, + joinedViaRequest: Boolean = false, + messages: List<String> = emptyList(), + ) = CordnMigrationGroup( + coordinatorPubKey = "cc".repeat(32), + coordinatorRelays = listOf("wss://coord.example"), + gid = gid, + clientStateBase64 = "c3RhdGUt$gid", + cursor = cursor, + roomStateBase64 = "cm9vbQ==", + echoStateBase64 = "ZWNobw==", + joinedViaRequest = joinedViaRequest, + messages = messages, + ) + + /** The two phones, one relay and one storage server, in memory. */ + private inner class World( + scope: CoroutineScope, + acceptUploads: Boolean = true, + ) { + val store = FakeBlobStore(acceptUploads) + val published = mutableListOf<Event>() + val client = LoopbackClient(scope, published) + + fun old() = CordnMigration(client, account, store) + + fun new(signer: NostrSignerInternal) = CordnMigration(client, signer, store) + + fun lastCode() = + CordnHandoffCode( + ephemeralPubKey = published.last().pubKey, + dTag = published.last().tags.first { it[0] == "d" }[1], + relays = listOf(relay.url), + ) + + /** A tip the owner sealed but somebody else signed inside. */ + suspend fun publishTipSignedBy(other: NostrSignerInternal) { + val dek = CordnDocumentSeal.newKey() + val inventory = + CordnTipInventory( + groups = emptyList(), + meta = null, + dekPrivateKey = dek.privKey!!.toHexString(), + servers = listOf(FakeBlobStore.SERVER), + ) + val inner = + other.sign<Event>( + createdAt = TimeUtils.now(), + kind = CordnDeviceTip.INNER_KIND, + tags = CordnDeviceTip.tags(inventory), + content = "", + ) + val outer = + NostrSignerInternal(KeyPair()).sign<Event>( + createdAt = TimeUtils.now(), + kind = CordnDeviceTip.OUTER_KIND, + tags = arrayOf(arrayOf("d", "mismatched-d")), + content = account.nip44Encrypt(JacksonMapper.toJson(inner), account.pubKey), + ) + client.publish(outer, setOf(relay)) + } + + /** + * Publishes a tip a foreign client would have written: a real document + * at a real address under a real owner signature, whose clientState + * only its own engine can read. + */ + suspend fun publishForeignTip(format: String) { + val dek = CordnDocumentSeal.newKey() + val document = + CordnGroupDocument( + gid = "gid-theirs", + coordinator = "cc".repeat(32), + clientState = "dGhlaXJz", + cursor = 0, + clientStateFormat = format, + ) + val blob = CordnDocumentSeal.seal(document, dek) + store.put(blob) + + val inventory = + CordnTipInventory( + groups = listOf(CordnTipEntry(CordnDocumentSeal.address(blob), "gid-theirs")), + meta = null, + dekPrivateKey = dek.privKey!!.toHexString(), + servers = listOf(FakeBlobStore.SERVER), + ) + + val inner = + account.sign<Event>( + createdAt = TimeUtils.now(), + kind = CordnDeviceTip.INNER_KIND, + tags = CordnDeviceTip.tags(inventory), + content = "", + ) + val ephemeral = KeyPair() + val outer = + NostrSignerInternal(ephemeral).sign<Event>( + createdAt = TimeUtils.now(), + kind = CordnDeviceTip.OUTER_KIND, + tags = arrayOf(arrayOf("d", "theirs-d")), + content = account.nip44Encrypt(JacksonMapper.toJson(inner), account.pubKey), + ) + client.publish(outer, setOf(relay)) + } + + /** The DEK the last publish minted, read back out of the tip. */ + suspend fun dek(): KeyPair { + val inner = account.nip44Decrypt(published.last().content, account.pubKey) + val parsed = + CordnDeviceTip.parse( + com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper + .fromJson(inner), + ) + return KeyPair(privKey = parsed.dekPrivateKey.hexToByteArray()) + } + } + + private class FakeBlobStore( + private val accept: Boolean, + ) : CordnBlobStore { + val blobs = LinkedHashMap<String, ByteArray>() + + override suspend fun put(blob: ByteArray): List<String> { + if (!accept) return emptyList() + blobs[sha256(blob).toHexString()] = blob + return listOf(SERVER) + } + + override suspend fun get( + address: String, + servers: List<String>, + ): ByteArray? = blobs[address] + + /** Serve different bytes under the first group's address. */ + fun corruptOne() { + val key = blobs.keys.first() + blobs[key] = "not the document".encodeToByteArray() + } + + fun dropOne() { + blobs.remove(blobs.keys.first()) + } + + companion object { + const val SERVER = "https://blobs.example" + } + } + + /** + * One relay, in memory: accepts a publish with an OK and serves matching + * events back on a REQ. + */ + private class LoopbackClient( + private val scope: CoroutineScope, + private val published: MutableList<Event>, + ) : INostrClient by EmptyNostrClient() { + private val listeners = mutableListOf<RelayConnectionListener>() + + override fun addConnectionListener(listener: RelayConnectionListener) { + listeners += listener + } + + override fun removeConnectionListener(listener: RelayConnectionListener) { + listeners -= listener + } + + override fun publish( + event: Event, + relayList: Set<NormalizedRelayUrl>, + ) { + published += event + // publishAndConfirm waits for an OK; a relay that only swallowed + // the event would look like a timeout. + val ok = OkMessage.accepted(event.id) + relayList.forEach { url -> + scope.launch(Dispatchers.Unconfined) { + listeners.toList().forEach { it.onIncomingMessage(FakeRelay(url), "", ok) } + } + } + } + + override fun subscribe( + subId: String, + filters: Map<NormalizedRelayUrl, List<Filter>>, + listener: SubscriptionListener?, + ) { + val target = listener ?: return + scope.launch(Dispatchers.Unconfined) { + filters.forEach { (relay, list) -> + published + .filter { event -> list.any { it.match(event) } } + .forEach { target.onEvent(it, false, relay, null) } + target.onEose(relay, null) + } + } + } + + override fun unsubscribe(subId: String) = Unit + } + + private class FakeRelay( + override val url: NormalizedRelayUrl, + ) : IRelayClient { + override fun connect() = Unit + + override fun needsToReconnect() = false + + override fun connectAndSyncFiltersIfDisconnected(ignoreRetryDelays: Boolean) = Unit + + override fun isConnected() = true + + override fun disconnect() = Unit + + override fun sendIfConnected(cmd: Command) = Unit + + override fun sendOrConnectAndSync(cmd: Command) = Unit + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnSyncLoopTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnSyncLoopTest.kt new file mode 100644 index 0000000000..38efb8ca15 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnSyncLoopTest.kt @@ -0,0 +1,394 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn + +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withTimeout +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +/** + * The loop that keeps one coordinator's groups current. + * + * Every test here is about the loop misbehaving rather than working: an + * account with no groups burning a core, a coordinator outage ending the + * session's sync silently, a backoff that never comes back down, a group + * joined into a subscription that was already open. The happy path is one + * `catch_up` and one `subscribe`, and it is not what this class is for. + */ +class CordnSyncLoopTest { + /** + * A [CordnSyncSource] that does exactly what it is told. + * + * `subscribe` parks on a signal rather than returning, because a real one + * holds the stream open — a fake that returned immediately would turn every + * test below into a spin and hide the very bug the no-spin test is for. + */ + private class FakeSource : CordnSyncSource { + private val _gids = MutableStateFlow<Set<String>>(emptySet()) + override val gids: StateFlow<Set<String>> = _gids.asStateFlow() + + var catchUps = 0 + var subscribes = 0 + var failCatchUpTimes = 0 + + /** + * How many catchUps blow their RPC budget. + * + * A real one throws [TimeoutCancellationException] -- a + * `CancellationException` -- which is a different failure from + * [failCatchUpTimes]'s plain exception and was, for a while, fatal to + * the loop. Produced with a real `withTimeout` rather than a + * hand-constructed instance, so the test cannot drift from what the + * coordinator client actually throws. + */ + var timeOutCatchUpTimes = 0 + + /** + * How many subscribes spend their budget instead of parking. Bounded, + * because a fake that always spends it turns the loop into a spin in + * virtual time and the test never returns. + */ + var subscribeSpendsBudgetTimes = 0 + + /** Handed to onDelivery once per catchUp, to prove the wiring. */ + var deliverOnCatchUp: CordnGroupManager.Delivery? = null + + /** Completed when the test wants the open subscription to close. */ + private val closeStream = MutableStateFlow(0) + + /** The gid sets each subscribe was opened for, in order. */ + val subscribedWith = mutableListOf<Set<String>>() + + fun setGroups(vararg gid: String) { + _gids.value = gid.toSet() + } + + fun closeOpenStream() { + closeStream.value++ + } + + override suspend fun catchUp(onDelivery: (CordnGroupManager.Delivery) -> Unit): Int { + catchUps++ + if (timeOutCatchUpTimes > 0) { + timeOutCatchUpTimes-- + withTimeout(1) { delay(1_000) } + } + if (failCatchUpTimes > 0) { + failCatchUpTimes-- + throw CordnGroupException("coordinator is down") + } + deliverOnCatchUp?.let(onDelivery) + return 0 + } + + override suspend fun subscribe( + timeoutMs: Long, + onDelivery: (CordnGroupManager.Delivery) -> Unit, + ) { + subscribes++ + subscribedWith += _gids.value + if (subscribeSpendsBudgetTimes > 0) { + subscribeSpendsBudgetTimes-- + withTimeout(1) { delay(1_000) } + } + val opened = closeStream.value + closeStream.first { it != opened } + } + } + + private fun loopOver( + source: FakeSource, + onDelivery: (CordnGroupManager.Delivery) -> Unit = {}, + ) = CordnSyncLoop(source, onDelivery, minBackoffMs = 1_000, maxBackoffMs = 8_000) + + @Test + fun `an account with no groups parks instead of spinning`() = + runTest { + // catchUp and subscribe are both no-ops with no groups, so a plain + // while(true) burns a core on every fresh account — and does it + // silently, because nothing fails. + val source = FakeSource() + val loop = loopOver(source) + loop.start(this) + + advanceTimeBy(60_000) + runCurrent() + + assertEquals(CordnSyncLoop.State.NoGroups, loop.state.value) + assertEquals(0, source.catchUps, "it called the coordinator with nothing to sync") + assertEquals(0, source.subscribes) + loop.stop() + } + + @Test + fun `it starts syncing as soon as the first group appears`() = + runTest { + // The other half: parking must not mean sleeping through the join. + val source = FakeSource() + val loop = loopOver(source) + loop.start(this) + runCurrent() + assertEquals(CordnSyncLoop.State.NoGroups, loop.state.value) + + source.setGroups("g1") + runCurrent() + + assertEquals(1, source.catchUps) + assertEquals(CordnSyncLoop.State.Live, loop.state.value) + loop.stop() + } + + @Test + fun `catch-up runs before the subscription`() = + runTest { + // Order, not preference: a subscription opened first would deliver + // from the live edge while catch-up is still walking history, and + // the UI would show today above last week. + val source = FakeSource() + val loop = loopOver(source) + source.setGroups("g1") + loop.start(this) + runCurrent() + + assertEquals(1, source.catchUps) + assertEquals(1, source.subscribes) + loop.stop() + } + + @Test + fun `a coordinator outage does not end the loop`() = + runTest { + // The failure this class exists for. An exception that escapes + // stops syncing for the rest of the session, and from the UI that + // is indistinguishable from a group where nobody is talking. + val source = FakeSource() + source.failCatchUpTimes = 3 + val loop = loopOver(source) + source.setGroups("g1") + loop.start(this) + runCurrent() + + assertIs<CordnSyncLoop.State.Retrying>(loop.state.value) + + advanceTimeBy(30_000) + runCurrent() + + assertEquals(CordnSyncLoop.State.Live, loop.state.value, "it never recovered") + assertEquals(1, source.subscribes) + loop.stop() + } + + @Test + fun `the backoff grows, caps, and resets after a success`() = + runTest { + // Growing matters so a dead coordinator is not hammered. Resetting + // matters more: without it one bad stretch leaves the loop at its + // maximum delay for the rest of the session, so the next outage + // costs a full cap even though the coordinator is fine. + val source = FakeSource() + source.failCatchUpTimes = 5 + val loop = loopOver(source) + source.setGroups("g1") + loop.start(this) + runCurrent() + + val waits = mutableListOf<Long>() + repeat(5) { + val state = loop.state.value + if (state is CordnSyncLoop.State.Retrying) { + waits += state.inMs + advanceTimeBy(state.inMs + 1) + runCurrent() + } + } + + assertEquals(listOf(1_000L, 2_000L, 4_000L, 8_000L, 8_000L), waits, "growth or cap is wrong") + assertEquals(CordnSyncLoop.State.Live, loop.state.value) + + // Now fail once more: if the backoff had not reset, this would be + // the cap rather than the floor. + source.failCatchUpTimes = 1 + source.closeOpenStream() + runCurrent() + + val after = loop.state.value + assertIs<CordnSyncLoop.State.Retrying>(after) + assertEquals(1_000L, after.inMs, "the backoff did not reset on success") + loop.stop() + } + + @Test + fun `a closed stream is re-subscribed, not treated as a failure`() = + runTest { + // subscribe() returns normally on timeout. Counting that as an + // error would put a healthy loop into permanent backoff. + val source = FakeSource() + val loop = loopOver(source) + source.setGroups("g1") + loop.start(this) + runCurrent() + assertEquals(1, source.subscribes) + + source.closeOpenStream() + runCurrent() + + assertEquals(2, source.subscribes) + assertEquals(CordnSyncLoop.State.Live, loop.state.value, "a timeout was treated as an outage") + loop.stop() + } + + @Test + fun `an RPC that times out is retried, not the end of the loop`() = + runTest { + // A coordinator that does not answer inside the 20-second RPC + // budget throws TimeoutCancellationException, which IS a + // CancellationException. Rethrowing it ended run() -- the job + // completed, the state stayed CatchingUp, and the account received + // no cordn message again until the app was restarted. Observed on + // device: one msg_fetch_many timeout, then silence. + val source = FakeSource() + val loop = loopOver(source) + source.setGroups("g1") + source.timeOutCatchUpTimes = 1 + loop.start(this) + // The budget is virtual time, so it only expires once time moves. + advanceTimeBy(10) + runCurrent() + + assertIs<CordnSyncLoop.State.Retrying>(loop.state.value, "a timeout ended the loop") + + advanceTimeBy(1_100) + runCurrent() + + assertEquals(2, source.catchUps, "it never tried again") + assertEquals(CordnSyncLoop.State.Live, loop.state.value) + loop.stop() + } + + @Test + fun `a subscription that spends its budget re-subscribes`() = + runTest { + // msg_sub_many is given a total-time budget and throws when it runs + // out; the stream ending on schedule is the normal case the loop + // exists to re-open. As a CancellationException it did neither -- + // it ended the loop. + val source = FakeSource() + val loop = loopOver(source) + source.setGroups("g1") + source.subscribeSpendsBudgetTimes = 1 + loop.start(this) + advanceTimeBy(10) + runCurrent() + + assertEquals(CordnSyncLoop.State.Live, loop.state.value, "a spent budget was treated as an outage") + assertEquals(2, source.subscribes, "it did not re-subscribe") + loop.stop() + } + + @Test + fun `joining a group re-opens the subscription with it`() = + runTest { + // A subscription is opened for a fixed set of gids, so a group + // joined a moment later is not in it. Without this the new room + // stays empty until the stream times out — up to a minute of a + // chat that looks broken. + val source = FakeSource() + val loop = loopOver(source) + source.setGroups("g1") + loop.start(this) + runCurrent() + assertEquals(listOf(setOf("g1")), source.subscribedWith) + + source.setGroups("g1", "g2") + runCurrent() + + assertEquals(setOf("g1", "g2"), source.subscribedWith.last(), "the new group was not picked up") + assertEquals(2, source.subscribes) + loop.stop() + } + + @Test + fun `stopping ends the loop and leaves nothing running`() = + runTest { + val source = FakeSource() + val loop = loopOver(source) + source.setGroups("g1") + loop.start(this) + runCurrent() + + loop.stop() + val subscribesAtStop = source.subscribes + source.closeOpenStream() + advanceTimeBy(120_000) + runCurrent() + + assertEquals(CordnSyncLoop.State.Stopped, loop.state.value) + assertEquals(subscribesAtStop, source.subscribes, "it kept running after stop()") + } + + @Test + fun `starting twice does not double every message`() = + runTest { + // Two loops on one session would both catch up and both deliver, + // so every message would appear twice in the room. + val source = FakeSource() + val loop = loopOver(source) + source.setGroups("g1") + + loop.start(this) + loop.start(this) + runCurrent() + + assertEquals(1, source.catchUps) + assertEquals(1, source.subscribes) + loop.stop() + } + + @Test + fun `deliveries reach the caller`() = + runTest { + // The loop's whole output. A loop that syncs perfectly and drops + // what it syncs is the same as one that does not run. + val delivered = mutableListOf<CordnGroupManager.Delivery>() + val source = FakeSource() + source.deliverOnCatchUp = CordnGroupManager.Delivery.Undecryptable("g1", 7, "sample") + val loop = loopOver(source) { delivered += it } + source.setGroups("g1") + loop.start(this) + runCurrent() + + assertEquals(1, delivered.size, "nothing reached the caller") + val only = delivered.single() + assertIs<CordnGroupManager.Delivery.Undecryptable>(only) + assertEquals("g1", only.gid) + assertEquals(7L, only.cursor) + loop.stop() + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnTransportHarness.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnTransportHarness.kt new file mode 100644 index 0000000000..2e2812df14 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnTransportHarness.kt @@ -0,0 +1,158 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn + +import com.vitorpamplona.quartz.contextvm.cep04Encryption.CvmGiftWrap +import com.vitorpamplona.quartz.contextvm.fixture.CvmFixtureServer +import com.vitorpamplona.quartz.contextvm.fixture.InMemoryRelayPool +import com.vitorpamplona.quartz.contextvm.mcp.CvmMcpClient +import com.vitorpamplona.quartz.contextvm.transport.CvmTransport +import com.vitorpamplona.quartz.contextvm.transport.DualSigner +import com.vitorpamplona.quartz.cordn.fixture.CordnFixtureCoordinator +import com.vitorpamplona.quartz.cordn.spec00Coordinator.CoordinatorClient +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.yield + +/** + * Accounts wired to a coordinator through the **real** ContextVM stack. + * + * `CvmTransport` → `InMemoryRelayPool` → `CvmFixtureServer` → + * `CordnFixtureCoordinator`. The only stand-ins are the relay (in memory) and + * the coordinator (ours, because the reference one is unlicensed — see + * `quartz/plans/2026-09-17-cordn-interop.md` §7). Everything between an account + * and the relay is the shipped code. + * + * **Encryption is left at its default**, which is `EncryptionMode.REQUIRED` + * (§8.6). Passing `DISABLED` for convenience would test a transport we do not + * ship, and it is the kind of convenience that survives into the one test that + * was supposed to catch a downgrade. + */ +abstract class CordnTransportHarness { + protected val relays = InMemoryRelayPool() + protected val serverSigner = NostrSignerInternal(KeyPair()) + protected val coordinator = CordnFixtureCoordinator() + + /** + * A second, unrelated coordinator. + * + * Present because several rules are only visible with two — a `gid` is + * unique within one coordinator (`spec/00.md` §4), so "these are two + * different groups" cannot be stated at all against a single fixture. + */ + protected val secondServerSigner = NostrSignerInternal(KeyPair()) + protected val secondCoordinator = CordnFixtureCoordinator() + + protected val secondCoordinatorPubKey: HexKey get() = secondServerSigner.pubKey + + protected val config = + CoordinatorConfig( + pubKey = serverSigner.pubKey, + relays = listOf(RelayUrlNormalizer.normalizeOrNull("wss://relay.example.com")!!), + ) + + /** One account: a stable identity, a per-session ephemeral one, and its state. */ + protected inner class Account { + val stable = NostrSignerInternal(KeyPair()) + val ephemeral = NostrSignerInternal(KeyPair()) + val pubKey: HexKey get() = stable.pubKey + + /** + * The identity split of `spec/00.md` §8, wired as production does it. + * Which key signs which call is fixed by `CoordinatorMethod`, so a test + * cannot accidentally widen it — only this pairing can be wrong. + */ + val coordinatorClient = clientFor(serverSigner.pubKey) + + /** This account's client for whichever coordinator [serverPubKey] names. */ + fun clientFor(serverPubKey: HexKey) = + CoordinatorClient( + CvmMcpClient( + CvmTransport( + relays = relays, + signers = DualSigner(stable, ephemeral), + serverPubKey = serverPubKey, + crypto = CvmGiftWrap(), + ), + ), + ) + + val groupStore = InMemoryCordnGroupStore() + val keyPackageStore = InMemoryCordnKeyPackageStore() + + val keyPackages = CordnKeyPackages(stable.pubKey, coordinatorClient, keyPackageStore) + + val manager = + CordnGroupManager( + accountPubKey = stable.pubKey, + config = config, + coordinator = coordinatorClient, + store = groupStore, + clock = { 1_757_000_000L }, + ) + } + + private fun serve() = + listOf(serverSigner to coordinator, secondServerSigner to secondCoordinator).map { (signer, fixture) -> + CvmFixtureServer( + relays = relays, + signer = signer, + crypto = CvmGiftWrap(), + // CEP-16: how the coordinator learns who is calling, and what every + // identity-split assertion rests on. + injectClientPubkey = true, + handler = fixture::handle, + ).also { server -> + server.start() + // `msg_sub_many` answers with stream frames rather than a + // result, so the fixture needs a way back onto the transport. + // Wired here because only this layer holds both halves. + fixture.emitStream = { frames, clientPubKey, requestEventId -> + frames.forEach { server.reply(it, clientPubKey, requestEventId) } + } + } + } + + /** + * Runs [block] while pumping the server. + * + * Explicit because a relay callback cannot suspend. The spin guard turns a + * call nobody answers into a named failure instead of a test that looks + * merely slow. + */ + protected suspend fun <T> driving(block: suspend () -> T): T = + coroutineScope { + val servers = serve() + val work = async { block() } + var spins = 0 + while (!work.isCompleted) { + yield() + servers.forEach { it.pump() } + yield() + check(++spins < 100_000) { "the fixture never answered — something is not replying" } + } + work.await() + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnTransportIntegrationTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnTransportIntegrationTest.kt new file mode 100644 index 0000000000..8a4aea054d --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/CordnTransportIntegrationTest.kt @@ -0,0 +1,220 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn + +import com.vitorpamplona.quartz.cordn.spec01GroupMetadata.CordnGroupMetadata +import com.vitorpamplona.quartz.mls.messages.KeyPackageBundle +import kotlinx.coroutines.test.runTest +import kotlin.io.encoding.ExperimentalEncodingApi +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * Two accounts, one coordinator, and the **real** ContextVM stack between them. + * + * `CordnGroupManagerTest` drives the manager against a plain Kotlin object. That + * proves its logic and nothing about the wire: no gift wrap, no relay, no + * JSON-RPC framing, no CEP-16 `_meta`, no stable/ephemeral identity split. Every + * one of those is a place the whole feature can be broken while that suite stays + * green. + * + * This is the same lifecycle over `CvmTransport` → `InMemoryRelayPool` → + * `CvmFixtureServer` → `CordnFixtureCoordinator`. The only thing standing in for + * production is the relay (in memory) and the coordinator (ours, because the + * reference one is unlicensed — see `quartz/plans/2026-09-17-cordn-interop.md` + * §7). Everything between the manager and the relay is the shipped code. + * + * **Encryption is left at its default.** `CvmGiftWrap` defaults to + * `EncryptionMode.REQUIRED` and §8.6 of that plan says it must; a test that + * passed `DISABLED` for convenience would be testing a transport we do not ship. + */ +@OptIn(ExperimentalEncodingApi::class) +class CordnTransportIntegrationTest : CordnTransportHarness() { + private val gid = "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21" + + /** A KeyPackage bundle for [account], published through the real transport. */ + private suspend fun publishKeyPackage(account: Account): Pair<KeyPackageBundle, String> { + val published = driving { account.keyPackages.publishNew() } + val bundle = account.keyPackages.bundleFor(published.keyPackageRef)!! + return bundle to published.keyPackageRef + } + + @Test + fun `alice invites bob over the wire, and bob joins and reads`() = + runTest { + val alice = Account() + val bob = Account() + + // Bob publishes a KeyPackage. The coordinator keeps the signed + // kind-25910 request event, because spec/00.md §7 has no KeyPackage + // event kind and that request IS the publication payload. + val (bobBundle, bobRef) = publishKeyPackage(bob) + + alice.manager.createGroup(gid, CordnGroupMetadata(name = "Over the wire", adminPubkeys = listOf(alice.pubKey))) + + // invite() takes Bob's KeyPackage back off the coordinator and runs + // the §9 verification on the publication event before adding him. + // That check only means something because the event round-tripped. + val invite = driving { alice.manager.invite(gid, bob.pubKey) } + assertEquals(bob.pubKey, invite.invited) + assertEquals(1L, alice.manager.group(gid)!!.epoch) + + driving { alice.manager.send(gid, "hello over the wire") } + + val joined = driving { bob.manager.joinPendingWelcomes({ ref -> bobBundle.takeIf { ref == bobRef } }) } + assertEquals(listOf(gid), joined.joined, "skipped: ${joined.skipped}") + + val delivered = mutableListOf<CordnGroupManager.Delivery>() + driving { bob.manager.catchUp { delivered += it } } + + val messages = delivered.filterIsInstance<CordnGroupManager.Delivery.Message>() + assertEquals(1, messages.size, "got ${delivered.map { it::class.simpleName }}") + assertEquals("hello over the wire", messages[0].received.envelope.content) + assertEquals(alice.pubKey, messages[0].received.sender) + } + + @Test + fun `nothing readable crosses the relay`() = + runTest { + // The claim §8 makes about content, checked against the actual bytes + // on the actual wire rather than against the manager's intent. + val alice = Account() + alice.manager.createGroup(gid, CordnGroupMetadata(name = "Secret")) + driving { alice.manager.send(gid, "a very distinctive plaintext") } + + assertTrue(relays.published.isNotEmpty(), "nothing was published — this test would pass vacuously") + relays.published.forEach { event -> + assertTrue( + !event.content.contains("a very distinctive plaintext"), + "message text reached the relay in the clear", + ) + assertTrue(!event.content.contains(gid), "the group id reached the relay in the clear") + } + } + + @Test + fun `the message path never names the account`() = + runTest { + // spec/00.md §8: msg_post, msg_fetch_many and kp_take ride a + // throwaway key. A client that signed them with the account key + // would work perfectly and tie every message to a real npub on a + // server that keeps ordered history forever. + val alice = Account() + alice.manager.createGroup(gid, CordnGroupMetadata(name = "Quiet")) + + driving { + alice.manager.send(gid, "one") + alice.manager.catchUp { } + } + + val messagePathCalls = coordinator.calls.filter { it.method in setOf("msg_post", "msg_fetch_many") } + assertTrue(messagePathCalls.isNotEmpty()) + messagePathCalls.forEach { + assertEquals(alice.ephemeral.pubKey, it.callerPubKey, "${it.method} must not be attributable to the account") + } + + // ...while publishing a KeyPackage deliberately does name it (§8.4). + driving { alice.manager.publishKeyPackage("ref", "a2s=") } + val publish = assertNotNull(coordinator.calls.lastOrNull { it.method == "kp_publish" }) + assertEquals(alice.pubKey, publish.callerPubKey, "kp_publish is a signed record of this account, by design") + } + + @Test + fun `a welcome addressed to bob is invisible to alice`() = + runTest { + // welcome-delivery.md: the coordinator serves a Welcome to the member + // it names. If it broadcast them, the join test above would pass for + // the wrong reason and any account could join any group. + val alice = Account() + val bob = Account() + val (_, bobRef) = publishKeyPackage(bob) + + alice.manager.createGroup(gid, CordnGroupMetadata(name = "Private")) + driving { alice.manager.invite(gid, bob.pubKey) } + + val aliceSees = driving { alice.manager.joinPendingWelcomes({ null }) } + assertTrue(aliceSees.joined.isEmpty()) + assertTrue( + aliceSees.skipped.isEmpty(), + "alice was offered a Welcome addressed to bob: ${aliceSees.skipped}", + ) + + // Bob is offered it, and only lacks the private half here. + val bobSees = driving { bob.manager.joinPendingWelcomes({ null }) } + assertEquals(1, bobSees.skipped.size) + assertEquals(bobRef, bobSees.skipped.single().keyPackageRef) + } + + @Test + fun `a live subscription delivers over the wire, not just a catch-up fetch`() = + runTest { + // The path that had no coverage at all. `msg_sub_many` is the one + // coordinator tool whose answer is not its result: it holds the + // call open and pushes each message as a CEP-41 stream fragment, + // which `CoordinatorClient` reassembles through `onStreamFragment`. + // Everything else about cordn delivery was exercised through + // `msg_fetch_many`, so the half the sync loop actually lives in was + // the untested one. + val alice = Account() + alice.manager.createGroup(gid, CordnGroupMetadata(name = "Live")) + driving { alice.manager.send(gid, "pushed, not polled") } + + val delivered = mutableListOf<CordnGroupManager.Delivery>() + driving { alice.manager.subscribe(timeoutMs = 2_000) { delivered += it } } + + // Alice's own message comes back as an Echo -- she sent it, so she + // cannot decrypt it, and that is the shape the sync loop files. + assertTrue(delivered.isNotEmpty(), "nothing arrived over the subscription") + assertTrue( + delivered.all { it.gid == gid }, + "a subscription must not deliver another group's stream: ${delivered.map { it.gid }}", + ) + } + + @Test + fun `a subscriber reads what another member said, live`() = + runTest { + val alice = Account() + val bob = Account() + val (bobBundle, bobRef) = publishKeyPackage(bob) + + val gid = "live-room" + alice.manager.createGroup(gid, CordnGroupMetadata(name = "Live")) + driving { alice.manager.invite(gid, bob.pubKey) } + driving { bob.manager.joinPendingWelcomes({ ref -> bobBundle.takeIf { ref == bobRef } }) } + driving { bob.manager.catchUp { } } + + driving { alice.manager.send(gid, "said out loud") } + + val delivered = mutableListOf<CordnGroupManager.Delivery>() + driving { bob.manager.subscribe(timeoutMs = 2_000) { delivered += it } } + + val messages = delivered.filterIsInstance<CordnGroupManager.Delivery.Message>() + assertEquals( + listOf("said out loud"), + messages.map { it.received.envelope.content }, + "got ${delivered.map { it::class.simpleName }}", + ) + assertEquals(alice.pubKey, messages.single().received.sender, "MLS-authenticated, not claimed") + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/FakeCoordinator.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/FakeCoordinator.kt new file mode 100644 index 0000000000..9470e587b7 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/FakeCoordinator.kt @@ -0,0 +1,227 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn + +import com.vitorpamplona.quartz.cordn.spec00Coordinator.AvailableKeyPackage +import com.vitorpamplona.quartz.cordn.spec00Coordinator.ConsumedJoinRequestRef +import com.vitorpamplona.quartz.cordn.spec00Coordinator.ConsumedWelcomeRef +import com.vitorpamplona.quartz.cordn.spec00Coordinator.GroupMessage +import com.vitorpamplona.quartz.cordn.spec00Coordinator.ICoordinator +import com.vitorpamplona.quartz.cordn.spec00Coordinator.JoinRequest +import com.vitorpamplona.quartz.cordn.spec00Coordinator.PendingWelcome +import com.vitorpamplona.quartz.cordn.spec00Coordinator.PostedMessage +import com.vitorpamplona.quartz.cordn.spec00Coordinator.PublishedKeyPackage +import com.vitorpamplona.quartz.cordn.spec00Coordinator.TakenKeyPackage +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** + * A coordinator that behaves, in memory. + * + * It models the two things `spec/00.md` makes the coordinator responsible for + * and that the manager's correctness depends on: a **monotonic per-group + * cursor** and **per-account Welcome inboxes**. Everything else it stores + * verbatim, which is also what a real one does — §8: it cannot parse payloads + * and does not try. + * + * Wire shapes are not this fake's job. Those are pinned against cordn's own zod + * schemas in `quartz`'s `CoordinatorContractVectorTest`; here the point is the + * manager's behaviour on top of them. + */ +class FakeCoordinator( + /** The account the manager under test is calling as, for Welcome routing. */ + private val callerPubKey: HexKey, +) : ICoordinator { + /** + * A second account's view of *this* coordinator's storage. + * + * The caller is fixed at construction because Welcome routing depends on + * it, so two accounts on one coordinator need two instances over one set + * of maps rather than one instance with a mutable caller. Everything + * stored is shared; only who is asking differs — which is exactly the + * situation a join request exists to handle. + */ + fun viewAs(otherPubKey: HexKey): FakeCoordinator = + FakeCoordinator(otherPubKey).also { + it.keyPackages = keyPackages + it.welcomes = welcomes + it.streams = streams + it.joinRequests = joinRequests + } + + class StoredKeyPackage( + val pubKey: HexKey, + val keyPackageRef: String, + val base64: String, + /** + * The signed `kp_publish` request event (`spec/00.md` §7), or null when + * this fake recorded the publish itself. + * + * Null is structural, not laziness. `ICoordinator.publishKeyPackage` + * carries a ref and bytes and nothing else — the publication event is + * the *transport's* request event, which `CoordinatorClient` supplies + * and a fake sitting directly on the interface never sees. So this fake + * can say a package exists but cannot make it takeable; + * `CordnFixtureCoordinator`, which handles the request itself, can. + * Seed one with [seedKeyPackage] when a test needs an invite to work. + */ + val publicationEvent: Event?, + val lastResort: Boolean = false, + ) + + var keyPackages = mutableMapOf<String, StoredKeyPackage>() + var welcomes = mutableMapOf<HexKey, MutableList<PendingWelcome>>() + var streams = mutableMapOf<String, MutableList<GroupMessage>>() + var joinRequests = mutableMapOf<String, MutableList<JoinRequest>>() + + /** Every method name called, in order. */ + val calls = mutableListOf<String>() + + private var cursor = 0L + private var clock = 1_757_000_000L + + /** Fails the next [failNext] calls, to exercise the health surface. */ + var failNext = 0 + + /** Pre-loads a KeyPackage as if its owner had published it. */ + fun seedKeyPackage(stored: StoredKeyPackage) { + keyPackages[stored.keyPackageRef] = stored + } + + /** Everything posted to [gid], oldest first. */ + fun posted(gid: String): List<String> = streams[gid].orEmpty().map { it.sealedBase64 } + + private fun record(method: String) { + calls += method + if (failNext > 0) { + failNext-- + throw IllegalStateException("$method failed") + } + } + + override suspend fun publishKeyPackage( + keyPackageRef: String, + keyPackageBase64: String, + ): PublishedKeyPackage { + record("kp_publish") + // Stored, so `kp_list` serves it back the way a real coordinator does. + // It used to return a receipt and keep nothing, which is invisible + // until someone tests `topUp` -- that counts what the coordinator + // holds, so against a fake that forgets, it publishes forever. + keyPackages[keyPackageRef] = + StoredKeyPackage(callerPubKey, keyPackageRef, keyPackageBase64, publicationEvent = null) + return PublishedKeyPackage(keyPackageRef, false, clock++) + } + + override suspend fun removeKeyPackages(keyPackageRefs: List<String>): List<String> { + record("kp_remove") + return keyPackageRefs.filter { keyPackages.remove(it) != null } + } + + override suspend fun listKeyPackages(): List<AvailableKeyPackage> { + record("kp_list") + return keyPackages.values.map { AvailableKeyPackage(it.pubKey, it.keyPackageRef, it.lastResort, clock) } + } + + override suspend fun takeKeyPackage(id: String): TakenKeyPackage? { + record("kp_take") + // `id` accepts a ref or an account hex, like the real one. + // Only a seeded package is takeable: without the publication event + // there is nothing for §9 verification to check, and handing back a + // package that cannot be verified would test a path no real + // coordinator can produce. See [StoredKeyPackage.publicationEvent]. + val stored = + keyPackages[id]?.takeIf { it.publicationEvent != null } + ?: keyPackages.values.firstOrNull { it.pubKey == id && it.publicationEvent != null } + ?: return null + return TakenKeyPackage(stored.pubKey, stored.keyPackageRef, stored.lastResort, clock++, stored.publicationEvent!!) + } + + override suspend fun storeWelcome( + targetPubKey: HexKey, + keyPackageRef: String, + welcomeBase64: String, + after: Long?, + ): Long { + record("welcome_store") + val at = clock++ + welcomes.getOrPut(targetPubKey) { mutableListOf() } += PendingWelcome(keyPackageRef, welcomeBase64, at, after) + return at + } + + override suspend fun takeWelcomes(consumed: List<ConsumedWelcomeRef>): List<PendingWelcome> { + record("welcome_take") + val mine = welcomes[callerPubKey].orEmpty() + consumed.forEach { ack -> welcomes[callerPubKey]?.removeAll { it.keyPackageRef == ack.keyPackageRef && it.at == ack.at } } + return mine.toList() + } + + override suspend fun storeJoinRequest( + gid: String, + keyPackageRef: String, + ): Long { + record("join_request_store") + val at = clock++ + // Keyed by group, not by caller: any member of the group can serve it, + // which is what `spec/01.md` §5.3 leaves open. + joinRequests.getOrPut(gid) { mutableListOf() } += JoinRequest(gid, callerPubKey, keyPackageRef, at) + return at + } + + override suspend fun takeJoinRequests( + gids: List<String>, + consumed: List<ConsumedJoinRequestRef>, + ): List<JoinRequest> { + record("join_request_take_many") + consumed.forEach { ack -> + joinRequests[ack.gid]?.removeAll { it.gid == ack.gid && it.pubKey == ack.pubKey && it.at == ack.at } + } + return gids.flatMap { joinRequests[it].orEmpty() } + } + + override suspend fun postMessage( + gid: String, + sealedBase64: String, + ): PostedMessage { + record("msg_post") + // Monotonic across the whole coordinator, which is stricter than + // spec/00.md §4 requires (per group) and so a safe stand-in. + val assigned = ++cursor + streams.getOrPut(gid) { mutableListOf() } += GroupMessage(gid, assigned, sealedBase64, clock++) + return PostedMessage(gid, assigned, clock) + } + + override suspend fun fetchMessages(cursors: Map<String, Long?>): List<GroupMessage> { + record("msg_fetch_many") + return cursors.entries + .flatMap { (gid, after) -> streams[gid].orEmpty().filter { it.cursor > (after ?: 0L) } } + .sortedBy { it.cursor } + } + + override suspend fun subscribeMessages( + cursors: Map<String, Long?>, + timeoutMs: Long, + onMessage: (GroupMessage) -> Unit, + ) { + record("msg_sub_many") + fetchMessages(cursors).forEach(onMessage) + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/FileCordnStoresTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/FileCordnStoresTest.kt new file mode 100644 index 0000000000..a82edde651 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/FileCordnStoresTest.kt @@ -0,0 +1,448 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn + +import com.vitorpamplona.quartz.cordn.sync.GroupCursor +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withContext +import java.io.File +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The on-disk cordn stores. + * + * Driven through a fake cipher rather than the Android KeyStore, which is the + * point of the [CordnBlobCipher] seam: everything below the cipher — the + * layout, the key encoding, the atomic write, the account/coordinator scoping + * — is ordinary code that can fail in ordinary ways, and none of it is + * testable on a device-only primitive. The fake really transforms the bytes, + * so "the plaintext never reached the disk" is a claim these tests can make. + */ +class FileCordnStoresTest { + /** XOR: reversible, and unmistakably not the input. */ + private class XorCipher : CordnBlobCipher { + var encryptCalls = 0 + + override fun encrypt(bytes: ByteArray): ByteArray { + encryptCalls++ + return ByteArray(bytes.size) { (bytes[it].toInt() xor MASK).toByte() } + } + + override fun decrypt(bytes: ByteArray) = ByteArray(bytes.size) { (bytes[it].toInt() xor MASK).toByte() } + + companion object { + const val MASK = 0x5A + } + } + + private val root = + File.createTempFile("cordn-store", "").also { + it.delete() + it.mkdirs() + } + private val cipher = XorCipher() + + private val account = "a".repeat(64) + private val coordinator = "b".repeat(64) + + private fun dir() = CordnStorageLayout.directoryFor(root, account, coordinator) + + private fun groups() = FileCordnGroupStore(dir(), cipher) + + private fun coordinators() = FileCordnCoordinatorStore(CordnStorageLayout.accountDirectoryFor(root, account), cipher) + + private fun relay(url: String) = RelayUrlNormalizer.normalizeOrNull(url)!! + + private fun keyPackages() = FileCordnKeyPackageStore(dir(), cipher) + + @AfterTest + fun cleanUp() { + root.deleteRecursively() + } + + @Test + fun `a group round-trips through a restart`() = + runTest { + val state = byteArrayOf(1, 2, 3, 4, 5) + groups().saveGroup("g1", state) + + // A new instance over the same directory: what a relaunch is. + assertContentEquals(state, groups().loadGroup("g1")) + assertEquals(listOf("g1"), groups().listGroups()) + } + + @Test + fun `the plaintext never reaches the disk`() = + runTest { + // The one claim the whole class exists to make. An MlsGroupState is + // the ratchet tree and every epoch secret; on disk in the clear it + // is every message the group ever sent. + val state = "ratchet-tree-and-epoch-secrets".encodeToByteArray() + groups().saveGroup("g1", state) + + val onDisk = dir().walkTopDown().filter { it.isFile }.toList() + assertTrue(onDisk.isNotEmpty(), "nothing was written at all") + onDisk.forEach { + val raw = it.readBytes() + assertTrue(!raw.contentEquals(state), "${it.name} holds the plaintext") + assertTrue( + !raw.decodeToString().contains("ratchet-tree"), + "${it.name} holds recognisable plaintext", + ) + } + } + + @Test + fun `a gid that is a path traversal is stored, not rejected`() = + runTest { + // A cordn gid is caller-chosen and the coordinator never interprets + // it (spec/00.md §4), so "../../etc/passwd" is a gid the protocol + // allows. Marmot's store validates hex because a Marmot group id is + // a hash; doing that here would refuse legitimate groups. Encoding + // accepts it AND keeps it inside our directory. + val hostile = "../../../etc/passwd" + groups().saveGroup(hostile, byteArrayOf(9)) + + assertEquals(listOf(hostile), groups().listGroups(), "the gid must come back verbatim") + assertContentEquals(byteArrayOf(9), groups().loadGroup(hostile)) + + val escaped = File(root, "cordn/etc").exists() || File(root.parentFile, "etc").exists() + assertTrue(!escaped, "the write escaped the store directory") + assertTrue(dir().walkTopDown().any { it.isFile }, "it was not written anywhere at all") + } + + @Test + fun `gids that differ only in case are different groups`() = + runTest { + // Base64url is case-sensitive; a case-folding encoding would merge + // these two and let one group's state answer for the other. + groups().saveGroup("Group", byteArrayOf(1)) + groups().saveGroup("group", byteArrayOf(2)) + + assertContentEquals(byteArrayOf(1), groups().loadGroup("Group")) + assertContentEquals(byteArrayOf(2), groups().loadGroup("group")) + assertEquals(2, groups().listGroups().size) + } + + @Test + fun `two coordinators serving the same gid do not collide`() = + runTest { + // The rule the layout exists for. Two coordinators can both serve + // gid "shared" as unrelated groups with different ratchet trees. + val other = CordnStorageLayout.directoryFor(root, account, "c".repeat(64)) + FileCordnGroupStore(dir(), cipher).saveGroup("shared", byteArrayOf(1)) + FileCordnGroupStore(other, cipher).saveGroup("shared", byteArrayOf(2)) + + assertContentEquals(byteArrayOf(1), groups().loadGroup("shared")) + assertContentEquals(byteArrayOf(2), FileCordnGroupStore(other, cipher).loadGroup("shared")) + } + + @Test + fun `two accounts on one device do not see each other`() = + runTest { + val theirs = CordnStorageLayout.directoryFor(root, "d".repeat(64), coordinator) + groups().saveGroup("g1", byteArrayOf(1)) + + assertTrue(FileCordnGroupStore(theirs, cipher).listGroups().isEmpty()) + assertNull(FileCordnGroupStore(theirs, cipher).loadGroup("g1")) + } + + @Test + fun `a non-hex pubkey never becomes a directory`() { + assertFailsWith<IllegalArgumentException> { CordnStorageLayout.directoryFor(root, "../escape", coordinator) } + assertFailsWith<IllegalArgumentException> { CordnStorageLayout.directoryFor(root, account, "../escape") } + } + + @Test + fun `deleting a group takes its cursor with it`() = + runTest { + // Otherwise a later re-join of the same gid resumes from a cursor + // belonging to a group it is no longer in, and silently skips + // everything before it. + groups().saveGroup("g1", byteArrayOf(1)) + groups().saveCursor("g1", GroupCursor(fetchCursor = 42, lastCursor = 99)) + + groups().deleteGroup("g1") + + assertNull(groups().loadGroup("g1")) + assertNull(groups().loadCursor("g1"), "the cursor outlived the group") + assertTrue(groups().listGroups().isEmpty()) + } + + @Test + fun `a cursor round-trips both of its fields`() = + runTest { + // They are not the same number and conflating them is invisible + // until a fetch re-reads or skips a stretch of the stream. + groups().saveCursor("g1", GroupCursor(fetchCursor = 7, lastCursor = 1234567890123L)) + + val restored = assertNotNull(groups().loadCursor("g1")) + assertEquals(7L, restored.fetchCursor) + assertEquals(1234567890123L, restored.lastCursor) + } + + @Test + fun `a group with no state yet is not listed`() = + runTest { + // A cursor alone means a directory exists with nothing restorable + // in it; reporting it as a group would make restore() build an + // empty MlsGroup for a gid we do not actually hold. + groups().saveCursor("ghost", GroupCursor(fetchCursor = 1)) + + assertTrue(groups().listGroups().isEmpty()) + } + + @Test + fun `a key package bundle round-trips and can be withdrawn`() = + runTest { + val bundle = byteArrayOf(7, 7, 7) + keyPackages().save("ref1", bundle) + + assertContentEquals(bundle, keyPackages().load("ref1")) + assertEquals(listOf("ref1"), keyPackages().list()) + + keyPackages().delete("ref1") + assertNull(keyPackages().load("ref1")) + assertTrue(keyPackages().list().isEmpty()) + } + + @Test + fun `a half-written temp file is not served as a bundle`() = + runTest { + // atomicWrite leaves one behind if the process dies between write + // and rename. Listing it would hand joinPendingWelcomes a ref that + // decodes to nothing and burn the Welcome it was meant to open. + // + // What excludes it is the encoding, not a name check: '.' is not in + // the base64url alphabet. So this test is really a guard on the + // encoding — swap it for something that passes names through and + // this fails, which is the regression worth catching. + keyPackages().save("ref1", byteArrayOf(1)) + File(dir(), "keypackages/${CordnStorageLayout.encodeKey("ref2")}.tmp").writeBytes(byteArrayOf(2)) + + assertEquals(listOf("ref1"), keyPackages().list()) + } + + @Test + fun `a stray file in the directory does not break the listing`() = + runTest { + // Ignoring one unreadable name is better than failing the listing + // and hiding every real group behind it. + groups().saveGroup("g1", byteArrayOf(1)) + File(dir(), "groups/not-base64-@@@").mkdirs() + File(dir(), "groups/not-base64-@@@/state").writeBytes(byteArrayOf(0)) + + assertEquals(listOf("g1"), groups().listGroups()) + } + + @Test + fun `an empty store lists nothing rather than failing`() = + runTest { + assertTrue(groups().listGroups().isEmpty()) + assertTrue(keyPackages().list().isEmpty()) + assertNull(groups().loadGroup("nope")) + assertNull(groups().loadCursor("nope")) + assertNull(keyPackages().load("nope")) + } + + @Test + fun `concurrent saves of different groups all survive`() = + runTest { + // Dispatchers.IO is a pool, and a sync loop saving several groups + // at once is the ordinary case. + val store = groups() + withContext(Dispatchers.IO) { + (1..24).map { async { store.saveGroup("g$it", byteArrayOf(it.toByte())) } }.awaitAll() + } + + assertEquals(24, store.listGroups().size) + (1..24).forEach { assertContentEquals(byteArrayOf(it.toByte()), store.loadGroup("g$it")) } + } + + @Test + fun `a rewritten group leaves no temp file behind`() = + runTest { + val store = groups() + store.saveGroup("g1", byteArrayOf(1)) + store.saveGroup("g1", byteArrayOf(2, 2)) + + assertContentEquals(byteArrayOf(2, 2), store.loadGroup("g1")) + assertTrue( + dir().walkTopDown().none { it.name.endsWith(".tmp") }, + "a temp file survived the rename", + ) + } + + @Test + fun `the coordinator list survives a relaunch, encrypted`() = + runTest { + val configs = + listOf( + CoordinatorConfig(coordinator, listOf(relay("wss://one.example.com")), CoordinatorConfig.Origin.MANUAL, "Work"), + CoordinatorConfig("c".repeat(64), listOf(relay("wss://two.example.com"), relay("wss://three.example.com")), CoordinatorConfig.Origin.GROUP_REF), + ) + coordinators().save(configs) + + assertEquals(configs, coordinators().load()) + val onDisk = File(CordnStorageLayout.accountDirectoryFor(root, account), "coordinators").readBytes() + assertFalse(onDisk.decodeToString().contains(coordinator), "the pubkey went to disk in the clear") + } + + @Test + fun `a label with a separator in it round-trips`() = + runTest { + // The reason the format is length-prefixed rather than delimited: + // a label is free text a user typed, so any separator a line-based + // format picked is one they can put in it. + val messy = "tab\there\nnewline\u0000nul" + coordinators().save(listOf(CoordinatorConfig(coordinator, listOf(relay("wss://one.example.com")), label = messy))) + + assertEquals(messy, coordinators().load().single().label) + } + + @Test + fun `an account with no stored list gets an empty one, not a failure`() = + runTest { + assertEquals(emptyList(), coordinators().load()) + } + + @Test + fun `an unreadable list loses the coordinators rather than the login`() = + runTest { + coordinators().save(listOf(CoordinatorConfig(coordinator, listOf(relay("wss://one.example.com"))))) + File(CordnStorageLayout.accountDirectoryFor(root, account), "coordinators").writeBytes(byteArrayOf(9, 9, 9)) + + assertEquals(emptyList(), coordinators().load()) + } + + @Test + fun `the coordinator list sits above the per-coordinator directories, so forgetting one keeps the rest`() = + runTest { + // If it lived inside a coordinator's own directory, removing that + // coordinator would delete the record of every other one with it. + val listFile = File(CordnStorageLayout.accountDirectoryFor(root, account), "coordinators") + assertEquals(dir().parentFile, listFile.parentFile) + } + + @Test + fun `purging one coordinator leaves another coordinator's groups untouched`() = + runTest { + // What CordnRuntime.purge deletes. It has to be the coordinator's + // own directory and nothing above it: a gid is unique only within + // one coordinator, so two of them routinely hold state for the + // same gid and deleting a level too high takes both. + val other = "e".repeat(64) + groups().saveGroup("shared-gid", byteArrayOf(1, 2, 3)) + FileCordnGroupStore(CordnStorageLayout.directoryFor(root, account, other), cipher) + .saveGroup("shared-gid", byteArrayOf(4, 5, 6)) + + CordnStorageLayout.directoryFor(root, account, coordinator).deleteRecursively() + + assertNull(groups().loadGroup("shared-gid")) + assertContentEquals( + byteArrayOf(4, 5, 6), + FileCordnGroupStore(CordnStorageLayout.directoryFor(root, account, other), cipher).loadGroup("shared-gid"), + ) + } + + @Test + fun `purging a coordinator leaves the account's coordinator list alone`() = + runTest { + // The list names the coordinator being purged, so it has to be + // rewritten by whoever purges -- but it must not be destroyed by + // the delete itself, or purging one coordinator would forget every + // other one with it. + coordinators().save(listOf(CoordinatorConfig(coordinator, listOf(relay("wss://one.example.com"))))) + groups().saveGroup("gid", byteArrayOf(1)) + + CordnStorageLayout.directoryFor(root, account, coordinator).deleteRecursively() + + assertEquals(1, coordinators().load().size) + } + + @Test + fun `a join-request marker is written once and goes with its group`() = + runTest { + val store = groups() + store.saveGroup("gid", byteArrayOf(1)) + assertFalse(store.loadJoinedViaRequest("gid")) + + store.saveJoinedViaRequest("gid") + store.saveJoinedViaRequest("gid") + assertTrue(store.loadJoinedViaRequest("gid")) + + // A later re-join of the same gid is a different admission, so the + // old marker must not outlive the group it described. + store.deleteGroup("gid") + assertFalse(store.loadJoinedViaRequest("gid")) + } + + @Test + fun `a draft is encrypted at rest, like the messages it was going to become`() = + runTest { + val store = groups() + store.saveGroup("gid", byteArrayOf(1)) + store.saveRoomState("gid", CordnRoomState(draft = "the secret plan", lastReadCursor = 7)) + + assertEquals(CordnRoomState("the secret plan", 7), store.loadRoomState("gid")) + val onDisk = File(dir(), "groups/${CordnStorageLayout.encodeKey("gid")}/room").readBytes() + assertFalse(onDisk.decodeToString().contains("the secret plan"), "a draft went to disk in the clear") + } + + @Test + fun `clearing a draft removes the file rather than blanking it`() = + runTest { + // Overwriting in place would leave the old plaintext in whatever + // the filesystem still holds; there is nothing to keep once both + // fields are empty. + val store = groups() + store.saveGroup("gid", byteArrayOf(1)) + store.saveRoomState("gid", CordnRoomState(draft = "typed")) + store.saveRoomState("gid", CordnRoomState()) + + assertFalse(File(dir(), "groups/${CordnStorageLayout.encodeKey("gid")}/room").exists()) + assertEquals(CordnRoomState(), store.loadRoomState("gid")) + } + + @Test + fun `an unreadable room file costs the draft, never the room`() = + runTest { + val store = groups() + store.saveGroup("gid", byteArrayOf(1)) + store.saveRoomState("gid", CordnRoomState(draft = "typed", lastReadCursor = 3)) + File(dir(), "groups/${CordnStorageLayout.encodeKey("gid")}/room").writeBytes(byteArrayOf(7, 7, 7)) + + assertEquals(CordnRoomState(), store.loadRoomState("gid")) + assertNotNull(store.loadGroup("gid")) + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/NostrClientCvmRelayPoolTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/NostrClientCvmRelayPoolTest.kt new file mode 100644 index 0000000000..8bd6dfbd75 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/NostrClientCvmRelayPoolTest.kt @@ -0,0 +1,192 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn + +import com.vitorpamplona.quartz.contextvm.core.CvmKinds +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The adapter between Amethyst's relay client and what ContextVM asks of one. + * + * Small enough to look obviously right and wrong in three ways that a test is + * the only thing that catches, because each one fails as silence rather than + * as an error: a REQ sent to the account's relays instead of the + * coordinator's, a response dropped because it arrived as a stored event, and + * a subscription left open after the call that made it returned. + */ +class NostrClientCvmRelayPoolTest { + private val coordinatorRelay = relay("wss://coordinator.example") + private val otherRelay = relay("wss://elsewhere.example") + + @Test + fun `subscribes only to the relays it was given`() { + val client = RecordingClient() + val pool = NostrClientCvmRelayPool(client, setOf(coordinatorRelay)) + + pool.subscribe(SERVER, intArrayOf(CvmKinds.MESSAGE)) { } + + // A coordinator has no address beyond its pubkey (spec/00.md §8.5), so + // the relays whoever published it named are the only place its traffic + // exists. Widening this to the account's own relays would miss the + // coordinator AND tell relays with no part in the conversation that + // this account talks to it. + assertEquals( + setOf(coordinatorRelay), + client.subscribed + .single() + .filters.keys, + ) + } + + @Test + fun `filters on kind and on the recipient p-tag`() { + val client = RecordingClient() + val pool = NostrClientCvmRelayPool(client, setOf(coordinatorRelay, otherRelay)) + + pool.subscribe(SERVER, intArrayOf(CvmKinds.MESSAGE, CvmKinds.GIFT_WRAP)) { } + + val filter = + client.subscribed + .single() + .filters + .getValue(coordinatorRelay) + .single() + assertEquals(listOf(CvmKinds.MESSAGE, CvmKinds.GIFT_WRAP), filter.kinds) + // Addressed-to, not authored-by: a gift-wrapped response is signed by a + // one-time key, so `authors` would match nothing. + assertEquals(mapOf("p" to listOf(SERVER)), filter.tags) + assertNull(filter.authors) + } + + @Test + fun `delivers a stored event as readily as a live one`() = + runTest { + val client = RecordingClient() + val pool = NostrClientCvmRelayPool(client, setOf(coordinatorRelay)) + + val seen = mutableListOf<Event>() + pool.subscribe(SERVER, intArrayOf(CvmKinds.MESSAGE)) { seen += it } + + // Kind 25910 is ephemeral, so "stored" is not a state it can be + // in — but a relay that replays its own send buffer reports + // isLive=false anyway. Branching on it would drop the one response + // the call is waiting for and the call would time out instead. + client.subscribed + .single() + .listener + .onEvent(response(), isLive = false, coordinatorRelay, null) + + assertEquals(1, seen.size) + } + + @Test + fun `closing a subscription unsubscribes that id and no other`() { + val client = RecordingClient() + val pool = NostrClientCvmRelayPool(client, setOf(coordinatorRelay)) + + val first = pool.subscribe(SERVER, intArrayOf(CvmKinds.MESSAGE)) { } + pool.subscribe(SERVER, intArrayOf(CvmKinds.MESSAGE)) { } + + val ids = client.subscribed.map { it.subId } + assertEquals("two subscriptions must not share an id", 2, ids.toSet().size) + + first.close() + + // CvmTransport opens one of these per request and closes it in the same + // call. Unsubscribing the wrong id would leave a REQ open on the + // coordinator's relay for the life of the process and silently break + // the next response that a shared id was still listening for. + assertEquals(listOf(ids.first()), client.unsubscribed) + } + + @Test + fun `publishes to the coordinator relays`() = + runTest { + val client = RecordingClient() + val pool = NostrClientCvmRelayPool(client, setOf(coordinatorRelay, otherRelay)) + + pool.publish(response()) + + val (event, relays) = client.published.single() + assertEquals(setOf(coordinatorRelay, otherRelay), relays) + assertTrue(event.kind == CvmKinds.MESSAGE) + } + + private fun relay(url: String) = RelayUrlNormalizer.normalizeOrNull(url)!! + + private fun response() = + Event( + id = "aa".repeat(32), + pubKey = SERVER, + createdAt = 1, + kind = CvmKinds.MESSAGE, + tags = arrayOf(arrayOf("p", SERVER)), + content = "", + sig = "bb".repeat(32), + ) + + /** Records what the pool asked of the client, and nothing else. */ + private class RecordingClient : INostrClient by EmptyNostrClient() { + class Req( + val subId: String, + val filters: Map<NormalizedRelayUrl, List<Filter>>, + val listener: SubscriptionListener, + ) + + val subscribed = mutableListOf<Req>() + val unsubscribed = mutableListOf<String>() + val published = mutableListOf<Pair<Event, Set<NormalizedRelayUrl>>>() + + override fun subscribe( + subId: String, + filters: Map<NormalizedRelayUrl, List<Filter>>, + listener: SubscriptionListener?, + ) { + subscribed += Req(subId, filters, listener!!) + } + + override fun unsubscribe(subId: String) { + unsubscribed += subId + } + + override fun publish( + event: Event, + relayList: Set<NormalizedRelayUrl>, + ) { + published += event to relayList + } + } + + companion object { + private val SERVER = "cc".repeat(32) + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/favorites/FavoriteAppsRegistryTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/favorites/FavoriteAppsRegistryTest.kt new file mode 100644 index 0000000000..1d4e8f52d1 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/favorites/FavoriteAppsRegistryTest.kt @@ -0,0 +1,248 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.favorites + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import okio.Path.Companion.toOkioPath +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +/** + * The registry's disk lifecycle, which had no coverage while it was an Android-side `object` reaching + * into `Amethyst.instance` for its store. Taking the [DataStore] as a constructor argument is what + * makes these reachable. + * + * Every test drives the registry on the test scheduler, so "hydration has not finished yet" is a state + * the test controls rather than races: [advanceUntilIdle] is the only thing that lets the coroutine + * [FavoriteAppsRegistry.init] launches actually run. + */ +class FavoriteAppsRegistryTest { + @get:Rule + val folder = TemporaryFolder() + + private var seq = 0 + + private fun newFile() = File(folder.root, "favorite_apps_${seq++}.preferences_pb") + + private fun store( + scope: CoroutineScope, + file: File, + ): DataStore<Preferences> = PreferenceDataStoreFactory.createWithPath(scope = scope, produceFile = { file.toOkioPath() }) + + /** + * One app "session" over [file]. Each gets its own [Job] so the DataStore it opened is released + * when the session is cancelled: DataStore refuses a second live instance on a path that already + * has one, which is exactly why production keeps a single instance per file in the store holder. + * A restart test that skipped this would read an empty store and look like data loss. + */ + private fun TestScope.session(file: File): Pair<FavoriteAppsRegistry, CoroutineScope> { + val scope = CoroutineScope(coroutineContext + Job()) + return FavoriteAppsRegistry(store(scope, file), scope) to scope + } + + private fun web( + url: String, + label: String = url, + ) = FavoriteApp.WebApp(url, label, addedAt = 1L) + + /** What the user actually notices: a favorite added in one session is there in the next. */ + @Test + fun aFavoriteSurvivesARestart() = + runTest { + val file = newFile() + + val (first, firstScope) = session(file) + first.init() + advanceUntilIdle() + first.add(web("https://example.com")) + advanceUntilIdle() + firstScope.cancel() + + val (second, _) = session(file) + second.init() + advanceUntilIdle() + + assertEquals( + "the favorite written by the first session is what the second one hydrates", + listOf("url:https://example.com"), + second.favorites.value.map { it.id }, + ) + } + + /** + * The tombstone. Removing between init() and the merge landing must win, or the disk copy + * resurrects a favorite the user just deleted — and then persists it again. + */ + @Test + fun hydrationDoesNotResurrectAFavoriteRemovedBeforeItFinished() = + runTest { + val file = newFile() + + val (seeded, seededScope) = session(file) + seeded.init() + advanceUntilIdle() + seeded.add(web("https://gone.example")) + seeded.add(web("https://kept.example")) + advanceUntilIdle() + seededScope.cancel() + + val (reopened, _) = session(file) + reopened.init() + // Still inside the window: init() launched the merge but nothing has run it yet. + reopened.remove("url:https://gone.example") + advanceUntilIdle() + + assertEquals( + "the removal beat the merge and must survive it", + listOf("url:https://kept.example"), + reopened.favorites.value.map { it.id }, + ) + } + + /** The other side of the same window: an add made before the merge must not be dropped by it. */ + @Test + fun hydrationKeepsAnAddMadeBeforeItFinished() = + runTest { + val file = newFile() + + val (seeded, seededScope) = session(file) + seeded.init() + advanceUntilIdle() + seeded.add(web("https://ondisk.example")) + advanceUntilIdle() + seededScope.cancel() + + val (reopened, _) = session(file) + reopened.init() + reopened.add(web("https://thissession.example")) + advanceUntilIdle() + + assertEquals( + "both the disk copy and the pre-merge add are present", + setOf("url:https://ondisk.example", "url:https://thissession.example"), + reopened.favorites.value + .map { it.id } + .toSet(), + ) + } + + /** + * Persistence is gated on init(). Without the gate a write in that window flushes a list that has + * not merged with disk yet, which is the stored list being replaced by a partial one. + */ + @Test + fun nothingIsWrittenBeforeInit() = + runTest { + val file = newFile() + + val (registry, writerScope) = session(file) + registry.add(web("https://notpersisted.example")) + advanceUntilIdle() + + assertEquals( + "the add is live in memory", + listOf("url:https://notpersisted.example"), + registry.favorites.value.map { it.id }, + ) + writerScope.cancel() + advanceUntilIdle() + val readerScope = CoroutineScope(coroutineContext + Job()) + assertNull( + "but nothing reached disk, because init() never ran", + store(readerScope, file).data.first()[stringPreferencesKey("favorites")], + ) + readerScope.cancel() + } + + /** A favorite's cached manifest must not outlive the favorite. */ + @Test + fun removingANostrFavoriteDropsItsCachedManifest() = + runTest { + val (registry, _) = session(newFile()) + registry.init() + advanceUntilIdle() + + val coordinate = "31990:pubkey:slug" + registry.add(FavoriteApp.NostrApp(coordinate, "An App", addedAt = 1L)) + registry.cacheManifest(coordinate, """{"kind":31990}""") + assertEquals("cached while favorited", """{"kind":31990}""", registry.cachedManifest(coordinate)) + + registry.remove("nostr:$coordinate") + advanceUntilIdle() + + assertNull("and gone with the favorite", registry.cachedManifest(coordinate)) + } + + /** Garbage on disk must degrade to an empty list, never take the launcher down with it. */ + @Test + fun aCorruptStoredListHydratesAsEmpty() = + runTest { + val file = newFile() + val seedScope = CoroutineScope(coroutineContext + Job()) + store(seedScope, file).edit { it[stringPreferencesKey("favorites")] = "}not json[" } + advanceUntilIdle() + seedScope.cancel() + advanceUntilIdle() + + val (registry, _) = session(file) + registry.init() + advanceUntilIdle() + + assertTrue("decode failed softly", registry.favorites.value.isEmpty()) + } + + /** Ids are the identity: adding the same app twice is a no-op, not a duplicate row. */ + @Test + fun addingTheSameAppTwiceKeepsOneEntry() = + runTest { + val (registry, _) = session(newFile()) + registry.init() + advanceUntilIdle() + + registry.add(web("https://example.com", "First")) + registry.add(web("https://example.com", "Second")) + + assertEquals("de-duplicated by id", 1, registry.favorites.value.size) + assertEquals( + "and the first one won", + "First", + registry.favorites.value + .single() + .label, + ) + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/EncryptedMarmotStoresTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/EncryptedMarmotStoresTest.kt new file mode 100644 index 0000000000..4c9c0a3719 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/EncryptedMarmotStoresTest.kt @@ -0,0 +1,184 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.marmot + +import com.vitorpamplona.amethyst.commons.model.preferences.SecretEncryption +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +/** + * Round trips for the file-backed Marmot stores, which had no coverage while they sat in `amethyst/` + * behind an `Android` prefix they never earned. + * + * What matters here is that the bytes survive a restart: these hold MLS group state and the message + * log, so a store that writes but cannot read back loses a group's history and its ratchet — and MLS + * state that cannot be reloaded is not recoverable from the relays. + */ +class EncryptedMarmotStoresTest { + @get:Rule + val folder = TemporaryFolder() + + private var seq = 0 + + /** A fresh account directory plus its own key file, so tests cannot read each other's data. */ + private fun accountDir(): Pair<File, SecretEncryption> { + val n = seq++ + val dir = folder.newFolder("account_$n") + return dir to SecretEncryption(File(folder.root, "secret_$n.key")) + } + + // Group ids must be hex: both stores validate, which is what stops a crafted id escaping the + // account directory. Using a realistic 64-char id rather than a label keeps the tests honest. + private val groupId = "a".repeat(63) + "1" + private val otherGroupId = "b".repeat(63) + "2" + + @Test + fun groupStateSurvivesANewStoreOverTheSameDirectory() = + runTest { + val (dir, encryption) = accountDir() + val payload = byteArrayOf(1, 2, 3, 4, 5) + + EncryptedMlsGroupStateStore(dir, encryption).save(groupId, payload) + + val reopened = EncryptedMlsGroupStateStore(dir, encryption).load(groupId) + assertEquals("the same bytes come back", payload.toList(), reopened?.toList()) + } + + @Test + fun anUnknownGroupLoadsAsNull() = + runTest { + val (dir, encryption) = accountDir() + + assertNull(EncryptedMlsGroupStateStore(dir, encryption).load("c".repeat(63) + "3")) + } + + @Test + fun deletingAGroupRemovesItFromTheListing() = + runTest { + val (dir, encryption) = accountDir() + val store = EncryptedMlsGroupStateStore(dir, encryption) + store.save(groupId, byteArrayOf(9)) + store.save(otherGroupId, byteArrayOf(8)) + + store.delete(groupId) + + assertEquals("only the other group is left", listOf(otherGroupId), store.listGroups()) + assertNull("and its state is gone", store.load(groupId)) + } + + /** The sender ratchet is stored separately from the group blob; losing it breaks decryption. */ + @Test + fun theSenderRatchetRoundTripsIndependentlyOfTheGroupState() = + runTest { + val (dir, encryption) = accountDir() + val store = EncryptedMlsGroupStateStore(dir, encryption) + + store.save(groupId, byteArrayOf(1)) + store.saveSenderRatchet(groupId, byteArrayOf(7, 7, 7)) + + val reopened = EncryptedMlsGroupStateStore(dir, encryption) + assertEquals("ratchet preserved", listOf<Byte>(7, 7, 7), reopened.loadSenderRatchet(groupId)?.toList()) + assertEquals("and the group blob is untouched", listOf<Byte>(1), reopened.load(groupId)?.toList()) + } + + @Test + fun appendedMessagesComeBackInOrderAfterAReopen() = + runTest { + val (dir, encryption) = accountDir() + val store = EncryptedMarmotMessageStore(dir, encryption) + + store.appendMessage(groupId, """{"id":"one"}""") + store.appendMessage(groupId, """{"id":"two"}""") + + val reopened = EncryptedMarmotMessageStore(dir, encryption).loadMessages(groupId) + assertEquals("both, in append order", listOf("""{"id":"one"}""", """{"id":"two"}"""), reopened) + } + + @Test + fun aGroupWithNoMessagesLoadsEmptyRatherThanFailing() = + runTest { + val (dir, encryption) = accountDir() + + assertTrue(EncryptedMarmotMessageStore(dir, encryption).loadMessages("d".repeat(63) + "4").isEmpty()) + } + + @Test + fun deletingAGroupDropsItsMessageLog() = + runTest { + val (dir, encryption) = accountDir() + val store = EncryptedMarmotMessageStore(dir, encryption) + store.appendMessage(groupId, """{"id":"one"}""") + + store.delete(groupId) + + assertTrue(EncryptedMarmotMessageStore(dir, encryption).loadMessages(groupId).isEmpty()) + } + + /** The group snapshot is what a cold start restores from before replaying the log. */ + @Test + fun theGroupSnapshotRoundTrips() = + runTest { + val (dir, encryption) = accountDir() + val store = EncryptedMarmotMessageStore(dir, encryption) + + store.recordGroupSnapshot(groupId, """{"epoch":4}""") + + assertEquals("""{"epoch":4}""", EncryptedMarmotMessageStore(dir, encryption).loadGroupSnapshot(groupId)) + } + + /** Two accounts are two directories: one must never read the other's groups. */ + @Test + fun twoAccountDirectoriesDoNotSeeEachOther() = + runTest { + val (dirA, encA) = accountDir() + val (dirB, encB) = accountDir() + + EncryptedMlsGroupStateStore(dirA, encA).save(groupId, byteArrayOf(1)) + + assertNull("B cannot see A's group", EncryptedMlsGroupStateStore(dirB, encB).load(groupId)) + assertTrue("nor list it", EncryptedMlsGroupStateStore(dirB, encB).listGroups().isEmpty()) + } + + /** The hex check is a path-traversal guard: a crafted id must not be able to leave the account dir. */ + @Test + fun aNonHexGroupIdIsRejected() = + runTest { + val (dir, encryption) = accountDir() + val store = EncryptedMlsGroupStateStore(dir, encryption) + + listOf("../escape", "not hex", "abc/def", "").forEach { bad -> + val thrown = + try { + store.load(bad) + false + } catch (e: IllegalArgumentException) { + true + } + assertTrue("\"$bad\" must be rejected, not resolved to a path", thrown) + } + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/LegacyGroupContractTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/LegacyGroupContractTest.kt index b6c1a7643a..8dd2d9b75d 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/LegacyGroupContractTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/LegacyGroupContractTest.kt @@ -92,7 +92,7 @@ class LegacyGroupContractTest { ) } - private class ProbeStateStore : com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore { + private class ProbeStateStore : com.vitorpamplona.quartz.marmot.groups.MlsGroupStateStore { private val states = mutableMapOf<String, ByteArray>() private val retained = mutableMapOf<String, List<ByteArray>>() diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotAgentStreamWatcherTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotAgentStreamWatcherTest.kt index 91f8afb146..8f89009f85 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotAgentStreamWatcherTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotAgentStreamWatcherTest.kt @@ -27,10 +27,10 @@ import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.PreviewStat import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.transport.MarmotQuicException import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.transport.MarmotQuicStream import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.transport.MarmotQuicTransport +import com.vitorpamplona.quartz.marmot.groups.MarmotMessageStore +import com.vitorpamplona.quartz.marmot.groups.MlsGroupStateStore import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData -import com.vitorpamplona.quartz.marmot.mls.group.MarmotMessageStore -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import kotlinx.coroutines.CompletableDeferred diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotEditsAndSystemRowsTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotEditsAndSystemRowsTest.kt index 6e27962957..e2ea1de62e 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotEditsAndSystemRowsTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotEditsAndSystemRowsTest.kt @@ -27,7 +27,7 @@ import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal -import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent import kotlinx.coroutines.runBlocking import kotlin.test.Test import kotlin.test.assertEquals @@ -130,7 +130,7 @@ class MarmotEditsAndSystemRowsTest { val forged = MarmotAppEvent.build( pubKey = impostor, - kind = DeletionEvent.KIND, + kind = DeletionRequestEvent.KIND, content = "", createdAt = 1_800_000_000L, tags = arrayOf(arrayOf("e", mine.innerEvent.id)), @@ -168,7 +168,7 @@ class MarmotEditsAndSystemRowsTest { val forged = MarmotAppEvent.build( pubKey = f.signer.pubKey, - kind = DeletionEvent.KIND, + kind = DeletionRequestEvent.KIND, content = "", createdAt = 1_800_000_000L, tags = arrayOf(arrayOf("e", absent)), diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotKeyPackageRotationProfileTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotKeyPackageRotationProfileTest.kt index 68a0166fd7..bd33a29b0d 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotKeyPackageRotationProfileTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotKeyPackageRotationProfileTest.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.amethyst.commons.marmot +import com.vitorpamplona.quartz.marmot.groups.MarmotMessageStore +import com.vitorpamplona.quartz.marmot.groups.MlsGroupStateStore import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageUtils -import com.vitorpamplona.quartz.marmot.mls.group.MarmotMessageStore -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManagerLeaveRejoinTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManagerLeaveRejoinTest.kt index 51c248077f..427810d0f8 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManagerLeaveRejoinTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManagerLeaveRejoinTest.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.amethyst.commons.marmot +import com.vitorpamplona.quartz.marmot.groups.MarmotMessageStore +import com.vitorpamplona.quartz.marmot.groups.MlsGroupStateStore import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData -import com.vitorpamplona.quartz.marmot.mls.group.MarmotMessageStore -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal @@ -39,7 +39,7 @@ import kotlin.test.assertTrue /** * End-to-end leave + rejoin round-trip at the [MarmotManager] layer. * - * One level above [com.vitorpamplona.quartz.marmot.mls.MlsGroupManagerTest.testLeaveAndRejoin_SameGroupIdEndToEnd] + * One level above [com.vitorpamplona.quartz.mls.MlsGroupManagerTest.testLeaveAndRejoin_SameGroupIdEndToEnd] * in quartz, which only exercises the MLS engine. This one drives the full * commons-layer pipeline: * diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManagerRestoreTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManagerRestoreTest.kt index a923b2ec31..c1fc52ea02 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManagerRestoreTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManagerRestoreTest.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.amethyst.commons.marmot +import com.vitorpamplona.quartz.marmot.groups.MarmotMessageStore +import com.vitorpamplona.quartz.marmot.groups.MlsGroupStateStore import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData -import com.vitorpamplona.quartz.marmot.mls.group.MarmotMessageStore -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotPublishBeforeApplyTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotPublishBeforeApplyTest.kt index c63f506200..7e20aa987a 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotPublishBeforeApplyTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotPublishBeforeApplyTest.kt @@ -439,7 +439,7 @@ class MarmotPublishBeforeApplyTest { assertEquals(listOf(relay), fx.manager.groupRelays(fx.groupId)) } - private class InMemoryStateStore : com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore { + private class InMemoryStateStore : com.vitorpamplona.quartz.marmot.groups.MlsGroupStateStore { private val states = mutableMapOf<String, ByteArray>() private val retained = mutableMapOf<String, List<ByteArray>>() diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotPublishDurabilityTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotPublishDurabilityTest.kt index e06891794c..dc592a16c5 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotPublishDurabilityTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotPublishDurabilityTest.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.amethyst.commons.marmot +import com.vitorpamplona.quartz.marmot.groups.MlsGroupStateStore import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore import com.vitorpamplona.quartz.marmot.protocolCore.GroupLifecycleState import com.vitorpamplona.quartz.marmot.protocolCore.MarmotPublishObligationStore import com.vitorpamplona.quartz.nip01Core.core.Event diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotSendCostTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotSendCostTest.kt index 1953e9909f..48b2e5d479 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotSendCostTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotSendCostTest.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.amethyst.commons.marmot +import com.vitorpamplona.quartz.marmot.groups.MlsGroupStateStore import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import kotlinx.coroutines.runBlocking diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotSenderRatchetDurabilityTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotSenderRatchetDurabilityTest.kt index 7fb7350c55..0202f1816e 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotSenderRatchetDurabilityTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotSenderRatchetDurabilityTest.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.commons.marmot +import com.vitorpamplona.quartz.marmot.groups.MlsGroupStateStore import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore -import com.vitorpamplona.quartz.marmot.mls.group.OwnSenderRatchet +import com.vitorpamplona.quartz.mls.group.OwnSenderRatchet import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import kotlinx.coroutines.runBlocking diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotTestStores.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotTestStores.kt index 898a2a6341..158ccc81a9 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotTestStores.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotTestStores.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.amethyst.commons.marmot +import com.vitorpamplona.quartz.marmot.groups.MarmotMessageStore +import com.vitorpamplona.quartz.marmot.groups.MlsGroupStateStore import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore -import com.vitorpamplona.quartz.marmot.mls.group.MarmotMessageStore -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore import com.vitorpamplona.quartz.nip01Core.core.Event // In-memory stand-ins for the durable stores a MarmotManager needs. diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/cordnGroups/CordnGroupChatroomTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/cordnGroups/CordnGroupChatroomTest.kt new file mode 100644 index 0000000000..dff1b93179 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/cordnGroups/CordnGroupChatroomTest.kt @@ -0,0 +1,346 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.cordnGroups + +import com.vitorpamplona.quartz.cordn.groups.CordnCredential +import com.vitorpamplona.quartz.cordn.groups.CordnGroupPolicy +import com.vitorpamplona.quartz.cordn.spec01GroupMetadata.CordnGroupMetadata +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnDeliveredMessage +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnEnvelope +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnMessageKinds +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnMessageReferences +import com.vitorpamplona.quartz.mls.group.MlsGroup +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The room a cordn screen renders. + * + * The rules under test are the ones that fail quietly. A room that keys on the + * cursor duplicates history the moment a client re-syncs; a room that sorts on + * one clock alone either lets a coordinator reorder the past or lets one bad + * device clock scatter its owner's messages through it. Neither throws. + */ +class CordnGroupChatroomTest { + private val alice = "a".repeat(64) + private val bob = "b".repeat(64) + private val coordinator = "c".repeat(64) + + private fun room() = CordnGroupChatroom("gid-1", coordinator) + + /** The target an annotation points at: id, its author, and its kind. */ + private fun target( + id: String, + author: HexKey, + kind: Int = CordnMessageKinds.TEXT, + ) = CordnMessageReferences.Target(id = id.padEnd(64, '0'), pubKey = author, kind = kind) + + private fun message( + id: String, + at: Long, + cursor: Long, + author: HexKey = alice, + content: String = "msg-$id", + kind: Int = CordnMessageKinds.TEXT, + tags: Array<Array<String>> = emptyArray(), + ) = CordnDeliveredMessage( + CordnEnvelope(id = id.padEnd(64, '0'), pubKey = author, createdAt = at, kind = kind, tags = tags, content = content), + cursor, + ) + + @Test + fun `messages are keyed by envelope id, so a re-sync does not duplicate them`() { + // spec/02.md §7: the envelope id is the identity, the cursor is a + // delivery primitive. A catch_up after a restart re-walks the stream + // and hands back messages we already have — with a NEW cursor, because + // a cursor is coordinator-local. Keying on it would double the room + // every time it recovers, which is exactly when it must not. + val room = room() + assertTrue(room.add(message("1", at = 10, cursor = 1))) + + assertFalse(room.add(message("1", at = 10, cursor = 99)), "same message, different cursor") + + assertEquals(1, room.messages.value.size) + } + + @Test + fun `messages are ordered as the coordinator delivered them`() { + val room = room() + room.add(message("3", at = 30, cursor = 3)) + room.add(message("1", at = 10, cursor = 1)) + room.add(message("2", at = 20, cursor = 2)) + + assertEquals(listOf("msg-1", "msg-2", "msg-3"), room.messages.value.map { it.envelope.content }) + } + + @Test + fun `a sender's clock cannot move their message in the room`() { + // The rule the ordering exists for, and the only test that can tell + // the two orders apart — everywhere else the cursor and created_at + // agree. A device with a wrong clock, or a member who simply sets + // whatever they like, must not be able to reorder the room: sorting on + // created_at would let this message sit at the top of the history + // forever, above things said long before it. + val room = room() + room.add(message("1", at = 1_000, cursor = 1, content = "said first")) + room.add(message("2", at = 5, cursor = 2, content = "backdated to 1970")) + + assertEquals( + listOf("said first", "backdated to 1970"), + room.messages.value.map { it.envelope.content }, + "a backdated message jumped the queue", + ) + } + + @Test + fun `annotations are folded, not rendered as messages`() { + // A reaction is not a line in the chat. Showing one would put a bare + // "+" in the transcript and, worse, make it the room's preview. + val room = room() + room.add(message("1", at = 10, cursor = 1, content = "hello")) + room.add( + message( + "2", + at = 11, + cursor = 2, + author = bob, + kind = CordnMessageKinds.REACTION, + content = "+", + tags = CordnMessageReferences.reactionTags(target("1", alice)), + ), + ) + + assertEquals(listOf("hello"), room.messages.value.map { it.envelope.content }) + assertEquals( + "hello", + room.newest.value + ?.envelope + ?.content, + "a reaction must not become the preview", + ) + + val reacted = room.annotations.value.reactions["1".padEnd(64, '0')] + assertEquals(setOf(bob), reacted?.get("+"), "the reaction still has to land on its target") + } + + @Test + fun `an edit changes the rendered text without adding a row`() { + val room = room() + room.add(message("1", at = 10, cursor = 1, content = "typo")) + room.add( + message( + "2", + at = 11, + cursor = 2, + content = "fixed", + kind = CordnMessageKinds.EDIT, + tags = CordnMessageReferences.editTags(target("1", alice)), + ), + ) + + assertEquals(1, room.messages.value.size) + assertEquals("fixed", room.annotations.value.contentOf("1".padEnd(64, '0'))) + } + + @Test + fun `the newest message drives the inbox preview`() { + val room = room() + room.add(message("1", at = 10, cursor = 1, content = "older")) + room.add(message("2", at = 20, cursor = 2, content = "newer")) + + assertEquals( + "newer", + room.newest.value + ?.envelope + ?.content, + ) + } + + @Test + fun `an empty room previews nothing rather than failing`() { + val room = room() + assertNull(room.newest.value) + assertTrue(room.messages.value.isEmpty()) + } + + @Test + fun `addAll reports how many were actually new`() { + // The count feeds unread badges, so counting a re-delivery would make + // a recovering room look like it had new traffic. + val room = room() + room.add(message("1", at = 10, cursor = 1)) + + val added = room.addAll(listOf(message("1", at = 10, cursor = 50), message("2", at = 20, cursor = 51))) + + assertEquals(1, added) + assertEquals(2, room.messages.value.size) + } + + @Test + fun `a new room knows nothing until it is pointed at an MLS group`() { + val room = room() + + assertNull(room.name.value) + assertNull(room.description.value) + assertEquals(emptyList(), room.members.value) + assertEquals(0L, room.epoch.value) + } + + @Test + fun `refreshFrom reads the name, description and admins out of the metadata extension`() { + val room = room() + val metadata = CordnGroupMetadata(name = "Stage B", description = "the visible half", adminPubkeys = listOf(alice)) + + room.refreshFrom(groupOf(alice, metadata)) + + assertEquals("Stage B", room.name.value) + assertEquals("the visible half", room.description.value) + assertEquals(listOf(alice), room.adminPubkeys.value) + } + + @Test + fun `refreshFrom reports the creator as a member by account pubkey, not by hex-of-hex`() { + val room = room() + + room.refreshFrom(groupOf(alice, CordnGroupMetadata(name = "Stage B"))) + + // The trap MlsGroup.memberIdentityHex falls into: a cordn credential is + // already 64 chars of hex, so hex-encoding it again gives 128. + assertEquals(listOf(alice), room.members.value) + } + + @Test + fun `a group carrying no metadata extension leaves the room unnamed rather than mis-named`() { + val room = room() + room.refreshFrom(groupOf(alice, CordnGroupMetadata(name = "Stage B"))) + + room.refreshFrom(MlsGroup.create(CordnCredential.of(bob).identity, policy = CordnGroupPolicy)) + + // Not "Stage B" left over: the fields are derived from whatever group + // they were last pointed at, never accumulated across groups. + assertNull(room.name.value) + assertEquals(emptyList(), room.adminPubkeys.value) + } + + @Test + fun `an empty admin list stays empty, because egalitarian is a choice and not a gap`() { + val room = room() + + room.refreshFrom(groupOf(alice, CordnGroupMetadata(name = "Flat", adminPubkeys = emptyList()))) + + assertTrue(room.adminPubkeys.value.isEmpty()) + } + + @Test + fun `forgetting a coordinator drops its rooms and keeps everyone else's`() { + // CordnRuntime.purge deletes one coordinator's files; the in-memory + // list has to lose exactly the same rooms. A gid is unique only within + // a coordinator, so two of them can hold the same gid as unrelated + // groups -- dropping by gid would take a stranger's room with it. + val list = CordnGroupList() + val other = "d".repeat(64) + list.getOrCreate(coordinator, "shared-gid") + list.getOrCreate(other, "shared-gid") + list.getOrCreate(other, "another") + + list.forgetCoordinator(coordinator) + + assertNull(list.get(coordinator, "shared-gid")) + assertEquals(2, list.all.value.size) + assertEquals( + setOf(other), + list.all.value + .map { it.coordinatorPubKey } + .toSet(), + ) + } + + @Test + fun `unread counts what other people said, past the read position`() { + val room = CordnGroupChatroom("gid-1", coordinator, accountPubKey = alice) + room.addAll( + listOf( + message("01", at = 10, cursor = 1, author = bob), + message("02", at = 11, cursor = 2, author = bob), + ), + ) + assertEquals(2, room.unreadCount.value) + + room.markRead() + assertEquals(0, room.unreadCount.value) + + room.add(message("03", at = 12, cursor = 3, author = bob)) + assertEquals(1, room.unreadCount.value) + } + + @Test + fun `your own messages are not unread, however they come back`() { + // A message echoes back from the coordinator with a cursor past the + // read position, exactly like someone else's. Counting it would badge + // every room the moment its owner spoke in it. + val room = CordnGroupChatroom("gid-1", coordinator, accountPubKey = alice) + room.add(message("01", at = 10, cursor = 1, author = alice)) + + assertEquals(0, room.unreadCount.value) + } + + @Test + fun `a reaction to something already read is not an unread message`() { + val room = CordnGroupChatroom("gid-1", coordinator, accountPubKey = alice) + room.add(message("01", at = 10, cursor = 1, author = bob)) + room.markRead() + + room.add(message("02", at = 11, cursor = 2, author = bob, content = "\uD83D\uDC4D", kind = CordnMessageKinds.REACTION, tags = arrayOf(arrayOf("e", "01".padEnd(64, '0'))))) + + // Badging a room for a thumbs-up trains people to ignore the badge. + assertEquals(0, room.unreadCount.value) + } + + @Test + fun `a restored read position is honoured before anything new arrives`() { + val room = CordnGroupChatroom("gid-1", coordinator, accountPubKey = alice) + room.addAll( + listOf( + message("01", at = 10, cursor = 1, author = bob), + message("02", at = 11, cursor = 2, author = bob), + ), + ) + room.restoreState(draft = "half typed", lastReadCursor = 1) + + assertEquals("half typed", room.draft.value) + assertEquals(1, room.unreadCount.value, "only the message past the saved cursor") + } + + /** A real cordn group, created the way [CordnGroupManager.createGroup] does. */ + private fun groupOf( + creator: HexKey, + metadata: CordnGroupMetadata, + ) = MlsGroup.create( + identity = CordnCredential.of(creator).identity, + policy = CordnGroupPolicy, + initialExtensions = listOf(metadata.toExtension()), + groupId = "gid-1".encodeToByteArray(), + ) +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/cordnGroups/CordnInboxRowTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/cordnGroups/CordnInboxRowTest.kt new file mode 100644 index 0000000000..3255803b78 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/cordnGroups/CordnInboxRowTest.kt @@ -0,0 +1,172 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.cordnGroups + +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnDeliveredMessage +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnEnvelope +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnMessageKinds +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +/** + * Where a cordn room lands in the Messages list. + * + * Both rules here failed silently and in the same direction: the inbox showed + * every cordn room with the right preview and the right time, at the wrong + * place in the list. A test that only reads a room is blind to it, because + * nothing about the room was wrong -- the row it hands the feed was. + */ +class CordnInboxRowTest { + private val alice = "a".repeat(64) + private val coordinator = "c".repeat(64) + + private fun message( + id: String, + at: Long, + cursor: Long, + author: HexKey = alice, + ) = CordnDeliveredMessage( + CordnEnvelope( + id = id.padEnd(64, '0'), + pubKey = author, + createdAt = at, + kind = CordnMessageKinds.TEXT, + tags = emptyArray(), + content = "msg-$id", + ), + cursor, + ) + + @Test + fun `the row's time is the newest message's, so the inbox can sort on it`() { + // The feed sorts on Note.createdAt() and maps null to 0L. A row that + // returns null is not "undated", it is pinned to the bottom of the + // Messages list forever, under every DM and every other group chat. + val room = CordnGroupChatroom("gid-1", coordinator) + room.add(message("1", at = 1_700_000_000, cursor = 1)) + + assertEquals(1_700_000_000L, room.inboxRow().createdAt()) + } + + @Test + fun `a room with no messages has no time rather than a wrong one`() { + // 0L would be a claim about when the room last spoke. Null says there + // is nothing to sort on, which is what a just-joined room means. + assertNull(CordnGroupChatroom("gid-1", coordinator).inboxRow().createdAt()) + } + + @Test + fun `the row follows the coordinator's newest, not the last one handed to it`() { + // A catch_up walks the stream in order, but a live subscription can + // interleave, and the room orders by cursor rather than arrival. The + // row has to agree with the room about which message is newest, or the + // inbox sorts a group by whichever message happened to land last. + val room = CordnGroupChatroom("gid-1", coordinator) + room.add(message("2", at = 20, cursor = 2)) + room.add(message("1", at = 10, cursor = 1)) + + assertEquals(20L, room.inboxRow().createdAt()) + } + + @Test + fun `the row is one stable instance, so the feed does not see a new room each rebuild`() { + val room = CordnGroupChatroom("gid-1", coordinator) + + assertSame(room.inboxRow(), room.inboxRow()) + } + + @Test + fun `a message for a room the list already holds still tells the inbox to rebuild`() { + // The half of the bug that no amount of correct sorting fixes: the feed + // rebuilds off a signal from the list, and the room *set* does not + // change when a message arrives for a room already in it. Without this + // the inbox sorted cordn rooms once, at join, and never again. + val list = CordnGroupList() + assertTrue(list.add(coordinator, "gid-1", message("1", at = 10, cursor = 1))) + + val roomsBefore = list.all.value + val revisionBefore = list.revision.value + + assertTrue(list.add(coordinator, "gid-1", message("2", at = 20, cursor = 2))) + + assertSame(roomsBefore, list.all.value, "the room set did not change, which is the trap") + assertTrue(list.revision.value > revisionBefore, "but the inbox still has to re-sort") + } + + @Test + fun `a message put straight onto the room still tells the inbox to rebuild`() { + // The case that matters most and was missed: sending does not go through + // CordnGroupList at all -- the chat screen adds the optimistic message to + // the room directly, and the coordinator's echo is then already held by + // id so it files as a duplicate. Hanging the signal off the list meant + // your own messages never re-sorted the inbox, only other people's. + val list = CordnGroupList() + val room = list.getOrCreate(coordinator, "gid-1") + val revisionBefore = list.revision.value + + assertTrue(room.add(message("1", at = 10, cursor = 1))) + + assertTrue(list.revision.value > revisionBefore, "an optimistic send has to re-sort the inbox") + } + + @Test + fun `an annotation does not tell the inbox to rebuild`() { + // A reaction leaves the newest message exactly as it was, so re-sorting + // for one would be a rebuild per reaction for no visible change. + val list = CordnGroupList() + val room = list.getOrCreate(coordinator, "gid-1") + room.add(message("1", at = 10, cursor = 1)) + + val revisionBefore = list.revision.value + room.add( + CordnDeliveredMessage( + CordnEnvelope( + id = "2".padEnd(64, '0'), + pubKey = alice, + createdAt = 20, + kind = CordnMessageKinds.REACTION, + tags = arrayOf(arrayOf("e", "1".padEnd(64, '0'))), + content = "+", + ), + 2, + ), + ) + + assertEquals(revisionBefore, list.revision.value) + } + + @Test + fun `a re-delivered message does not tell the inbox to rebuild`() { + // A re-sync re-walks the whole stream. Bumping per echo would rebuild + // the Messages feed once per message for no visible change. + val list = CordnGroupList() + list.add(coordinator, "gid-1", message("1", at = 10, cursor = 1)) + + val revisionBefore = list.revision.value + list.add(coordinator, "gid-1", message("1", at = 10, cursor = 99)) + + assertEquals(revisionBefore, list.revision.value) + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip52Calendar/IcsExportTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip52Calendar/IcsExportTest.kt index f6e5ec7e81..11cdde002e 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip52Calendar/IcsExportTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip52Calendar/IcsExportTest.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.commons.model.nip52Calendar import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent -import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent +import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarCollectionEvent import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue @@ -181,7 +181,7 @@ class IcsExportTest { sig = "sig", ) val calendar = - CalendarEvent( + CalendarCollectionEvent( id = "cal", pubKey = "pub", createdAt = 0L, diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip71Video/DivineVideoInteropTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip71Video/DivineVideoInteropTest.kt index c476dab342..db2424d860 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip71Video/DivineVideoInteropTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip71Video/DivineVideoInteropTest.kt @@ -31,7 +31,7 @@ import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip51Lists.videoCurationSet.VideoCurationSetEvent -import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent +import com.vitorpamplona.quartz.nip71Video.AddressableShortVideoEvent import com.vitorpamplona.quartz.nip71Video.credits.CreditTarget import com.vitorpamplona.quartz.nip71Video.credits.VideoCredit import com.vitorpamplona.quartz.nip71Video.textTrack.TextTrackEvent @@ -86,20 +86,20 @@ class DivineVideoInteropTest { fun aShortVideoIsAnAddressableVerticalVideo() { val event = Event.fromJson(video) - assertTrue(event is VideoVerticalEvent) + assertTrue(event is AddressableShortVideoEvent) // The address must come from the `d` tag: an `a` tag elsewhere on the network points at // `34236:<pubkey>:<d>`, and a class on the wrong base would split the cache in two. assertEquals( "34236:4d7dccc0a5116daa057348ef79c573873cddd9eff066fc6a5f3d37e8264afbeb:c855df3d07ba963e9097d5a151b0c14a0a3d494e4ff9b04a389bc0b5f5c16862", (event as AddressableEvent).address().toValue(), ) - assertEquals("Xmas Already??", (event as VideoVerticalEvent).title()) + assertEquals("Xmas Already??", (event as AddressableShortVideoEvent).title()) assertEquals(6, event.duration()) } @Test fun theBlossomUrlIsPlayedAsAVideoDespiteHavingNoFileExtension() { - val track = (Event.fromJson(video) as VideoVerticalEvent).selectVideoTrack() + val track = (Event.fromJson(video) as AddressableShortVideoEvent).selectVideoTrack() assertNotNull(track) assertEquals("https://media.divine.video/c855df3d07ba963e9097d5a151b0c14a0a3d494e4ff9b04a389bc0b5f5c16862", track!!.url) @@ -125,7 +125,7 @@ class DivineVideoInteropTest { // itself. LocalCache.computeReplyTo has no branch for video events, so this never turns // the post into a reply — which would drop it out of the home feed and thread it under // whatever video the audio came from. - val event = Event.fromJson(video) as VideoVerticalEvent + val event = Event.fromJson(video) as AddressableShortVideoEvent val audio = event.tags.first { it[0] == "e" } assertEquals("218af5fc90d66a16ce273f00a4e412a71443441c04c67d1e34ff99c654871d3d", audio[1]) @@ -136,7 +136,7 @@ class DivineVideoInteropTest { fun captionsComeFromTheTextTrackTagsAndNotTheAudioPointer() { // textTrack() used to run ETag::parse, so it returned the `e` tags instead — on this // event, the reused-soundtrack pointer, which is not a caption track at all. - val tracks = (Event.fromJson(video) as VideoVerticalEvent).textTrack() + val tracks = (Event.fromJson(video) as AddressableShortVideoEvent).textTrack() assertEquals(2, tracks.size) // Divine publishes the same track twice: the WebVTT file on Blossom, and the addressable @@ -153,7 +153,7 @@ class DivineVideoInteropTest { @Test fun theTwoFormsOfTheSameCaptionTrackCollapseIntoOne() { - val video = Event.fromJson(video) as VideoVerticalEvent + val video = Event.fromJson(video) as AddressableShortVideoEvent val refs = video.captionTracks() // One `text-track` is directly loadable, the other has to be fetched first. @@ -230,7 +230,7 @@ class DivineVideoInteropTest { @Test fun creditsReadTheMarkerWhicheverSlotItIsIn() { // divine-mobile: ["p", <pubkey>, <relay>, "inspired-by"] and ["e", <id>, <relay>, "audio"]. - val credits = (Event.fromJson(videoWithCredits) as VideoVerticalEvent).credits() + val credits = (Event.fromJson(videoWithCredits) as AddressableShortVideoEvent).credits() val person = credits.filterIsInstance<VideoCredit>().first { it.target is CreditTarget.Person } assertEquals("0db19e8f1b13e03cd07379edd37a71f07b08a4fb5e7eac222ba1d2c823807075", (person.target as CreditTarget.Person).pubKey) @@ -250,7 +250,7 @@ class DivineVideoInteropTest { // divine-web's collaborator invite writes ["p", <pubkey>, "<role>"] — no relay hint at all, // so the slot PTag reads for a hint holds the label instead. val invite = - VideoVerticalEvent( + AddressableShortVideoEvent( id = "a".repeat(64), pubKey = "b".repeat(64), createdAt = 1789666628, @@ -269,7 +269,7 @@ class DivineVideoInteropTest { // No marker means nothing is being credited — printing "references <id>" for a stray // threading tag would invent an attribution the publisher never made. val stray = - VideoVerticalEvent( + AddressableShortVideoEvent( id = "a".repeat(64), pubKey = "b".repeat(64), createdAt = 1789666628, diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip71Video/VideoTrackSelectionTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip71Video/VideoTrackSelectionTest.kt index 0a7453ddc8..e92f88c732 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip71Video/VideoTrackSelectionTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip71Video/VideoTrackSelectionTest.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.amethyst.commons.model.nip71Video +import com.vitorpamplona.quartz.nip71Video.AddressableShortVideoEvent import com.vitorpamplona.quartz.nip71Video.VideoMeta -import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag import org.junit.Assert.assertEquals import org.junit.Assert.assertNull @@ -36,7 +36,7 @@ class VideoTrackSelectionTest { private val hls = "application/vnd.apple.mpegurl" private fun event(vararg metas: VideoMeta) = - VideoVerticalEvent( + AddressableShortVideoEvent( id = "bfe2f2244fefc7cebc7b2eae825495f99dabb4649ee3f90ab1fa33bcd1e9bb9f", pubKey = "3b6187c08b9dd5617150ea047e788a0fdd44b4394cb5566cba76f683ddc027d2", createdAt = 1780894816, diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountIdentityStoreTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountIdentityStoreTest.kt new file mode 100644 index 0000000000..f9e19f0e29 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountIdentityStoreTest.kt @@ -0,0 +1,200 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.preferences + +import androidx.datastore.core.DataMigration +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.test.runTest +import okio.Path.Companion.toOkioPath +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +class AccountIdentityStoreTest { + @get:Rule + val folder = TemporaryFolder() + + private var seq = 0 + + private fun raw(migrations: List<DataMigration<Preferences>> = emptyList()): DataStore<Preferences> { + val file = File(folder.root, "identity_${seq++}.preferences_pb") + return PreferenceDataStoreFactory.createWithPath( + scope = CoroutineScope(Dispatchers.IO + SupervisorJob()), + migrations = migrations, + produceFile = { file.toOkioPath() }, + ) + } + + @Test + fun defaultsMatchTheLegacyOnes() = + runTest { + val loaded = AccountIdentityStore(raw()).load() + + assertNull(loaded.pubKeyHex) + assertEquals(false, loaded.loginWithExternalSigner) + assertNull(loaded.externalSignerPackageName) + assertEquals(emptySet<String>(), loaded.localRelayServers) + assertNull(loaded.openBackupConflictsJson) + } + + @Test + fun roundTrip() = + runTest { + val store = AccountIdentityStore(raw()) + val value = + AccountIdentity( + pubKeyHex = "aabbcc", + loginWithExternalSigner = true, + externalSignerPackageName = "com.greenart7c3.nostrsigner", + localRelayServers = setOf("ws://localhost:4869"), + openBackupConflictsJson = """[["a","b","c"]]""", + ) + + store.save(value) + + assertEquals(value, store.load()) + } + + /** + * Dropping an external signer, clearing the local relays or answering the + * last backup conflict has to leave those keys absent — not holding + * yesterday's value. + */ + @Test + fun savingEmptyValuesClearsWhatWasThere() = + runTest { + val store = AccountIdentityStore(raw()) + store.save( + AccountIdentity( + pubKeyHex = "aabbcc", + loginWithExternalSigner = true, + externalSignerPackageName = "com.example.signer", + localRelayServers = setOf("ws://localhost:4869"), + openBackupConflictsJson = "[]", + ), + ) + + store.save(AccountIdentity(pubKeyHex = "aabbcc")) + + val loaded = store.load() + assertEquals("aabbcc", loaded.pubKeyHex) + assertEquals(false, loaded.loginWithExternalSigner) + assertNull(loaded.externalSignerPackageName) + assertEquals(emptySet<String>(), loaded.localRelayServers) + assertNull(loaded.openBackupConflictsJson) + } + + /** Absent means "already backed up elsewhere", so the nudge stays off. */ + @Test + fun hasBackedUpKeysDefaultsToTrue() = + runTest { + assertEquals(true, AccountIdentityStore(raw()).hasBackedUpKeys()) + } + + /** + * The nudge writes this on its own. A group save must not carry a value + * its caller never knew about and flip the nudge back on. + */ + @Test + fun aGroupSaveLeavesHasBackedUpKeysAlone() = + runTest { + val store = AccountIdentityStore(raw()) + store.setHasBackedUpKeys(false) + + store.save(AccountIdentity(pubKeyHex = "aabbcc", localRelayServers = setOf("ws://x"))) + + assertEquals(false, store.hasBackedUpKeys()) + } + + @Test + fun theLegacyCopyCarriesEveryFieldIncludingTheBackupFlag() = + runTest { + val legacy = + FakeLegacySource( + mapOf( + "nostr_pubkey" to "aabbcc", + "login_with_external_signer" to true, + "signer_package_name" to "com.example.signer", + "localRelayServers" to setOf("ws://localhost:4869"), + "openBackupConflicts" to """[["a","b","c"]]""", + "has_backed_up_keys" to false, + ), + ) + + val store = AccountIdentityStore(raw(listOf(AccountIdentityStore.legacyTable.migration { legacy }))) + + assertEquals( + AccountIdentity( + pubKeyHex = "aabbcc", + loginWithExternalSigner = true, + externalSignerPackageName = "com.example.signer", + localRelayServers = setOf("ws://localhost:4869"), + openBackupConflictsJson = """[["a","b","c"]]""", + ), + store.load(), + ) + assertEquals(false, store.hasBackedUpKeys()) + } + + /** + * The fallback is all-or-nothing, and that is the point: with no pubkey + * the store answered from `emptyPreferences()`, where an absent boolean and + * a false one are the same value. Merging field by field would report an + * external-signer account as a local one — and a local one has no signer, + * so the account would go read-only. + */ + @Test + fun anIdentityWithoutAPubKeyFallsBackWholesale() { + val legacy = + AccountIdentity( + pubKeyHex = "aabbcc", + loginWithExternalSigner = true, + externalSignerPackageName = "com.example.signer", + ) + + assertEquals(legacy, AccountIdentity().orIfUnusable { legacy }) + } + + @Test + fun anIdentityWithAPubKeyIsAuthoritative() { + val stored = AccountIdentity(pubKeyHex = "aabbcc") + var called = false + + val result = + stored.orIfUnusable { + called = true + AccountIdentity(pubKeyHex = "ddeeff") + } + + assertSame(stored, result) + assertTrue(!called) + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountPreferenceStoresTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountPreferenceStoresTest.kt new file mode 100644 index 0000000000..34ba75fabd --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountPreferenceStoresTest.kt @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.preferences + +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import okio.Path.Companion.toOkioPath +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +class AccountPreferenceStoresTest { + @get:Rule + val folder = TemporaryFolder() + + private var seq = 0 + + /** + * The root is resolved once, outside the lambda: [AccountPreferenceStores] + * calls `rootFilesDir()` on every file() and a lambda that allocated a new + * directory each time would hand out a different path per call. + */ + private fun stores(): AccountPreferenceStores { + val root = File(folder.root, "r${seq++}").apply { mkdirs() } + return AccountPreferenceStores(rootFilesDir = { root.toOkioPath() }) + } + + private val key = stringPreferencesKey("k") + + @Test + fun valuesRoundTripPerAccount() = + runTest { + val subject = stores() + + subject.getDataStore("npub1a").edit { it[key] = "a" } + subject.getDataStore("npub1b").edit { it[key] = "b" } + + assertEquals("a", subject.getDataStore("npub1a").data.first()[key]) + assertEquals("b", subject.getDataStore("npub1b").data.first()[key]) + } + + @Test + fun removingAnAccountDeletesItsFile() = + runTest { + val subject = stores() + subject.getDataStore("npub1a").edit { it[key] = "a" } + + assertTrue(subject.removeAccount("npub1a")) + assertFalse(subject.file("npub1a").toFile().exists()) + } + + /** + * Deleting an account and adding it again in the same session. + * + * DataStore keeps a process-wide registry keyed by file path and only + * releases an entry when its scope ends, so a store that is merely dropped + * from the cache keeps the path claimed — and this throws "multiple + * DataStores active for the same file". + */ + @Test + fun anAccountCanBeAddedAgainAfterRemoval() = + runTest { + val subject = stores() + subject.getDataStore("npub1a").edit { it[key] = "before" } + subject.removeAccount("npub1a") + + val reopened = subject.getDataStore("npub1a") + + assertNull("the old value is gone", reopened.data.first()[key]) + reopened.edit { it[key] = "after" } + assertEquals("after", subject.getDataStore("npub1a").data.first()[key]) + } + + @Test + fun removingAnUnknownAccountReportsNothingDeleted() = + runTest { + assertFalse(stores().removeAccount("npub1missing")) + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountSecretsStoreTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountSecretsStoreTest.kt new file mode 100644 index 0000000000..5685c6c603 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountSecretsStoreTest.kt @@ -0,0 +1,320 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.preferences + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.test.runTest +import okio.Path.Companion.toOkioPath +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +/** + * The per-account secrets group: NIP-46 bunker material and wallet strings. + * + * These are encrypted at rest and reached through string keys, so a booleans + * and a set have to survive an encode/decode they did not before — that is + * what most of this covers. + */ +class AccountSecretsStoreTest { + @get:Rule + val folder = TemporaryFolder() + + private var seq = 0 + + private fun stores(): AccountSecretsEncryptedStores { + val root = File(folder.root, "acct_${seq++}").apply { mkdirs() } + return AccountSecretsEncryptedStores( + rootFilesDir = { root.toOkioPath() }, + scope = CoroutineScope(Dispatchers.IO + SupervisorJob()), + encryption = SecretEncryption(File(root, "secret.key")), + ) + } + + private val npub = "npub1abc" + + private val filled = + AccountSecrets( + nip46SignerEnabled = true, + nip46BunkerSecret = "bunker-secret", + nip46TransportKey = "transport-key", + nip46SeenRequestIds = setOf("aa11", "bb22", "cc33"), + nwcWalletsJson = """[{"uri":"nostr+walletconnect://x"}]""", + clinkDebitWalletsJson = """[{"id":"debit1"}]""", + defaultPaymentSourceId = "source-1", + ) + + /** + * Absent must read as null, not as a default-filled group: the caller uses + * null to decide whether to run the one-off copy out of the legacy file. + */ + @Test + fun anUnmigratedAccountReadsAsNull() = + runTest { + assertNull(stores().loadSecrets(npub)) + } + + @Test + fun theGroupRoundTrips() = + runTest { + val subject = stores() + + subject.saveSecrets(npub, filled) + + assertEquals(filled, subject.loadSecrets(npub)) + } + + /** An account that genuinely holds nothing must still read as migrated, not as null forever. */ + @Test + fun anEmptyGroupStillCountsAsMigrated() = + runTest { + val subject = stores() + + subject.saveSecrets(npub, AccountSecrets()) + + assertEquals(AccountSecrets(), subject.loadSecrets(npub)) + } + + /** The boolean and the set go through a string encoding that did not exist before. */ + @Test + fun theBooleanAndSetSurviveEncoding() = + runTest { + val subject = stores() + + subject.saveSecrets(npub, AccountSecrets(nip46SignerEnabled = true, nip46SeenRequestIds = setOf("a", "b"))) + val loaded = subject.loadSecrets(npub)!! + + assertTrue(loaded.nip46SignerEnabled) + assertEquals(setOf("a", "b"), loaded.nip46SeenRequestIds) + } + + @Test + fun anEmptySetDoesNotBecomeASetHoldingAnEmptyString() = + runTest { + val subject = stores() + + subject.saveSecrets(npub, AccountSecrets(nip46SeenRequestIds = emptySet())) + + assertTrue(subject.loadSecrets(npub)!!.nip46SeenRequestIds.isEmpty()) + } + + /** Clearing a wallet must remove it, not leave the previous JSON behind. */ + @Test + fun aClearedWalletIsRemoved() = + runTest { + val subject = stores() + subject.saveSecrets(npub, filled) + + subject.saveSecrets(npub, filled.copy(nwcWalletsJson = null, defaultPaymentSourceId = null)) + val loaded = subject.loadSecrets(npub)!! + + assertNull(loaded.nwcWalletsJson) + assertNull(loaded.defaultPaymentSourceId) + assertEquals("siblings untouched", filled.clinkDebitWalletsJson, loaded.clinkDebitWalletsJson) + } + + @Test + fun accountsAreIsolated() = + runTest { + val subject = stores() + + subject.saveSecrets("npub1aaa", filled) + + assertNull(subject.loadSecrets("npub1bbb")) + } + + /** The values must not be sitting in the store file in the clear. */ + @Test + fun secretsAreNotStoredInPlaintext() = + runTest { + val root = File(folder.root, "plain").apply { mkdirs() } + val subject = + AccountSecretsEncryptedStores( + rootFilesDir = { root.toOkioPath() }, + scope = CoroutineScope(Dispatchers.IO + SupervisorJob()), + encryption = SecretEncryption(File(root, "secret.key")), + ) + + subject.saveSecrets(npub, filled) + + val onDisk = + subject + .file(npub) + .toFile() + .readBytes() + .decodeToString() + assertFalse("the bunker secret must not be readable", onDisk.contains("bunker-secret")) + assertFalse("the wallet string must not be readable", onDisk.contains("nostr+walletconnect")) + } + + @Test + fun removingAnAccountDropsItsSecrets() = + runTest { + val subject = stores() + subject.saveSecrets(npub, filled) + assertNotNull(subject.loadSecrets(npub)) + + subject.removeAccount(npub) + + assertNull(subject.loadSecrets(npub)) + } + + /** + * Delete an account, then add the same npub back. + * + * DataStore keeps a process-wide registry keyed by file path and releases + * an entry only when the owning scope's job *completes* — cancelling it is + * just the request. [AccountSecretsEncryptedStores.removeAccount] waits for + * that, and without the wait this throws "multiple DataStores active for + * the same file" whenever the next open wins the race, which on a loaded + * machine it does. + */ + @Test + fun anAccountCanBeAddedBackAfterBeingRemoved() = + runTest { + val subject = stores() + subject.saveSecrets(npub, filled) + subject.removeAccount(npub) + + subject.saveSecrets(npub, filled) + + assertEquals(filled, subject.loadSecrets(npub)) + } + // ── the location-chat identity ──────────────────────────────────── + + @Test + fun anUnmigratedGeohashIdentityReadsAsNull() = + runTest { + assertNull(stores().loadGeohashIdentity(npub)) + } + + @Test + fun theGeohashIdentityRoundTrips() = + runTest { + val subject = stores() + val value = GeohashIdentitySecrets(deviceSeed = "a".repeat(64), nickname = "vitor") + + subject.saveGeohashIdentity(npub, value) + + assertEquals(value, subject.loadGeohashIdentity(npub)) + } + + /** + * An account that opened a location chat under a bunker signer has a seed but + * never set a handle. Absent must come back absent rather than as "". + */ + @Test + fun aSeedWithNoNicknameRoundTrips() = + runTest { + val subject = stores() + val value = GeohashIdentitySecrets(deviceSeed = "b".repeat(64), nickname = null) + + subject.saveGeohashIdentity(npub, value) + + assertEquals(value, subject.loadGeohashIdentity(npub)) + } + + /** An account that holds neither key still counts as migrated, or the copy runs forever. */ + @Test + fun anEmptyGeohashIdentityStillCountsAsMigrated() = + runTest { + val subject = stores() + + subject.saveGeohashIdentity(npub, GeohashIdentitySecrets()) + + assertEquals(GeohashIdentitySecrets(), subject.loadGeohashIdentity(npub)) + } + + /** + * The two groups migrate out of *different* legacy files — the secrets from + * `secret_keeper_<npub>`, the identity from `secret_keeper_<pubkey hex>` — + * so neither marker may stand in for the other. Saving one must leave the + * other reading as not-yet-copied. + */ + @Test + fun theTwoGroupsMigrateIndependently() = + runTest { + val subject = stores() + + subject.saveSecrets(npub, filled) + + assertNull("saving the secrets must not mark the identity migrated", subject.loadGeohashIdentity(npub)) + } + + @Test + fun savingTheIdentityDoesNotMarkTheSecretsMigrated() = + runTest { + val subject = stores() + + subject.saveGeohashIdentity(npub, GeohashIdentitySecrets(deviceSeed = "c".repeat(64))) + + assertNull(subject.loadSecrets(npub)) + } + + /** + * The seed must survive an unrelated account save. This is the whole reason + * the identity is its own group: every save mirrors a full AccountSecrets + * built from AccountSettings, which does not hold the seed, and the group + * save removes keys whose value is null. Folded into that group, the seed + * would be deleted here — and every geohash identity the user has would + * silently change. + */ + @Test + fun anAccountSaveLeavesTheGeohashIdentityAlone() = + runTest { + val subject = stores() + val identity = GeohashIdentitySecrets(deviceSeed = "d".repeat(64), nickname = "vitor") + subject.saveGeohashIdentity(npub, identity) + + subject.saveSecrets(npub, filled) + + assertEquals(identity, subject.loadGeohashIdentity(npub)) + } + + /** + * The copy has to be idempotent, because the loader now runs it on every + * account load rather than only when a location chat is opened. A second + * run must not overwrite what the user has changed since the first. + */ + @Test + fun recopyingDoesNotClobberALaterEdit() = + runTest { + val subject = stores() + subject.saveGeohashIdentity(npub, GeohashIdentitySecrets(deviceSeed = "a".repeat(64), nickname = "old")) + + // What a fresh load would find in the legacy file: the pre-migration value. + val stored = subject.loadGeohashIdentity(npub) + assertEquals("old", stored?.nickname) + + subject.saveGeohashIdentity(npub, GeohashIdentitySecrets(deviceSeed = "a".repeat(64), nickname = "new")) + + assertEquals("new", subject.loadGeohashIdentity(npub)?.nickname) + assertEquals("a".repeat(64), subject.loadGeohashIdentity(npub)?.deviceSeed) + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountSettingStoresTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountSettingStoresTest.kt new file mode 100644 index 0000000000..e6530895a7 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AccountSettingStoresTest.kt @@ -0,0 +1,193 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.preferences + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.test.runTest +import okio.Path.Companion.toOkioPath +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +/** + * The five per-account setting groups. + * + * The defaults are the point: they must match what the SharedPreferences + * implementation returned for a missing key, because an account that never + * touched a setting has no stored value and gets the default forever after. + * Several are `true`, so a group that defaulted everything to `false` would + * silently turn features off for every existing user. + */ +class AccountSettingStoresTest { + @get:Rule + val folder = TemporaryFolder() + + private var seq = 0 + + private fun raw(): DataStore<Preferences> { + val file = File(folder.root, "group_${seq++}.preferences_pb") + return PreferenceDataStoreFactory.createWithPath( + scope = CoroutineScope(Dispatchers.IO + SupervisorJob()), + produceFile = { file.toOkioPath() }, + ) + } + + @Test + fun uploadSettingsDefaultsMatchTheLegacyOnes() = + runTest { + val loaded = UploadSettingsStore(raw()).load() + + assertEquals(true, loaded.stripLocationOnUpload) + assertEquals(false, loaded.optimizeMediaOnUpload) + assertEquals(true, loaded.mirrorUploadsToAllServers) + assertEquals(true, loaded.useLocalBlossomCache) + assertEquals(false, loaded.localBlossomCacheProfilePicturesOnly) + assertNull(loaded.defaultFileServerJson) + } + + @Test + fun uploadSettingsRoundTrip() = + runTest { + val store = UploadSettingsStore(raw()) + val value = + UploadSettings( + stripLocationOnUpload = false, + optimizeMediaOnUpload = true, + mirrorUploadsToAllServers = false, + useLocalBlossomCache = false, + localBlossomCacheProfilePicturesOnly = true, + defaultFileServerJson = """{"name":"x"}""", + ) + + store.save(value) + + assertEquals(value, store.load()) + } + + @Test + fun dialogDismissalDefaultsToNothingDismissed() = + runTest { + val loaded = DialogDismissalStore(raw()).load() + + assertEquals(false, loaded.hideDeleteRequestDialog) + assertEquals(false, loaded.hideBlockAlertDialog) + assertEquals(false, loaded.hideNip17WarningDialog) + assertEquals(false, loaded.hideCommunityRulesViolations) + assertTrue(loaded.dismissedPollNoteIds.isEmpty()) + assertTrue(loaded.dismissedChannelInvites.isEmpty()) + assertTrue(loaded.mutedPublicChats.isEmpty()) + assertTrue(loaded.hasDonatedInVersion.isEmpty()) + } + + @Test + fun dialogDismissalRoundTripsSets() = + runTest { + val store = DialogDismissalStore(raw()) + val value = + DialogDismissal( + hideDeleteRequestDialog = true, + dismissedPollNoteIds = setOf("a", "b"), + mutedPublicChats = setOf("chat1"), + hasDonatedInVersion = setOf("1.2.3"), + ) + + store.save(value) + + assertEquals(value, store.load()) + } + + /** Three of these default to true — trusting by default — so a wrong default weakens AUTH behaviour. */ + @Test + fun relayAuthDefaults() = + runTest { + val loaded = RelayAuthStore(raw()).load() + + assertNull("absent means CUSTOM, decided by the caller", loaded.policyName) + assertEquals(true, loaded.trustMyRelays) + assertEquals(true, loaded.trustReadFollows) + assertEquals(true, loaded.trustMessageFollows) + assertEquals(false, loaded.trustMessageStrangers) + } + + @Test + fun relayAuthRoundTrip() = + runTest { + val store = RelayAuthStore(raw()) + val value = RelayAuth("ALWAYS", trustMyRelays = false, trustMessageStrangers = true) + + store.save(value) + + assertEquals(value, store.load()) + } + + /** The disabled-feed keys store what is OFF, so absent must mean everything on. */ + @Test + fun feedVisibilityDefaultsToEverythingEnabled() = + runTest { + val loaded = FeedVisibilityStore(raw()).load() + + assertNull(loaded.disabledChatFeeds) + assertNull(loaded.disabledHomeFeedTypes) + assertEquals(true, loaded.callsEnabled) + } + + @Test + fun notificationPrefsDefaults() = + runTest { + val loaded = NotificationPrefsStore(raw()).load() + + assertEquals(false, loaded.alwaysOnService) + assertEquals(true, loaded.showMessagesInNotifications) + assertEquals(false, loaded.splitNotificationsEnabled) + } + + /** A null string field must clear its key rather than leave the old value behind. */ + @Test + fun aNullStringFieldClearsTheKey() = + runTest { + val store = FeedVisibilityStore(raw()) + + store.save(FeedVisibility(disabledChatFeeds = "a,b")) + store.save(FeedVisibility(disabledChatFeeds = null)) + + assertNull(store.load().disabledChatFeeds) + } + + /** Key strings are a compatibility surface — renaming one resets that setting for everyone. */ + @Test + fun keyNamesMatchTheLegacyOnes() { + assertEquals("stripLocationOnUpload", UploadSettingsStore.stripLocationOnUpload.name) + assertEquals("hide_delete_request_dialog", DialogDismissalStore.hideDeleteRequestDialog.name) + assertEquals("hide_nip24_warning_dialog", DialogDismissalStore.hideNip17WarningDialog.name) + assertEquals("default_relay_auth_policy", RelayAuthStore.policyName.name) + assertEquals("disabled_chat_feeds", FeedVisibilityStore.disabledChatFeeds.name) + assertEquals("always_on_notification_service", NotificationPrefsStore.alwaysOnService.name) + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AppPreferenceStoresTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AppPreferenceStoresTest.kt new file mode 100644 index 0000000000..59dfcefb7a --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/AppPreferenceStoresTest.kt @@ -0,0 +1,182 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.preferences + +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import okio.Path.Companion.toOkioPath +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class AppPreferenceStoresTest { + @get:Rule + val folder = TemporaryFolder() + + private fun stores() = AppPreferenceStores(rootFilesDir = { folder.root.toOkioPath() }) + + /** + * The load-bearing assumption of the whole move. + * + * Android's `Context.preferencesDataStore(name = "x")` resolves to + * `filesDir/datastore/x.preferences_pb`. A store taken off that delegate + * and put on this holder has to land on the same file, or every existing + * install silently starts from empty settings. This pins the shape. + */ + @Test + fun theFilePathMatchesTheAndroidDelegate() { + val root = folder.root.toOkioPath() + + assertEquals(root / "datastore" / "shared_settings.preferences_pb", stores().file("shared_settings")) + assertEquals(root / "datastore" / "favorite_apps.preferences_pb", stores().file("favorite_apps")) + } + + /** DataStore refuses a second live store on one path, so this must dedupe. */ + @Test + fun oneStorePerFile() { + val subject = stores() + + assertSame(subject.getDataStore("shared_settings"), subject.getDataStore("shared_settings")) + assertSame(subject.sharedSettings(), subject.getDataStore("shared_settings")) + } + + @Test + fun differentNamesAreDifferentStores() = + runTest { + val subject = stores() + val key = stringPreferencesKey("k") + + subject.getDataStore("one").edit { it[key] = "first" } + subject.getDataStore("two").edit { it[key] = "second" } + + assertEquals("first", subject.getDataStore("one").data.first()[key]) + assertEquals("second", subject.getDataStore("two").data.first()[key]) + } + + @Test + fun writesLandInTheExpectedFile() = + runTest { + val subject = stores() + subject.sharedSettings().edit { it[stringPreferencesKey("ui.theme")] = "DARK" } + + val expected = java.io.File(folder.root, "datastore/shared_settings.preferences_pb") + assertTrue(expected.absolutePath, expected.exists()) + } + + /** + * Migrations are chosen per file name, and the name reaches the chooser. + * + * Both users of this depend on it: shared_settings takes the UI copy, and + * the Cashu counters take a different migration per account because they + * are one file per npub. A holder that ignored the name, or applied one + * file's migration to another, would copy an account's counters into + * someone else's — and a counter that moves backwards costs real ecash. + */ + @Test + fun migrationsAreChosenPerFileNameAndTheNameIsPassedThrough() = + runTest { + val asked = mutableListOf<String>() + val marker = stringPreferencesKey("from") + + val subject = + AppPreferenceStores( + rootFilesDir = { folder.root.toOkioPath() }, + migrations = { name -> + asked += name + if (name.startsWith("cashu_")) { + listOf(CopyOnceMigration("migrated.$name") { out -> out[marker] = name }) + } else { + emptyList() + } + }, + ) + + assertEquals("cashu_npubA", subject.getDataStore("cashu_npubA").data.first()[marker]) + assertEquals("cashu_npubB", subject.getDataStore("cashu_npubB").data.first()[marker]) + assertNull("a file with no migration must stay untouched", subject.sharedSettings().data.first()[marker]) + + assertTrue("cashu_npubA" in asked && "cashu_npubB" in asked && "shared_settings" in asked) + } + + /** + * Releasing a store lets the same file be opened again. + * + * DataStore's registry is keyed by path and `cancel()` only asks, so this is + * only safe because [AppPreferenceStores.release] joins the scope's job. + * Without the join this test is exactly the "multiple DataStores active for + * the same file" crash. + */ + @Test + fun aReleasedStoreCanBeReopenedAndStillHasItsData() = + runTest { + val key = stringPreferencesKey("k") + val subject = stores() + + subject.getDataStore("reopen").edit { it[key] = "written once" } + + assertTrue("something was open", subject.release("reopen")) + assertFalse("and now nothing is", subject.release("reopen")) + + // a genuinely new instance over the same file + val reopened = subject.getDataStore("reopen") + assertEquals("written once", reopened.data.first()[key]) + } + + /** + * The guard the Cashu and UI copies both rest on, now checked the way it + * actually happens at runtime: the migration runs when the file is first + * opened, and must not run again when a later instance opens the same file. + */ + @Test + fun aMigrationRunsOnceEvenAcrossAReopen() = + runTest { + val marker = stringPreferencesKey("copied") + var runs = 0 + + val subject = + AppPreferenceStores( + rootFilesDir = { folder.root.toOkioPath() }, + migrations = { + listOf( + CopyOnceMigration("migrated.once") { out -> + runs++ + out[marker] = "run $runs" + }, + ) + }, + ) + + assertEquals("run 1", subject.getDataStore("once").data.first()[marker]) + assertEquals(1, runs) + + subject.release("once") + + assertEquals("still the first copy", "run 1", subject.getDataStore("once").data.first()[marker]) + assertEquals("the migration must not run a second time", 1, runs) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/preferences/BuzzAttestationRestoreTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/BuzzAttestationRestoreTest.kt similarity index 81% rename from amethyst/src/test/java/com/vitorpamplona/amethyst/model/preferences/BuzzAttestationRestoreTest.kt rename to commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/BuzzAttestationRestoreTest.kt index a14c8cdac8..77c9c06c99 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/preferences/BuzzAttestationRestoreTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/BuzzAttestationRestoreTest.kt @@ -18,17 +18,17 @@ * 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.preferences +package com.vitorpamplona.amethyst.commons.model.preferences import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Test /** - * The migration precedence in [BuzzAttestationPreferences.restoreFrom]: which of the two on-disk + * The migration precedence in [BuzzAttestationStore.restoreFrom]: which of the two on-disk * shapes wins when the held attestation moved from one device-global list to a per-account key. * - * The store itself needs a `Context`, so the decision is pulled out as a pure function — this is + * The store itself needs a DataStore, so the decision is pulled out as a pure function — this is * the part with the sharp edge, and it is the part the DataStore round-trip cannot express. */ class BuzzAttestationRestoreTest { @@ -46,12 +46,12 @@ class BuzzAttestationRestoreTest { @Test fun nothingSavedAnywhereRestoresNothing() { - assertNull(BuzzAttestationPreferences.restoreFrom(null, null, me)) + assertNull(BuzzAttestationStore.restoreFrom(null, null, me)) } @Test fun thisAccountsOwnKeyWins() { - val restored = BuzzAttestationPreferences.restoreFrom(saved(), legacyList(me), me) + val restored = BuzzAttestationStore.restoreFrom(saved(), legacyList(me), me) assertEquals(owner, restored?.ownerPubKey) } @@ -61,12 +61,12 @@ class BuzzAttestationRestoreTest { // per-account key, which is indistinguishable from "never migrated" — so the next launch // seeded it straight back out of the legacy list, which nothing ever clears. An explicit // tombstone is the only thing that can say "migrated, and holding nothing". - assertNull(BuzzAttestationPreferences.restoreFrom("", legacyList(me), me)) + assertNull(BuzzAttestationStore.restoreFrom("", legacyList(me), me)) } @Test fun aNeverMigratedAccountTakesItsOwnEntryFromTheLegacyList() { - val restored = BuzzAttestationPreferences.restoreFrom(null, legacyList(someoneElse, me), me) + val restored = BuzzAttestationStore.restoreFrom(null, legacyList(someoneElse, me), me) assertEquals(owner, restored?.ownerPubKey) } @@ -74,6 +74,6 @@ class BuzzAttestationRestoreTest { fun anotherAgentsLegacyEntryIsNeverPickedUp() { // The legacy list was already agent-keyed, so the migration is exact rather than // best-effort: there is no shared blob to accidentally inherit. - assertNull(BuzzAttestationPreferences.restoreFrom(null, legacyList(someoneElse), me)) + assertNull(BuzzAttestationStore.restoreFrom(null, legacyList(someoneElse), me)) } } diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/CalendarReminderStoresTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/CalendarReminderStoresTest.kt new file mode 100644 index 0000000000..06a9219e4d --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/CalendarReminderStoresTest.kt @@ -0,0 +1,144 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.preferences + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.longPreferencesKey +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import okio.Path.Companion.toOkioPath +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +/** Ports the coverage the Android CalendarReminderPrefsTest had, onto the DataStore stores. */ +class CalendarReminderStoresTest { + @get:Rule + val folder = TemporaryFolder() + + private var seq = 0 + + private fun raw(): DataStore<Preferences> { + val file = File(folder.root, "cal_${seq++}.preferences_pb") + return PreferenceDataStoreFactory.createWithPath( + scope = CoroutineScope(Dispatchers.IO + SupervisorJob()), + produceFile = { file.toOkioPath() }, + ) + } + + // ── settings ────────────────────────────────────────────────────── + + /** Reminders are ON out of the box; a wrong default here silently stops everyone's reminders. */ + @Test + fun defaultsAreEnabledAtFifteenMinutes() = + runTest { + val loaded = CalendarReminderSettingsStore(raw()).load() + + assertEquals(true, loaded.enabled) + assertEquals(15, loaded.leadMinutes) + } + + @Test + fun settingsRoundTrip() = + runTest { + val store = CalendarReminderSettingsStore(raw()) + + store.setEnabled(false) + store.setLeadMinutes(60) + + assertEquals(CalendarReminderSettings(enabled = false, leadMinutes = 60), store.load()) + } + + /** The choices are anchored to the worker cadence — below 15 min cannot be honoured. */ + @Test + fun leadTimeChoicesAreUnchanged() { + assertEquals(listOf(5, 15, 30, 60), CalendarReminderSettings.LEAD_TIME_CHOICES) + assertTrue(CalendarReminderSettings.DEFAULT_LEAD_MINUTES in CalendarReminderSettings.LEAD_TIME_CHOICES) + } + + // ── log ─────────────────────────────────────────────────────────── + + @Test + fun anUnknownEventWasNotNotified() = + runTest { + assertFalse(CalendarReminderLogStore(raw()).wasNotified("abc", 1_000L)) + } + + @Test + fun markingMakesItNotified() = + runTest { + val store = CalendarReminderLogStore(raw()) + + store.markNotified("abc", 1_000L) + + assertTrue(store.wasNotified("abc", 1_000L)) + } + + /** + * The stored value is the event-start the reminder fired for, not a flag: + * a moved meeting must fire again rather than be silently skipped. + */ + @Test + fun aMovedEventIsNotifiedAgain() = + runTest { + val store = CalendarReminderLogStore(raw()) + store.markNotified("abc", 1_000L) + + assertFalse("the new start time has not been notified", store.wasNotified("abc", 2_000L)) + } + + @Test + fun forgetBeforeDropsOnlyOlderEntries() = + runTest { + val store = CalendarReminderLogStore(raw()) + store.markNotified("old", 100L) + store.markNotified("new", 900L) + + store.forgetBefore(500L) + + assertFalse(store.wasNotified("old", 100L)) + assertTrue(store.wasNotified("new", 900L)) + } + + /** forgetBefore must only prune its own keys, not anything else sharing the store. */ + @Test + fun forgetBeforeLeavesForeignKeysAlone() = + runTest { + val foreignKey = longPreferencesKey("someone_elses_counter") + val raw = raw() + val log = CalendarReminderLogStore(raw) + log.markNotified("old", 100L) + raw.updateData { prefs -> prefs.toMutablePreferences().apply { this[foreignKey] = 1L } } + + log.forgetBefore(500L) + + assertEquals(1L, raw.data.first()[foreignKey]) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/preferences/DrawerSectionCollapsePreferencesTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/DrawerSectionCollapsePreferencesTest.kt similarity index 92% rename from amethyst/src/test/java/com/vitorpamplona/amethyst/model/preferences/DrawerSectionCollapsePreferencesTest.kt rename to commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/DrawerSectionCollapsePreferencesTest.kt index d7c4aa0d4f..83bfc0a5b9 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/preferences/DrawerSectionCollapsePreferencesTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/DrawerSectionCollapsePreferencesTest.kt @@ -18,12 +18,12 @@ * 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.preferences +package com.vitorpamplona.amethyst.commons.model.preferences import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.PreferenceDataStoreFactory import androidx.datastore.preferences.core.Preferences -import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerSectionId +import com.vitorpamplona.amethyst.commons.model.navigation.DrawerSectionId import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.cancel @@ -59,7 +59,11 @@ class DrawerSectionCollapsePreferencesTest { advanceUntilIdle() prefs.taps() advanceUntilIdle() + // On the test dispatcher advanceUntilIdle is the join: cancel() only + // asks, and the next session() opens the same file, which DataStore + // refuses while the previous instance is still winding down. scope.cancel() + advanceUntilIdle() return prefs.flow.value } diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/EncryptedDataStoreTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/EncryptedDataStoreTest.kt new file mode 100644 index 0000000000..07b4a055f4 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/EncryptedDataStoreTest.kt @@ -0,0 +1,300 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.preferences + +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.job +import kotlinx.coroutines.test.runTest +import okio.Path.Companion.toOkioPath +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +/** + * Round-trip coverage for the encrypted store. + * + * The store shipped with `decrypt` reading + * `encryption.decrypt(...).contentToString()`, which renders a ByteArray as + * `"[104, 101, 108]"` instead of decoding it, so every value read back was the + * debug rendering of its own bytes. Nothing caught it because the class had no + * callers. [saveAndGetRoundTrips] is the test that fails against that version. + */ +class EncryptedDataStoreTest { + @get:Rule + val folder = TemporaryFolder() + + private val key = stringPreferencesKey("nwc") + + private var seq = 0 + + /** + * A store over its own pair of fresh paths. + * + * The files are named, never created: DataStore writes them itself, and an + * empty file left behind by `newFile()` is not a valid preferences_pb. The + * path is resolved once and captured, because `produceFile` may be invoked + * more than once and must answer the same file every time. + */ + private fun store(scope: CoroutineScope): EncryptedDataStore { + val n = seq++ + val dataFile = File(folder.root, "secrets_$n.preferences_pb") + val keyFile = File(folder.root, "secret_$n.key") + return EncryptedDataStore( + PreferenceDataStoreFactory.createWithPath(scope = scope, produceFile = { dataFile.toOkioPath() }), + SecretEncryption(keyFile), + scope = scope, + ) + } + + @Test + fun saveAndGetRoundTrips() = + runTest { + val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + val subject = store(scope) + val nsec = "e5e2b1d3f6a94c8d7b0e1f2a3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d" + + subject.save(key, nsec) + + assertEquals(nsec, subject.get(key)) + } + + @Test + fun missingKeyReadsBackAsNull() = + runTest { + val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + assertNull(store(scope).get(key)) + } + + @Test + fun valuesAreNotStoredInPlaintext() = + runTest { + val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + val file = File(folder.root, "plaintext_check.preferences_pb") + val subject = + EncryptedDataStore( + PreferenceDataStoreFactory.createWithPath(scope = scope, produceFile = { file.toOkioPath() }), + SecretEncryption(File(folder.root, "plaintext_check.key")), + scope = scope, + ) + val secret = "correct-horse-battery-staple" + + subject.save(key, secret) + + val onDisk = file.readBytes().decodeToString() + assertEquals("the secret must not be readable in the store file", false, onDisk.contains(secret)) + } + + @Test + fun overwriteReplacesTheValue() = + runTest { + val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + val subject = store(scope) + + subject.save(key, "first") + subject.save(key, "second") + + assertEquals("second", subject.get(key)) + } + + @Test + fun removeClearsTheValue() = + runTest { + val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + val subject = store(scope) + + subject.save(key, "value") + subject.remove(key) + + assertNull(subject.get(key)) + } + + /** + * [EncryptedDataStore.get] flattens a read failure into null; + * [EncryptedDataStore.getOrThrow] does not. + * + * The difference guards a live key: a probe that decides whether to create + * one must not read "absent" from a store it merely failed to open, or it + * overwrites what is already there. + */ + @Test + fun getSwallowsAReadFailureButGetOrThrowDoesNot() = + runTest { + val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + val n = seq++ + val dataFile = File(folder.root, "corrupt_$n.preferences_pb") + val keyFile = File(folder.root, "corrupt_$n.key") + val subject = + EncryptedDataStore( + PreferenceDataStoreFactory.createWithPath(scope = scope, produceFile = { dataFile.toOkioPath() }), + SecretEncryption(keyFile), + scope = scope, + ) + subject.save(key, "a real value") + scope.cancel() + scope.coroutineContext.job.join() + + // Truncate the store so opening it fails rather than reading empty. + dataFile.writeBytes(byteArrayOf(0x01, 0x02, 0x03)) + + val readScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + val reopened = + EncryptedDataStore( + PreferenceDataStoreFactory.createWithPath(scope = readScope, produceFile = { dataFile.toOkioPath() }), + SecretEncryption(keyFile), + scope = readScope, + ) + + assertNull("get() reports the unreadable store as absent", reopened.get(key)) + assertTrue( + "getOrThrow() must not call it absent", + runCatching { reopened.getOrThrow(key) }.isFailure, + ) + readScope.cancel() + } + + /** + * A stock DataMigration writes values as-is, but this store decrypts on + * read — so plaintext put there by one cannot survive the trip, and the + * read raises rather than returning something wrong. + * + * This is why secrets migrate lazily, through [save], instead of through a + * DataMigration the way the plain preference stores do. Getting it wrong + * would leave an account's wallet strings unreadable rather than obviously + * missing. + */ + @Test + fun aRawMigrationIntoAnEncryptedStoreIsNotReadable() = + runTest { + val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + val n = seq++ + val dataFile = File(folder.root, "rawmig_$n.preferences_pb") + val keyFile = File(folder.root, "rawmig_$n.key") + val subject = + EncryptedDataStore( + PreferenceDataStoreFactory.createWithPath( + scope = scope, + migrations = listOf(CopyOnceMigration("probe") { out -> out[key] = "plaintext-secret" }), + produceFile = { dataFile.toOkioPath() }, + ), + SecretEncryption(keyFile), + scope = scope, + ) + + assertTrue( + "a raw-migrated value must not read back as if it were valid", + runCatching { subject.get(key) }.isFailure, + ) + scope.cancel() + } + + @Test + fun editWritesEveryKeyInOneGo() = + runTest { + val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + val subject = store(scope) + val other = stringPreferencesKey("bunker") + + subject.edit { + put(key, "wallet") + put(other, "secret") + } + + val snapshot = subject.snapshot() + assertEquals("wallet", snapshot[key]) + assertEquals("secret", snapshot[other]) + } + + /** + * The whole point of writing a group in one edit: a marker written beside + * its values cannot be found on disk without them. + */ + @Test + fun editIsOneTransaction() = + runTest { + val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + val subject = store(scope) + val marker = stringPreferencesKey("migrated") + + runCatching { + subject.edit { + put(key, "wallet") + put(marker, "true") + throw IllegalStateException("crash midway") + } + } + + val snapshot = subject.snapshot() + assertNull(snapshot[marker]) + assertNull(snapshot[key]) + } + + @Test + fun putOrRemoveClearsANullValue() = + runTest { + val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + val subject = store(scope) + + subject.edit { put(key, "wallet") } + subject.edit { putOrRemove(key, null) } + + assertNull(subject.snapshot()[key]) + } + + /** + * `contains` must not decrypt. + * + * A value the current key cannot decrypt — a rotated or wiped keystore — is + * still a value that is there, and deleting it has to happen anyway. + * `deletePrivateKey` gated its removal on a decrypting read and so skipped + * exactly the case that needed it, leaving a deleted account's private key + * on disk. + */ + @Test + fun containsSeesAValueThatCannotBeDecrypted() = + runTest { + val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + val n = seq++ + val dataFile = File(folder.root, "secrets_$n.preferences_pb") + val raw = + PreferenceDataStoreFactory.createWithPath(scope = scope, produceFile = { dataFile.toOkioPath() }) + // Ciphertext this store's key was never used to produce. + raw.edit { prefs -> prefs[key] = "bm90LWFjdHVhbGx5LWNpcGhlcnRleHQ=" } + + val subject = + EncryptedDataStore(raw, SecretEncryption(File(folder.root, "secret_$n.key")), scope = scope) + + assertTrue(subject.contains(key)) + assertNull(runCatching { subject.get(key) }.getOrNull()) + + subject.remove(key) + assertTrue(!subject.contains(key)) + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/LatestEventCacheStoreTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/LatestEventCacheStoreTest.kt new file mode 100644 index 0000000000..32d0003c47 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/LatestEventCacheStoreTest.kt @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.preferences + +import androidx.datastore.core.DataMigration +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.test.runTest +import okio.Path.Companion.toOkioPath +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +class LatestEventCacheStoreTest { + @get:Rule + val folder = TemporaryFolder() + + private var seq = 0 + + private fun store(migrations: List<DataMigration<Preferences>> = emptyList()): LatestEventCacheStore { + val file = File(folder.root, "cache_${seq++}.preferences_pb") + return LatestEventCacheStore( + PreferenceDataStoreFactory.createWithPath( + scope = CoroutineScope(Dispatchers.IO + SupervisorJob()), + migrations = migrations, + produceFile = { file.toOkioPath() }, + ), + ) + } + + /** An absent slot means nothing was cached — not an empty string that would fail to parse. */ + @Test + fun anEmptyStoreReturnsNoSlots() = + runTest { + assertTrue(store().load().isEmpty()) + } + + @Test + fun savedJsonReadsBackVerbatim() = + runTest { + val subject = store() + val json = """{"id":"abc","kind":0,"content":"{}"}""" + + subject.saveAll(mapOf(LatestEventSlot.USER_METADATA to json)) + + assertEquals(json, subject.load()[LatestEventSlot.USER_METADATA]) + } + + /** + * A null must remove the key. Leaving the previous value behind would keep + * serving an event the account no longer has. + */ + @Test + fun aNullValueRemovesTheSlot() = + runTest { + val subject = store() + subject.saveAll(mapOf(LatestEventSlot.MUTE_LIST to """{"kind":10000}""")) + + subject.saveAll(mapOf(LatestEventSlot.MUTE_LIST to null)) + + assertFalse(subject.load().containsKey(LatestEventSlot.MUTE_LIST)) + } + + @Test + fun slotsAreIndependent() = + runTest { + val subject = store() + + subject.saveAll( + mapOf( + LatestEventSlot.CONTACT_LIST to """{"kind":3}""", + LatestEventSlot.MUTE_LIST to null, + ), + ) + + assertEquals("""{"kind":3}""", subject.load()[LatestEventSlot.CONTACT_LIST]) + assertFalse(subject.load().containsKey(LatestEventSlot.MUTE_LIST)) + } + + @Test + fun prefKeysMatchWhatTheAppHasAlwaysWritten() { + assertEquals("latestUserMetadata", LatestEventSlot.USER_METADATA.prefKey) + assertEquals("latestContactList", LatestEventSlot.CONTACT_LIST.prefKey) + assertEquals("latestNIP65RelayList", LatestEventSlot.NIP65_RELAY_LIST.prefKey) + assertEquals(26, LatestEventSlot.entries.size) + assertEquals( + "prefKeys must be unique", + 26, + LatestEventSlot.entries + .map { it.prefKey } + .toSet() + .size, + ) + } + + @Test + fun migrationCopiesTheLegacyCache() = + runTest { + val legacy = mapOf(Pair(LatestEventSlot.USER_METADATA.key, """{"kind":0}""")) + + val loaded = store(listOf(CopyOnceMigration("migrated.latestEvents") { out -> legacy.forEach { (k, v) -> out[k] = v } })).load() + + assertEquals("""{"kind":0}""", loaded[LatestEventSlot.USER_METADATA]) + assertFalse("slots absent from the legacy store stay absent", loaded.containsKey(LatestEventSlot.MUTE_LIST)) + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/LegacyKeyTableTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/LegacyKeyTableTest.kt new file mode 100644 index 0000000000..8b5787bc83 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/LegacyKeyTableTest.kt @@ -0,0 +1,232 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.preferences + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.core.stringSetPreferencesKey +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.job +import kotlinx.coroutines.test.runTest +import okio.Path.Companion.toOkioPath +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +/** A [LegacyPreferenceSource] over a map, standing in for the encrypted file. */ +class FakeLegacySource( + private val values: Map<String, Any>, +) : LegacyPreferenceSource { + var opened = 0 + private set + + init { + opened++ + } + + override fun keys() = values.keys + + override fun getBoolean(name: String) = values[name] as Boolean? + + override fun getString(name: String) = values[name] as String? + + @Suppress("UNCHECKED_CAST") + override fun getStringSet(name: String) = values[name] as Set<String>? +} + +class LegacyKeyTableTest { + @get:Rule + val folder = TemporaryFolder() + + private val flag = booleanPreferencesKey("flag") + private val text = stringPreferencesKey("text") + private val bag = stringSetPreferencesKey("bag") + + private val table = + LegacyKeyTable( + "migrated.test", + listOf( + LegacyBooleanKey("legacy_flag", flag), + LegacyStringKey("legacy_text", text), + LegacyStringSetKey("legacy_bag", bag), + ), + ) + + private var seq = 0 + + private fun store( + source: () -> LegacyPreferenceSource, + name: String = "t_${seq++}", + ): DataStore<Preferences> { + val file = File(folder.root, "$name.preferences_pb") + return PreferenceDataStoreFactory.createWithPath( + scope = CoroutineScope(Dispatchers.IO + SupervisorJob()), + migrations = listOf(table.migration(source)), + produceFile = { file.toOkioPath() }, + ) + } + + @Test + fun copiesEveryTypeAcrossTheRename() = + runTest { + val legacy = + FakeLegacySource( + mapOf( + "legacy_flag" to true, + "legacy_text" to "hello", + "legacy_bag" to setOf("a", "b"), + ), + ) + + val prefs = store({ legacy }).data.first() + + assertEquals(true, prefs[flag]) + assertEquals("hello", prefs[text]) + assertEquals(setOf("a", "b"), prefs[bag]) + } + + /** + * The distinction the whole copy rests on: a key the user never set must + * stay absent, so it keeps reading as unset and falls back to its own + * default. Writing `false` here would turn off features whose default is on. + */ + @Test + fun anAbsentLegacyKeyStaysAbsent() = + runTest { + val prefs = store({ FakeLegacySource(mapOf("legacy_text" to "only me")) }).data.first() + + assertEquals("only me", prefs[text]) + assertNull(prefs[flag]) + assertNull(prefs[bag]) + } + + @Test + fun hasRunReportsTheMarker() = + runTest { + assertFalse(table.hasRun(emptyPreferences())) + + val prefs = store({ FakeLegacySource(emptyMap()) }).data.first() + + assertTrue(table.hasRun(prefs)) + } + + /** + * Decrypting an `EncryptedSharedPreferences` is not free, so the legacy + * file must not be opened on launches where the copy has already run. + */ + @Test + fun theLegacyFileIsNotOpenedOnceTheCopyHasRun() = + runTest { + var opens = 0 + val open = { + opens++ + FakeLegacySource(mapOf("legacy_text" to "x")) as LegacyPreferenceSource + } + val file = File(folder.root, "reopen.preferences_pb") + + // DataStore registers a live store per file path and only releases + // it when the owning scope ends, so each "launch" gets its own + // scope and gives it back. + fun open(scope: CoroutineScope) = + PreferenceDataStoreFactory.createWithPath( + scope = scope, + migrations = listOf(table.migration(open)), + produceFile = { file.toOkioPath() }, + ) + + val first = CoroutineScope(Dispatchers.IO + SupervisorJob()) + assertEquals("x", open(first).data.first()[text]) + assertEquals(1, opens) + first.cancel() + first.coroutineContext.job.join() + + val second = CoroutineScope(Dispatchers.IO + SupervisorJob()) + assertEquals("x", open(second).data.first()[text]) + assertEquals(1, opens) + second.cancel() + } + + /** + * The legacy names are what the Android app has written since its first + * release. A rename here resets that setting for everyone who had it, so + * the list is pinned rather than regenerated. + */ + @Test + fun theShippedTablesCoverTheirKeys() { + assertEquals( + setOf( + "stripLocationOnUpload", + "optimizeMediaOnUpload", + "mirrorUploadsToAllServers", + "useLocalBlossomCache", + "localBlossomCacheProfilePicturesOnly", + "defaultFileServer", + ), + UploadSettingsStore.legacyTable.legacyNames, + ) + + assertEquals( + setOf( + "nostr_pubkey", + "login_with_external_signer", + "signer_package_name", + "localRelayServers", + "openBackupConflicts", + "has_backed_up_keys", + ), + AccountIdentityStore.legacyTable.legacyNames, + ) + + assertEquals(FollowListSlot.entries.size, TopNavFollowListStore.legacyTable.keys.size) + assertEquals(LatestEventSlot.entries.size, LatestEventCacheStore.legacyTable.keys.size) + } + + /** Several tables share one store, so their markers must not collide. */ + @Test + fun everyShippedMarkerIsDistinct() { + val markers = + listOf( + TopNavFollowListStore.legacyTable, + LatestEventCacheStore.legacyTable, + UploadSettingsStore.legacyTable, + DialogDismissalStore.legacyTable, + RelayAuthStore.legacyTable, + FeedVisibilityStore.legacyTable, + NotificationPrefsStore.legacyTable, + AccountIdentityStore.legacyTable, + ).map { it.markerName } + + assertEquals(markers.size, markers.toSet().size) + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/SecretEncryptionTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/SecretEncryptionTest.kt new file mode 100644 index 0000000000..65a93958ec --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/SecretEncryptionTest.kt @@ -0,0 +1,127 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.preferences + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +import java.nio.file.FileSystems +import java.nio.file.Files +import java.nio.file.attribute.PosixFilePermission + +class SecretEncryptionTest { + @get:Rule + val folder = TemporaryFolder() + + private fun subject(name: String = "secret.key") = SecretEncryption(File(folder.root, name)) + + @Test + fun roundTripsBytes() { + val encryption = subject() + val plaintext = "an nsec, a wallet string, a group state".encodeToByteArray() + + assertArrayEquals(plaintext, encryption.decrypt(encryption.encrypt(plaintext))) + } + + @Test + fun roundTripsEmptyInput() { + val encryption = subject() + + assertArrayEquals(ByteArray(0), encryption.decrypt(encryption.encrypt(ByteArray(0)))) + } + + @Test + fun ciphertextDiffersFromPlaintext() { + val plaintext = "correct-horse-battery-staple".encodeToByteArray() + + val ciphertext = subject().encrypt(plaintext) + + assertFalse(ciphertext.decodeToString().contains("correct-horse")) + } + + /** GCM must never reuse an IV under the same key, so the same input encrypts differently each time. */ + @Test + fun encryptingTwiceProducesDifferentCiphertext() { + val encryption = subject() + val plaintext = "same input".encodeToByteArray() + + assertNotEquals( + encryption.encrypt(plaintext).toList(), + encryption.encrypt(plaintext).toList(), + ) + } + + /** A second instance over the same key file must read the first one's output. */ + @Test + fun keyPersistsAcrossInstances() { + val plaintext = "survives a restart".encodeToByteArray() + val ciphertext = subject().encrypt(plaintext) + + assertArrayEquals(plaintext, subject().decrypt(ciphertext)) + } + + /** A different key file must not decrypt — otherwise the key is not doing anything. */ + @Test + fun aDifferentKeyCannotDecrypt() { + val ciphertext = subject("first.key").encrypt("secret".encodeToByteArray()) + + assertThrows(Exception::class.java) { subject("second.key").decrypt(ciphertext) } + } + + private fun isPosix() = FileSystems.getDefault().supportedFileAttributeViews().contains("posix") + + @Test + fun keyFileIsOwnerOnly() { + // Windows has no POSIX permissions, and `restrictToOwner` is documented + // as a silent no-op there — the user profile's NTFS ACLs apply instead. + // Asking for them anyway raises UnsupportedOperationException, which is + // how this failed on the Windows CI runner while passing everywhere else. + if (!isPosix()) return + + subject().encrypt("x".encodeToByteArray()) + + val perms = Files.getPosixFilePermissions(File(folder.root, "secret.key").toPath()) + assertEquals(setOf(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE), perms) + } + + /** + * A wrong-sized key file means data already on disk was written under a key + * we no longer have. Overwriting it would strand that data silently. + */ + @Test + fun refusesToOverwriteAMalformedKeyFile() { + val keyFile = File(folder.root, "truncated.key") + keyFile.writeBytes(ByteArray(7)) + + val error = + assertThrows(IllegalStateException::class.java) { + SecretEncryption(keyFile).encrypt("x".encodeToByteArray()) + } + assertTrue(error.message!!.contains("Refusing to overwrite")) + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/TopNavFollowListStoreTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/TopNavFollowListStoreTest.kt new file mode 100644 index 0000000000..f0fa524877 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/TopNavFollowListStoreTest.kt @@ -0,0 +1,229 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.preferences + +import androidx.datastore.core.DataMigration +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter +import com.vitorpamplona.quartz.nip01Core.core.JsonMapper +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.job +import kotlinx.coroutines.test.runTest +import okio.Path.Companion.toOkioPath +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +class TopNavFollowListStoreTest { + @get:Rule + val folder = TemporaryFolder() + + private var seq = 0 + + private fun store( + scope: CoroutineScope, + migrations: List<DataMigration<Preferences>> = emptyList(), + ): TopNavFollowListStore { + val file = File(folder.root, "acct_${seq++}.preferences_pb") + return TopNavFollowListStore( + PreferenceDataStoreFactory.createWithPath( + scope = scope, + migrations = migrations, + produceFile = { file.toOkioPath() }, + ), + ) + } + + private fun scope() = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + /** + * Encodes as the app does. + * + * The parameter type matters: kotlinx serializes by STATIC type, so + * `encode(TopFilter.Global)` sees the concrete object and writes + * `{}` with no polymorphic discriminator, which cannot be read back as a + * TopFilter. Widening to TopFilter here is what the production call sites do + * by reading a `StateFlow<TopFilter>.value`. + */ + private fun encode(filter: TopFilter): String = JsonMapper.toJson(filter) + + /** An empty store must hand back each slot's documented default, not null and not a blanket Global. */ + @Test + fun emptyStoreReturnsEachSlotsOwnDefault() = + runTest { + val loaded = store(scope()).load() + + assertEquals(FollowListSlot.entries.size, loaded.size) + FollowListSlot.entries.forEach { slot -> + assertEquals("default for ${slot.prefKey}", slot.default, loaded.getValue(slot)) + } + } + + /** The defaults are not uniform — a regression that collapsed them would pass the test above. */ + @Test + fun defaultsAreNotAllTheSame() { + val defaults = FollowListSlot.entries.map { it.default }.toSet() + + assertTrue("expected several distinct defaults, got $defaults", defaults.size >= 4) + assertEquals(TopFilter.AllFollows, FollowListSlot.HOME.default) + assertEquals(TopFilter.Selected, FollowListSlot.NOTIFICATION.default) + assertEquals(TopFilter.AroundMe, FollowListSlot.GEOCACHES.default) + assertEquals(TopFilter.Mine, FollowListSlot.BADGES.default) + } + + @Test + fun savedValuesReadBack() = + runTest { + val subject = store(scope()) + + subject.save(FollowListSlot.HOME, TopFilter.Global) + + assertEquals(TopFilter.Global, subject.load().getValue(FollowListSlot.HOME)) + assertEquals("other slots keep their defaults", TopFilter.Selected, subject.load().getValue(FollowListSlot.NOTIFICATION)) + } + + @Test + fun saveAllWritesEverySlot() = + runTest { + val subject = store(scope()) + + subject.saveAll(FollowListSlot.entries.associateWith { TopFilter.Mine }) + + assertTrue(subject.load().values.all { it == TopFilter.Mine }) + } + + /** A corrupt entry costs that one feed's filter, not the whole account load. */ + @Test + fun anUnparseableValueFallsBackToThatSlotsDefault() = + runTest { + val scope = scope() + val file = File(folder.root, "corrupt.preferences_pb") + val raw = + PreferenceDataStoreFactory.createWithPath(scope = scope, produceFile = { file.toOkioPath() }) + raw.updateData { prefs -> + prefs.toMutablePreferences().apply { + this[FollowListSlot.HOME.key] = "{ not json" + this[FollowListSlot.STORIES.key] = encode(TopFilter.Mine) + } + } + + val loaded = TopNavFollowListStore(raw).load() + + assertEquals(FollowListSlot.HOME.default, loaded.getValue(FollowListSlot.HOME)) + assertEquals("a sibling slot still loads", TopFilter.Mine, loaded.getValue(FollowListSlot.STORIES)) + } + + /** The prefKeys are a compatibility surface: renaming one silently resets that feed for everyone. */ + @Test + fun prefKeysAreTheOnesTheAndroidAppHasAlwaysWritten() { + assertEquals("defaultHomeFollowList", FollowListSlot.HOME.prefKey) + assertEquals("defaultNotificationFollowList", FollowListSlot.NOTIFICATION.prefKey) + assertEquals("defaultAppRecommendationsFollowList", FollowListSlot.APP_RECOMMENDATIONS.prefKey) + assertEquals(31, FollowListSlot.entries.size) + assertEquals( + "prefKeys must be unique", + 31, + FollowListSlot.entries + .map { it.prefKey } + .toSet() + .size, + ) + } + + // ── migration ────────────────────────────────────────────────────── + + @Test + fun migrationCopiesLegacyValuesOnFirstRead() = + runTest { + val legacy = + mapOf( + Pair(FollowListSlot.HOME.key, encode(TopFilter.Global)), + Pair(FollowListSlot.BADGES.key, encode(TopFilter.AllFollows)), + ) + + val loaded = store(scope(), listOf(CopyOnceMigration("migrated.followLists") { out -> legacy.forEach { (k, v) -> out[k] = v } })).load() + + assertEquals(TopFilter.Global, loaded.getValue(FollowListSlot.HOME)) + assertEquals(TopFilter.AllFollows, loaded.getValue(FollowListSlot.BADGES)) + assertEquals("unmigrated slots keep defaults", FollowListSlot.STORIES.default, loaded.getValue(FollowListSlot.STORIES)) + } + + /** + * The migration must not run a second time and overwrite what the user has + * changed since — the marker key in the destination is what prevents it. + * + * Each open gets its own scope, cancelled before the next: DataStore + * refuses two live instances over one file, which is exactly why + * [AccountPreferenceStores] caches one per account. + */ + @Test + fun migrationDoesNotClobberLaterEdits() = + runTest { + val file = File(folder.root, "once.preferences_pb") + var reads = 0 + val legacy = mapOf(Pair(FollowListSlot.HOME.key, encode(TopFilter.Global))) + + suspend fun <T> withStore(block: suspend (TopNavFollowListStore) -> T): T { + val scope = scope() + try { + return block( + TopNavFollowListStore( + PreferenceDataStoreFactory.createWithPath( + scope = scope, + migrations = + listOf( + CopyOnceMigration("migrated.followLists") { out -> + reads++ + legacy.forEach { (k, v) -> out[k] = v } + }, + ), + produceFile = { file.toOkioPath() }, + ), + ), + ) + } finally { + // cancel() only asks. DataStore's registry keeps the entry until + // the owning job actually completes, so without this join the + // next open of the same file races it — which is what failed on + // a loaded CI runner while passing locally every time. + scope.cancel() + scope.coroutineContext.job.join() + } + } + + assertEquals(TopFilter.Global, withStore { it.load().getValue(FollowListSlot.HOME) }) + withStore { it.save(FollowListSlot.HOME, TopFilter.Mine) } + + assertEquals( + "the user's later choice survives a reopen", + TopFilter.Mine, + withStore { it.load().getValue(FollowListSlot.HOME) }, + ) + assertEquals("the legacy store is read once", 1, reads) + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/TorSettingsStoreTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/TorSettingsStoreTest.kt new file mode 100644 index 0000000000..ecf403972d --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/TorSettingsStoreTest.kt @@ -0,0 +1,156 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.preferences + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import com.vitorpamplona.amethyst.commons.tor.TorSettings +import com.vitorpamplona.amethyst.commons.tor.TorType +import kotlinx.coroutines.test.runTest +import okio.Path.Companion.toOkioPath +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class TorSettingsStoreTest { + @get:Rule + val folder = TemporaryFolder() + + private fun rawStore(name: String): DataStore<Preferences> = + PreferenceDataStoreFactory.createWithPath( + produceFile = { folder.root.toOkioPath() / "$name.preferences_pb" }, + ) + + /** + * These defaults decide what an install that never opened the privacy screen + * sends over Tor, so getting one wrong is a privacy regression in one + * direction and a connectivity regression in the other. + * + * Three are on — onion, DM and new relays — and the rest are off. Note that + * the desktop front end's own `DesktopTorPreferences` reads most of these as + * `true` instead; the two have never agreed, and this pins the Android + * behaviour that the shared store inherits. + */ + @Test + fun anEmptyStoreReadsTheDefaultsTheAndroidStoreAlwaysHad() = + runTest { + val settings = TorSettingsStore.torPreferences(rawStore("empty"))!! + + assertEquals(TorType.INTERNAL, settings.torType) + assertEquals(9050, settings.externalSocksPort) + + assertTrue(settings.onionRelaysViaTor) + assertTrue(settings.dmRelaysViaTor) + assertTrue(settings.newRelaysViaTor) + + assertFalse(settings.trustedRelaysViaTor) + assertFalse(settings.urlPreviewsViaTor) + assertFalse(settings.profilePicsViaTor) + assertFalse(settings.imagesViaTor) + assertFalse(settings.videosViaTor) + assertFalse(settings.moneyOperationsViaTor) + assertFalse(settings.nip05VerificationsViaTor) + assertFalse(settings.mediaUploadsViaTor) + } + + /** The data class and the store must agree, or "unset" and "default" drift apart. */ + @Test + fun theEmptyStoreMatchesTheDataClassDefaults() = + runTest { + assertEquals(TorSettings(), TorSettingsStore.torPreferences(rawStore("match"))) + } + + @Test + fun everySettingSurvivesARoundTrip() = + runTest { + val store = rawStore("roundtrip") + val settings = + TorSettings( + torType = TorType.EXTERNAL, + externalSocksPort = 9150, + onionRelaysViaTor = false, + dmRelaysViaTor = false, + newRelaysViaTor = false, + trustedRelaysViaTor = true, + urlPreviewsViaTor = true, + profilePicsViaTor = true, + imagesViaTor = true, + videosViaTor = true, + moneyOperationsViaTor = true, + nip05VerificationsViaTor = true, + mediaUploadsViaTor = true, + ) + + TorSettingsStore.save(settings, store) + + assertEquals(settings, TorSettingsStore.torPreferences(store)) + } + + /** + * The bypass timestamp is deliberately not part of [TorSettings] — it is + * bookkeeping for the connection-failure dialog, not a user setting — so it + * has its own pair of accessors and its own key. + */ + @Test + fun theBypassApprovalTimestampIsStoredApartFromTheSettings() = + runTest { + val store = rawStore("bypass") + + assertEquals(0L, TorSettingsStore.loadLastBypassApprovalMs(store)) + + TorSettingsStore.saveLastBypassApprovalMs(1_700_000_000_000L, store) + assertEquals(1_700_000_000_000L, TorSettingsStore.loadLastBypassApprovalMs(store)) + + // and a settings save must not clear it + TorSettingsStore.save(TorSettings(torType = TorType.OFF), store) + assertEquals(1_700_000_000_000L, TorSettingsStore.loadLastBypassApprovalMs(store)) + } + + /** + * An unreadable enum name must not take the whole settings object down with + * it — a store written by a newer build that added a TorType would otherwise + * strand the user with no Tor settings at all. + */ + @Test + fun anUnknownTorTypeFallsBackRatherThanThrowing() = + runTest { + val store = rawStore("garbage") + store.edit { it[TorSettingsStore.TOR_TYPE_KEY] = "SOMETHING_NEWER" } + + assertNull(TorSettingsStore.torPreferences(store)) + } + + /** The keys are the ones the Android store has always written. */ + @Test + fun theKeyNamesAreUnchanged() { + assertEquals("tor.torType", TorSettingsStore.TOR_TYPE_KEY.name) + assertEquals("tor.externalSocksPort", TorSettingsStore.EXTERNAL_SOCKS_PORT_KEY.name) + assertEquals("tor.lastBypassApprovalMs", TorSettingsStore.LAST_BYPASS_APPROVAL_MS_KEY.name) + assertEquals("tor.onionRelaysViaTor", TorSettingsStore.ONION_RELAYS_VIA_TOR_KEY.name) + assertEquals("tor.mediaUploadsViaTor", TorSettingsStore.MEDIA_UPLOADS_VIA_TOR_KEY.name) + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/UiSettingsStoreTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/UiSettingsStoreTest.kt new file mode 100644 index 0000000000..efefb1eecb --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/UiSettingsStoreTest.kt @@ -0,0 +1,207 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.preferences + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.mutablePreferencesOf +import com.vitorpamplona.amethyst.commons.model.AccentColorType +import com.vitorpamplona.amethyst.commons.model.BooleanType +import com.vitorpamplona.amethyst.commons.model.ConnectivityType +import com.vitorpamplona.amethyst.commons.model.FeatureSetType +import com.vitorpamplona.amethyst.commons.model.FontSizeType +import com.vitorpamplona.amethyst.commons.model.ThemeType +import com.vitorpamplona.amethyst.commons.model.UiSettings +import kotlinx.coroutines.test.runTest +import okio.Path.Companion.toOkioPath +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class UiSettingsStoreTest { + @get:Rule + val folder = TemporaryFolder() + + private fun stores(migrations: (String) -> List<androidx.datastore.core.DataMigration<Preferences>> = { emptyList() }) = AppPreferenceStores(rootFilesDir = { folder.root.toOkioPath() }, migrations = migrations) + + private fun rawStore(name: String): DataStore<Preferences> = + PreferenceDataStoreFactory.createWithPath( + produceFile = { folder.root.toOkioPath() / "$name.preferences_pb" }, + ) + + /** + * An account that never opened Settings has no stored value for anything, + * so every default here is what that install gets forever. Several of them + * are `true` — reading `false` would quietly turn those features off for + * everyone who never touched them. + */ + @Test + fun anEmptyStoreReadsTheSameDefaultsSharedPreferencesDid() = + runTest { + val settings = UiSettingsStore(rawStore("empty")).load()!! + + assertEquals(ThemeType.SYSTEM, settings.theme) + assertNull(settings.preferredLanguage) + assertEquals(ConnectivityType.ALWAYS, settings.automaticallyShowImages) + assertEquals(FeatureSetType.SIMPLIFIED, settings.featureSet) + assertEquals(AccentColorType.PURPLE, settings.accentColor) + assertEquals(FontSizeType.NORMAL, settings.fontSize) + assertEquals("", settings.composeSignature) + + // Not a constant, and not the data class default either: UiSettings() + // says ALWAYS, but a store with no feature set reads SIMPLIFIED, and + // SIMPLIFIED means NEVER. Reading an empty store is therefore not the + // same as constructing UiSettings() — pinned here because the two look + // interchangeable at a call site. + assertEquals(BooleanType.NEVER, settings.useTrackedBroadcasts) + + // the ones that default on + assertTrue(settings.showHomeNewThreadsTab) + assertTrue(settings.showHomeConversationsTab) + assertTrue(settings.showProfileBadges) + assertTrue(settings.showProfileAppRecommendations) + assertTrue(settings.showProfileZapReceivedFeed) + assertTrue(settings.showProfileFollowersFeed) + assertTrue(settings.showOnchainWallet) + assertTrue(settings.showPayToZapChip) + + // and the ones that default off + assertFalse(settings.showHomeEverythingTab) + assertFalse(settings.dontShowPushNotificationSelector) + assertFalse(settings.dontAskForNotificationPermissions) + assertFalse(settings.dontShowOnchainPublicWarning) + } + + /** + * `useTrackedBroadcasts` is the one default that is not a constant: it + * follows the feature set when it has never been set explicitly, and an + * explicit value wins over that. + */ + @Test + fun trackedBroadcastsFollowsTheFeatureSetUntilItIsSetExplicitly() = + runTest { + val complete = rawStore("complete") + complete.edit { it[UiSettingsStore.UI_FEATURE_SET] = FeatureSetType.COMPLETE.name } + assertEquals(BooleanType.ALWAYS, UiSettingsStore(complete).load()!!.useTrackedBroadcasts) + + val simplified = rawStore("simplified") + simplified.edit { it[UiSettingsStore.UI_FEATURE_SET] = FeatureSetType.SIMPLIFIED.name } + assertEquals(BooleanType.NEVER, UiSettingsStore(simplified).load()!!.useTrackedBroadcasts) + + val explicit = rawStore("explicit") + explicit.edit { + it[UiSettingsStore.UI_FEATURE_SET] = FeatureSetType.SIMPLIFIED.name + it[UiSettingsStore.UI_USE_TRACKED_BROADCASTS] = BooleanType.ALWAYS.name + } + assertEquals(BooleanType.ALWAYS, UiSettingsStore(explicit).load()!!.useTrackedBroadcasts) + } + + @Test + fun everySettingSurvivesARoundTrip() = + runTest { + val store = UiSettingsStore(rawStore("roundtrip")) + val settings = + UiSettings( + theme = ThemeType.DARK, + preferredLanguage = "pt-BR", + automaticallyShowImages = ConnectivityType.WIFI_ONLY, + automaticallyPlayVideos = BooleanType.NEVER, + featureSet = FeatureSetType.COMPLETE, + accentColor = AccentColorType.GREEN, + fontSize = FontSizeType.HUGE, + composeSignature = "— sent from Amethyst", + showHomeEverythingTab = true, + showProfileBadges = false, + showOnchainWallet = false, + showPayToZapChip = false, + ) + + store.save(settings) + + assertEquals(settings, store.load()) + } + + /** + * A blank language is stored as "" (the key is written unconditionally) and + * has to read back as null, or "no preference" turns into a locale tag the + * `LocaleListCompat` call cannot parse. + */ + @Test + fun aBlankLanguageReadsBackAsNoPreference() = + runTest { + val store = UiSettingsStore(rawStore("blank")) + store.save(UiSettings(preferredLanguage = null)) + + assertNull(store.load()!!.preferredLanguage) + } + + /** + * The copy out of the old blob must not run on an install that already has + * these settings here — that would undo every change made since this store + * took over. `ui.theme` is the test, because [UiSettingsStore.write] sets + * every key and is the only writer. + * + * Driven against the [androidx.datastore.core.DataMigration] directly rather + * than through two DataStores over one file: DataStore keeps a process-wide + * registry keyed by path and refuses the second one, so "reopen it and look" + * is a crash, not a test. + */ + @Test + fun theLegacyCopyRunsOnlyWhenTheStoreHasNeverBeenSaved() = + runTest { + val legacy = UiSettings(theme = ThemeType.LIGHT, composeSignature = "from the old blob") + val migration = UiSettingsStore.migrations { legacy }.single() + + // A store nothing has written: the copy lands. + val fresh = emptyPreferences() + assertTrue(migration.shouldMigrate(fresh)) + assertEquals(legacy, UiSettingsStore.read(migration.migrate(fresh))) + + // A store this app has already saved to: the copy must leave it alone. + val mine = UiSettings(theme = ThemeType.DARK, composeSignature = "mine") + val used = mutablePreferencesOf().apply { with(UiSettingsStore) { write(mine) } }.toPreferences() + + val after = migration.migrate(used) + assertEquals(mine, UiSettingsStore.read(after)) + + // ...and having run once, it never runs again. + assertFalse(migration.shouldMigrate(after)) + } + + /** No legacy reader (desktop, CLI) is not an error — it just means no copy. */ + @Test + fun aFrontEndWithoutALegacyFileGetsDefaults() = + runTest { + val subject = stores { UiSettingsStore.migrations { null } } + + assertEquals( + UiSettings(useTrackedBroadcasts = BooleanType.NEVER), + UiSettingsStore(subject.sharedSettings()).load(), + ) + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessDismissedGamesStoreTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessDismissedGamesStoreTest.kt new file mode 100644 index 0000000000..9e28130961 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessDismissedGamesStoreTest.kt @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.nip64Chess + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import okio.Path.Companion.toOkioPath +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +class ChessDismissedGamesStoreTest { + @get:Rule + val folder = TemporaryFolder() + + private var seq = 0 + + private fun raw(): DataStore<Preferences> { + val file = File(folder.root, "chess_${seq++}.preferences_pb") + return PreferenceDataStoreFactory.createWithPath( + scope = CoroutineScope(Dispatchers.IO + SupervisorJob()), + produceFile = { file.toOkioPath() }, + ) + } + + @Test + fun anUnknownUserHasDismissedNothing() = + runTest { + assertTrue(ChessDismissedGamesStore(raw()).load("npub1").isEmpty()) + } + + @Test + fun savedIdsReadBack() = + runTest { + val store = ChessDismissedGamesStore(raw()) + + store.save("npub1", setOf("game1", "game2")) + + assertEquals(setOf("game1", "game2"), store.load("npub1")) + } + + /** Two accounts on one device must not see each other's dismissals. */ + @Test + fun usersAreIsolated() = + runTest { + val store = ChessDismissedGamesStore(raw()) + + store.save("npub1", setOf("game1")) + store.save("npub2", setOf("game2")) + + assertEquals(setOf("game1"), store.load("npub1")) + assertEquals(setOf("game2"), store.load("npub2")) + } + + /** An empty set removes the key rather than storing an empty one. */ + @Test + fun savingAnEmptySetClearsTheEntry() = + runTest { + val raw = raw() + val store = ChessDismissedGamesStore(raw) + store.save("npub1", setOf("game1")) + + store.save("npub1", emptySet()) + + assertTrue(store.load("npub1").isEmpty()) + assertFalse(raw.data.first().contains(ChessDismissedGamesStore.keyFor("npub1"))) + } + + @Test + fun aLaterSaveReplacesTheSet() = + runTest { + val store = ChessDismissedGamesStore(raw()) + + store.save("npub1", setOf("a", "b")) + store.save("npub1", setOf("c")) + + assertEquals(setOf("c"), store.load("npub1")) + } + + /** + * The desktop factory has to hand back one store, not a new one per call. + * + * DataStore registers a live store per file path and only releases it when + * the owning scope ends — and the factory's own scope never does. Building + * a fresh one per call meant the second `IllegalStateException: multiple + * DataStores active for the same file`, thrown from the chess view model's + * constructor the second time that screen opened. + */ + @Test + fun theDesktopFactoryReturnsOneStore() { + assertSame(desktopChessDismissedGamesStore(), desktopChessDismissedGamesStore()) + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlPreviewSizeTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlPreviewSizeTest.kt new file mode 100644 index 0000000000..82bbca3157 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/preview/UrlPreviewSizeTest.kt @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.preview + +import kotlinx.coroutines.test.runTest +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Protocol +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * How much of a page [UrlPreview] reads before giving up on its `<head>`. + */ +class UrlPreviewSizeTest { + private val contentType = "text/html; charset=utf-8" + + private fun serving(html: String): (String) -> OkHttpClient = + { _ -> + OkHttpClient + .Builder() + .addInterceptor { chain -> + Response + .Builder() + .request(chain.request()) + .protocol(Protocol.HTTP_1_1) + .code(200) + .message("OK") + .header("Content-Type", contentType) + .body(html.toResponseBody(contentType.toMediaType())) + .build() + }.build() + } + + /** `<head>` opens with [scriptBytes] of inline script before the page's own meta tags. */ + private fun pageWithScriptHeavyHead(scriptBytes: Int): String = + buildString { + append("<!DOCTYPE html><html><head><script>var ytcfg = {") + while (length < scriptBytes) append("\"k\":\"vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv\",") + append("};</script>") + append("<title>Goals vs. Systems - YouTube") + append("") + append("") + append("") + append("") + append("") + } + + @Test + fun readsOgTagsBehindAYouTubeSizedHead() = + runTest { + // A YouTube watch page (youtu.be/fwcKTYvupJw, 2026-09) is 1.29 MB, and its og: block + // starts 708,776 bytes in, still inside (which closes at 716,956). A 512 KB read + // stopped short of it, so every YouTube link lost its card. + val url = "https://youtu.be/fwcKTYvupJw" + val info = UrlPreview().getDocument(url, serving(pageWithScriptHeavyHead(708_000))) + + assertEquals("Goals vs. Systems", info.title) + assertEquals("https://i.ytimg.com/vi/fwcKTYvupJw/maxresdefault.jpg", info.image) + assertTrue(info.fetchComplete()) + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/nip17Dm/DmInboxRelayResolverOutboxTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/nip17Dm/DmInboxRelayResolverOutboxTest.kt index 417ef9478d..d539f693fc 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/nip17Dm/DmInboxRelayResolverOutboxTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/nip17Dm/DmInboxRelayResolverOutboxTest.kt @@ -27,7 +27,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.nip17Dm.settings.DmRelayListEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals @@ -60,11 +60,11 @@ class DmInboxRelayResolverOutboxTest { ) private fun dm10050(inbox: List) = - ChatMessageRelayListEvent( + DmRelayListEvent( id = "c".repeat(64), pubKey = peer, createdAt = 1_700_000_050, - tags = ChatMessageRelayListEvent.createTagArray(inbox), + tags = DmRelayListEvent.createTagArray(inbox), content = "", sig = dummySig(), ) @@ -115,7 +115,7 @@ class DmInboxRelayResolverOutboxTest { // Indexer knows WHERE the peer writes (10002) but not their 10050. client.scriptEvent(AdvertisedRelayListEvent.KIND, indexer, listOf(outbox10002(listOf(peerOutbox)))) // The peer's own write relay serves their 10050. - client.scriptEvent(ChatMessageRelayListEvent.KIND, peerOutbox, listOf(dm10050(listOf(peerDmInbox)))) + client.scriptEvent(DmRelayListEvent.KIND, peerOutbox, listOf(dm10050(listOf(peerDmInbox)))) val resolver = DmInboxRelayResolver( @@ -136,7 +136,7 @@ class DmInboxRelayResolverOutboxTest { runBlocking { val client = ScriptedClient() // No 10002 on the indexer; the write relay is only known from cache. - client.scriptEvent(ChatMessageRelayListEvent.KIND, peerOutbox, listOf(dm10050(listOf(peerDmInbox)))) + client.scriptEvent(DmRelayListEvent.KIND, peerOutbox, listOf(dm10050(listOf(peerDmInbox)))) val resolver = DmInboxRelayResolver( diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/DataStoreRelayAuthPermissionStoreTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relayauth/DataStoreRelayAuthPermissionStoreTest.kt similarity index 83% rename from amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/DataStoreRelayAuthPermissionStoreTest.kt rename to commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relayauth/DataStoreRelayAuthPermissionStoreTest.kt index a67cc661a2..9cdb366b08 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/DataStoreRelayAuthPermissionStoreTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relayauth/DataStoreRelayAuthPermissionStoreTest.kt @@ -18,11 +18,11 @@ * 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.relayClient.authCommand.model +package com.vitorpamplona.amethyst.commons.relayauth -import com.vitorpamplona.amethyst.commons.relayauth.AuthPurposeKind -import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision +import com.vitorpamplona.amethyst.commons.model.preferences.AppPreferenceStores import kotlinx.coroutines.runBlocking +import okio.Path.Companion.toOkioPath import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull @@ -33,14 +33,31 @@ import org.junit.rules.TemporaryFolder /** * Round-trip tests for the DataStore-backed relay-auth permission store. Backed by a real - * PreferenceDataStore on a per-test temp directory (the store takes a plain filesDir), so it runs - * on the JVM without Robolectric. A fresh directory per test dodges DataStore's per-file - * single-instance guard. + * PreferenceDataStore on a per-test temp directory, so it runs on the JVM without Robolectric. + * A fresh directory per test dodges DataStore's per-file single-instance guard. */ class DataStoreRelayAuthPermissionStoreTest { @get:Rule val tmp = TemporaryFolder() - private fun newStore() = DataStoreRelayAuthPermissionStore(tmp.newFolder()) + private fun newStore() = + DataStoreRelayAuthPermissionStore( + AppPreferenceStores(rootFilesDir = { tmp.newFolder().toOkioPath() }) + .getDataStore(DataStoreRelayAuthPermissionStore.FILE_NAME), + ) + + /** + * The relay-URL hash is a stored key, so it must keep producing exactly what + * `MessageDigest.getInstance("SHA-256")` truncated to 8 bytes and formatted + * with `"%02x"` produced on Android. A different digest would not throw — it + * would silently drop every ALLOW/DENY the user has ever set. Expected values + * are SHA-256 prefixes computed outside this codebase. + */ + @Test + fun theRelayHashMatchesTheAndroidImplementation() { + assertEquals("88b21471340e72df", DataStoreRelayAuthPermissionStore.hash("wss://auth.relay.test")) + assertEquals("2535089fcc9a2cf1", DataStoreRelayAuthPermissionStore.hash("wss://other.relay.test")) + assertEquals("e3b0c44298fc1c14", DataStoreRelayAuthPermissionStore.hash("")) + } private val relay = "wss://auth.relay.test" private val other = "wss://other.relay.test" diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/Nip11RetrieverTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relays/nip11RelayInfo/Nip11RetrieverTest.kt similarity index 97% rename from amethyst/src/test/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/Nip11RetrieverTest.kt rename to commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relays/nip11RelayInfo/Nip11RetrieverTest.kt index 07c46de62e..73a11a6335 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/Nip11RetrieverTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relays/nip11RelayInfo/Nip11RetrieverTest.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.model.nip11RelayInfo +package com.vitorpamplona.amethyst.commons.relays.nip11RelayInfo import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import kotlinx.coroutines.runBlocking diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostStoreTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostStoreTest.kt index 6033715624..bf0fdb92e2 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostStoreTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostStoreTest.kt @@ -47,7 +47,7 @@ class ScheduledPostStoreTest { file = File(temp.root, "scheduled_posts.json") } - private fun newStore(now: () -> Long = { System.currentTimeMillis() / 1000 }) = ScheduledPostStore(file, now) + private fun newStore(now: () -> Long = { System.currentTimeMillis() / 1000 }) = ScheduledPostStore(file.path, now) private fun samplePost( id: String = "id-1", diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/service/OnlineCheckerTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/service/OnlineCheckerTest.kt new file mode 100644 index 0000000000..70ebf5bf93 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/service/OnlineCheckerTest.kt @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.service + +import com.vitorpamplona.quartz.utils.TimeUtils +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** + * [OnlineChecker]'s cache predicates — the part that decides, without touching the network, whether a + * media URL is known-good, known-bad, or unknown. + * + * These decide whether the UI shows a player or a "this is offline" placeholder, and a stale entry + * being trusted is the difference between a video that plays and one that never gets retried. Nothing + * here had coverage while the object sat in `amethyst/`. + * + * The suspend `isOnline` probe is deliberately not exercised: it needs a real OkHttp round trip and + * `commons` has no MockWebServer, so there is no honest way to drive it from here. + */ +class OnlineCheckerTest { + private val url = "https://example.com/video.mp4" + + @Before + fun clearSharedCache() { + // OnlineChecker is an object, so its LruCache outlives each test. + OnlineChecker.checkOnlineCache.evictAll() + } + + private fun seed( + online: Boolean, + ageSeconds: Long, + ) { + OnlineChecker.checkOnlineCache.put(url, OnlineCheckResult(TimeUtils.now() - ageSeconds, online)) + } + + @Test + fun anUnknownUrlIsNeitherOnlineNorKnownOffline() { + assertFalse("nothing cached, so not known online", OnlineChecker.isOnlineCached(url)) + assertFalse("and not known offline either", OnlineChecker.isCachedAndOffline(url)) + } + + @Test + fun aFreshOnlineEntryReadsOnline() { + seed(online = true, ageSeconds = 10) + + assertTrue(OnlineChecker.isOnlineCached(url)) + assertFalse("an online entry is not 'cached and offline'", OnlineChecker.isCachedAndOffline(url)) + } + + @Test + fun aFreshOfflineEntryReadsOffline() { + seed(online = false, ageSeconds = 10) + + assertTrue(OnlineChecker.isCachedAndOffline(url)) + assertFalse("and must not read as online", OnlineChecker.isOnlineCached(url)) + } + + /** + * The five-minute TTL in both directions. Trusting a stale *online* entry shows a player for + * something that has since gone; trusting a stale *offline* one never retries a URL that came back. + */ + @Test + fun anEntryOlderThanFiveMinutesIsTrustedForNothing() { + seed(online = true, ageSeconds = 301) + assertFalse("a stale online entry is no longer online", OnlineChecker.isOnlineCached(url)) + + seed(online = false, ageSeconds = 301) + assertFalse("and a stale offline entry no longer counts as known-offline", OnlineChecker.isCachedAndOffline(url)) + } + + @Test + fun anEntryJustInsideFiveMinutesIsStillTrusted() { + seed(online = true, ageSeconds = 290) + + assertTrue(OnlineChecker.isOnlineCached(url)) + } + + /** Retry is for failures only: dropping a good entry would refetch every URL that already worked. */ + @Test + fun resetIfOfflineToRetryDropsOnlyTheOfflineEntries() { + seed(online = false, ageSeconds = 10) + OnlineChecker.resetIfOfflineToRetry(url) + assertFalse("the offline entry is gone, so the next check refetches", OnlineChecker.isCachedAndOffline(url)) + + seed(online = true, ageSeconds = 10) + OnlineChecker.resetIfOfflineToRetry(url) + assertTrue("the online entry survived", OnlineChecker.isOnlineCached(url)) + } + + @Test + fun aBlankOrNullUrlIsNeverOnline() { + assertFalse(OnlineChecker.isOnlineCached(null)) + assertFalse(OnlineChecker.isOnlineCached(" ")) + assertFalse(OnlineChecker.isCachedAndOffline(null)) + assertFalse(OnlineChecker.isCachedAndOffline(" ")) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpWebSocketCloseHandshakeTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/service/http/OkHttpWebSocketCloseHandshakeTest.kt similarity index 99% rename from amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpWebSocketCloseHandshakeTest.kt rename to commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/service/http/OkHttpWebSocketCloseHandshakeTest.kt index 907a0b1ec4..424bba7093 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpWebSocketCloseHandshakeTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/service/http/OkHttpWebSocketCloseHandshakeTest.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.service.okhttp +package com.vitorpamplona.amethyst.commons.service.http import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocketListener diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/EncryptedAppendLogTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/storage/EncryptedAppendLogTest.kt similarity index 99% rename from commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/EncryptedAppendLogTest.kt rename to commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/storage/EncryptedAppendLogTest.kt index acf4be7de4..87629543b7 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/EncryptedAppendLogTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/storage/EncryptedAppendLogTest.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.commons.marmot +package com.vitorpamplona.amethyst.commons.storage import java.io.File import java.nio.file.Files diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModelTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModelTest.kt index 10293574a4..afb4eba786 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModelTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModelTest.kt @@ -35,8 +35,8 @@ import com.vitorpamplona.nestsclient.transport.WebTransportSession import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import com.vitorpamplona.quartz.nip57Zaps.PrivateZapEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapRequestEvent import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -589,7 +589,7 @@ class NestViewModelTest { fromPublicKey: String, ): String = error("not used") - override suspend fun decryptZapEvent(event: LnZapRequestEvent): LnZapPrivateEvent = error("not used") + override suspend fun decryptZapEvent(event: ZapRequestEvent): PrivateZapEvent = error("not used") override suspend fun deriveKey(nonce: String): String = error("not used") diff --git a/commonsUI/ARCHITECTURE.md b/commonsUI/ARCHITECTURE.md index b693e06a2b..8a091c05f7 100644 --- a/commonsUI/ARCHITECTURE.md +++ b/commonsUI/ARCHITECTURE.md @@ -58,7 +58,10 @@ is specific to the UI module. `tools/icon-font/build_icon_font.py` read `icons/` and write `composeResources/font/` in this module (see root `.claude/CLAUDE.md`, "Icons"). - Translations: `crowdin.yml` and `tools/strings-migrate/` target - `commonsUI/src/commonMain/composeResources/`. + `commonsUI/src/commonMain/composeResources/`. This is where **every new + user-visible string** goes, Android-only screens included; the app's own + `res/values/strings.xml` holds only the synchronous-platform tier (see root + `.claude/CLAUDE.md`, "Strings"). - CI: `.github/workflows/build.yml` runs `:commonsUI:jvmTest`, `:commonsUI:verifyKmpPurity` and the iOS compile/test tasks next to the `:commons` ones. diff --git a/commonsUI/build.gradle.kts b/commonsUI/build.gradle.kts index 5cadbc3ea5..4d54405f96 100644 --- a/commonsUI/build.gradle.kts +++ b/commonsUI/build.gradle.kts @@ -138,6 +138,17 @@ kotlin { } } + jvmTest { + dependencies { + // Compose Desktop on the test classpath so composables can be + // rendered headlessly to a Skia surface (ImageComposeScene) and + // asserted on. No new third-party dependency: this is the same + // artifact jvmMain already uses, and it rasterises in software, + // so it needs no display. + implementation(compose.desktop.currentOs) + } + } + // Shared JVM code for both Android and Desktop val jvmAndroid = create("jvmAndroid") { diff --git a/commonsUI/src/commonMain/composeResources/values-ar-rSA/strings.xml b/commonsUI/src/commonMain/composeResources/values-ar-rSA/strings.xml index bb77569f41..c9b3131eff 100644 --- a/commonsUI/src/commonMain/composeResources/values-ar-rSA/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-ar-rSA/strings.xml @@ -234,7 +234,7 @@ نسخ معرف المستخدم إلغاء حظر عن مستخدم هذا المستخدم محظور/مكتوم. منشوراته مخفية. - npub, hex, username + npub, hex, username نظف شعار التطبيق nsec / npub / hex private key @@ -282,15 +282,8 @@ ضع علامة على جميع الرسائل/الاشعارات المعروفة كمقروء تعيين الجديد كمقروء تعيين الكل كمقروء - ## النصائح الرئيسية للنسخ الاحتياطي والسلامة - \n\n- يتم تأمين حسابك بمفتاح سري. المفتاح السري هو سلسلة عشوائية طويلة تبدأ بـ **nsec1**. أي شخص لديه مفتاحك السري يمكنه نشر المحتوى باستخدام هويتك. - \n\n- لا تقم بوضع مفتاحك السري في أي موقع أو برنامج لا تثق به. - \n- مطوري Amethyst لن يطلبوا منك **أبدا** المفتاح السري الخاص بك. - \n- حافظ على نسخة احتياطية آمنة لمفتاحك السري لاسترداد الحساب. نوصي باستخدام برنامج لادارة كلمات المرور. - - للحصول على أمان إضافي، يمكنك تشفير مفتاحك الخاص بكلمة مرور. يبدأ هذا المفتاح المشفر بـ **ncryptsec1** ولا يمكن استخدامه دون كلمة المرور الخاصة بك. - \n\nإذا فقدت كلمة المرور، لن تتمكن من استرداد مفتاحك الخاص. - + ## النصائح الرئيسية للنسخ الاحتياطي والسلامة \n\n- يتم تأمين حسابك بمفتاح سري. المفتاح السري هو سلسلة عشوائية طويلة تبدأ بـ **nsec1**. أي شخص لديه مفتاحك السري يمكنه نشر المحتوى باستخدام هويتك. \n\n- لا تقم بوضع مفتاحك السري في أي موقع أو برنامج لا تثق به. \n- مطوري Amethyst لن يطلبوا منك **أبدا** المفتاح السري الخاص بك. \n- حافظ على نسخة احتياطية آمنة لمفتاحك السري لاسترداد الحساب. نوصي باستخدام برنامج لادارة كلمات المرور. + للحصول على أمان إضافي، يمكنك تشفير مفتاحك الخاص بكلمة مرور. يبدأ هذا المفتاح المشفر بـ **ncryptsec1** ولا يمكن استخدامه دون كلمة المرور الخاصة بك. \n\nإذا فقدت كلمة المرور، لن تتمكن من استرداد مفتاحك الخاص. تشفير و نسخ المفتاح الخاص صورة شارة مكافئة ل %1$s "صورة منح الشارة @@ -311,7 +304,7 @@ إزالة هذه الوسائط من معرضك. طلب حذفها سيطلب Amethyst حذف ملاحظتك من المرحلات المتصل بها حاليا، لا يوجد ضمان لحذفها من هذه المرحلات أو من المرحلات الأخرى التي ربما حفظت فيها. - حظر + حظر حذف حظر التبليغ @@ -693,8 +686,7 @@ إضافة شرح توضيحي صديقي المميز وصف المحتوى/المحتويات - A blue boat in a white sandy beach at sunset -(قارب أزرق في شاطئ رملي أبيض عند غروب الشمس) + A blue boat in a white sandy beach at sunset (قارب أزرق في شاطئ رملي أبيض عند غروب الشمس) اقتراح من الذكاء الاصطناعي، يمكنك تعديله رفض اقتراح الذكاء الاصطناعي نوع الزاب @@ -847,7 +839,7 @@ منذ %1$s حتى %1$s الحد %1$d - الحد الأدنى لل prefix + الحد الأدنى لل prefix الحد الأقصى لوسوم الحدث الحد الأقصى لطول المحتوى تُهمَل أقدم من @@ -886,10 +878,8 @@ العلامات المُتابعة هذه قوائم بالمستخدمين التي توصي بها للآخرين. يُسمح فقط بالمستخدمين العامين. المنشورات الموافق عليها - هذه المجموعة ليس لديها وصف أو قواعد. - تحدث إلى المالك لإضافة مستخدم جديد - هذا المجتمع ليس لديه وصف أو قواعد. - تحدث إلى المالك لإضافة مستخدم جديد + هذه المجموعة ليس لديها وصف أو قواعد. تحدث إلى المالك لإضافة مستخدم جديد + هذا المجتمع ليس لديه وصف أو قواعد. تحدث إلى المالك لإضافة مستخدم جديد تفضيلات المستخدم البحث في الإعدادات لا توجد إعدادات لـ "%1$s" @@ -982,9 +972,7 @@ تعطيل الإشعارات يتم استخدام هذا التطبيق: %1$s إعدادات الإشعارات - لاستخدام الإشعارات حمل أي تطبيق يدعم ال [Unified Push](https://unifiedpush.org/) -مثل [Ntfy](https://ntfy.sh/). -بعد تثبيتك التطبيق قم باختياره من الإعدادات + لاستخدام الإشعارات حمل أي تطبيق يدعم ال [Unified Push](https://unifiedpush.org/) مثل [Ntfy](https://ntfy.sh/). بعد تثبيتك التطبيق قم باختياره من الإعدادات الخيط أرسل رسالة إلى البائع مرحبًا، هل هذا لا يزال متاحًا؟ @@ -1876,7 +1864,7 @@ فشلت المصادقة فشل التحقق البيومتري في مصادقة مالك هذا الهاتف فشل التحقق البيومتري في مصادقة مالك هذا الهاتف. الخطأ: %1$s - حظر + حظر لن يتصل Amethyst أبدًا بهذه الـ relays المستخدمين المحظورين قائمة إشارات مرجعية جديدة @@ -1986,8 +1974,7 @@ نشر طلب حذف لتوصية kind:38000 الخاصة بك لـ %1$s. ستحذفها الـ relays التي تحترم NIP-09. تم استرداد %1$s sats عبر %2$s أدلة. تم استلام Cashu - %1$s sats أرسلت الى محفظتك -(الرسوم: sats %2$s ) + %1$s sats أرسلت الى محفظتك (الرسوم: sats %2$s ) لا أموال محتجزة في mint غير مستخدم أموال محتجزة في %1$d mint لا تستخدمه @@ -2060,15 +2047,7 @@ تعذّر جلب الفاتورة من %1$s: %2$s تعذّر فتح الملف المضغوط لم يتم العثور على المحفظة - لم يتمكن Amethyst من العثور على أي تطبيقات محافظ تدعم Nostr Wallet Connect (NWC). -\n\nما يمكنك فعله: -\n\t1. التحقق من دعم NWC: تأكد من أن تطبيق محفظتك المفضل متوافق مع Nostr Wallet Connect. -\n\t2. إنشاء اتصال: انتقل إلى تطبيق محفظتك وأنشئ اتصال NWC جديدًا. -\n\t3. الربط بـ Amethyst: عادةً ما تتيح محفظتك ثلاثة خيارات: -\n\t\t3.1 ربط تلقائي: زر لفتح Amethyst والاتصال به تلقائيًا. إذا كان الأمر كذلك، اضغط على الزر، وافتح Amethyst واضغط على حفظ في الشاشة الجديدة. -\n\t\t3.2 ربط يدوي: عنوان URI للاتصال يمكنك نسخه والضغط على أيقونة اللصق في هذه الشاشة لإدخاله مباشرةً. -\n\t\t3.3 رمز QR: تنشئ المحفظة رمز QR يمكنك قراءته من زر رمز QR في هذه الشاشة. - + لم يتمكن Amethyst من العثور على أي تطبيقات محافظ تدعم Nostr Wallet Connect (NWC). \n\nما يمكنك فعله: \n\t1. التحقق من دعم NWC: تأكد من أن تطبيق محفظتك المفضل متوافق مع Nostr Wallet Connect. \n\t2. إنشاء اتصال: انتقل إلى تطبيق محفظتك وأنشئ اتصال NWC جديدًا. \n\t3. الربط بـ Amethyst: عادةً ما تتيح محفظتك ثلاثة خيارات: \n\t\t3.1 ربط تلقائي: زر لفتح Amethyst والاتصال به تلقائيًا. إذا كان الأمر كذلك، اضغط على الزر، وافتح Amethyst واضغط على حفظ في الشاشة الجديدة. \n\t\t3.2 ربط يدوي: عنوان URI للاتصال يمكنك نسخه والضغط على أيقونة اللصق في هذه الشاشة لإدخاله مباشرةً. \n\t\t3.3 رمز QR: تنشئ المحفظة رمز QR يمكنك قراءته من زر رمز QR في هذه الشاشة. إنشاء يوم منطقة الخطر @@ -2451,7 +2430,7 @@ قم بتسجيل الدخول باستخدام مفتاح خاص لتتمكن من إرسال Zaps قم بتسجيل الدخول باستخدام مفتاحك السري لتتمكن من إظهار الكلمة أو الجملة أنت تستخدم مفتاحًا عامًا وهو للقراءة فقط. سجّل الدخول بمفتاح خاص لتتمكن من توقيع الأحداث - قم بتسجيل الدخول بمفتاحك الخاص للتتمكن من إلغاء متابعة المستخدمين + قم بتسجيل الدخول بمفتاحك الخاص للتتمكن من إلغاء متابعة المستخدمين أنت تستخدم مفتاحًا عامًا وهو للقراءة فقط. سجّل الدخول بمفتاح خاص لتتمكن من الرفع تسجيل الدخول باستخدام مفتاح خاص لإبداء الإعجاب بالمنشورات مقاطع فيديو @@ -2786,8 +2765,7 @@ معاينة صورة البطاقة لـ %1$s خيارات الخصوصية سياسة الخصوصية - <استحالة فك تشفير الرسالة الخاصة> -\n\n أنت الآن في محادثة خاصة/مشفرة بين %1$s و %2$s . + <استحالة فك تشفير الرسالة الخاصة> \n\n أنت الآن في محادثة خاصة/مشفرة بين %1$s و %2$s . أدخل ما بين 1 و3 relays لتكون صندوقًا واردًا خاصًا. سيستخدم الآخرون هذه الـ relays لإرسال رسائل مباشرة إليك. يجب أن تقبل DM Inbox relays أي رسالة من أي شخص، لكن تسمح لك وحدك بتنزيلها. خيارات جيدة:\n - inbox.nostr.wine (مدفوع)\n - auth.nostr1.com (مجاني)\n - you.nostr1.com (relays شخصية - مدفوعة) المستخدم يستقبل الرسائل المباشرة على هذه الـ relays رسالة خاصة @@ -2807,11 +2785,8 @@ اقتراح تعديل الـ relays التجميعية التي يجب أن يستخدمها التطبيق لتنزيل تغذياتك منها، مثل filter.nostr.wine. هذا يستبدل نموذج outbox وسيجعل التطبيق يتصل فقط بالـ relays الموجودة في قوائمك. الدردشة العامة - المحادثات العامة مرئية للجميع على Nostr ويمكن لأي شخص - المشاركة فيها. إنها مناسبة للمجتمعات المفتوحة حول مواضيع محددة. - يمكن التحكم في الإشراف عبر حذف المنشورات على relay المجموعة - أدخل من 1 إلى 3 relay تستضيف هذه المجموعة. - تستخدم تطبيقات Nostr هذا الإعداد لمعرفة مكان تنزيل الرسائل وإرسالها. + المحادثات العامة مرئية للجميع على Nostr ويمكن لأي شخص المشاركة فيها. إنها مناسبة للمجتمعات المفتوحة حول مواضيع محددة. يمكن التحكم في الإشراف عبر حذف المنشورات على relay المجموعة + أدخل من 1 إلى 3 relay تستضيف هذه المجموعة. تستخدم تطبيقات Nostr هذا الإعداد لمعرفة مكان تنزيل الرسائل وإرسالها. بيانات المحادثة العامة المحادثات العامة يخزّن هذا النوع من الـ relay جميع محتواك. سيرسل Amethyst منشوراتك إليها وسيستخدم الآخرون هذه الـ relays للعثور على محتواك. أدخل ما بين 1 و3 relays. يمكن أن تكون relays شخصية أو مدفوعة أو عامة. @@ -2833,10 +2808,7 @@ مستخدم في وضع القراءة فقط اضغط مطوّلًا لتسجيل رسالة محادثة relay - محادثات relay هي مجموعات دردشة يتحكم فيها الـ relay المضيف. - إنها مرئية للجميع على Nostr ويمكن لأي شخص المشاركة فيها. - إنها مناسبة للمجتمعات المفتوحة حول مواضيع محددة. بعض هذه المجموعات مؤقتة - وبالتالي تختفي رسائل الدردشة بمرور الوقت + محادثات relay هي مجموعات دردشة يتحكم فيها الـ relay المضيف. إنها مرئية للجميع على Nostr ويمكن لأي شخص المشاركة فيها. إنها مناسبة للمجتمعات المفتوحة حول مواضيع محددة. بعض هذه المجموعات مؤقتة وبالتالي تختفي رسائل الدردشة بمرور الوقت محادثات relay الأحداث عناوين IP @@ -3098,4 +3070,15 @@ خدمة Lightning للمستلم على %1$s غير متاحة. تم احتسابها من عنوان Lightning "%2$s". الخطأ: %3$s. تحقق مما إذا كان الخادم يعمل وما إذا كان عنوان Lightning صحيحاً تعذر إنشاء فاتورة Lightning من %1$s: العنصر pr غير موجود في JSON الناتج. تعذر إنشاء فاتورة Lightning. رسالة من %1$s: %2$s + + + + diff --git a/commonsUI/src/commonMain/composeResources/values-bn-rBD/strings.xml b/commonsUI/src/commonMain/composeResources/values-bn-rBD/strings.xml index 37830c135f..75664b26ee 100644 --- a/commonsUI/src/commonMain/composeResources/values-bn-rBD/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-bn-rBD/strings.xml @@ -271,15 +271,8 @@ পরিচিত সবগুলোকে পঠিত হিসেবে চিহ্নিত করুন নতুন সবগুলোকে পঠিত হিসেবে চিহ্নিত করুন সবগুলোকে পঠিত হিসেবে চিহ্নিত করুন - ## মূল ব্যাকআপ এবং নিরাপত্তা টিপস - \n\n আপনার অ্যাকাউন্ট একটি গোপন কী দ্বারা সুরক্ষিত। মূলটি হল অক্ষরগুলির একটি দীর্ঘ ক্রম যা শুরু হয় **nsec1**. যে কেউ এই গোপন কী অ্যাক্সেস করতে পারে পোস্ট এবং আপনার পরিচয় পরিবর্তন করতে পারেন. - \n\n- আপনি বিশ্বাস করেন না এমন কোনো ওয়েবসাইট বা সফ্টওয়্যারে আপনার গোপন কী **করবেন না**. - \n- অ্যামেথিস্ট ডেভেলপাররা **কখনই** আপনার গোপন কী চাইবে না. - \n- **করুন** অ্যাকাউন্ট পুনরুদ্ধারের জন্য আপনার গোপন কীটির একটি নিরাপদ ব্যাকআপ রাখুন৷. আমরা একটি পাসওয়ার্ড ম্যানেজার ব্যবহার করার পরামর্শ দিই।. - - অতিরিক্ত নিরাপত্তার জন্য, আপনি একটি পাসওয়ার্ড দিয়ে আপনার কী এনক্রিপ্ট করতে পারেন. এই কী দিয়ে শুরু হয় **ncryptsec1** এবং আপনার পাসওয়ার্ড ছাড়া ব্যবহার করা যাবে না।. - \n\n আপনি যদি আপনার পাসওয়ার্ড হারিয়ে ফেলেন, আপনি আপনার কী পুনরুদ্ধার করতে পারবেন না।. - + ## মূল ব্যাকআপ এবং নিরাপত্তা টিপস \n\n আপনার অ্যাকাউন্ট একটি গোপন কী দ্বারা সুরক্ষিত। মূলটি হল অক্ষরগুলির একটি দীর্ঘ ক্রম যা শুরু হয় **nsec1**. যে কেউ এই গোপন কী অ্যাক্সেস করতে পারে পোস্ট এবং আপনার পরিচয় পরিবর্তন করতে পারেন. \n\n- আপনি বিশ্বাস করেন না এমন কোনো ওয়েবসাইট বা সফ্টওয়্যারে আপনার গোপন কী **করবেন না**. \n- অ্যামেথিস্ট ডেভেলপাররা **কখনই** আপনার গোপন কী চাইবে না. \n- **করুন** অ্যাকাউন্ট পুনরুদ্ধারের জন্য আপনার গোপন কীটির একটি নিরাপদ ব্যাকআপ রাখুন৷. আমরা একটি পাসওয়ার্ড ম্যানেজার ব্যবহার করার পরামর্শ দিই।. + অতিরিক্ত নিরাপত্তার জন্য, আপনি একটি পাসওয়ার্ড দিয়ে আপনার কী এনক্রিপ্ট করতে পারেন. এই কী দিয়ে শুরু হয় **ncryptsec1** এবং আপনার পাসওয়ার্ড ছাড়া ব্যবহার করা যাবে না।. \n\n আপনি যদি আপনার পাসওয়ার্ড হারিয়ে ফেলেন, আপনি আপনার কী পুনরুদ্ধার করতে পারবেন না।. এনক্রিপ্ট এবং আমার গোপন কী অনুলিপি %1$s -এর জন্য ব্যাজ পুরষ্কারের ছবি "ব্যাজ পুরস্কার চিত্র @@ -963,9 +956,7 @@ Push বিজ্ঞপ্তি নিষ্ক্রিয় করে অ্যাপ %1$s ব্যবহার করে Push বিজ্ঞপ্তি সেটআপ - পুশ নোটিফিকেশন পেতে, [Unified Push](https://unifiedpush.org/) সমর্থন করে এমন যেকোনো অ্যাপ ইনস্টল করুন, যেমন [Ntfy](https://ntfy.sh/)। - ইনস্টল করার পরে, সেটিংসে আপনি যে অ্যাপটি ব্যবহার করতে চান তা নির্বাচন করুন। - + পুশ নোটিফিকেশন পেতে, [Unified Push](https://unifiedpush.org/) সমর্থন করে এমন যেকোনো অ্যাপ ইনস্টল করুন, যেমন [Ntfy](https://ntfy.sh/)। ইনস্টল করার পরে, সেটিংসে আপনি যে অ্যাপটি ব্যবহার করতে চান তা নির্বাচন করুন। থ্রেড বিক্রেতাকে একটি বার্তা পাঠান হাই, এটা কি এখনও পাওয়া যায়? @@ -2022,15 +2013,7 @@ %1$s থেকে ইনভয়েস আনতে পারেনি: %2$s সংকুচিত ফাইলটি খোলা যায়নি ওয়ালেট পাওয়া যায়নি - Amethyst কোনো ওয়ালেট অ্যাপ খুঁজে পায়নি যা Nostr Wallet Connect (NWC) সমর্থন করে। -\n\nআপনি যা করতে পারেন: -\n\t১. NWC সমর্থন যাচাই করুন: নিশ্চিত করুন যে আপনার পছন্দের ওয়ালেট অ্যাপ Nostr Wallet Connect-এর সাথে সামঞ্জস্যপূর্ণ। -\n\t২. সংযোগ তৈরি করুন: আপনার ওয়ালেট অ্যাপে যান এবং একটি নতুন NWC সংযোগ তৈরি করুন। -\n\t৩. Amethyst-এ লিঙ্ক করুন: আপনার ওয়ালেট সাধারণত তিনটি বিকল্প দেবে: -\n\t\t৩.১ স্বয়ংক্রিয় লিঙ্ক: একটি বোতাম যা স্বয়ংক্রিয়ভাবে Amethyst খুলে সংযুক্ত করবে। যদি তাই হয়, সেই বোতামে ক্লিক করুন, Amethyst খুলুন এবং নতুন স্ক্রিনে সেভ চাপুন। -\n\t\t৩.২ ম্যানুয়াল লিঙ্ক: একটি Connection URI যা কপি করে এই স্ক্রিনের পেস্ট আইকনে ক্লিক করে সরাসরি ইনপুট করতে পারবেন। -\n\t\t৩.৩ QR কোড: ওয়ালেট একটি QR কোড তৈরি করে যা আপনি এই স্ক্রিনের QR কোড বোতাম থেকে পড়তে পারবেন। - + Amethyst কোনো ওয়ালেট অ্যাপ খুঁজে পায়নি যা Nostr Wallet Connect (NWC) সমর্থন করে। \n\nআপনি যা করতে পারেন: \n\t১. NWC সমর্থন যাচাই করুন: নিশ্চিত করুন যে আপনার পছন্দের ওয়ালেট অ্যাপ Nostr Wallet Connect-এর সাথে সামঞ্জস্যপূর্ণ। \n\t২. সংযোগ তৈরি করুন: আপনার ওয়ালেট অ্যাপে যান এবং একটি নতুন NWC সংযোগ তৈরি করুন। \n\t৩. Amethyst-এ লিঙ্ক করুন: আপনার ওয়ালেট সাধারণত তিনটি বিকল্প দেবে: \n\t\t৩.১ স্বয়ংক্রিয় লিঙ্ক: একটি বোতাম যা স্বয়ংক্রিয়ভাবে Amethyst খুলে সংযুক্ত করবে। যদি তাই হয়, সেই বোতামে ক্লিক করুন, Amethyst খুলুন এবং নতুন স্ক্রিনে সেভ চাপুন। \n\t\t৩.২ ম্যানুয়াল লিঙ্ক: একটি Connection URI যা কপি করে এই স্ক্রিনের পেস্ট আইকনে ক্লিক করে সরাসরি ইনপুট করতে পারবেন। \n\t\t৩.৩ QR কোড: ওয়ালেট একটি QR কোড তৈরি করে যা আপনি এই স্ক্রিনের QR কোড বোতাম থেকে পড়তে পারবেন। তৈরি করুন d বিপদজনক এলাকা @@ -2699,11 +2682,8 @@ প্রক্সি Relay অ্যাগ্রিগেটর relay যা অ্যাপটিকে আপনার ফিড ডাউনলোড করতে অবশ্যই ব্যবহার করতে হবে, যেমন filter.nostr.wine। এটি আউটবক্স মডেলকে প্রতিস্থাপন করে এবং অ্যাপটিকে শুধুমাত্র আপনার তালিকার relay গুলোতে সংযুক্ত করবে। সার্বজনীন আলাপ - পাবলিক চ্যাটগুলো Nostr-এর সবার কাছে দৃশ্যমান এবং যে কেউ - এতে অংশগ্রহণ করতে পারে। নির্দিষ্ট বিষয়ের চারপাশে উন্মুক্ত সম্প্রদায়ের জন্য এগুলো দুর্দান্ত। - এর relay-তে পোস্ট মুছে পরিচালনা নিয়ন্ত্রণ করা যায় - এই গ্রুপটি হোস্ট করে এমন ১-৩ টি relay যোগ করুন। - Nostr ক্লায়েন্টগুলো এই সেটিং ব্যবহার করে জানে যে কোথা থেকে বার্তা ডাউনলোড করতে হবে এবং আপনার বার্তা কোথায় পাঠাতে হবে। + পাবলিক চ্যাটগুলো Nostr-এর সবার কাছে দৃশ্যমান এবং যে কেউ এতে অংশগ্রহণ করতে পারে। নির্দিষ্ট বিষয়ের চারপাশে উন্মুক্ত সম্প্রদায়ের জন্য এগুলো দুর্দান্ত। এর relay-তে পোস্ট মুছে পরিচালনা নিয়ন্ত্রণ করা যায় + এই গ্রুপটি হোস্ট করে এমন ১-৩ টি relay যোগ করুন। Nostr ক্লায়েন্টগুলো এই সেটিং ব্যবহার করে জানে যে কোথা থেকে বার্তা ডাউনলোড করতে হবে এবং আপনার বার্তা কোথায় পাঠাতে হবে। Relay-গুলি পাবলিক চ্যাট মেটাডেটা পাবলিক চ্যাট @@ -2728,10 +2708,7 @@ শুধু-পড়ার ব্যবহারকারী একটি বার্তা রেকর্ড করতে ক্লিক করুন এবং ধরে রাখুন Relay চ্যাট - Relay চ্যাটগুলো হলো চ্যাট গ্রুপ যা তাদের হোম relay দ্বারা নিয়ন্ত্রিত হয়। - এগুলো Nostr-এর সবার কাছে দৃশ্যমান এবং যে কেউ এতে অংশগ্রহণ করতে পারে। - নির্দিষ্ট বিষয়ের চারপাশে উন্মুক্ত সম্প্রদায়ের জন্য এগুলো দুর্দান্ত। এই গ্রুপগুলোর কিছু ক্ষণস্থায়ী - এবং সেজন্য চ্যাট বার্তাগুলো সময়ের সাথে মুছে যায় + Relay চ্যাটগুলো হলো চ্যাট গ্রুপ যা তাদের হোম relay দ্বারা নিয়ন্ত্রিত হয়। এগুলো Nostr-এর সবার কাছে দৃশ্যমান এবং যে কেউ এতে অংশগ্রহণ করতে পারে। নির্দিষ্ট বিষয়ের চারপাশে উন্মুক্ত সম্প্রদায়ের জন্য এগুলো দুর্দান্ত। এই গ্রুপগুলোর কিছু ক্ষণস্থায়ী এবং সেজন্য চ্যাট বার্তাগুলো সময়ের সাথে মুছে যায় Relay চ্যাট ইভেন্ট আইপি @@ -2968,4 +2945,15 @@ %1$s-এ প্রাপকের Lightning সেবা পাওয়া যাচ্ছে না। এটি Lightning ঠিকানা "%2$s" থেকে গণনা করা হয়েছে। ত্রুটি: %3$s। সার্ভার চালু আছে কিনা এবং Lightning ঠিকানাটি সঠিক কিনা পরীক্ষা করুন %1$s থেকে একটি Lightning ইনভয়েস তৈরি করতে অক্ষম: ফলাফলের JSON-এ উপাদান pr পাওয়া যায়নি। একটি Lightning ইনভয়েস তৈরি করতে অক্ষম। %1$s থেকে বার্তা: %2$s + + + + diff --git a/commonsUI/src/commonMain/composeResources/values-cs/strings.xml b/commonsUI/src/commonMain/composeResources/values-cs/strings.xml index d64c41b311..b21d6ee05a 100644 --- a/commonsUI/src/commonMain/composeResources/values-cs/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-cs/strings.xml @@ -473,13 +473,8 @@ Označit všechny známé jako přečtené Označit všechny nové jako přečtené Označit všechny jako přečtené - ## Tipy pro zálohování klíčů a bezpečnost - \n\nVaše konto je zabezpečeno tajným klíčem. Klíč je dlouhá posloupnost znaků začínající s **nsec1**. Kdo má přístup k tomuto tajnému klíči, může přispívat a měnit vaši identitu. - \n\n- **Nedávejte** svůj tajný klíč na žádnou webovou stránku nebo do žádného softwaru, kterému nedůvěřujete. - \n- Vývojáři Amethystu nikdy **nebudou** žádat o váš tajný klíč. - \n- **Udržujte** bezpečnou zálohu vašeho tajného klíče pro obnovení účtu. Doporučujeme použití správce hesel. - Pro další zabezpečení můžete svůj klíč zašifrovat heslem. Tento klíč začíná s **ncryptsec1** a nelze ho použít bez vašeho hesla. - \n\nPokud zapomenete heslo, nebudete moci obnovit svůj klíč. + ## Tipy pro zálohování klíčů a bezpečnost \n\nVaše konto je zabezpečeno tajným klíčem. Klíč je dlouhá posloupnost znaků začínající s **nsec1**. Kdo má přístup k tomuto tajnému klíči, může přispívat a měnit vaši identitu. \n\n- **Nedávejte** svůj tajný klíč na žádnou webovou stránku nebo do žádného softwaru, kterému nedůvěřujete. \n- Vývojáři Amethystu nikdy **nebudou** žádat o váš tajný klíč. \n- **Udržujte** bezpečnou zálohu vašeho tajného klíče pro obnovení účtu. Doporučujeme použití správce hesel. + Pro další zabezpečení můžete svůj klíč zašifrovat heslem. Tento klíč začíná s **ncryptsec1** a nelze ho použít bez vašeho hesla. \n\nPokud zapomenete heslo, nebudete moci obnovit svůj klíč. Zálohujte si klíče Váš tajný klíč je jediný způsob, jak se k tomuto účtu dostat. Pokud ho ztratíte, nelze ho už nikdy obnovit. Uložte si ho někam do bezpečí. Zálohovat nyní @@ -1598,9 +1593,7 @@ Zakázat Push oznámení Použít aplikaci %1$s Nastavení Push-oznámení - Pro příjem Push-oznámení nainstalujte libovolnou aplikaci, která podporuje [Unified Push](https://unifiedpush.org/), jako je [Ntfy](https://ntfy.sh/). - Po instalaci vyberte aplikaci kterou chcete používat v Nastavení. - + Pro příjem Push-oznámení nainstalujte libovolnou aplikaci, která podporuje [Unified Push](https://unifiedpush.org/), jako je [Ntfy](https://ntfy.sh/). Po instalaci vyberte aplikaci kterou chcete používat v Nastavení. Vlákno Poslat prodejci zprávu Ahoj, je to stále k dispozici? @@ -3393,14 +3386,7 @@ Nelze získat fakturu z %1$s: %2$s Nepodařilo se otevřít komprimovaný soubor Peněženka nebyla nalezená - Amethyst nenašel žádné aplikace peněženek, které podporují Nostr Wallet Connect (NWC). -\n\nCo můžete udělat: -\n\t1. Ověřte podporu NWC: Zkontrolujte, že vaše oblíbená peněženka podporuje Nostr Wallet Connect. -\n\t2. Vytvořte připojení: Otevřete svou peněženku a vytvořte nové NWC připojení. -\n\t3. Propojte s Amethyst: Vaše peněženka obvykle nabídne tři možnosti: -\n\t\t3.1 Automatické propojení: Tlačítko pro automatické otevření a připojení k Amethyst. Klikněte na něj, otevřete Amethyst a uložte na nové obrazovce. -\n\t\t3.2 Ruční propojení: URI připojení, které můžete zkopírovat a vložit přes ikonu vložení na této obrazovce. -\n\t\t3.3 QR kód: Peněženka vytvoří QR kód, který můžete naskenovat pomocí tlačítka QR kódu na této obrazovce. + Amethyst nenašel žádné aplikace peněženek, které podporují Nostr Wallet Connect (NWC). \n\nCo můžete udělat: \n\t1. Ověřte podporu NWC: Zkontrolujte, že vaše oblíbená peněženka podporuje Nostr Wallet Connect. \n\t2. Vytvořte připojení: Otevřete svou peněženku a vytvořte nové NWC připojení. \n\t3. Propojte s Amethyst: Vaše peněženka obvykle nabídne tři možnosti: \n\t\t3.1 Automatické propojení: Tlačítko pro automatické otevření a připojení k Amethyst. Klikněte na něj, otevřete Amethyst a uložte na nové obrazovce. \n\t\t3.2 Ruční propojení: URI připojení, které můžete zkopírovat a vložit přes ikonu vložení na této obrazovce. \n\t\t3.3 QR kód: Peněženka vytvoří QR kód, který můžete naskenovat pomocí tlačítka QR kódu na této obrazovce. Vytvořit d Nebezpečná zóna @@ -4611,11 +4597,8 @@ Proxy přenašeče Agregační přenašeče, které aplikace používá ke stahování vašich zdrojů, jako např. filter.nostr.wine Veřejný chat - Veřejné konverzace jsou viditelné pro všechny na Nostru a kdokoli - se na nich může podílet. Jsou skvělé pro otevřené komunity kolem konkrétních témat. - Moderaci lze ovládat smazáním příspěvků na relé - Vložte mezi 1–3 relé, které hostí tuto skupinu. - Klienti Nostr používají toto nastavení, aby věděli, kam stahovat zprávy a odesílat zprávy. + Veřejné konverzace jsou viditelné pro všechny na Nostru a kdokoli se na nich může podílet. Jsou skvělé pro otevřené komunity kolem konkrétních témat. Moderaci lze ovládat smazáním příspěvků na relé + Vložte mezi 1–3 relé, které hostí tuto skupinu. Klienti Nostr používají toto nastavení, aby věděli, kam stahovat zprávy a odesílat zprávy. Rele Metadata veřejného chatu Veřejné chaty @@ -4684,10 +4667,7 @@ auth autentizace relay podepsat ověřit nip-42 identita Autentizace relay Relé chat - Relay chaty jsou chatové skupiny ovládané jejich domácí relé. - Jsou viditelné pro všechny na Nostru a každý se jich může zúčastnit. - Jsou skvělé pro otevřené komunity kolem konkrétních témat. Některé z těchto skupin jsou efemérní - a proto zprávy časem mizí + Relay chaty jsou chatové skupiny ovládané jejich domácí relé. Jsou viditelné pro všechny na Nostru a každý se jich může zúčastnit. Jsou skvělé pro otevřené komunity kolem konkrétních témat. Některé z těchto skupin jsou efemérní a proto zprávy časem mizí Relé chaty Váš vlastní profil, nastavení a koncepty na vašich domovských relayích. Relaye, na které každá komunita publikuje své roviny. @@ -5148,4 +5128,15 @@ Služba blesku příjemce v %1$s není k dispozici. Byla vypočtena z adresy blesku "%2$s". Chyba: %3$s. Zkontrolujte, zda je server aktivní a zda je adresa blesku správná Nelze vytvořit bleskovou fakturu z %1$s: Prvek pr nebyl nalezen ve výsledném JSON. Nelze vytvořit bleskovou fakturu. Zpráva od %1$s: %2$s + + + + diff --git a/commonsUI/src/commonMain/composeResources/values-de-rDE/strings.xml b/commonsUI/src/commonMain/composeResources/values-de-rDE/strings.xml index 595a734106..17617a8ae7 100644 --- a/commonsUI/src/commonMain/composeResources/values-de-rDE/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-de-rDE/strings.xml @@ -361,7 +361,7 @@ Benutzer-ID kopieren Benutzer freigeben Dieser Nutzer ist blockiert/stummgeschaltet. Seine Beiträge sind ausgeblendet. - npub, Benutzername, Text + npub, Benutzername, Text Löschen App-Logo nsec.. oder npub.. @@ -463,13 +463,8 @@ Alle Kontakte als gelesen markieren Alle Unbekannten als gelesen markieren Alle als gelesen markieren - ## Tipps zur Schlüsselsicherheit - \n\nDein Konto ist durch einen privaten Schlüssel gesichert. Der Schlüssel ist eine lange Zeichenfolge, die mit **nsec1** beginnt. Jeder, der Zugriff auf diesen privaten Schlüssel hat, kann in deinem Namen posten und dein Profil ändern. - \n\n- Lege deinen privaten Schlüssel **nicht** auf einer Website oder in einer Software ab, der du nicht vertraust. - \n- Die Entwickler von Amethyst werden dich **niemals** nach deinem privaten Schlüssel fragen. - \n- **Bewahre** eine sichere Kopie deines privaten Schlüssels zur Kontowiederherstellung auf. Wir empfehlen die Verwendung eines Passwort-Managers. - Für zusätzliche Sicherheit kannst du deinen Schlüssel mit einem Passwort verschlüsseln. Dieser Schlüssel beginnt mit **ncryptsec1** und kann ohne dein Passwort nicht verwendet werden. - \n\nWenn du dein Passwort verlierst, kannst du deinen Schlüssel nicht wiederherstellen. + ## Tipps zur Schlüsselsicherheit \n\nDein Konto ist durch einen privaten Schlüssel gesichert. Der Schlüssel ist eine lange Zeichenfolge, die mit **nsec1** beginnt. Jeder, der Zugriff auf diesen privaten Schlüssel hat, kann in deinem Namen posten und dein Profil ändern. \n\n- Lege deinen privaten Schlüssel **nicht** auf einer Website oder in einer Software ab, der du nicht vertraust. \n- Die Entwickler von Amethyst werden dich **niemals** nach deinem privaten Schlüssel fragen. \n- **Bewahre** eine sichere Kopie deines privaten Schlüssels zur Kontowiederherstellung auf. Wir empfehlen die Verwendung eines Passwort-Managers. + Für zusätzliche Sicherheit kannst du deinen Schlüssel mit einem Passwort verschlüsseln. Dieser Schlüssel beginnt mit **ncryptsec1** und kann ohne dein Passwort nicht verwendet werden. \n\nWenn du dein Passwort verlierst, kannst du deinen Schlüssel nicht wiederherstellen. Sichere deine Schlüssel Dein geheimer Schlüssel ist der einzige Weg zu diesem Konto. Wenn du ihn verlierst, kann er nie wiederhergestellt werden. Speichere ihn jetzt an einem sicheren Ort. Jetzt sichern @@ -1584,9 +1579,7 @@ Push-Benachrichtigungen deaktivieren Verwendet die App %1$s Push-Benachrichtigungen einrichten - Um Push-Benachrichtigungen zu erhalten, installiere eine App, die [Unified Push](https://unifiedpush.org/) unterstützt, wie zum Beispiel [Ntfy](https://ntfy.sh/). - Nach der Installation wähle in den Einstellungen die App aus, die du verwenden möchtest. - + Um Push-Benachrichtigungen zu erhalten, installiere eine App, die [Unified Push](https://unifiedpush.org/) unterstützt, wie zum Beispiel [Ntfy](https://ntfy.sh/). Nach der Installation wähle in den Einstellungen die App aus, die du verwenden möchtest. Faden Dem Verkäufer eine Nachricht senden Hallo, ist das noch verfügbar? @@ -3323,14 +3316,7 @@ Rechnung konnte nicht von %1$s abgerufen werden: %2$s Konnte die komprimierte Datei nicht öffnen Wallet nicht gefunden - Amethyst konnte keine Wallet-Apps finden, die Nostr Wallet Connect (NWC) unterstützen. -\n\nWas du tun kannst: -\n\t1. NWC-Unterstützung überprüfen: Stelle sicher, dass deine bevorzugte Wallet-App mit Nostr Wallet Connect kompatibel ist. -\n\t2. Verbindung erstellen: Öffne deine Wallet-App und erstelle eine neue NWC-Verbindung. -\n\t3. Mit Amethyst verknüpfen: Deine Wallet bietet normalerweise drei Optionen: -\n\t\t3.1 Automatische Verknüpfung: Eine Schaltfläche zum automatischen Öffnen und Verbinden mit Amethyst. Klicke darauf, öffne Amethyst und speichere auf dem neuen Bildschirm. -\n\t\t3.2 Manuelle Verknüpfung: Eine Verbindungs-URI, die du kopieren und über das Einfügesymbol auf diesem Bildschirm einfügen kannst. -\n\t\t3.3 QR-Code: Die Wallet erstellt einen QR-Code, den du über die QR-Schaltfläche auf diesem Bildschirm scannen kannst. + Amethyst konnte keine Wallet-Apps finden, die Nostr Wallet Connect (NWC) unterstützen. \n\nWas du tun kannst: \n\t1. NWC-Unterstützung überprüfen: Stelle sicher, dass deine bevorzugte Wallet-App mit Nostr Wallet Connect kompatibel ist. \n\t2. Verbindung erstellen: Öffne deine Wallet-App und erstelle eine neue NWC-Verbindung. \n\t3. Mit Amethyst verknüpfen: Deine Wallet bietet normalerweise drei Optionen: \n\t\t3.1 Automatische Verknüpfung: Eine Schaltfläche zum automatischen Öffnen und Verbinden mit Amethyst. Klicke darauf, öffne Amethyst und speichere auf dem neuen Bildschirm. \n\t\t3.2 Manuelle Verknüpfung: Eine Verbindungs-URI, die du kopieren und über das Einfügesymbol auf diesem Bildschirm einfügen kannst. \n\t\t3.3 QR-Code: Die Wallet erstellt einen QR-Code, den du über die QR-Schaltfläche auf diesem Bildschirm scannen kannst. Erstellen t Gefahrenzone @@ -4427,11 +4413,8 @@ Proxy-Relays Aggregator-Relays wie filter.nostr.wine, von denen die App deine Feeds herunterlädt Öffentlicher Chat - Öffentliche Chats sind für jeden auf Nostr sichtbar und jeder - kann daran teilnehmen. Sie eignen sich hervorragend für offene Gemeinschaften rund um bestimmte Themen. - Moderation kann durch Löschen von Beiträgen auf den Relays gesteuert werden - Füge zwischen 1–3 Relays ein, die diese Gruppe beherbergen. - Nostr Clients verwenden diese Einstellung, um zu wissen, woher sie Nachrichten herunterladen und an welche gesendet werden sollen. + Öffentliche Chats sind für jeden auf Nostr sichtbar und jeder kann daran teilnehmen. Sie eignen sich hervorragend für offene Gemeinschaften rund um bestimmte Themen. Moderation kann durch Löschen von Beiträgen auf den Relays gesteuert werden + Füge zwischen 1–3 Relays ein, die diese Gruppe beherbergen. Nostr Clients verwenden diese Einstellung, um zu wissen, woher sie Nachrichten herunterladen und an welche gesendet werden sollen. Relays Öffentliche Chat Metadaten Öffentliche Chats @@ -4496,10 +4479,7 @@ auth authentifizierung relay signieren verifizieren nip-42 identität Relay-Authentifizierung Relay-Chat - Relay-Chats sind Chatgruppen, die von ihrem Home-Relay gesteuert werden. - Sie sind für jeden auf Nostr sichtbar und jeder kann an ihnen teilnehmen. - Sie eignen sich hervorragend für offene Communities rund um bestimmte Themen. Einige dieser Gruppen sind kurzsichtig - und daher verschwinden Chat-Nachrichten im Laufe der Zeit + Relay-Chats sind Chatgruppen, die von ihrem Home-Relay gesteuert werden. Sie sind für jeden auf Nostr sichtbar und jeder kann an ihnen teilnehmen. Sie eignen sich hervorragend für offene Communities rund um bestimmte Themen. Einige dieser Gruppen sind kurzsichtig und daher verschwinden Chat-Nachrichten im Laufe der Zeit Relay-Chats Dein eigenes Profil, deine Einstellungen und Entwürfe auf deinen Heim-Relays. Die Relays, auf denen jede Community ihre Planes veröffentlicht. @@ -4932,4 +4912,15 @@ Der Lightning-Service des Empfängers unter %1$s ist nicht verfügbar. Er wurde aus der Lightning-Adresse "%2$s" berechnet. Fehler: %3$s. Überprüfen Sie, ob der Server online ist und ob die Lightning-Adresse korrekt ist Kann keine Blitzrechnung von %1$s erstellen: Element pr nicht im resultierenden JSON gefunden. Blitzrechnung konnte nicht erstellt werden. Nachricht von %1$s: %2$s + + + + diff --git a/commonsUI/src/commonMain/composeResources/values-de/strings.xml b/commonsUI/src/commonMain/composeResources/values-de/strings.xml index fe28311d40..a1bc04624c 100644 --- a/commonsUI/src/commonMain/composeResources/values-de/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-de/strings.xml @@ -211,7 +211,7 @@ Benutzer-ID kopieren Benutzer freigeben Dieser Nutzer ist blockiert/stummgeschaltet. Seine Beiträge sind ausgeblendet. - Npub, Hex, Benutzername + Npub, Hex, Benutzername Löschen App-Logo Nsec / Npub / Hex-Privatschlüssel @@ -219,9 +219,7 @@ Passwort anzeigen Ich akzeptiere die Nutzungsbedingungen - Die Akzept - -anz der Bedingungen ist erforderlich + Die Akzept anz der Bedingungen ist erforderlich Anmeldung Anmelden Konto erstellen @@ -261,13 +259,8 @@ anz der Bedingungen ist erforderlich Alle als bekannt markieren Alle als neu markieren Alle als gelesen markieren - ## Schlüsselsicherheits-Tipps - \n\nIhr Konto ist durch einen geheimen Schlüssel gesichert. Der Schlüssel ist eine lange Zeichenfolge, die mit **nsec1** beginnt. Jeder, der Zugriff auf diesen geheimen Schlüssel hat, kann Ihre Identität posten und ändern. - \n\n- Legen Sie Ihren geheimen Schlüssel **nicht** auf eine Website oder in eine Software, der Sie nicht vertrauen. - \n- Die Entwickler von Amethyst werden Sie **niemals** nach Ihrem geheimen Schlüssel fragen. - \n- **Bewahren Sie** eine sichere Sicherung Ihres geheimen Schlüssels zur Kontowiederherstellung auf. Wir empfehlen die Verwendung eines Passwort-Managers. - Für zusätzliche Sicherheit können Sie Ihren Schlüssel mit einem Passwort verschlüsseln. Dieser Schlüssel beginnt mit **ncryptsec1** und kann ohne Ihr Passwort nicht verwendet werden. - \n\nWenn Sie Ihr Passwort verlieren, können Sie Ihren Schlüssel nicht wiederherstellen. + ## Schlüsselsicherheits-Tipps \n\nIhr Konto ist durch einen geheimen Schlüssel gesichert. Der Schlüssel ist eine lange Zeichenfolge, die mit **nsec1** beginnt. Jeder, der Zugriff auf diesen geheimen Schlüssel hat, kann Ihre Identität posten und ändern. \n\n- Legen Sie Ihren geheimen Schlüssel **nicht** auf eine Website oder in eine Software, der Sie nicht vertrauen. \n- Die Entwickler von Amethyst werden Sie **niemals** nach Ihrem geheimen Schlüssel fragen. \n- **Bewahren Sie** eine sichere Sicherung Ihres geheimen Schlüssels zur Kontowiederherstellung auf. Wir empfehlen die Verwendung eines Passwort-Managers. + Für zusätzliche Sicherheit können Sie Ihren Schlüssel mit einem Passwort verschlüsseln. Dieser Schlüssel beginnt mit **ncryptsec1** und kann ohne Ihr Passwort nicht verwendet werden. \n\nWenn Sie Ihr Passwort verlieren, können Sie Ihren Schlüssel nicht wiederherstellen. Verschlüsseln und kopieren Sie meinen geheimen Schlüssel Auszeichnungsbild für %1$s Auszeichnungsbild @@ -960,9 +953,7 @@ anz der Bedingungen ist erforderlich Push-Benachrichtigungen deaktivieren Verwendet die App %1$s Push-Benachrichtigungen einrichten - Um Push-Benachrichtigungen zu erhalten, installieren Sie eine App, die [Unified Push](https://unifiedpush.org/) unterstützt, wie zum Beispiel [Ntfy](https://ntfy.sh/). - Nach der Installation wählen Sie die App aus, die Sie in den Einstellungen verwenden möchten. - + Um Push-Benachrichtigungen zu erhalten, installieren Sie eine App, die [Unified Push](https://unifiedpush.org/) unterstützt, wie zum Beispiel [Ntfy](https://ntfy.sh/). Nach der Installation wählen Sie die App aus, die Sie in den Einstellungen verwenden möchten. Faden Dem Verkäufer eine Nachricht senden Hallo, ist das noch verfügbar? @@ -2064,14 +2055,7 @@ anz der Bedingungen ist erforderlich Rechnung konnte nicht von %1$s abgerufen werden: %2$s Konnte die komprimierte Datei nicht öffnen Wallet nicht gefunden - Amethyst konnte keine Wallet-Apps finden, die Nostr Wallet Connect (NWC) unterstützen. -\n\nWas du tun kannst: -\n\t1. NWC-Unterstützung überprüfen: Stelle sicher, dass deine bevorzugte Wallet-App mit Nostr Wallet Connect kompatibel ist. -\n\t2. Verbindung erstellen: Öffne deine Wallet-App und erstelle eine neue NWC-Verbindung. -\n\t3. Mit Amethyst verknüpfen: Deine Wallet bietet normalerweise drei Optionen: -\n\t\t3.1 Automatische Verknüpfung: Eine Schaltfläche zum automatischen Öffnen und Verbinden mit Amethyst. Klicke darauf, öffne Amethyst und speichere auf dem neuen Bildschirm. -\n\t\t3.2 Manuelle Verknüpfung: Eine Verbindungs-URI, die du kopieren und über das Einfügesymbol auf diesem Bildschirm einfügen kannst. -\n\t\t3.3 QR-Code: Die Wallet erstellt einen QR-Code, den du über die QR-Schaltfläche auf diesem Bildschirm scannen kannst. + Amethyst konnte keine Wallet-Apps finden, die Nostr Wallet Connect (NWC) unterstützen. \n\nWas du tun kannst: \n\t1. NWC-Unterstützung überprüfen: Stelle sicher, dass deine bevorzugte Wallet-App mit Nostr Wallet Connect kompatibel ist. \n\t2. Verbindung erstellen: Öffne deine Wallet-App und erstelle eine neue NWC-Verbindung. \n\t3. Mit Amethyst verknüpfen: Deine Wallet bietet normalerweise drei Optionen: \n\t\t3.1 Automatische Verknüpfung: Eine Schaltfläche zum automatischen Öffnen und Verbinden mit Amethyst. Klicke darauf, öffne Amethyst und speichere auf dem neuen Bildschirm. \n\t\t3.2 Manuelle Verknüpfung: Eine Verbindungs-URI, die du kopieren und über das Einfügesymbol auf diesem Bildschirm einfügen kannst. \n\t\t3.3 QR-Code: Die Wallet erstellt einen QR-Code, den du über die QR-Schaltfläche auf diesem Bildschirm scannen kannst. Erstellen t Gefahrenzone @@ -2274,9 +2258,7 @@ anz der Bedingungen ist erforderlich Schleife erkannt - Der Server erkennt eine Endlosschleife, während er die Anfrage bearbeitet Netzwerk-Authentifizierung erforderlich - Der Client muss authentifiziert werden, um auf das Netzwerk zuzugreifen Illegales Verhalten - Bild in der Gal - -erie gespeichert + Bild in der Gal erie gespeichert Vortäuschung Kontakte, folgen, Folgeliste, importieren Eingehende Relays @@ -2777,11 +2759,8 @@ erie gespeichert Proxy-Relays Aggregator-Relays wie filter.nostr.wine, von denen die App deine Feeds herunterlädt Öffentlicher Chat - Öffentliche Chats sind für jeden auf Nostr sichtbar und jeder - kann daran teilnehmen. Sie eignen sich hervorragend für offene Gemeinschaften rund um bestimmte Themen. - Moderation kann durch Löschen von Beiträgen auf den Relais gesteuert werden - Fügen Sie zwischen 1–3 Relais ein, die diese Gruppe beherbergen. - Nostr Clients verwenden diese Einstellung, um zu wissen, woher sie Nachrichten herunterladen und an welche gesendet werden sollen. + Öffentliche Chats sind für jeden auf Nostr sichtbar und jeder kann daran teilnehmen. Sie eignen sich hervorragend für offene Gemeinschaften rund um bestimmte Themen. Moderation kann durch Löschen von Beiträgen auf den Relais gesteuert werden + Fügen Sie zwischen 1–3 Relais ein, die diese Gruppe beherbergen. Nostr Clients verwenden diese Einstellung, um zu wissen, woher sie Nachrichten herunterladen und an welche gesendet werden sollen. Relais Öffentliche Chat Metadaten Öffentliche Chats @@ -2808,10 +2787,7 @@ erie gespeichert Nur-Lese-Benutzer Zum Aufnehmen einer Nachricht gedrückt halten Relay-Chat - Relaischats sind Chatgruppen, die von ihrem Home-Relais gesteuert werden. - Sie sind für jeden auf Nostr sichtbar und jeder kann an ihnen teilnehmen. - Sie eignen sich hervorragend für offene Communities rund um bestimmte Themen. Einige dieser Gruppen sind kurzsichtig - und daher verschwinden Chat-Nachrichten im Laufe der Zeit + Relaischats sind Chatgruppen, die von ihrem Home-Relais gesteuert werden. Sie sind für jeden auf Nostr sichtbar und jeder kann an ihnen teilnehmen. Sie eignen sich hervorragend für offene Communities rund um bestimmte Themen. Einige dieser Gruppen sind kurzsichtig und daher verschwinden Chat-Nachrichten im Laufe der Zeit Relay-Chats Ereignisse IPs diff --git a/commonsUI/src/commonMain/composeResources/values-el-rGR/strings.xml b/commonsUI/src/commonMain/composeResources/values-el-rGR/strings.xml index 100d10a1b4..ffe6a08835 100644 --- a/commonsUI/src/commonMain/composeResources/values-el-rGR/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-el-rGR/strings.xml @@ -271,15 +271,8 @@ Σήμανση όλων των γνωστοποιήσεων ως αναγνωσμένων Σήμανση όλων ως αναγνωσμένων Σήμανση όλων ως αναγνωσμένα - ## Συμβουλές ασφαλείας και δημιουργίας αντιγράφων ασφαλείας κλειδιού - \n\nΟ λογαριασμός σου ασφαλίζεται με ένα μυστικό κλειδί. Το κλειδί είναι μια μακρά ακολουθία χαρακτήρων που ξεκινά με **nsec1**. Όποιος έχει πρόσβαση σε αυτό το μυστικό κλειδί μπορεί να δημοσιεύει και να αλλάζει την ταυτότητά σου. - \n\n- **Μην** βάζεις το μυστικό σου κλειδί σε κανέναν ιστότοπο ή λογισμικό που δεν εμπιστεύεσαι. - \n- Οι προγραμματιστές του Amethyst **δεν** θα ζητήσουν ποτέ το μυστικό σου κλειδί. - \n- **Διατήρησε** ένα ασφαλές αντίγραφο ασφαλείας του μυστικού σου κλειδιού για ανάκτηση λογαριασμού. Συνιστούμε τη χρήση διαχειριστή κωδικών πρόσβασης. - - Για πρόσθετη ασφάλεια, μπορείς να κρυπτογραφήσεις το κλειδί σου με κωδικό πρόσβασης. Αυτό το κλειδί ξεκινά με **ncryptsec1** και δεν μπορεί να χρησιμοποιηθεί χωρίς τον κωδικό σου. - \n\nΑν χάσεις τον κωδικό σου, δεν θα μπορείς να ανακτήσεις το κλειδί σου. - + ## Συμβουλές ασφαλείας και δημιουργίας αντιγράφων ασφαλείας κλειδιού \n\nΟ λογαριασμός σου ασφαλίζεται με ένα μυστικό κλειδί. Το κλειδί είναι μια μακρά ακολουθία χαρακτήρων που ξεκινά με **nsec1**. Όποιος έχει πρόσβαση σε αυτό το μυστικό κλειδί μπορεί να δημοσιεύει και να αλλάζει την ταυτότητά σου. \n\n- **Μην** βάζεις το μυστικό σου κλειδί σε κανέναν ιστότοπο ή λογισμικό που δεν εμπιστεύεσαι. \n- Οι προγραμματιστές του Amethyst **δεν** θα ζητήσουν ποτέ το μυστικό σου κλειδί. \n- **Διατήρησε** ένα ασφαλές αντίγραφο ασφαλείας του μυστικού σου κλειδιού για ανάκτηση λογαριασμού. Συνιστούμε τη χρήση διαχειριστή κωδικών πρόσβασης. + Για πρόσθετη ασφάλεια, μπορείς να κρυπτογραφήσεις το κλειδί σου με κωδικό πρόσβασης. Αυτό το κλειδί ξεκινά με **ncryptsec1** και δεν μπορεί να χρησιμοποιηθεί χωρίς τον κωδικό σου. \n\nΑν χάσεις τον κωδικό σου, δεν θα μπορείς να ανακτήσεις το κλειδί σου. Κρυπτογράφηση και αντιγραφή του μυστικού μου κλειδιού Σήμα βράβευσης για %1$s "Εικόνα βραβείου παρασήμου @@ -934,8 +927,7 @@ Συντονιστές Σύνδεση με Amber Ενημέρωση κατάστασης - Η ψηφοφορία σε μια δημοσίευση Δημοσκόπησης γίνεται με zaps. Τα zaps είναι μικροπληρωμές σε satoshi (sats). Τα sats είναι υποδιαίρεση του Bitcoin. Κάθε Bitcoin έχει 100.000.000 sats. -Η επιλογή με τα περισσότερα zaps (δηλαδή τα περισσότερα sats) είναι η επικρατέστερη. Μπορείτε να ορίσετε ένα ελάχιστο ποσό zap για να αποφύγετε την ψήφο spammers αλλά και ένα μέγιστο ποσό ώστε να αποφύγετε μεγάλους zappers (χρήστες που στέλνουν zaps) που επιθυμούν να χειραγωγήσουν την ψηφοφορία. Χρησιμοποιήστε το ίδιο ποσό και στα δύο πεδία για να βεβαιωθείτε ότι κάθε ψήφος αποτιμάται το ίδιο. Ή αφήστε τα πεδία κενά για να αποδεχτείτε οποιοδήποτε ποσό. + Η ψηφοφορία σε μια δημοσίευση Δημοσκόπησης γίνεται με zaps. Τα zaps είναι μικροπληρωμές σε satoshi (sats). Τα sats είναι υποδιαίρεση του Bitcoin. Κάθε Bitcoin έχει 100.000.000 sats. Η επιλογή με τα περισσότερα zaps (δηλαδή τα περισσότερα sats) είναι η επικρατέστερη. Μπορείτε να ορίσετε ένα ελάχιστο ποσό zap για να αποφύγετε την ψήφο spammers αλλά και ένα μέγιστο ποσό ώστε να αποφύγετε μεγάλους zappers (χρήστες που στέλνουν zaps) που επιθυμούν να χειραγωγήσουν την ψηφοφορία. Χρησιμοποιήστε το ίδιο ποσό και στα δύο πεδία για να βεβαιωθείτε ότι κάθε ψήφος αποτιμάται το ίδιο. Ή αφήστε τα πεδία κενά για να αποδεχτείτε οποιοδήποτε ποσό. Μήνυμα στον Χρήστη Μήνυμα σε %1$s ΟΚ @@ -963,9 +955,7 @@ Απενεργοποίηση Ειδοποιήσεων Push Χρήση εφαρμογής %1$s Ρυθμίσεις Ειδοποιήσεων Push - Για να λαμβάνετε ειδοποιήσεις Push, εγκαταστήστε οποιαδήποτε εφαρμογή τις υποστηρίζει [Unified Push](https://unifiedpush.org/), όπως πχ το [Ntfy](https://ntfy.sh/). - Μετά την εγκατάσταση, επιλέξτε την εφαρμογή που θέλετε να χρησιμοποιήσετε από τις Ρυθμίσεις. - + Για να λαμβάνετε ειδοποιήσεις Push, εγκαταστήστε οποιαδήποτε εφαρμογή τις υποστηρίζει [Unified Push](https://unifiedpush.org/), όπως πχ το [Ntfy](https://ntfy.sh/). Μετά την εγκατάσταση, επιλέξτε την εφαρμογή που θέλετε να χρησιμοποιήσετε από τις Ρυθμίσεις. Νήμα Στείλτε μήνυμα στον πωλητή Γεια σου, είναι ακόμα διαθέσιμο; @@ -2008,15 +1998,7 @@ Δεν ήταν δυνατή η λήψη τιμολογίου από %1$s: %2$s Δεν ήταν δυνατό το άνοιγμα του συμπιεσμένου αρχείου Το πορτοφόλι δεν βρέθηκε - Το Amethyst δεν βρήκε εφαρμογές πορτοφολιού που υποστηρίζουν Nostr Wallet Connect (NWC). -\n\nΤι μπορείτε να κάνετε: -\n\t1. Επαλήθευση υποστήριξης NWC: Βεβαιωθείτε ότι η προτιμώμενη εφαρμογή πορτοφολιού σας είναι συμβατή με Nostr Wallet Connect. -\n\t2. Δημιουργία σύνδεσης: Μεταβείτε στην εφαρμογή πορτοφολιού σας και δημιουργήστε μια νέα σύνδεση NWC. -\n\t3. Σύνδεση με το Amethyst: Το πορτοφόλι σας συνήθως θα σας δώσει τρεις επιλογές: -\n\t\t3.1 Αυτόματη σύνδεση: Ένα κουμπί για αυτόματο άνοιγμα και σύνδεση με το Amethyst. Αν ναι, κάντε κλικ σε αυτό το κουμπί, ανοίξτε το Amethyst και πατήστε αποθήκευση στη νέα οθόνη. -\n\t\t3.2 Χειροκίνητη σύνδεση: Ένα URI σύνδεσης που μπορείτε να αντιγράψετε και να κάνετε κλικ στο εικονίδιο επικόλλησης σε αυτή την οθόνη για απευθείας εισαγωγή. -\n\t\t3.3 Κωδικός QR: Το πορτοφόλι δημιουργεί κωδικό QR τον οποίο μπορείτε να σαρώσετε από το κουμπί κωδικού QR σε αυτή την οθόνη. - + Το Amethyst δεν βρήκε εφαρμογές πορτοφολιού που υποστηρίζουν Nostr Wallet Connect (NWC). \n\nΤι μπορείτε να κάνετε: \n\t1. Επαλήθευση υποστήριξης NWC: Βεβαιωθείτε ότι η προτιμώμενη εφαρμογή πορτοφολιού σας είναι συμβατή με Nostr Wallet Connect. \n\t2. Δημιουργία σύνδεσης: Μεταβείτε στην εφαρμογή πορτοφολιού σας και δημιουργήστε μια νέα σύνδεση NWC. \n\t3. Σύνδεση με το Amethyst: Το πορτοφόλι σας συνήθως θα σας δώσει τρεις επιλογές: \n\t\t3.1 Αυτόματη σύνδεση: Ένα κουμπί για αυτόματο άνοιγμα και σύνδεση με το Amethyst. Αν ναι, κάντε κλικ σε αυτό το κουμπί, ανοίξτε το Amethyst και πατήστε αποθήκευση στη νέα οθόνη. \n\t\t3.2 Χειροκίνητη σύνδεση: Ένα URI σύνδεσης που μπορείτε να αντιγράψετε και να κάνετε κλικ στο εικονίδιο επικόλλησης σε αυτή την οθόνη για απευθείας εισαγωγή. \n\t\t3.3 Κωδικός QR: Το πορτοφόλι δημιουργεί κωδικό QR τον οποίο μπορείτε να σαρώσετε από το κουμπί κωδικού QR σε αυτή την οθόνη. Δημιουργία η Επικίνδυνη ζώνη @@ -2653,11 +2635,8 @@ Relay Διαμεσολαβητή Relay συγκεντρωτές που η εφαρμογή πρέπει να χρησιμοποιεί για να κατεβάσει τις τροφοδοσίες σας, όπως filter.nostr.wine. Αυτό αντικαθιστά το μοντέλο outbox και θα κάνει την εφαρμογή να συνδέεται μόνο στα relay στις λίστες σας. Δημόσια Συζήτηση - Οι δημόσιες συνομιλίες είναι ορατές σε όλους στο Nostr και οποιοσδήποτε - μπορεί να συμμετάσχει σε αυτές. Είναι ιδανικές για ανοιχτές κοινότητες γύρω από συγκεκριμένα θέματα. - Η εποπτεία μπορεί να ελεγχθεί με διαγραφή αναρτήσεων στα relay της - Εισάγετε 1–3 relay που φιλοξενούν αυτή την ομάδα. - Οι πελάτες Nostr χρησιμοποιούν αυτή τη ρύθμιση για να γνωρίζουν πού να κατεβάσουν μηνύματα και πού να στέλνουν τα μηνύματά σου. + Οι δημόσιες συνομιλίες είναι ορατές σε όλους στο Nostr και οποιοσδήποτε μπορεί να συμμετάσχει σε αυτές. Είναι ιδανικές για ανοιχτές κοινότητες γύρω από συγκεκριμένα θέματα. Η εποπτεία μπορεί να ελεγχθεί με διαγραφή αναρτήσεων στα relay της + Εισάγετε 1–3 relay που φιλοξενούν αυτή την ομάδα. Οι πελάτες Nostr χρησιμοποιούν αυτή τη ρύθμιση για να γνωρίζουν πού να κατεβάσουν μηνύματα και πού να στέλνουν τα μηνύματά σου. Μεταδεδομένα δημόσιας συνομιλίας Δημόσιες Συνομιλίες Δημόσια Relay Εξερχόμενων/Αρχικής @@ -2681,10 +2660,7 @@ Χρήστης μόνο για ανάγνωση Πάτα παρατεταμένα για να καταγράψεις ένα μήνυμα Συνομιλία relay - Οι συνομιλίες relay είναι ομάδες συνομιλίας που ελέγχονται από το αρχικό τους relay. - Είναι ορατές σε όλους στο Nostr και οποιοσδήποτε μπορεί να συμμετάσχει σε αυτές. - Είναι ιδανικές για ανοιχτές κοινότητες γύρω από συγκεκριμένα θέματα. Ορισμένες από αυτές τις ομάδες είναι εφήμερες - και έτσι τα μηνύματα συνομιλίας εξαφανίζονται με την πάροδο του χρόνου + Οι συνομιλίες relay είναι ομάδες συνομιλίας που ελέγχονται από το αρχικό τους relay. Είναι ορατές σε όλους στο Nostr και οποιοσδήποτε μπορεί να συμμετάσχει σε αυτές. Είναι ιδανικές για ανοιχτές κοινότητες γύρω από συγκεκριμένα θέματα. Ορισμένες από αυτές τις ομάδες είναι εφήμερες και έτσι τα μηνύματα συνομιλίας εξαφανίζονται με την πάροδο του χρόνου Συνομιλίες relay Συμβάντα Χρήστες @@ -2917,4 +2893,15 @@ Η υπηρεσία Lightning του αποδέκτη στο %1$s δεν είναι διαθέσιμη. Υπολογίστηκε από τη διεύθυνση Lightning "%2$s". Σφάλμα: %3$s. Ελέγξτε αν ο διακομιστής λειτουργεί και αν η διεύθυνση Lightning είναι σωστή Δεν είναι δυνατή η δημιουργία τιμολογίου Lightning από τον/την %1$s: Το στοιχείο pr δεν βρέθηκε στο αποτέλεσμα JSON. Δεν είναι δυνατή η δημιουργία τιμολογίου Lightning. Μήνυμα από τον/την %1$s: %2$s + + + + diff --git a/commonsUI/src/commonMain/composeResources/values-en-rGB/strings.xml b/commonsUI/src/commonMain/composeResources/values-en-rGB/strings.xml index d1c1c4d417..44a5f2359e 100644 --- a/commonsUI/src/commonMain/composeResources/values-en-rGB/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-en-rGB/strings.xml @@ -23,4 +23,15 @@ + + + + diff --git a/commonsUI/src/commonMain/composeResources/values-eo-rUY/strings.xml b/commonsUI/src/commonMain/composeResources/values-eo-rUY/strings.xml index d2798cecb9..35481e4172 100644 --- a/commonsUI/src/commonMain/composeResources/values-eo-rUY/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-eo-rUY/strings.xml @@ -225,7 +225,7 @@ Kopii ID de uzanto Malbloki Uzanton Tiu ĉi uzanto estas blokita/silentita. Iliaj afiŝoj estas kaŝitaj. - npub, hekso, uzantnomo + npub, hekso, uzantnomo Klarigi Logo de Apo nsec / npub / heksa privata ŝlosilo @@ -273,15 +273,8 @@ Marki ĉiujn Konatajn legitaj Marki ĉiujn Novajn legitaj Marki ĉiujn legitajn - ## Konsiletoj pri Ŝlosila Sekurkopiao kaj Sekureco - \n\nVia konto estas sekurigita per sekreta ŝlosilo. La ŝlosilo estas longa sinsekvo de signoj komencanta per **nsec1**. Ĉiu kiu havas aliron al ĉi tiu sekreta ŝlosilo povas afiŝi kaj ŝanĝi vian identecon. - \n\n- **Ne** metu vian sekretan ŝlosilon en ajnan retejon aŭ programaron, kiun vi ne fidas. - \n- Amethyst-programistoj **neniam** petos vian sekretan ŝlosilon. - \n- **Faru** sekuran sekurkopion de via sekreta ŝlosilo por reakiro de konto. Ni rekomendas uzi pasvortan administrilon. - - Por plia sekureco, vi povas ĉifri vian ŝlosilon per pasvorto. Ĉi tiu ŝlosilo komenciĝas per **ncryptsec1** kaj ne povas esti uzata sen via pasvorto. - \n\nSe vi perdas vian pasvorton, vi ne povos reakiri vian ŝlosilon. - + ## Konsiletoj pri Ŝlosila Sekurkopiao kaj Sekureco \n\nVia konto estas sekurigita per sekreta ŝlosilo. La ŝlosilo estas longa sinsekvo de signoj komencanta per **nsec1**. Ĉiu kiu havas aliron al ĉi tiu sekreta ŝlosilo povas afiŝi kaj ŝanĝi vian identecon. \n\n- **Ne** metu vian sekretan ŝlosilon en ajnan retejon aŭ programaron, kiun vi ne fidas. \n- Amethyst-programistoj **neniam** petos vian sekretan ŝlosilon. \n- **Faru** sekuran sekurkopion de via sekreta ŝlosilo por reakiro de konto. Ni rekomendas uzi pasvortan administrilon. + Por plia sekureco, vi povas ĉifri vian ŝlosilon per pasvorto. Ĉi tiu ŝlosilo komenciĝas per **ncryptsec1** kaj ne povas esti uzata sen via pasvorto. \n\nSe vi perdas vian pasvorton, vi ne povos reakiri vian ŝlosilon. Ĉifri kaj kopii mian seketan ŝlosilon Ŝildeto-premia bildo por %1$s "Insigna premibildeto @@ -971,9 +964,7 @@ Malaktivigi Puŝajn Sciigojn Uzas aplikaĵon %1$s Puŝa Sciig-Agordo - Por ricevi puŝajn sciigojn, instalu ajnan aplikaĵon kiu subtenas [Unified Push](https://unifiedpush.org/), kiel ekzemple [Ntfy](https://ntfy.sh/). - Post instalado, elektu la aplikaĵon, kiun vi volas uzi en la Agordoj. - + Por ricevi puŝajn sciigojn, instalu ajnan aplikaĵon kiu subtenas [Unified Push](https://unifiedpush.org/), kiel ekzemple [Ntfy](https://ntfy.sh/). Post instalado, elektu la aplikaĵon, kiun vi volas uzi en la Agordoj. Fadeno Sendi mesaĝon al la vendisto Saluton, ĉu ĉi tio ankoraŭ disponeblas? @@ -2031,15 +2022,7 @@ Ne eblis preni fakturon de %1$s: %2$s Ne eblis malfermi la kunpremitan dosieron Monujo ne trovita - Amethyst ne povis trovi ajnajn monujo-aplikojn, kiuj subtenas Nostr Wallet Connect (NWC). -\n\nKion vi povas fari: -\n\t1. Kontrolu NWC-subtenon: Duoble kontrolu, ĉu via preferata monujo-aplikaĵo estas kongrua kun Nostr Wallet Connect. -\n\t2. Krei Konekton: Iru al via monujo-aplikaĵo kaj kreu novan NWC-konekton. -\n\t3. Ligi al Amethyst: Via monujo kutime donos al vi tri opciojn: -\n\t\t3.1 Aŭtomata Ligo: Butono por aŭtomate malfermi kaj konekti al Amethyst. Se jes, alklaku tiun butonon, malfermu Amethyst kaj alklaku konservi sur la nova ekrano. -\n\t\t3.2 Mana Ligo: Konekta URI, kiun vi povas kopii kaj alklaki la algluu-ikonon sur ĉi tiu ekrano por rekte enigi ĝin. -\n\t\t3.3 QR-Kodo: La monujo kreas QR-kodon, kiun vi povas legi per la QR-koda butono sur ĉi tiu ekrano. - + Amethyst ne povis trovi ajnajn monujo-aplikojn, kiuj subtenas Nostr Wallet Connect (NWC). \n\nKion vi povas fari: \n\t1. Kontrolu NWC-subtenon: Duoble kontrolu, ĉu via preferata monujo-aplikaĵo estas kongrua kun Nostr Wallet Connect. \n\t2. Krei Konekton: Iru al via monujo-aplikaĵo kaj kreu novan NWC-konekton. \n\t3. Ligi al Amethyst: Via monujo kutime donos al vi tri opciojn: \n\t\t3.1 Aŭtomata Ligo: Butono por aŭtomate malfermi kaj konekti al Amethyst. Se jes, alklaku tiun butonon, malfermu Amethyst kaj alklaku konservi sur la nova ekrano. \n\t\t3.2 Mana Ligo: Konekta URI, kiun vi povas kopii kaj alklaki la algluu-ikonon sur ĉi tiu ekrano por rekte enigi ĝin. \n\t\t3.3 QR-Kodo: La monujo kreas QR-kodon, kiun vi povas legi per la QR-koda butono sur ĉi tiu ekrano. Krei t Danĝera Zono @@ -2701,11 +2684,8 @@ Prokuro-Relay-oj Agregataj relay-oj, kiujn la aplikaĵo devas uzi por elŝuti viajn fluojn, ekzemple filter.nostr.wine. Ĉi tio anstataŭas la sendkesta modelon kaj igos la aplikaĵon konektiĝi nur al la relay-oj en viaj listoj. Publika Babilado - Publikaj babilejoj estas videblaj al ĉiuj en Nostr kaj ĉiu ajn - povas partopreni en ili. Ili estas bonegaj por malfermitaj komunumoj pri specifaj temoj. - Moderado povas esti kontrolata per forigo de afiŝoj sur siaj relays - Enigu inter 1–3 relays, kiuj gastigas ĉi tiun grupon. - Nostr-klientoj uzas ĉi tiun agordon por scii, kie elŝuti mesaĝojn kaj kien sendi viajn mesaĝojn. + Publikaj babilejoj estas videblaj al ĉiuj en Nostr kaj ĉiu ajn povas partopreni en ili. Ili estas bonegaj por malfermitaj komunumoj pri specifaj temoj. Moderado povas esti kontrolata per forigo de afiŝoj sur siaj relays + Enigu inter 1–3 relays, kiuj gastigas ĉi tiun grupon. Nostr-klientoj uzas ĉi tiun agordon por scii, kie elŝuti mesaĝojn kaj kien sendi viajn mesaĝojn. Relay-oj Publikaj Babilejaj Metadatenoj Publikaj Babilejoj @@ -2730,10 +2710,7 @@ Nur-lega uzanto Alklaku kaj tenu por registri mesaĝon Relay-Babilejo - Relay-babilejoj estas babilaj grupoj kontrolataj de sia hejma relay. - Ili estas videblaj al ĉiuj en Nostr kaj ĉiu ajn povas partopreni en ili. - Ili estas bonegaj por malfermitaj komunumoj pri specifaj temoj. Kelkaj el ĉi tiuj grupoj estas efemeraj - kaj tial babilmesaĝoj malaperas kun la tempo + Relay-babilejoj estas babilaj grupoj kontrolataj de sia hejma relay. Ili estas videblaj al ĉiuj en Nostr kaj ĉiu ajn povas partopreni en ili. Ili estas bonegaj por malfermitaj komunumoj pri specifaj temoj. Kelkaj el ĉi tiuj grupoj estas efemeraj kaj tial babilmesaĝoj malaperas kun la tempo Relay-Babilejaroj Eventoj IP-oj @@ -2972,4 +2949,15 @@ La Lightning-servo de la ricevanto ĉe %1$s ne disponeblas. Ĝi estis kalkulita el la Lightning-adreso „%2$s". Eraro: %3$s. Kontrolu ĉu la servilo funkcias kaj ĉu la Lightning-adreso estas ĝusta Ne eblas krei Lightning-fakturon de %1$s: Elemento pr ne trovita en la rezulta JSON. Ne eblas krei Lightning-fakturon. Mesaĝo de %1$s: %2$s + + + + diff --git a/commonsUI/src/commonMain/composeResources/values-eo/strings.xml b/commonsUI/src/commonMain/composeResources/values-eo/strings.xml index f9cf0c8844..ef18a4b455 100644 --- a/commonsUI/src/commonMain/composeResources/values-eo/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-eo/strings.xml @@ -211,7 +211,7 @@ Kopii ID de uzanto Malbloki Uzanton Tiu ĉi uzanto estas blokita/silentita. Iliaj afiŝoj estas kaŝitaj. - npub, hekso, uzantnomo + npub, hekso, uzantnomo Klarigi Logo de Apo nsec / npub / heksa privata ŝlosilo @@ -259,17 +259,8 @@ Marki ĉiujn Konatajn legitaj Marki ĉiujn Novajn legitaj Marki ĉiujn legitajn - - ## Konsiletoj pri Ŝlosila Sekurkopiao kaj Sekureco - \n\nVia konto estas sekurigita per sekreta ŝlosilo. La ŝlosilo estas longa sinsekvo de signoj komencanta per **nsec1**. Ĉiu kiu havas aliron al ĉi tiu sekreta ŝlosilo povas afiŝi kaj ŝanĝi vian identecon. - \n\n- **Ne** metu vian sekretan ŝlosilon en ajnan retejon aŭ programaron, kiun vi ne fidas. - \n- Amethyst-programistoj **neniam** petos vian sekretan ŝlosilon. - \n- **Faru** sekuran sekurkopion de via sekreta ŝlosilo por reakiro de konto. Ni rekomendas uzi pasvortan administrilon. - - - Por plia sekureco, vi povas ĉifri vian ŝlosilon per pasvorto. Ĉi tiu ŝlosilo komenciĝas per **ncryptsec1** kaj ne povas esti uzata sen via pasvorto. - \n\nSe vi perdas vian pasvorton, vi ne povos reakiri vian ŝlosilon. - + ## Konsiletoj pri Ŝlosila Sekurkopiao kaj Sekureco \n\nVia konto estas sekurigita per sekreta ŝlosilo. La ŝlosilo estas longa sinsekvo de signoj komencanta per **nsec1**. Ĉiu kiu havas aliron al ĉi tiu sekreta ŝlosilo povas afiŝi kaj ŝanĝi vian identecon. \n\n- **Ne** metu vian sekretan ŝlosilon en ajnan retejon aŭ programaron, kiun vi ne fidas. \n- Amethyst-programistoj **neniam** petos vian sekretan ŝlosilon. \n- **Faru** sekuran sekurkopion de via sekreta ŝlosilo por reakiro de konto. Ni rekomendas uzi pasvortan administrilon. + Por plia sekureco, vi povas ĉifri vian ŝlosilon per pasvorto. Ĉi tiu ŝlosilo komenciĝas per **ncryptsec1** kaj ne povas esti uzata sen via pasvorto. \n\nSe vi perdas vian pasvorton, vi ne povos reakiri vian ŝlosilon. Ĉifri kaj kopii mian seketan ŝlosilon Ŝildeto-premia bildo por %1$s "Insigna premibildeto @@ -962,10 +953,7 @@ Malaktivigi Puŝajn Sciigojn Uzas aplikaĵon %1$s Puŝa Sciig-Agordo - - Por ricevi puŝajn sciigojn, instalu ajnan aplikaĵon kiu subtenas [Unified Push](https://unifiedpush.org/), kiel ekzemple [Ntfy](https://ntfy.sh/). - Post instalado, elektu la aplikaĵon, kiun vi volas uzi en la Agordoj. - + Por ricevi puŝajn sciigojn, instalu ajnan aplikaĵon kiu subtenas [Unified Push](https://unifiedpush.org/), kiel ekzemple [Ntfy](https://ntfy.sh/). Post instalado, elektu la aplikaĵon, kiun vi volas uzi en la Agordoj. Fadeno Sendi mesaĝon al la vendisto Saluton, ĉu ĉi tio ankoraŭ disponeblas? @@ -2058,15 +2046,7 @@ Ne eblis preni fakturon de %1$s: %2$s Ne eblis malfermi la kunpremitan dosieron Monujo ne trovita - Amethyst ne povis trovi ajnajn monujo-aplikojn, kiuj subtenas Nostr Wallet Connect (NWC). -\n\nKion vi povas fari: -\n\t1. Kontrolu NWC-subtenon: Duoble kontrolu, ĉu via preferata monujo-aplikaĵo estas kongrua kun Nostr Wallet Connect. -\n\t2. Krei Konekton: Iru al via monujo-aplikaĵo kaj kreu novan NWC-konekton. -\n\t3. Ligi al Amethyst: Via monujo kutime donos al vi tri opciojn: -\n\t\t3.1 Aŭtomata Ligo: Butono por aŭtomate malfermi kaj konekti al Amethyst. Se jes, alklaku tiun butonon, malfermu Amethyst kaj alklaku konservi sur la nova ekrano. -\n\t\t3.2 Mana Ligo: Konekta URI, kiun vi povas kopii kaj alklaki la algluu-ikonon sur ĉi tiu ekrano por rekte enigi ĝin. -\n\t\t3.3 QR-Kodo: La monujo kreas QR-kodon, kiun vi povas legi per la QR-koda butono sur ĉi tiu ekrano. - + Amethyst ne povis trovi ajnajn monujo-aplikojn, kiuj subtenas Nostr Wallet Connect (NWC). \n\nKion vi povas fari: \n\t1. Kontrolu NWC-subtenon: Duoble kontrolu, ĉu via preferata monujo-aplikaĵo estas kongrua kun Nostr Wallet Connect. \n\t2. Krei Konekton: Iru al via monujo-aplikaĵo kaj kreu novan NWC-konekton. \n\t3. Ligi al Amethyst: Via monujo kutime donos al vi tri opciojn: \n\t\t3.1 Aŭtomata Ligo: Butono por aŭtomate malfermi kaj konekti al Amethyst. Se jes, alklaku tiun butonon, malfermu Amethyst kaj alklaku konservi sur la nova ekrano. \n\t\t3.2 Mana Ligo: Konekta URI, kiun vi povas kopii kaj alklaki la algluu-ikonon sur ĉi tiu ekrano por rekte enigi ĝin. \n\t\t3.3 QR-Kodo: La monujo kreas QR-kodon, kiun vi povas legi per la QR-koda butono sur ĉi tiu ekrano. Krei t Danĝera Zono @@ -2754,11 +2734,8 @@ Prokuro-Relay-oj Agregataj relay-oj, kiujn la aplikaĵo devas uzi por elŝuti viajn fluojn, ekzemple filter.nostr.wine. Ĉi tio anstataŭas la sendkesta modelon kaj igos la aplikaĵon konektiĝi nur al la relay-oj en viaj listoj. Publika Babilado - Publikaj babilejoj estas videblaj al ĉiuj en Nostr kaj ĉiu ajn - povas partopreni en ili. Ili estas bonegaj por malfermitaj komunumoj pri specifaj temoj. - Moderado povas esti kontrolata per forigo de afiŝoj sur siaj relays - Enigu inter 1–3 relays, kiuj gastigas ĉi tiun grupon. - Nostr-klientoj uzas ĉi tiun agordon por scii, kie elŝuti mesaĝojn kaj kien sendi viajn mesaĝojn. + Publikaj babilejoj estas videblaj al ĉiuj en Nostr kaj ĉiu ajn povas partopreni en ili. Ili estas bonegaj por malfermitaj komunumoj pri specifaj temoj. Moderado povas esti kontrolata per forigo de afiŝoj sur siaj relays + Enigu inter 1–3 relays, kiuj gastigas ĉi tiun grupon. Nostr-klientoj uzas ĉi tiun agordon por scii, kie elŝuti mesaĝojn kaj kien sendi viajn mesaĝojn. Relay-oj Publikaj Babilejaj Metadatenoj Publikaj Babilejoj @@ -2783,10 +2760,7 @@ Nur-lega uzanto Alklaku kaj tenu por registri mesaĝon Relay-Babilejo - Relay-babilejoj estas babilaj grupoj kontrolataj de sia hejma relay. - Ili estas videblaj al ĉiuj en Nostr kaj ĉiu ajn povas partopreni en ili. - Ili estas bonegaj por malfermitaj komunumoj pri specifaj temoj. Kelkaj el ĉi tiuj grupoj estas efemeraj - kaj tial babilmesaĝoj malaperas kun la tempo + Relay-babilejoj estas babilaj grupoj kontrolataj de sia hejma relay. Ili estas videblaj al ĉiuj en Nostr kaj ĉiu ajn povas partopreni en ili. Ili estas bonegaj por malfermitaj komunumoj pri specifaj temoj. Kelkaj el ĉi tiuj grupoj estas efemeraj kaj tial babilmesaĝoj malaperas kun la tempo Relay-Babilejaroj Eventoj IP-oj diff --git a/commonsUI/src/commonMain/composeResources/values-es-rES/strings.xml b/commonsUI/src/commonMain/composeResources/values-es-rES/strings.xml index 2c001b81ca..45b069c10c 100644 --- a/commonsUI/src/commonMain/composeResources/values-es-rES/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-es-rES/strings.xml @@ -224,7 +224,7 @@ Copiar ID de usuario Desbloquear usuario Este usuario está bloqueado/silenciado. Sus publicaciones están ocultas. - npub, hex, username + npub, hex, username Limpiar Logo de la app nsec o npub @@ -272,15 +272,8 @@ Marcar los conocidos como leídos Marcar los nuevos como leídos Marcar todos como leídos - ## Consejos sobre la protección y la copia de seguridad de las claves - \n\nTu cuenta está protegida por una clave secreta. La clave es una larga secuencia de caracteres que empieza por **nsec1**. Cualquiera que tenga acceso a esta clave secreta puede publicar y cambiar tu identidad. - \n\n- **No** guardes la clave secreta en ningún sitio web o software en los que no confíes. - \n- Los desarrolladores de Amethyst **nunca** te pedirán la clave secreta. - \n- **Sí** puedes realizar una copia de seguridad de la clave secreta para recuperar la cuenta. Para ello, te recomendamos usar un gestor de contraseñas. - - Para mayor seguridad, puedes cifrar la clave con una contraseña. Esta clave empieza por **ncryptsec1** y no puede utilizarse sin tu contraseña. - \n\nSi pierdes la contraseña, no podrás recuperar tu clave. - + ## Consejos sobre la protección y la copia de seguridad de las claves \n\nTu cuenta está protegida por una clave secreta. La clave es una larga secuencia de caracteres que empieza por **nsec1**. Cualquiera que tenga acceso a esta clave secreta puede publicar y cambiar tu identidad. \n\n- **No** guardes la clave secreta en ningún sitio web o software en los que no confíes. \n- Los desarrolladores de Amethyst **nunca** te pedirán la clave secreta. \n- **Sí** puedes realizar una copia de seguridad de la clave secreta para recuperar la cuenta. Para ello, te recomendamos usar un gestor de contraseñas. + Para mayor seguridad, puedes cifrar la clave con una contraseña. Esta clave empieza por **ncryptsec1** y no puede utilizarse sin tu contraseña. \n\nSi pierdes la contraseña, no podrás recuperar tu clave. Cifrar y copiar mi clave secreta Imagen del Badge para %1$s "Imagen de premio de insignia @@ -962,9 +955,7 @@ Desactiva las notificaciones push Usa la app %1$s Configuración de notificaciones push - Para recibir notificaciones push, instala cualquier aplicación compatible con [Unified Push](https://unifiedpush.org/), como [Ntfy](https://ntfy.sh/). - Tras la instalación, selecciona la aplicación que deseas usar en la configuración. - + Para recibir notificaciones push, instala cualquier aplicación compatible con [Unified Push](https://unifiedpush.org/), como [Ntfy](https://ntfy.sh/). Tras la instalación, selecciona la aplicación que deseas usar en la configuración. Conversación Envía un mensaje al vendedor Hola, ¿esto todavía está disponible? @@ -2028,15 +2019,7 @@ No se ha podido obtener la factura de %1$s: %2$s No se pudo abrir el archivo comprimido Cartera no encontrada - Amethyst no encontró ninguna aplicación de cartera compatible con Nostr Wallet Connect (NWC). -\n\nQué puedes hacer: -\n\t1. Verificar la compatibilidad con NWC: Comprueba que tu aplicación de cartera preferida es compatible con Nostr Wallet Connect. -\n\t2. Crear conexión: Ve a tu aplicación de cartera y crea una nueva conexión NWC. -\n\t3. Vincular con Amethyst: Tu cartera normalmente te ofrecerá tres opciones: -\n\t\t3.1 Enlace automático: Un botón para abrir y conectar automáticamente a Amethyst. Si es así, pulsa ese botón, abre Amethyst y guarda en la nueva pantalla. -\n\t\t3.2 Enlace manual: Un URI de conexión que puedes copiar y pegar usando el icono de pegado en esta pantalla para introducirlo directamente. -\n\t\t3.3 Código QR: La cartera genera un código QR que puedes leer con el botón de código QR en esta pantalla. - + Amethyst no encontró ninguna aplicación de cartera compatible con Nostr Wallet Connect (NWC). \n\nQué puedes hacer: \n\t1. Verificar la compatibilidad con NWC: Comprueba que tu aplicación de cartera preferida es compatible con Nostr Wallet Connect. \n\t2. Crear conexión: Ve a tu aplicación de cartera y crea una nueva conexión NWC. \n\t3. Vincular con Amethyst: Tu cartera normalmente te ofrecerá tres opciones: \n\t\t3.1 Enlace automático: Un botón para abrir y conectar automáticamente a Amethyst. Si es así, pulsa ese botón, abre Amethyst y guarda en la nueva pantalla. \n\t\t3.2 Enlace manual: Un URI de conexión que puedes copiar y pegar usando el icono de pegado en esta pantalla para introducirlo directamente. \n\t\t3.3 Código QR: La cartera genera un código QR que puedes leer con el botón de código QR en esta pantalla. Crear d Zona peligrosa @@ -2715,11 +2698,8 @@ Relés de proxy Relés recopiladores que la app debe usar para descargar tus fuentes, como filter.nostr.wine. Esto reemplaza el modelo de bandeja de salida y hará que la app se conecte solo a los relés de tus listas. Chat público - Los chats públicos son visibles para todos en Nostr y cualquiera - puede participar en ellos. Son ideales para comunidades abiertas alrededor de temas específicos. - La moderación puede controlarse eliminando publicaciones en sus relés. - Inserta entre 1 y 3 relés que alojan este grupo. - Los clientes de Nostr utilizan esta configuración para saber de dónde descargar y adónde enviar los mensajes. + Los chats públicos son visibles para todos en Nostr y cualquiera puede participar en ellos. Son ideales para comunidades abiertas alrededor de temas específicos. La moderación puede controlarse eliminando publicaciones en sus relés. + Inserta entre 1 y 3 relés que alojan este grupo. Los clientes de Nostr utilizan esta configuración para saber de dónde descargar y adónde enviar los mensajes. Relés Metadatos públicos del chat Chats públicos @@ -2744,10 +2724,7 @@ Usuario de solo lectura Haz clic y mantén pulsado para grabar un mensaje Chat en relé - Los chats en relé son grupos de chat controlados por su relé de origen. - Son visibles para todos los usuarios de Nostr y cualquiera puede participar en ellos. - Son ideales para comunidades abiertas en torno a temas específicos. Algunos de estos grupos son efímeros - y, por lo tanto, los mensajes del chat desaparecen con el tiempo + Los chats en relé son grupos de chat controlados por su relé de origen. Son visibles para todos los usuarios de Nostr y cualquiera puede participar en ellos. Son ideales para comunidades abiertas en torno a temas específicos. Algunos de estos grupos son efímeros y, por lo tanto, los mensajes del chat desaparecen con el tiempo Chats en relé Eventos Tipos @@ -2988,4 +2965,15 @@ El servicio de Lightning del destinatario en %1$s no está disponible. Se calculó a partir de la dirección de Lightning "%2$s". Error: %3$s. Comprueba si el servidor está activo y si la dirección de Lightning es correcta. No se pudo crear una factura de Lightning desde %1$s: no se encontró el elemento pr en el JSON resultante. No se pudo crear una factura de Lightning. Mensaje de %1$s: %2$s. + + + + diff --git a/commonsUI/src/commonMain/composeResources/values-es-rMX/strings.xml b/commonsUI/src/commonMain/composeResources/values-es-rMX/strings.xml index dd88cd7f7f..d941f9ea99 100644 --- a/commonsUI/src/commonMain/composeResources/values-es-rMX/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-es-rMX/strings.xml @@ -272,15 +272,8 @@ Marcar todos los conocidos como leídos Marcar todos los nuevos como leídos Marcar todos como leídos - ## Consejos sobre la protección y la copia de seguridad de las claves - \n\nTu cuenta está protegida por una clave secreta. La clave es una larga secuencia de caracteres que empieza por **nsec1**. Cualquiera que tenga acceso a esta clave secreta puede publicar y cambiar tu identidad. - \n\n- **No** guardes la clave secreta en ningún sitio web o software en los que no confíes. - \n- Los desarrolladores de Amethyst **nunca** te pedirán la clave secreta. - \n- **Sí** puedes realizar una copia de seguridad de la clave secreta para recuperar la cuenta. Para ello, te recomendamos usar un gestor de contraseñas. - - Para mayor seguridad, puedes cifrar la clave con una contraseña. Esta clave empieza por **ncryptsec1** y no puede utilizarse sin tu contraseña. - \n\nSi pierdes la contraseña, no podrás recuperar tu clave. - + ## Consejos sobre la protección y la copia de seguridad de las claves \n\nTu cuenta está protegida por una clave secreta. La clave es una larga secuencia de caracteres que empieza por **nsec1**. Cualquiera que tenga acceso a esta clave secreta puede publicar y cambiar tu identidad. \n\n- **No** guardes la clave secreta en ningún sitio web o software en los que no confíes. \n- Los desarrolladores de Amethyst **nunca** te pedirán la clave secreta. \n- **Sí** puedes realizar una copia de seguridad de la clave secreta para recuperar la cuenta. Para ello, te recomendamos usar un gestor de contraseñas. + Para mayor seguridad, puedes cifrar la clave con una contraseña. Esta clave empieza por **ncryptsec1** y no puede utilizarse sin tu contraseña. \n\nSi pierdes la contraseña, no podrás recuperar tu clave. Cifrar y copiar mi clave secreta Imagen de premio de insignia por %1$s "Imagen de premio de insignia @@ -961,9 +954,7 @@ Desactiva las notificaciones push Usa la app %1$s Configuración de notificaciones push - Para recibir notificaciones push, instala cualquier aplicación compatible con [Unified Push](https://unifiedpush.org/), como [Ntfy](https://ntfy.sh/). - Tras la instalación, selecciona la aplicación que deseas usar en la configuración. - + Para recibir notificaciones push, instala cualquier aplicación compatible con [Unified Push](https://unifiedpush.org/), como [Ntfy](https://ntfy.sh/). Tras la instalación, selecciona la aplicación que deseas usar en la configuración. Hilo Envía un mensaje al vendedor Hola, ¿esto todavía está disponible? @@ -2027,15 +2018,7 @@ No se pudo obtener la factura de %1$s: %2$s No se pudo abrir el archivo comprimido Billetera no encontrada - Amethyst no encontró ninguna aplicación de cartera compatible con Nostr Wallet Connect (NWC). -\n\nQué puedes hacer: -\n\t1. Verificar la compatibilidad con NWC: Comprueba que tu aplicación de cartera preferida es compatible con Nostr Wallet Connect. -\n\t2. Crear conexión: Ve a tu aplicación de cartera y crea una nueva conexión NWC. -\n\t3. Vincular con Amethyst: Tu cartera normalmente te ofrecerá tres opciones: -\n\t\t3.1 Enlace automático: Un botón para abrir y conectar automáticamente a Amethyst. Si es así, pulsa ese botón, abre Amethyst y guarda en la nueva pantalla. -\n\t\t3.2 Enlace manual: Un URI de conexión que puedes copiar y pegar usando el icono de pegado en esta pantalla para introducirlo directamente. -\n\t\t3.3 Código QR: La cartera genera un código QR que puedes leer con el botón de código QR en esta pantalla. - + Amethyst no encontró ninguna aplicación de cartera compatible con Nostr Wallet Connect (NWC). \n\nQué puedes hacer: \n\t1. Verificar la compatibilidad con NWC: Comprueba que tu aplicación de cartera preferida es compatible con Nostr Wallet Connect. \n\t2. Crear conexión: Ve a tu aplicación de cartera y crea una nueva conexión NWC. \n\t3. Vincular con Amethyst: Tu cartera normalmente te ofrecerá tres opciones: \n\t\t3.1 Enlace automático: Un botón para abrir y conectar automáticamente a Amethyst. Si es así, pulsa ese botón, abre Amethyst y guarda en la nueva pantalla. \n\t\t3.2 Enlace manual: Un URI de conexión que puedes copiar y pegar usando el icono de pegado en esta pantalla para introducirlo directamente. \n\t\t3.3 Código QR: La cartera genera un código QR que puedes leer con el botón de código QR en esta pantalla. Crear d Zona peligrosa @@ -2708,11 +2691,8 @@ Relés de proxy Relés recopiladores que la app debe usar para descargar tus feeds, como filter.nostr.wine. Esto reemplaza el modelo de bandeja de salida y hará que la app se conecte solo a los relés de tus listas. Chat público - Los chats públicos son visibles para todos en Nostr y cualquiera - puede participar en ellos. Son ideales para comunidades abiertas alrededor de temas específicos. - La moderación puede controlarse eliminando publicaciones en sus relés. - Inserta entre 1 y 3 relés que alojan este grupo. - Los clientes de Nostr utilizan esta configuración para saber de dónde descargar y adónde enviar los mensajes. + Los chats públicos son visibles para todos en Nostr y cualquiera puede participar en ellos. Son ideales para comunidades abiertas alrededor de temas específicos. La moderación puede controlarse eliminando publicaciones en sus relés. + Inserta entre 1 y 3 relés que alojan este grupo. Los clientes de Nostr utilizan esta configuración para saber de dónde descargar y adónde enviar los mensajes. Relés Metadatos públicos del chat Chats públicos @@ -2737,10 +2717,7 @@ Usuario de solo lectura Haz clic y mantén presionado para grabar un mensaje Chat en relé - Los chats en relé son grupos de chat controlados por su relé de origen. - Son visibles para todos los usuarios de Nostr y cualquiera puede participar en ellos. - Son ideales para comunidades abiertas en torno a temas específicos. Algunos de estos grupos son efímeros - y, por lo tanto, los mensajes del chat desaparecen con el tiempo + Los chats en relé son grupos de chat controlados por su relé de origen. Son visibles para todos los usuarios de Nostr y cualquiera puede participar en ellos. Son ideales para comunidades abiertas en torno a temas específicos. Algunos de estos grupos son efímeros y, por lo tanto, los mensajes del chat desaparecen con el tiempo Chats en relé Eventos Tipos @@ -2981,4 +2958,15 @@ El servicio de Lightning del destinatario en %1$s no está disponible. Se calculó a partir de la dirección de Lightning "%2$s". Error: %3$s. Comprueba si el servidor está activo y si la dirección de Lightning es correcta. No se pudo crear una factura de Lightning desde %1$s: no se encontró el elemento pr en el JSON resultante. No se pudo crear una factura de Lightning. Mensaje de %1$s: %2$s. + + + + diff --git a/commonsUI/src/commonMain/composeResources/values-es-rUS/strings.xml b/commonsUI/src/commonMain/composeResources/values-es-rUS/strings.xml index 3aef3ede45..3e6060ab9c 100644 --- a/commonsUI/src/commonMain/composeResources/values-es-rUS/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-es-rUS/strings.xml @@ -272,15 +272,8 @@ Marcar todos los conocidos como leídos Marcar todos los nuevos como leídos Marcar todos como leídos - ## Consejos sobre la protección y la copia de seguridad de las claves - \n\nTu cuenta está protegida por una clave secreta. La clave es una larga secuencia de caracteres que empieza por **nsec1**. Cualquiera que tenga acceso a esta clave secreta puede publicar y cambiar tu identidad. - \n\n- **No** guardes la clave secreta en ningún sitio web o software en los que no confíes. - \n- Los desarrolladores de Amethyst **nunca** te pedirán la clave secreta. - \n- **Sí** puedes realizar una copia de seguridad de la clave secreta para recuperar la cuenta. Para ello, te recomendamos usar un gestor de contraseñas. - - Para mayor seguridad, puedes cifrar la clave con una contraseña. Esta clave empieza por **ncryptsec1** y no puede utilizarse sin tu contraseña. - \n\nSi pierdes la contraseña, no podrás recuperar tu clave. - + ## Consejos sobre la protección y la copia de seguridad de las claves \n\nTu cuenta está protegida por una clave secreta. La clave es una larga secuencia de caracteres que empieza por **nsec1**. Cualquiera que tenga acceso a esta clave secreta puede publicar y cambiar tu identidad. \n\n- **No** guardes la clave secreta en ningún sitio web o software en los que no confíes. \n- Los desarrolladores de Amethyst **nunca** te pedirán la clave secreta. \n- **Sí** puedes realizar una copia de seguridad de la clave secreta para recuperar la cuenta. Para ello, te recomendamos usar un gestor de contraseñas. + Para mayor seguridad, puedes cifrar la clave con una contraseña. Esta clave empieza por **ncryptsec1** y no puede utilizarse sin tu contraseña. \n\nSi pierdes la contraseña, no podrás recuperar tu clave. Cifrar y copiar mi clave secreta Imagen de premio de insignia por %1$s "Imagen de premio de insignia @@ -961,9 +954,7 @@ Desactiva las notificaciones push Usa la app %1$s Configuración de notificaciones push - Para recibir notificaciones push, instala cualquier aplicación compatible con [Unified Push](https://unifiedpush.org/), como [Ntfy](https://ntfy.sh/). - Tras la instalación, selecciona la aplicación que deseas usar en la configuración. - + Para recibir notificaciones push, instala cualquier aplicación compatible con [Unified Push](https://unifiedpush.org/), como [Ntfy](https://ntfy.sh/). Tras la instalación, selecciona la aplicación que deseas usar en la configuración. Hilo Envía un mensaje al vendedor Hola, ¿esto todavía está disponible? @@ -2027,15 +2018,7 @@ No se pudo obtener la factura de %1$s: %2$s No se pudo abrir el archivo comprimido Billetera no encontrada - Amethyst no encontró ninguna aplicación de cartera compatible con Nostr Wallet Connect (NWC). -\n\nQué puedes hacer: -\n\t1. Verificar la compatibilidad con NWC: Comprueba que tu aplicación de cartera preferida es compatible con Nostr Wallet Connect. -\n\t2. Crear conexión: Ve a tu aplicación de cartera y crea una nueva conexión NWC. -\n\t3. Vincular con Amethyst: Tu cartera normalmente te ofrecerá tres opciones: -\n\t\t3.1 Enlace automático: Un botón para abrir y conectar automáticamente a Amethyst. Si es así, pulsa ese botón, abre Amethyst y guarda en la nueva pantalla. -\n\t\t3.2 Enlace manual: Un URI de conexión que puedes copiar y pegar usando el icono de pegado en esta pantalla para introducirlo directamente. -\n\t\t3.3 Código QR: La cartera genera un código QR que puedes leer con el botón de código QR en esta pantalla. - + Amethyst no encontró ninguna aplicación de cartera compatible con Nostr Wallet Connect (NWC). \n\nQué puedes hacer: \n\t1. Verificar la compatibilidad con NWC: Comprueba que tu aplicación de cartera preferida es compatible con Nostr Wallet Connect. \n\t2. Crear conexión: Ve a tu aplicación de cartera y crea una nueva conexión NWC. \n\t3. Vincular con Amethyst: Tu cartera normalmente te ofrecerá tres opciones: \n\t\t3.1 Enlace automático: Un botón para abrir y conectar automáticamente a Amethyst. Si es así, pulsa ese botón, abre Amethyst y guarda en la nueva pantalla. \n\t\t3.2 Enlace manual: Un URI de conexión que puedes copiar y pegar usando el icono de pegado en esta pantalla para introducirlo directamente. \n\t\t3.3 Código QR: La cartera genera un código QR que puedes leer con el botón de código QR en esta pantalla. Crear d Zona peligrosa @@ -2708,11 +2691,8 @@ Relés de proxy Relés recopiladores que la app debe usar para descargar tus feeds, como filter.nostr.wine. Esto reemplaza el modelo de bandeja de salida y hará que la app se conecte solo a los relés de tus listas. Chat público - Los chats públicos son visibles para todos en Nostr y cualquiera - puede participar en ellos. Son ideales para comunidades abiertas alrededor de temas específicos. - La moderación puede controlarse eliminando mensajes en sus relés. - Inserta entre 1 y 3 relés que alojan este grupo. - Los clientes de Nostr utilizan esta configuración para saber de dónde descargar y adónde enviar los mensajes. + Los chats públicos son visibles para todos en Nostr y cualquiera puede participar en ellos. Son ideales para comunidades abiertas alrededor de temas específicos. La moderación puede controlarse eliminando mensajes en sus relés. + Inserta entre 1 y 3 relés que alojan este grupo. Los clientes de Nostr utilizan esta configuración para saber de dónde descargar y adónde enviar los mensajes. Relés Metadatos públicos del chat Chats públicos @@ -2737,10 +2717,7 @@ Usuario de solo lectura Haz clic y mantén presionado para grabar un mensaje Chat en relé - Los chats en relé son grupos de chat controlados por su relé de origen. - Son visibles para todos los usuarios de Nostr y cualquiera puede participar en ellos. - Son ideales para comunidades abiertas en torno a temas específicos. Algunos de estos grupos son efímeros - y, por lo tanto, los mensajes del chat desaparecen con el tiempo + Los chats en relé son grupos de chat controlados por su relé de origen. Son visibles para todos los usuarios de Nostr y cualquiera puede participar en ellos. Son ideales para comunidades abiertas en torno a temas específicos. Algunos de estos grupos son efímeros y, por lo tanto, los mensajes del chat desaparecen con el tiempo Chats en relé Eventos Tipos @@ -2981,4 +2958,15 @@ El servicio de Lightning del destinatario en %1$s no está disponible. Se calculó a partir de la dirección de Lightning "%2$s". Error: %3$s. Comprueba si el servidor está activo y si la dirección de Lightning es correcta. No se pudo crear una factura de Lightning desde %1$s: no se encontró el elemento pr en el JSON resultante. No se pudo crear una factura de Lightning. Mensaje de %1$s: %2$s. + + + + diff --git a/commonsUI/src/commonMain/composeResources/values-es/strings.xml b/commonsUI/src/commonMain/composeResources/values-es/strings.xml index d9570812e6..0f52873364 100644 --- a/commonsUI/src/commonMain/composeResources/values-es/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-es/strings.xml @@ -211,7 +211,7 @@ Copiar ID de usuario Desbloquear usuario Este usuario está bloqueado/silenciado. Sus publicaciones están ocultas. - npub, hex, username + npub, hex, username Limpiar Logo de la app nsec / npub / hex private key @@ -951,9 +951,7 @@ Desactiva las notificaciones push Usa la app %1$s Configuración de notificaciones push - Para recibir notificaciones push, instala cualquier aplicación compatible con [Unified Push](https://unifiedpush.org/), como [Ntfy](https://ntfy.sh/). - Tras la instalación, selecciona la aplicación que deseas usar en la configuración. - + Para recibir notificaciones push, instala cualquier aplicación compatible con [Unified Push](https://unifiedpush.org/), como [Ntfy](https://ntfy.sh/). Tras la instalación, selecciona la aplicación que deseas usar en la configuración. Conversación Envía un mensaje al vendedor Hola, ¿esto todavía está disponible? @@ -1802,15 +1800,8 @@ URL base de la API del explorador Pagar factura del DVM Pagar %1$s sats al DVM - ## Consejos sobre la protección y la copia de seguridad de las claves - \n\nTu cuenta está protegida por una clave secreta. La clave es una larga secuencia de caracteres que empieza por **nsec1**. Cualquiera que tenga acceso a esta clave secreta puede publicar y cambiar tu identidad. - \n\n- **No** guardes la clave secreta en ningún sitio web o software en los que no confíes. - \n- Los desarrolladores de Amethyst **nunca** te pedirán la clave secreta. - \n- **Sí** puedes realizar una copia de seguridad de la clave secreta para recuperar la cuenta. Para ello, te recomendamos usar un gestor de contraseñas. - - Para mayor seguridad, puedes cifrar la clave con una contraseña. Esta clave empieza por **ncryptsec1** y no puede utilizarse sin tu contraseña. - \n\nSi pierdes la contraseña, no podrás recuperar tu clave. - + ## Consejos sobre la protección y la copia de seguridad de las claves \n\nTu cuenta está protegida por una clave secreta. La clave es una larga secuencia de caracteres que empieza por **nsec1**. Cualquiera que tenga acceso a esta clave secreta puede publicar y cambiar tu identidad. \n\n- **No** guardes la clave secreta en ningún sitio web o software en los que no confíes. \n- Los desarrolladores de Amethyst **nunca** te pedirán la clave secreta. \n- **Sí** puedes realizar una copia de seguridad de la clave secreta para recuperar la cuenta. Para ello, te recomendamos usar un gestor de contraseñas. + Para mayor seguridad, puedes cifrar la clave con una contraseña. Esta clave empieza por **ncryptsec1** y no puede utilizarse sin tu contraseña. \n\nSi pierdes la contraseña, no podrás recuperar tu clave. Usar URL directa nSite: %1$s nApplet: %1$s @@ -2055,15 +2046,7 @@ No se ha podido obtener la factura de %1$s: %2$s No se pudo abrir el archivo comprimido Cartera no encontrada - Amethyst no encontró ninguna aplicación de cartera compatible con Nostr Wallet Connect (NWC). -\n\nQué puedes hacer: -\n\t1. Verificar la compatibilidad con NWC: Comprueba que tu aplicación de cartera preferida es compatible con Nostr Wallet Connect. -\n\t2. Crear conexión: Ve a tu aplicación de cartera y crea una nueva conexión NWC. -\n\t3. Vincular con Amethyst: Tu cartera normalmente te ofrecerá tres opciones: -\n\t\t3.1 Enlace automático: Un botón para abrir y conectar automáticamente a Amethyst. Si es así, pulsa ese botón, abre Amethyst y guarda en la nueva pantalla. -\n\t\t3.2 Enlace manual: Un URI de conexión que puedes copiar y pegar usando el icono de pegado en esta pantalla para introducirlo directamente. -\n\t\t3.3 Código QR: La cartera genera un código QR que puedes leer con el botón de código QR en esta pantalla. - + Amethyst no encontró ninguna aplicación de cartera compatible con Nostr Wallet Connect (NWC). \n\nQué puedes hacer: \n\t1. Verificar la compatibilidad con NWC: Comprueba que tu aplicación de cartera preferida es compatible con Nostr Wallet Connect. \n\t2. Crear conexión: Ve a tu aplicación de cartera y crea una nueva conexión NWC. \n\t3. Vincular con Amethyst: Tu cartera normalmente te ofrecerá tres opciones: \n\t\t3.1 Enlace automático: Un botón para abrir y conectar automáticamente a Amethyst. Si es así, pulsa ese botón, abre Amethyst y guarda en la nueva pantalla. \n\t\t3.2 Enlace manual: Un URI de conexión que puedes copiar y pegar usando el icono de pegado en esta pantalla para introducirlo directamente. \n\t\t3.3 Código QR: La cartera genera un código QR que puedes leer con el botón de código QR en esta pantalla. Crear d Zona peligrosa @@ -2751,11 +2734,8 @@ Relés de proxy Relés recopiladores que la app debe usar para descargar tus fuentes, como filter.nostr.wine. Esto reemplaza el modelo de bandeja de salida y hará que la app se conecte solo a los relés de tus listas. Chat público - Los chats públicos son visibles para todos en Nostr y cualquiera - puede participar en ellos. Son ideales para comunidades abiertas alrededor de temas específicos. - La moderación puede controlarse eliminando publicaciones en sus relés. - Inserta entre 1 y 3 relés que alojan este grupo. - Los clientes de Nostr utilizan esta configuración para saber de dónde descargar y adónde enviar los mensajes. + Los chats públicos son visibles para todos en Nostr y cualquiera puede participar en ellos. Son ideales para comunidades abiertas alrededor de temas específicos. La moderación puede controlarse eliminando publicaciones en sus relés. + Inserta entre 1 y 3 relés que alojan este grupo. Los clientes de Nostr utilizan esta configuración para saber de dónde descargar y adónde enviar los mensajes. Relés Metadatos públicos del chat Chats públicos @@ -2780,10 +2760,7 @@ Usuario de solo lectura Haz clic y mantén pulsado para grabar un mensaje Chat en relé - Los chats en relé son grupos de chat controlados por su relé de origen. - Son visibles para todos los usuarios de Nostr y cualquiera puede participar en ellos. - Son ideales para comunidades abiertas en torno a temas específicos. Algunos de estos grupos son efímeros - y, por lo tanto, los mensajes del chat desaparecen con el tiempo + Los chats en relé son grupos de chat controlados por su relé de origen. Son visibles para todos los usuarios de Nostr y cualquiera puede participar en ellos. Son ideales para comunidades abiertas en torno a temas específicos. Algunos de estos grupos son efímeros y, por lo tanto, los mensajes del chat desaparecen con el tiempo Chats en relé Eventos IPs diff --git a/commonsUI/src/commonMain/composeResources/values-fa-rIR/strings.xml b/commonsUI/src/commonMain/composeResources/values-fa-rIR/strings.xml index 41e4b8540e..df5f3b4363 100644 --- a/commonsUI/src/commonMain/composeResources/values-fa-rIR/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-fa-rIR/strings.xml @@ -10,7 +10,7 @@ - پاسخ به + پاسخ به مجوزها: سایت اصلی @@ -226,7 +226,7 @@ کپی شناسه کاربر از بلاک درآوردن کاربر این کاربر مسدود/بی‌صدا شده است. پست‌های آن‌ها مخفی است. - npub, hex, نام کاربری + npub, hex, نام کاربری پاک کردن لوگوی اپ nsec / npub / hex کلید خصوصی @@ -248,7 +248,7 @@ در حال جستجوی پیام اصلی… این پیام پیدا نشد همه relay ها جستجو شد · برای مشاهده ضربه بزنید - :خطا در بارگیری پاسخ ها + :خطا در بارگیری پاسخ ها دوباره تلاش کنید هنوز هیچ اعلانی نیست. نظرسنجی باز @@ -274,14 +274,8 @@ همه شناس ها خوانده شده کن همه جدیدها را خوانده شده کن همه را خوانده شده کن - نکات ایمنی و ذخیره کلید - -حساب کاربری شما با یک کلید خصوصی محافظت می شود. این کلید دنباله ای بلند از کاراکترها است که با **nsec1** آغاز می گردد. هر کس به این کلید خصوصی دسترسی داشته باشد می تواند بجای شما یادداشت پست کرده یا هویت شما را تغییر دهد. -هرگز کلید خصوصی خود را در هیچ وبسایت با نرم افزاری که به آن اطمینان ندارید **وارد نکنید**. -سازندگان امتیست **هرگز** کلیدتان را از شما نمی خواهند. -یک پشتیبان یدکی مخفی از کلید خود به منظور بازیابی **نگه دارید**. ما استفاده از نرم افزار مدیریت پسورد را توصیه می کنیم. - برای ایمنی بیشتر، می توانید کلید خود را با یک رمزعبور رمزنگاری کنید. این نوع کلید با **ncryptsec1** آغاز می شود و نمی تواند بدون رمز عبور شما استفاده شود. -اگر پسوردتان را گم کنید، نخواهید توانست کلیدتان را بازیابی کنید. + نکات ایمنی و ذخیره کلید حساب کاربری شما با یک کلید خصوصی محافظت می شود. این کلید دنباله ای بلند از کاراکترها است که با **nsec1** آغاز می گردد. هر کس به این کلید خصوصی دسترسی داشته باشد می تواند بجای شما یادداشت پست کرده یا هویت شما را تغییر دهد. هرگز کلید خصوصی خود را در هیچ وبسایت با نرم افزاری که به آن اطمینان ندارید **وارد نکنید**. سازندگان امتیست **هرگز** کلیدتان را از شما نمی خواهند. یک پشتیبان یدکی مخفی از کلید خود به منظور بازیابی **نگه دارید**. ما استفاده از نرم افزار مدیریت پسورد را توصیه می کنیم. + برای ایمنی بیشتر، می توانید کلید خود را با یک رمزعبور رمزنگاری کنید. این نوع کلید با **ncryptsec1** آغاز می شود و نمی تواند بدون رمز عبور شما استفاده شود. اگر پسوردتان را گم کنید، نخواهید توانست کلیدتان را بازیابی کنید. رمزنگاری و کپی کلید من تصویر مدال پاداش برای %1$s تصویر جایزه نشان @@ -301,7 +295,7 @@ حذف از گالری این رسانه را از گالری خود حذف کنید. درخواست حذف - آماتیست درخواست می کند که یادداشت شما از رله هایی که درحال حاضر به آن متصل هستید حذف شود. هیچ تضمینی نیست که یادداشت شما برای همیشه از آن رله ها یا از رله های دیگری که ممکن است در آنها ذخیره شده باشد حذف خواهد شد.. + آماتیست درخواست می کند که یادداشت شما از رله هایی که درحال حاضر به آن متصل هستید حذف شود. هیچ تضمینی نیست که یادداشت شما برای همیشه از آن رله ها یا از رله های دیگری که ممکن است در آنها ذخیره شده باشد حذف خواهد شد.. بلاک حذف بلاک @@ -970,9 +964,7 @@ غیرفعال سازی اعلان ها از اپلیکیشن %1$s استفاده می کند تنظیمات اعلان ها - برای دریافت اعلان ها، می توانید هر اپلیکیشنی که از [Unified Push](https://unifiedpush.org/) پشتیبانی می کند نصب کنید، مانند[Ntfy](https://ntfy.sh/). - پس از نصب، اپلیکیشنی را که می خواهید استفاده کنید در تنظیمات انتخاب کنید. - + برای دریافت اعلان ها، می توانید هر اپلیکیشنی که از [Unified Push](https://unifiedpush.org/) پشتیبانی می کند نصب کنید، مانند[Ntfy](https://ntfy.sh/). پس از نصب، اپلیکیشنی را که می خواهید استفاده کنید در تنظیمات انتخاب کنید. رسته به فروشنده پیغام دهید سلام، هنوز موجوده؟ @@ -1315,10 +1307,7 @@ نمایش nprofile به عنوان کد QR تنظیم رله های صندوق دریافت خصوصی با این تنظیمات همه می توانند بدانند که هنگام ارسال پیام به شما از کدام رله ها استفاده کنند. بدون این تنظیمات ممکن است برخی پیام ها را از دست بدهید. - گزینه های خوب: -- inbox.nostr.wine (پولی) -- auth.nostr1.com (رایگان) -- you.nostr1.com (رله شخصی - پولی) + گزینه های خوب: - inbox.nostr.wine (پولی) - auth.nostr1.com (رایگان) - you.nostr1.com (رله شخصی - پولی) از ۱ تا ۳ رله به عنوان رله خصوصی وارد کنید. رله های صندوق دربافت پیام های خصوصی می بایست هر پیامی از هرکس را بپذیرند، ولی تنها به شما اجازه بارگیری پیام ها را می دهند. هم اکنون تنظیم شود relay های صندوق ورودی DM یافت نشد. پیام‌ها تحویل داده نمی‌شوند تا زمانی که لیست relay خود را پیکربندی کنند. @@ -1345,7 +1334,7 @@ به توسعه دهندگان رپ دهید! هدیه شما به ما برای تاثیرگذاری کمک می کند. هر ساتوشی به حساب می آید! اکنون کمک کنید - تقدیم از طرف: + تقدیم از طرف: این نسخه نرم‌افزار پیشکش از: نسخه %1$s متشکریم! @@ -2025,7 +2014,7 @@ (@note1)شناسه یادداشت در کلیپبورد کپی شد یادداشت در کلیپبورد کپی شد در کلیپ‌بورد کپی شد - @npubنویسنده در کلیپبورد کپی شد + @npubنویسنده در کلیپبورد کپی شد کلید خصوصیم را کپی کن کپی به کلیپ‌بورد LNUrl از آدرس لایتنینگ "%1$s" بدست نیامد. تنظیمات کاربر را بررسی کنید. @@ -2034,15 +2023,7 @@ صورتحساب از %1$s گرفته نشد: %2$s فایل فشرده باز نشد کیف پول یافت نشد - Amethyst هیچ برنامه کیف پولی که از Nostr Wallet Connect (NWC) پشتیبانی کند پیدا نکرد. -\n\nکارهایی که می‌توانید انجام دهید: -\n\t1. بررسی پشتیبانی NWC: مطمئن شوید که برنامه کیف پول مورد نظر شما با Nostr Wallet Connect سازگار است. -\n\t2. ایجاد اتصال: به برنامه کیف پول خود بروید و یک اتصال NWC جدید ایجاد کنید. -\n\t3. اتصال به Amethyst: کیف پول شما معمولاً سه گزینه ارائه می‌دهد: -\n\t\t3.1 اتصال خودکار: دکمه‌ای برای باز کردن و اتصال خودکار به Amethyst. اگر این گزینه وجود دارد، آن دکمه را بزنید، Amethyst را باز کنید و روی صفحه جدید ذخیره کنید. -\n\t\t3.2 اتصال دستی: یک URI اتصال که می‌توانید کپی کنید و با کلیک روی آیکون paste در این صفحه مستقیماً وارد کنید. -\n\t\t3.3 کد QR: کیف پول یک کد QR ایجاد می‌کند که می‌توانید با دکمه کد QR در این صفحه آن را بخوانید. - + Amethyst هیچ برنامه کیف پولی که از Nostr Wallet Connect (NWC) پشتیبانی کند پیدا نکرد. \n\nکارهایی که می‌توانید انجام دهید: \n\t1. بررسی پشتیبانی NWC: مطمئن شوید که برنامه کیف پول مورد نظر شما با Nostr Wallet Connect سازگار است. \n\t2. ایجاد اتصال: به برنامه کیف پول خود بروید و یک اتصال NWC جدید ایجاد کنید. \n\t3. اتصال به Amethyst: کیف پول شما معمولاً سه گزینه ارائه می‌دهد: \n\t\t3.1 اتصال خودکار: دکمه‌ای برای باز کردن و اتصال خودکار به Amethyst. اگر این گزینه وجود دارد، آن دکمه را بزنید، Amethyst را باز کنید و روی صفحه جدید ذخیره کنید. \n\t\t3.2 اتصال دستی: یک URI اتصال که می‌توانید کپی کنید و با کلیک روی آیکون paste در این صفحه مستقیماً وارد کنید. \n\t\t3.3 کد QR: کیف پول یک کد QR ایجاد می‌کند که می‌توانید با دکمه کد QR در این صفحه آن را بخوانید. ساختن روز منطقه خطر @@ -2689,10 +2670,7 @@ سیاست حریم خصوصی <پیام خصوصی رمزگشایی نشد>\\n\nدر یک پیام خصوصی/رمزنگاری شده بین %1$s و %2$s نام شما آمده بود. رله های صندوق دریافت پیام خصوصی - بین ۱-۳ رله به عنوان رله صندوق ورودی خصوصی وارد کنید. دیگران از این رله ها برای ارسال پیام خصوصی به شما استفاده خواهند کرد. رله های صندوق دریافت پیام خصوصی می بایست هر پیامی از هر کسی را بپذیرند، اما فقط بگذارند شما آن ها را بارگیری کنید. انتخاب های خوب شامل: - - inbox.nostr.wine (پولی) -- auth.nostr1.com (رایگان) -- you.nostr1.com (رله پولی یا شخصی) + بین ۱-۳ رله به عنوان رله صندوق ورودی خصوصی وارد کنید. دیگران از این رله ها برای ارسال پیام خصوصی به شما استفاده خواهند کرد. رله های صندوق دریافت پیام خصوصی می بایست هر پیامی از هر کسی را بپذیرند، اما فقط بگذارند شما آن ها را بارگیری کنید. انتخاب های خوب شامل: - inbox.nostr.wine (پولی) - auth.nostr1.com (رایگان) - you.nostr1.com (رله پولی یا شخصی) کاربر DM ها را روی این relay ها دریافت می‌کند پیام خصوصی خصوصی کردن: یادداشت را به صورت gift-wrap فقط برای کاربران مطلع‌شده ارسال کن @@ -2713,11 +2691,8 @@ relay های پراکسی relay های تجمیع‌کننده که برنامه باید برای دانلود فیدهای شما از آن‌ها استفاده کند، مانند filter.nostr.wine. این جایگزین مدل outbox می‌شود و باعث می‌شود برنامه فقط به relay های موجود در لیست‌هایتان متصل شود. گفتگوی عمومی - گفتگوهای عمومی در ناستر برای همه نمایان هستند - می توانند در آن شرکت کنند. آنها برای انجمن های باز درباره موضوعات به‌خصوص عالی هستند. - نظارت می تواند توسط حذف پست ها از رله ها انجام گیرد - برای میزبانی این گروه ۱تا ۳ رله وارد کنید. - کلاینت های ناستر از این تنظیمات استفاده می کنند تا بدانند پیام ها را از کجا بارگیری کنند و به کجا بفرستند. + گفتگوهای عمومی در ناستر برای همه نمایان هستند می توانند در آن شرکت کنند. آنها برای انجمن های باز درباره موضوعات به‌خصوص عالی هستند. نظارت می تواند توسط حذف پست ها از رله ها انجام گیرد + برای میزبانی این گروه ۱تا ۳ رله وارد کنید. کلاینت های ناستر از این تنظیمات استفاده می کنند تا بدانند پیام ها را از کجا بارگیری کنند و به کجا بفرستند. رله ها فراداده گفتگوی عمومی گفتگوهای عمومی @@ -2742,10 +2717,7 @@ کاربر با امکان خواندن فقط برای ضبط پیام کلیک کنید و نگه دارید گفتگوی relay - گفتگوهای relay گروه‌های چت هستند که توسط relay خانگی خود کنترل می‌شوند. - آن‌ها برای همه در Nostr قابل مشاهده هستند و هر کسی می‌تواند در آن‌ها شرکت کند. - آن‌ها برای جوامع باز حول موضوعات خاص عالی هستند. برخی از این گروه‌ها ناپایدار - هستند و بنابراین پیام‌های چت با گذشت زمان ناپدید می‌شوند + گفتگوهای relay گروه‌های چت هستند که توسط relay خانگی خود کنترل می‌شوند. آن‌ها برای همه در Nostr قابل مشاهده هستند و هر کسی می‌تواند در آن‌ها شرکت کند. آن‌ها برای جوامع باز حول موضوعات خاص عالی هستند. برخی از این گروه‌ها ناپایدار هستند و بنابراین پیام‌های چت با گذشت زمان ناپدید می‌شوند گفتگوهای relay رویدادها آدرس‌های IP @@ -2903,7 +2875,7 @@ تور را برای رله های ارسال/دریافت الزامی کن تایید آدرس ناستر تور را برای تایید آدرس های NIP-05 الزامی کن - رله ها / آدرس های Onion + رله ها / آدرس های Onion از تور برای هر آدرس اونیون استفاده کن تصاویر نمایه تور را برای بارگیری تصاویر نمایه اجباری کن @@ -2987,4 +2959,15 @@ خدمات لایتنینگ %1$s گیرنده دردسترس نیست. از آدرس لایتنینگ "%2$s" محاسبه شد. خطا: %3$s. بررسی کنید آیا سرور کار می کند و آیا آدرس لایتنینگ صحیح است. نمی توان صورتحساب لایتنینگ از %1$s ساخت. المنت pr در JSON بدست آمده یافت نشد. صورت حساب لایتنینگ ساخته نشد. پیغام از %1$s: %2$s + + + + diff --git a/commonsUI/src/commonMain/composeResources/values-fa/strings.xml b/commonsUI/src/commonMain/composeResources/values-fa/strings.xml index a20d36b070..cf8347e236 100644 --- a/commonsUI/src/commonMain/composeResources/values-fa/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-fa/strings.xml @@ -88,7 +88,7 @@ نقل قول فورک مبلغ جدید به ساتوشی - پاسخ به + پاسخ به و دنبال شونده دنبال کننده @@ -211,7 +211,7 @@ کپی شناسه کاربر از بلاک درآوردن کاربر این کاربر مسدود/بی‌صدا شده است. پست‌های آن‌ها مخفی است. - npub, hex, نام کاربری + npub, hex, نام کاربری پاک کردن لوگوی اپ nsec / npub / hex کلید خصوصی @@ -233,7 +233,7 @@ در حال جستجوی پیام اصلی… این پیام پیدا نشد همه relay ها جستجو شد · برای مشاهده ضربه بزنید - :خطا در بارگیری پاسخ ها + :خطا در بارگیری پاسخ ها دوباره تلاش کنید هنوز هیچ اعلانی نیست. نظرسنجی باز @@ -259,14 +259,8 @@ همه شناس ها خوانده شده کن همه جدیدها را خوانده شده کن همه را خوانده شده کن - نکات ایمنی و ذخیره کلید - -حساب کاربری شما با یک کلید خصوصی محافظت می شود. این کلید دنباله ای بلند از کاراکترها است که با **nsec1** آغاز می گردد. هر کس به این کلید خصوصی دسترسی داشته باشد می تواند بجای شما یادداشت پست کرده یا هویت شما را تغییر دهد. -هرگز کلید خصوصی خود را در هیچ وبسایت با نرم افزاری که به آن اطمینان ندارید **وارد نکنید**. -سازندگان امتیست **هرگز** کلیدتان را از شما نمی خواهند. -یک پشتیبان یدکی مخفی از کلید خود به منظور بازیابی **نگه دارید**. ما استفاده از نرم افزار مدیریت پسورد را توصیه می کنیم. - برای ایمنی بیشتر، می توانید کلید خود را با یک رمزعبور رمزنگاری کنید. این نوع کلید با **ncryptsec1** آغاز می شود و نمی تواند بدون رمز عبور شما استفاده شود. -اگر پسوردتان را گم کنید، نخواهید توانست کلیدتان را بازیابی کنید. + نکات ایمنی و ذخیره کلید حساب کاربری شما با یک کلید خصوصی محافظت می شود. این کلید دنباله ای بلند از کاراکترها است که با **nsec1** آغاز می گردد. هر کس به این کلید خصوصی دسترسی داشته باشد می تواند بجای شما یادداشت پست کرده یا هویت شما را تغییر دهد. هرگز کلید خصوصی خود را در هیچ وبسایت با نرم افزاری که به آن اطمینان ندارید **وارد نکنید**. سازندگان امتیست **هرگز** کلیدتان را از شما نمی خواهند. یک پشتیبان یدکی مخفی از کلید خود به منظور بازیابی **نگه دارید**. ما استفاده از نرم افزار مدیریت پسورد را توصیه می کنیم. + برای ایمنی بیشتر، می توانید کلید خود را با یک رمزعبور رمزنگاری کنید. این نوع کلید با **ncryptsec1** آغاز می شود و نمی تواند بدون رمز عبور شما استفاده شود. اگر پسوردتان را گم کنید، نخواهید توانست کلیدتان را بازیابی کنید. رمزنگاری و کپی کلید من تصویر مدال پاداش برای %1$s تصویر جایزه نشان @@ -286,7 +280,7 @@ حذف از گالری این رسانه را از گالری خود حذف کنید. درخواست حذف - آماتیست درخواست می کند که یادداشت شما از رله هایی که درحال حاضر به آن متصل هستید حذف شود. هیچ تضمینی نیست که یادداشت شما برای همیشه از آن رله ها یا از رله های دیگری که ممکن است در آنها ذخیره شده باشد حذف خواهد شد.. + آماتیست درخواست می کند که یادداشت شما از رله هایی که درحال حاضر به آن متصل هستید حذف شود. هیچ تضمینی نیست که یادداشت شما برای همیشه از آن رله ها یا از رله های دیگری که ممکن است در آنها ذخیره شده باشد حذف خواهد شد.. بلاک حذف بلاک @@ -959,9 +953,7 @@ غیرفعال سازی اعلان ها از اپلیکیشن %1$s استفاده می کند تنظیمات اعلان ها - برای دریافت اعلان ها، می توانید هر اپلیکیشنی که از [Unified Push](https://unifiedpush.org/) پشتیبانی می کند نصب کنید، مانند[Ntfy](https://ntfy.sh/). - پس از نصب، اپلیکیشنی را که می خواهید استفاده کنید در تنظیمات انتخاب کنید. - + برای دریافت اعلان ها، می توانید هر اپلیکیشنی که از [Unified Push](https://unifiedpush.org/) پشتیبانی می کند نصب کنید، مانند[Ntfy](https://ntfy.sh/). پس از نصب، اپلیکیشنی را که می خواهید استفاده کنید در تنظیمات انتخاب کنید. رسته به فروشنده پیغام دهید سلام، هنوز موجوده؟ @@ -1313,10 +1305,7 @@ نمایش nprofile به عنوان کد QR تنظیم رله های صندوق دریافت خصوصی با این تنظیمات همه می توانند بدانند که هنگام ارسال پیام به شما از کدام رله ها استفاده کنند. بدون این تنظیمات ممکن است برخی پیام ها را از دست بدهید. - گزینه های خوب: -- inbox.nostr.wine (پولی) -- auth.nostr1.com (رایگان) -- you.nostr1.com (رله شخصی - پولی) + گزینه های خوب: - inbox.nostr.wine (پولی) - auth.nostr1.com (رایگان) - you.nostr1.com (رله شخصی - پولی) از ۱ تا ۳ رله به عنوان رله خصوصی وارد کنید. رله های صندوق دربافت پیام های خصوصی می بایست هر پیامی از هرکس را بپذیرند، ولی تنها به شما اجازه بارگیری پیام ها را می دهند. هم اکنون تنظیم شود relay های صندوق ورودی DM یافت نشد. پیام‌ها تحویل داده نمی‌شوند تا زمانی که لیست relay خود را پیکربندی کنند. @@ -1343,7 +1332,7 @@ به توسعه دهندگان رپ دهید! هدیه شما به ما برای تاثیرگذاری کمک می کند. هر ساتوشی به حساب می آید! اکنون کمک کنید - تقدیم از طرف: + تقدیم از طرف: این نسخه نرم‌افزار پیشکش از: نسخه %1$s متشکریم! @@ -2048,7 +2037,7 @@ (@note1)شناسه یادداشت در کلیپبورد کپی شد یادداشت در کلیپبورد کپی شد در کلیپ‌بورد کپی شد - @npubنویسنده در کلیپبورد کپی شد + @npubنویسنده در کلیپبورد کپی شد کلید خصوصیم را کپی کن کپی به کلیپ‌بورد LNUrl از آدرس لایتنینگ "%1$s" بدست نیامد. تنظیمات کاربر را بررسی کنید. @@ -2057,15 +2046,7 @@ صورتحساب از %1$s گرفته نشد: %2$s فایل فشرده باز نشد کیف پول یافت نشد - Amethyst هیچ برنامه کیف پولی که از Nostr Wallet Connect (NWC) پشتیبانی کند پیدا نکرد. -\n\nکارهایی که می‌توانید انجام دهید: -\n\t1. بررسی پشتیبانی NWC: مطمئن شوید که برنامه کیف پول مورد نظر شما با Nostr Wallet Connect سازگار است. -\n\t2. ایجاد اتصال: به برنامه کیف پول خود بروید و یک اتصال NWC جدید ایجاد کنید. -\n\t3. اتصال به Amethyst: کیف پول شما معمولاً سه گزینه ارائه می‌دهد: -\n\t\t3.1 اتصال خودکار: دکمه‌ای برای باز کردن و اتصال خودکار به Amethyst. اگر این گزینه وجود دارد، آن دکمه را بزنید، Amethyst را باز کنید و روی صفحه جدید ذخیره کنید. -\n\t\t3.2 اتصال دستی: یک URI اتصال که می‌توانید کپی کنید و با کلیک روی آیکون paste در این صفحه مستقیماً وارد کنید. -\n\t\t3.3 کد QR: کیف پول یک کد QR ایجاد می‌کند که می‌توانید با دکمه کد QR در این صفحه آن را بخوانید. - + Amethyst هیچ برنامه کیف پولی که از Nostr Wallet Connect (NWC) پشتیبانی کند پیدا نکرد. \n\nکارهایی که می‌توانید انجام دهید: \n\t1. بررسی پشتیبانی NWC: مطمئن شوید که برنامه کیف پول مورد نظر شما با Nostr Wallet Connect سازگار است. \n\t2. ایجاد اتصال: به برنامه کیف پول خود بروید و یک اتصال NWC جدید ایجاد کنید. \n\t3. اتصال به Amethyst: کیف پول شما معمولاً سه گزینه ارائه می‌دهد: \n\t\t3.1 اتصال خودکار: دکمه‌ای برای باز کردن و اتصال خودکار به Amethyst. اگر این گزینه وجود دارد، آن دکمه را بزنید، Amethyst را باز کنید و روی صفحه جدید ذخیره کنید. \n\t\t3.2 اتصال دستی: یک URI اتصال که می‌توانید کپی کنید و با کلیک روی آیکون paste در این صفحه مستقیماً وارد کنید. \n\t\t3.3 کد QR: کیف پول یک کد QR ایجاد می‌کند که می‌توانید با دکمه کد QR در این صفحه آن را بخوانید. ساختن روز منطقه خطر @@ -2732,10 +2713,7 @@ سیاست حریم خصوصی <پیام خصوصی رمزگشایی نشد>\\n\nدر یک پیام خصوصی/رمزنگاری شده بین %1$s و %2$s نام شما آمده بود. رله های صندوق دریافت پیام خصوصی - بین ۱-۳ رله به عنوان رله صندوق ورودی خصوصی وارد کنید. دیگران از این رله ها برای ارسال پیام خصوصی به شما استفاده خواهند کرد. رله های صندوق دریافت پیام خصوصی می بایست هر پیامی از هر کسی را بپذیرند، اما فقط بگذارند شما آن ها را بارگیری کنید. انتخاب های خوب شامل: - - inbox.nostr.wine (پولی) -- auth.nostr1.com (رایگان) -- you.nostr1.com (رله پولی یا شخصی) + بین ۱-۳ رله به عنوان رله صندوق ورودی خصوصی وارد کنید. دیگران از این رله ها برای ارسال پیام خصوصی به شما استفاده خواهند کرد. رله های صندوق دریافت پیام خصوصی می بایست هر پیامی از هر کسی را بپذیرند، اما فقط بگذارند شما آن ها را بارگیری کنید. انتخاب های خوب شامل: - inbox.nostr.wine (پولی) - auth.nostr1.com (رایگان) - you.nostr1.com (رله پولی یا شخصی) کاربر DM ها را روی این relay ها دریافت می‌کند پیام خصوصی خصوصی کردن: یادداشت را به صورت gift-wrap فقط برای کاربران مطلع‌شده ارسال کن @@ -2782,10 +2760,7 @@ کاربر با امکان خواندن فقط برای ضبط پیام کلیک کنید و نگه دارید گفتگوی relay - گفتگوهای relay گروه‌های چت هستند که توسط relay خانگی خود کنترل می‌شوند. - آن‌ها برای همه در Nostr قابل مشاهده هستند و هر کسی می‌تواند در آن‌ها شرکت کند. - آن‌ها برای جوامع باز حول موضوعات خاص عالی هستند. برخی از این گروه‌ها ناپایدار - هستند و بنابراین پیام‌های چت با گذشت زمان ناپدید می‌شوند + گفتگوهای relay گروه‌های چت هستند که توسط relay خانگی خود کنترل می‌شوند. آن‌ها برای همه در Nostr قابل مشاهده هستند و هر کسی می‌تواند در آن‌ها شرکت کند. آن‌ها برای جوامع باز حول موضوعات خاص عالی هستند. برخی از این گروه‌ها ناپایدار هستند و بنابراین پیام‌های چت با گذشت زمان ناپدید می‌شوند گفتگوهای relay رویدادها آدرس‌های IP @@ -2943,7 +2918,7 @@ تور را برای رله های ارسال/دریافت الزامی کن تایید آدرس ناستر تور را برای تایید آدرس های NIP-05 الزامی کن - رله ها / آدرس های Onion + رله ها / آدرس های Onion از تور برای هر آدرس اونیون استفاده کن تصاویر نمایه تور را برای بارگیری تصاویر نمایه اجباری کن diff --git a/commonsUI/src/commonMain/composeResources/values-fi-rFI/strings.xml b/commonsUI/src/commonMain/composeResources/values-fi-rFI/strings.xml index 6e1a231b1c..f2784cced4 100644 --- a/commonsUI/src/commonMain/composeResources/values-fi-rFI/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-fi-rFI/strings.xml @@ -268,15 +268,8 @@ Merkitse kaikki Tunnetut luetuiksi Merkitse kaikki Uudet luetuiksi Merkitse kaikki luetuiksi - ## Avainten varmuuskopiointi ja turvallisuusvinkit - \n\nTilisi on suojattu salaisella avaimella. Avain on pitkä merkkijono, joka alkaa **nsec1**:llä. Kuka tahansa, jolla on pääsy tähän salaiseen avaimeen, voi julkaista ja muuttaa henkilöllisyyttäsi. - \n\n- **Älä** anna salaista avaintasi mihinkään verkkosivustoon tai ohjelmistoon, johon et luota. - \n- Amethystin kehittäjät eivät **koskaan** pyydä salaista avaintasi. - \n- **Tee** turvallinen varmuuskopio salaisesta avaimestasi tilin palauttamista varten. Suosittelemme salasananhallintaohjelman käyttöä. - - Lisäturvallisuuden vuoksi voit salata avaimesi salasanalla. Tämä avain alkaa **ncryptsec1**:llä eikä sitä voi käyttää ilman salasanaasi. - \n\nJos kadotat salasanasi, et pysty palauttamaan avaintasi. - + ## Avainten varmuuskopiointi ja turvallisuusvinkit \n\nTilisi on suojattu salaisella avaimella. Avain on pitkä merkkijono, joka alkaa **nsec1**:llä. Kuka tahansa, jolla on pääsy tähän salaiseen avaimeen, voi julkaista ja muuttaa henkilöllisyyttäsi. \n\n- **Älä** anna salaista avaintasi mihinkään verkkosivustoon tai ohjelmistoon, johon et luota. \n- Amethystin kehittäjät eivät **koskaan** pyydä salaista avaintasi. \n- **Tee** turvallinen varmuuskopio salaisesta avaimestasi tilin palauttamista varten. Suosittelemme salasananhallintaohjelman käyttöä. + Lisäturvallisuuden vuoksi voit salata avaimesi salasanalla. Tämä avain alkaa **ncryptsec1**:llä eikä sitä voi käyttää ilman salasanaasi. \n\nJos kadotat salasanasi, et pysty palauttamaan avaintasi. Salaa ja kopioi salainen avaimeni Merkki kohteelle %1$s "Merkkipalkinnon kuva @@ -959,8 +952,7 @@ Poistaa Push-ilmoitukset käytöstä Käyttää sovellusta %1$s Push-ilmoituksen asennus - Saadaksesi push-ilmoituksia, asenna mikä tahansa sovellus, joka tukee Unified Push:ia, kuten Ntfy. - Asennuksen jälkeen valitse Asetuksista sovellus, jota haluat käyttää. + Saadaksesi push-ilmoituksia, asenna mikä tahansa sovellus, joka tukee Unified Push:ia, kuten Ntfy. Asennuksen jälkeen valitse Asetuksista sovellus, jota haluat käyttää. Keskusteluketju Lähetä myyjälle viesti Hei, onko tämä vielä saatavilla? @@ -2011,15 +2003,7 @@ Laskun hakeminen epäonnistui kohteesta %1$s: %2$s Pakattua tiedostoa ei voitu avata Lompakkoa ei löydy - Amethyst ei löytänyt yhtään lompakkosovellusta, joka tukee Nostr Wallet Connect (NWC) -toimintoa. -\n\nMitä voit tehdä: -\n\t1. Tarkista NWC-tuki: Varmista, että haluamasi lompakkosovellus on yhteensopiva Nostr Wallet Connectin kanssa. -\n\t2. Luo yhteys: Siirry lompakkosovellukseesi ja luo uusi NWC-yhteys. -\n\t3. Linkitä Amethystiin: Lompakkosi tarjoaa yleensä kolme vaihtoehtoa: -\n\t\t3.1 Automaattinen linkitys: Painike, joka avaa Amethystin ja yhdistää automaattisesti. Jos näet sen, paina painiketta, avaa Amethyst ja tallenna uudella näytöllä. -\n\t\t3.2 Manuaalinen linkitys: YhteysURI, jonka voit kopioida ja liittää tämän näytön liitä-kuvakkeella. -\n\t\t3.3 QR-koodi: Lompakko luo QR-koodin, jonka voit lukea tämän näytön QR-koodipainikkeella. - + Amethyst ei löytänyt yhtään lompakkosovellusta, joka tukee Nostr Wallet Connect (NWC) -toimintoa. \n\nMitä voit tehdä: \n\t1. Tarkista NWC-tuki: Varmista, että haluamasi lompakkosovellus on yhteensopiva Nostr Wallet Connectin kanssa. \n\t2. Luo yhteys: Siirry lompakkosovellukseesi ja luo uusi NWC-yhteys. \n\t3. Linkitä Amethystiin: Lompakkosi tarjoaa yleensä kolme vaihtoehtoa: \n\t\t3.1 Automaattinen linkitys: Painike, joka avaa Amethystin ja yhdistää automaattisesti. Jos näet sen, paina painiketta, avaa Amethyst ja tallenna uudella näytöllä. \n\t\t3.2 Manuaalinen linkitys: YhteysURI, jonka voit kopioida ja liittää tämän näytön liitä-kuvakkeella. \n\t\t3.3 QR-koodi: Lompakko luo QR-koodin, jonka voit lukea tämän näytön QR-koodipainikkeella. Luo pv Vaara-alue @@ -2677,11 +2661,8 @@ Proxy-relait Aggregaattorirelait, joita sovelluksen täytyy käyttää feedien lataamiseen, kuten filter.nostr.wine. Tämä korvaa outbox-mallin ja saa sovelluksen yhdistämään vain listoissasi oleviin relaihin. Julkinen keskustelu - Julkiset chatit ovat näkyvissä kaikille Nostrissa ja kuka tahansa - voi osallistua niihin. Ne sopivat hyvin avoimiin yhteisöihin tiettyjen aiheiden ympärille. - Moderointia voidaan hallita poistamalla julkaisuja sen relay-palvelimilta - Syötä 1–3 relay-palvelinta, jotka isännöivät tätä ryhmää. - Nostr-asiakkaat käyttävät tätä asetusta tietääkseen, mistä ladata viestejä ja mihin lähettää viestisi. + Julkiset chatit ovat näkyvissä kaikille Nostrissa ja kuka tahansa voi osallistua niihin. Ne sopivat hyvin avoimiin yhteisöihin tiettyjen aiheiden ympärille. Moderointia voidaan hallita poistamalla julkaisuja sen relay-palvelimilta + Syötä 1–3 relay-palvelinta, jotka isännöivät tätä ryhmää. Nostr-asiakkaat käyttävät tätä asetusta tietääkseen, mistä ladata viestejä ja mihin lähettää viestisi. relay-palvelimet Julkisen chatin metatiedot Julkiset keskustelut @@ -2706,10 +2687,7 @@ Vain luku -käyttäjä Paina ja pidä pohjassa tallentaaksesi ääniviesti relay-chat - relay-chatit ovat chat-ryhmiä, joita hallinnoi niiden kotipalvelin. - Ne ovat näkyvissä kaikille Nostrissa ja kuka tahansa voi osallistua niihin. - Ne sopivat hyvin avoimiin yhteisöihin tiettyjen aiheiden ympärille. Osa näistä ryhmistä on katoavia, - joten chat-viestit häviävät ajan myötä + relay-chatit ovat chat-ryhmiä, joita hallinnoi niiden kotipalvelin. Ne ovat näkyvissä kaikille Nostrissa ja kuka tahansa voi osallistua niihin. Ne sopivat hyvin avoimiin yhteisöihin tiettyjen aiheiden ympärille. Osa näistä ryhmistä on katoavia, joten chat-viestit häviävät ajan myötä relay-chatit Tapahtumat IP-osoitteet @@ -2947,4 +2925,15 @@ Vastaanottajan Lightning-palvelu osoitteessa %1$s ei ole käytettävissä. Se laskettiin Lightning-osoitteesta "%2$s". Virhe: %3$s. Tarkista, onko palvelin ylhäällä ja onko Lightning-osoite oikein Lightning-laskua ei voitu luoda kohteesta %1$s: Elementtiä pr ei löydy palautetusta JSON-vastauksesta. Lightning-laskua ei voitu luoda. Viesti kohteelta %1$s: %2$s + + + + diff --git a/commonsUI/src/commonMain/composeResources/values-fr-rCA/strings.xml b/commonsUI/src/commonMain/composeResources/values-fr-rCA/strings.xml index 3cc4d4a8b3..677d4afb32 100644 --- a/commonsUI/src/commonMain/composeResources/values-fr-rCA/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-fr-rCA/strings.xml @@ -218,7 +218,7 @@ Copier l'ID utilisateur Débloquer l'utilisateur Cet utilisateur est bloqué/muet. Ses publications sont masquées. - npub, hex, nom d'utilisateur + npub, hex, nom d'utilisateur Nettoyer Logo de l'App nsec / npub / hex private key @@ -266,15 +266,8 @@ Marquer tous les Connus comme lu Marquer tous les Nouveaux comme lu Tout marquer comme lu - ## Sauvegarde des Clés et Conseils de Sécurité - \n\nVotre compte est sécurisé par une clé secrète. La clé est une longue séquence de caractères commençant par **nsec1**. Toute personne ayant accès à cette clé secrète peut publier et modifier votre identité. - \n\n- Ne mettez **pas** votre clé secrète sur un site web ou un logiciel auquel vous ne faites pas confiance. - \n- Les développeurs d'Amethyst ne vous demanderont **jamais** votre clé secrète. - \n- **Faites** une sauvegarde sécurisée de votre clé secrète pour la récupération de votre compte. Nous vous recommandons d'utiliser un gestionnaire de mots de passe. - - Pour plus de sécurité, vous pouvez chiffrer votre clé avec un mot de passe. Cette clé commence par **ncryptsec1** et ne peut être utilisée sans votre mot de passe. - \n\nSi vous perdez votre mot de passe, vous ne pourrez pas récupérer votre clé. - + ## Sauvegarde des Clés et Conseils de Sécurité \n\nVotre compte est sécurisé par une clé secrète. La clé est une longue séquence de caractères commençant par **nsec1**. Toute personne ayant accès à cette clé secrète peut publier et modifier votre identité. \n\n- Ne mettez **pas** votre clé secrète sur un site web ou un logiciel auquel vous ne faites pas confiance. \n- Les développeurs d'Amethyst ne vous demanderont **jamais** votre clé secrète. \n- **Faites** une sauvegarde sécurisée de votre clé secrète pour la récupération de votre compte. Nous vous recommandons d'utiliser un gestionnaire de mots de passe. + Pour plus de sécurité, vous pouvez chiffrer votre clé avec un mot de passe. Cette clé commence par **ncryptsec1** et ne peut être utilisée sans votre mot de passe. \n\nSi vous perdez votre mot de passe, vous ne pourrez pas récupérer votre clé. Chiffrer et copier ma clé secrète Image de badge pour %1$s Image du badge de récompense @@ -823,7 +816,7 @@ Souscription maximale Nombre maximum de filtres par sub Limite Max (Renvoi des Évènements) - Longueur maximale du SubID + Longueur maximale du SubID Mint : %1$s Échanger Copier le Jeton @@ -938,9 +931,7 @@ Désactive les Notifications Push Utilise l'application %1$s Configuration des Notifications Push - Pour recevoir des notifications push, installez n'importe quelle application qui supporte les [Unified Push](https://unifiedpush.org/), tel que [Ntfy](https://ntfy.sh/). - Après installation, sélectionnez l'application que vous voulez utiliser dans les Paramètres. - + Pour recevoir des notifications push, installez n'importe quelle application qui supporte les [Unified Push](https://unifiedpush.org/), tel que [Ntfy](https://ntfy.sh/). Après installation, sélectionnez l'application que vous voulez utiliser dans les Paramètres. Fil de discussion Envoyer un message au vendeur Bonjour, est-ce toujours disponible ? @@ -2603,11 +2594,8 @@ Relais Proxy Relais agrégateurs que l'application doit utiliser pour télécharger vos flux, tels que filter.nostr.wine. Cela remplace le modèle de boîte d'envoi et permettra à l'application de se connecter uniquement aux relais de vos listes. Chat public - Les conversations publiques sont visibles pour tout le monde sur Nostr et n'importe qui - peut y participer. Elles sont idéales pour les communautés ouvertes autour de sujets spécifiques. - La modération peut être contrôlée en supprimant les messages sur ses relais - Insérez entre 1 et 3 relais qui hébergent ce groupe. - Les clients Nostr utilisent ce paramètre pour savoir où télécharger les messages et où les envoyer. + Les conversations publiques sont visibles pour tout le monde sur Nostr et n'importe qui peut y participer. Elles sont idéales pour les communautés ouvertes autour de sujets spécifiques. La modération peut être contrôlée en supprimant les messages sur ses relais + Insérez entre 1 et 3 relais qui hébergent ce groupe. Les clients Nostr utilisent ce paramètre pour savoir où télécharger les messages et où les envoyer. Relais Métadonnées de tchat public Chats publics @@ -2632,10 +2620,7 @@ Utilisateur en lecture seule Cliquez et maintenez pour enregistrer un message Relais de discussion - Les relais de discussions sont des groupes de discussion contrôlés par leur relais d'origine. - Ils sont visibles par tous les utilisateurs de Nostr et tout le monde peut y participer. - Ils sont idéaux pour les communautés ouvertes autour de sujets spécifiques. Certains de ces groupes sont éphémères - et, par conséquent, les messages de discussion disparaissent avec le temps + Les relais de discussions sont des groupes de discussion contrôlés par leur relais d'origine. Ils sont visibles par tous les utilisateurs de Nostr et tout le monde peut y participer. Ils sont idéaux pour les communautés ouvertes autour de sujets spécifiques. Certains de ces groupes sont éphémères et, par conséquent, les messages de discussion disparaissent avec le temps Relais de discussions Événements Utilisateurs @@ -2864,4 +2849,15 @@ Le service lightning du récepteur à %1$s n'est pas disponible. Il a été calculé à partir de l'adresse lightning "%2$s". Erreur: %3$s. Vérifiez si le serveur est en ligne et si l'adresse lightning est correcte Impossible de créer une facture lightning avec %1$s. Élément pr introuvable dans le JSON résultant. Impossible de créer une facture lightning. Message de %1$s: %2$s + + + + diff --git a/commonsUI/src/commonMain/composeResources/values-fr-rFR/strings.xml b/commonsUI/src/commonMain/composeResources/values-fr-rFR/strings.xml index 787a99ce99..76a4f93daf 100644 --- a/commonsUI/src/commonMain/composeResources/values-fr-rFR/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-fr-rFR/strings.xml @@ -220,7 +220,7 @@ Copier l'ID utilisateur Débloquer l'utilisateur Cet utilisateur est bloqué/muet. Ses publications sont masquées. - npub, hex, nom d'utilisateur + npub, hex, nom d'utilisateur Nettoyer Logo de l'App nsec / npub / hex private key @@ -268,15 +268,8 @@ Marquer tous les Connus comme lu Marquer tous les Nouveaux comme lu Tout marquer comme lu - ## Sauvegarde des Clés et Conseils de Sécurité - \n\nVotre compte est sécurisé par une clé secrète. La clé est une longue séquence de caractères commençant par **nsec1**. Toute personne ayant accès à cette clé secrète peut publier et modifier votre identité. - \n\n- Ne mettez **pas** votre clé secrète sur un site web ou un logiciel auquel vous ne faites pas confiance. - \n- Les développeurs d'Amethyst ne vous demanderont **jamais** votre clé secrète. - \n- **Faites** une sauvegarde sécurisée de votre clé secrète pour la récupération de votre compte. Nous vous recommandons d'utiliser un gestionnaire de mots de passe. - - Pour plus de sécurité, vous pouvez chiffrer votre clé avec un mot de passe. Cette clé commence par **ncryptsec1** et ne peut être utilisée sans votre mot de passe. - \n\nSi vous perdez votre mot de passe, vous ne pourrez pas récupérer votre clé. - + ## Sauvegarde des Clés et Conseils de Sécurité \n\nVotre compte est sécurisé par une clé secrète. La clé est une longue séquence de caractères commençant par **nsec1**. Toute personne ayant accès à cette clé secrète peut publier et modifier votre identité. \n\n- Ne mettez **pas** votre clé secrète sur un site web ou un logiciel auquel vous ne faites pas confiance. \n- Les développeurs d'Amethyst ne vous demanderont **jamais** votre clé secrète. \n- **Faites** une sauvegarde sécurisée de votre clé secrète pour la récupération de votre compte. Nous vous recommandons d'utiliser un gestionnaire de mots de passe. + Pour plus de sécurité, vous pouvez chiffrer votre clé avec un mot de passe. Cette clé commence par **ncryptsec1** et ne peut être utilisée sans votre mot de passe. \n\nSi vous perdez votre mot de passe, vous ne pourrez pas récupérer votre clé. Chiffrer et copier ma clé secrète Image de badge pour %1$s Image du badge de récompense @@ -905,7 +898,7 @@ Souscription maximale Nombre maximum de filtres par sub Limite Max (Renvoi des Évènements) - Longueur maximale du SubID + Longueur maximale du SubID Mint : %1$s Échanger Copier le Jeton @@ -1039,9 +1032,7 @@ Désactive les Notifications Push Utilise l'application %1$s Configuration des Notifications Push - Pour recevoir des notifications push, installez n'importe quelle application qui supporte les [Unified Push](https://unifiedpush.org/), tel que [Ntfy](https://ntfy.sh/). - Après installation, sélectionnez l'application que vous voulez utiliser dans les Paramètres. - + Pour recevoir des notifications push, installez n'importe quelle application qui supporte les [Unified Push](https://unifiedpush.org/), tel que [Ntfy](https://ntfy.sh/). Après installation, sélectionnez l'application que vous voulez utiliser dans les Paramètres. Fil de discussion Envoyer un message au vendeur Bonjour, est-ce toujours disponible ? @@ -2883,11 +2874,8 @@ Relais Proxy Relais agrégateurs que l'application doit utiliser pour télécharger vos flux, tels que filter.nostr.wine. Cela remplace le modèle de boîte d'envoi et permettra à l'application de se connecter uniquement aux relais de vos listes. Chat public - Les conversations publiques sont visibles pour tout le monde sur Nostr et n'importe qui - peut y participer. Elles sont idéales pour les communautés ouvertes autour de sujets spécifiques. - La modération peut être contrôlée en supprimant les messages sur ses relais - Insérez entre 1 et 3 relais qui hébergent ce groupe. - Les clients Nostr utilisent ce paramètre pour savoir où télécharger les messages et où les envoyer. + Les conversations publiques sont visibles pour tout le monde sur Nostr et n'importe qui peut y participer. Elles sont idéales pour les communautés ouvertes autour de sujets spécifiques. La modération peut être contrôlée en supprimant les messages sur ses relais + Insérez entre 1 et 3 relais qui hébergent ce groupe. Les clients Nostr utilisent ce paramètre pour savoir où télécharger les messages et où les envoyer. Relais Métadonnées de tchat public Chats publics @@ -2912,10 +2900,7 @@ Utilisateur en lecture seule Cliquez et maintenez pour enregistrer un message Relais de discussion - Les relais de discussions sont des groupes de discussion contrôlés par leur relais d'origine. - Ils sont visibles par tous les utilisateurs de Nostr et tout le monde peut y participer. - Ils sont idéaux pour les communautés ouvertes autour de sujets spécifiques. Certains de ces groupes sont éphémères - et, par conséquent, les messages de discussion disparaissent avec le temps + Les relais de discussions sont des groupes de discussion contrôlés par leur relais d'origine. Ils sont visibles par tous les utilisateurs de Nostr et tout le monde peut y participer. Ils sont idéaux pour les communautés ouvertes autour de sujets spécifiques. Certains de ces groupes sont éphémères et, par conséquent, les messages de discussion disparaissent avec le temps Relais de discussions Privé Événements @@ -3157,4 +3142,15 @@ Le service lightning du récepteur à %1$s n'est pas disponible. Il a été calculé à partir de l'adresse lightning "%2$s". Erreur: %3$s. Vérifiez si le serveur est en ligne et si l'adresse lightning est correcte Impossible de créer une facture lightning avec %1$s. Élément pr introuvable dans le JSON résultant. Impossible de créer une facture lightning. Message de %1$s: %2$s + + + + diff --git a/commonsUI/src/commonMain/composeResources/values-fr/strings.xml b/commonsUI/src/commonMain/composeResources/values-fr/strings.xml index 2f9de43271..41b7112fcb 100644 --- a/commonsUI/src/commonMain/composeResources/values-fr/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-fr/strings.xml @@ -211,7 +211,7 @@ Copier l'ID utilisateur Débloquer l'utilisateur Cet utilisateur est bloqué/muet. Ses publications sont masquées. - npub, hex, nom d'utilisateur + npub, hex, nom d'utilisateur Nettoyer Logo de l'App nsec / npub / hex private key @@ -259,15 +259,8 @@ Marquer tous les Connus comme lu Marquer tous les Nouveaux comme lu Tout marquer comme lu - ## Sauvegarde des Clés et Conseils de Sécurité - \n\nVotre compte est sécurisé par une clé secrète. La clé est une longue séquence de caractères commençant par **nsec1**. Toute personne ayant accès à cette clé secrète peut publier et modifier votre identité. - \n\n- Ne mettez **pas** votre clé secrète sur un site web ou un logiciel auquel vous ne faites pas confiance. - \n- Les développeurs d'Amethyst ne vous demanderont **jamais** votre clé secrète. - \n- **Faites** une sauvegarde sécurisée de votre clé secrète pour la récupération de votre compte. Nous vous recommandons d'utiliser un gestionnaire de mots de passe. - - Pour plus de sécurité, vous pouvez chiffrer votre clé avec un mot de passe. Cette clé commence par **ncryptsec1** et ne peut être utilisée sans votre mot de passe. - \n\nSi vous perdez votre mot de passe, vous ne pourrez pas récupérer votre clé. - + ## Sauvegarde des Clés et Conseils de Sécurité \n\nVotre compte est sécurisé par une clé secrète. La clé est une longue séquence de caractères commençant par **nsec1**. Toute personne ayant accès à cette clé secrète peut publier et modifier votre identité. \n\n- Ne mettez **pas** votre clé secrète sur un site web ou un logiciel auquel vous ne faites pas confiance. \n- Les développeurs d'Amethyst ne vous demanderont **jamais** votre clé secrète. \n- **Faites** une sauvegarde sécurisée de votre clé secrète pour la récupération de votre compte. Nous vous recommandons d'utiliser un gestionnaire de mots de passe. + Pour plus de sécurité, vous pouvez chiffrer votre clé avec un mot de passe. Cette clé commence par **ncryptsec1** et ne peut être utilisée sans votre mot de passe. \n\nSi vous perdez votre mot de passe, vous ne pourrez pas récupérer votre clé. Chiffrer et copier ma clé secrète Image de badge pour %1$s Image du badge de récompense @@ -844,7 +837,7 @@ Souscription maximale Nombre maximum de filtres par sub Limite Max (Renvoi des Évènements) - Longueur maximale du SubID + Longueur maximale du SubID Mint : %1$s Échanger Copier le Jeton @@ -960,9 +953,7 @@ Désactive les Notifications Push Utilise l'application %1$s Configuration des Notifications Push - Pour recevoir des notifications push, installez n'importe quelle application qui supporte les [Unified Push](https://unifiedpush.org/), tel que [Ntfy](https://ntfy.sh/). - Après installation, sélectionnez l'application que vous voulez utiliser dans les Paramètres. - + Pour recevoir des notifications push, installez n'importe quelle application qui supporte les [Unified Push](https://unifiedpush.org/), tel que [Ntfy](https://ntfy.sh/). Après installation, sélectionnez l'application que vous voulez utiliser dans les Paramètres. Fil de discussion Envoyer un message au vendeur Bonjour, est-ce toujours disponible ? @@ -2743,11 +2734,8 @@ Relais Proxy Relais agrégateurs que l'application doit utiliser pour télécharger vos flux, tels que filter.nostr.wine. Cela remplace le modèle de boîte d'envoi et permettra à l'application de se connecter uniquement aux relais de vos listes. Chat public - Les conversations publiques sont visibles pour tout le monde sur Nostr et n'importe qui - peut y participer. Elles sont idéales pour les communautés ouvertes autour de sujets spécifiques. - La modération peut être contrôlée en supprimant les messages sur ses relais - Insérez entre 1 et 3 relais qui hébergent ce groupe. - Les clients Nostr utilisent ce paramètre pour savoir où télécharger les messages et où les envoyer. + Les conversations publiques sont visibles pour tout le monde sur Nostr et n'importe qui peut y participer. Elles sont idéales pour les communautés ouvertes autour de sujets spécifiques. La modération peut être contrôlée en supprimant les messages sur ses relais + Insérez entre 1 et 3 relais qui hébergent ce groupe. Les clients Nostr utilisent ce paramètre pour savoir où télécharger les messages et où les envoyer. Relais Métadonnées de tchat public Chats publics @@ -2772,10 +2760,7 @@ Utilisateur en lecture seule Cliquez et maintenez pour enregistrer un message Relais de discussion - Les relais de discussions sont des groupes de discussion contrôlés par leur relais d'origine. - Ils sont visibles par tous les utilisateurs de Nostr et tout le monde peut y participer. - Ils sont idéaux pour les communautés ouvertes autour de sujets spécifiques. Certains de ces groupes sont éphémères - et, par conséquent, les messages de discussion disparaissent avec le temps + Les relais de discussions sont des groupes de discussion contrôlés par leur relais d'origine. Ils sont visibles par tous les utilisateurs de Nostr et tout le monde peut y participer. Ils sont idéaux pour les communautés ouvertes autour de sujets spécifiques. Certains de ces groupes sont éphémères et, par conséquent, les messages de discussion disparaissent avec le temps Relais de discussions Événements IPs diff --git a/commonsUI/src/commonMain/composeResources/values-hi-rIN/strings.xml b/commonsUI/src/commonMain/composeResources/values-hi-rIN/strings.xml index 0addde1cf5..cde8a721e1 100644 --- a/commonsUI/src/commonMain/composeResources/values-hi-rIN/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-hi-rIN/strings.xml @@ -646,15 +646,8 @@ सब ज्ञात को पढ लिया चिह्नित करें सब नये को पढ लिया चिह्नित करें सब को पढ लिया चिह्नित करें - ## कुंचिका सुरक्षार्थ अनुकृति तथा सुरक्षा सुझाव - \n\nआपकी लेखा एक गुप्त कुंचिका से सुरक्षित किया गया है। कुंचिका अक्षरों की एक लम्बी श्रृंखला है जो **nsec1** से आरम्भ होता है। जिस किसी के पास यह कुंचिका है वह पत्र प्रकाशित कर सकेगा तथा आपका परिचय परिवर्तित कर सकेगा। - \n\n- आपकी गुप्त कुंचिका किसी भी जालस्थान अथवा क्रमक में **ना** डालें जिसपर आपका विश्वास ना हो। - \n- अमेथिस्ट क्रमलेख विकासकर्ता आपकी गुप्त कुंचिका **कभी नहीं** पूछेंगे। - \n- आपकी गुप्त कुंचिका की एक सुरक्षित अनुकृति **अवश्य** रखें अपनी लेखा पुनः प्राप्त करने के लिए। पारणशब्ध प्रबंधन क्रमक की पुनः प्रशंसा करते हैं। - - अधिक सुरक्षा के लिए आप अपनी कुंचिका का रहस्यीकरण कर सकते हैं एक पारणशब्द के साथ। यह कुंचिका आरम्भ होगी **ncryptsec1** से तथा पारणशब्द के बिना प्रयोग साध्य नहीं। - \n\n यदि आप अपना पारणशब्द खो देते हैं, तो आप अपनी कुंचिका को पुनः प्राप्त नहीं कर सकते। - + ## कुंचिका सुरक्षार्थ अनुकृति तथा सुरक्षा सुझाव \n\nआपकी लेखा एक गुप्त कुंचिका से सुरक्षित किया गया है। कुंचिका अक्षरों की एक लम्बी श्रृंखला है जो **nsec1** से आरम्भ होता है। जिस किसी के पास यह कुंचिका है वह पत्र प्रकाशित कर सकेगा तथा आपका परिचय परिवर्तित कर सकेगा। \n\n- आपकी गुप्त कुंचिका किसी भी जालस्थान अथवा क्रमक में **ना** डालें जिसपर आपका विश्वास ना हो। \n- अमेथिस्ट क्रमलेख विकासकर्ता आपकी गुप्त कुंचिका **कभी नहीं** पूछेंगे। \n- आपकी गुप्त कुंचिका की एक सुरक्षित अनुकृति **अवश्य** रखें अपनी लेखा पुनः प्राप्त करने के लिए। पारणशब्ध प्रबंधन क्रमक की पुनः प्रशंसा करते हैं। + अधिक सुरक्षा के लिए आप अपनी कुंचिका का रहस्यीकरण कर सकते हैं एक पारणशब्द के साथ। यह कुंचिका आरम्भ होगी **ncryptsec1** से तथा पारणशब्द के बिना प्रयोग साध्य नहीं। \n\n यदि आप अपना पारणशब्द खो देते हैं, तो आप अपनी कुंचिका को पुनः प्राप्त नहीं कर सकते। अपने कुंचिकाओं को सुरक्षित रखें आपकी गुप्त कुंचिका एकमात्र विधि है इस लेखा का अभिगमन करने के लिए। यदि आप इसे खो देते हैं तो इसे कभी भी पुनःप्राप्त नहीं कर सकते। इसे कहीं सुरक्षित रखें अभी इस समय। अभी इसी समय सुरक्षित अनुकृति बनाएँ @@ -1834,9 +1827,7 @@ प्रेषित सूचनाएँ अक्षम करता है %1$s क्रमक का प्रयोग करता है प्रेषित सूचना स्थापन - प्रेषित सूचनाएँ प्राप्त करने के लिए, किसी भी क्रमक की स्थापना करें जो [संयुक्त प्रेषण](https://unifiedpush.org/) का अवलम्बन करता है, जैसे [एनटीफै](https://ntfy.sh/)। - स्थापना पश्चात, जिस क्रमक का उपयोग करना चाहते हैं उसका चयन करें स्थापना विकल्पों में। - + प्रेषित सूचनाएँ प्राप्त करने के लिए, किसी भी क्रमक की स्थापना करें जो [संयुक्त प्रेषण](https://unifiedpush.org/) का अवलम्बन करता है, जैसे [एनटीफै](https://ntfy.sh/)। स्थापना पश्चात, जिस क्रमक का उपयोग करना चाहते हैं उसका चयन करें स्थापना विकल्पों में। सूत्र विक्रेता को संदेश भेजें नमस्ते, क्या यह अब भी उपलब्ध है? @@ -3585,17 +3576,9 @@ चालान नहीं लाया जा सका %1$s से : %2$s संकुचित अभिलेख को खोल नहीं पाए धनकोष अप्राप्त - अमेथिस्ट को कोई धनकोष क्रमक प्राप्य नहीं जो नोस्टर धनकोष संयोजन का अवलम्बन करता हो। -\n\nआप यह कर सकते हैं : -\n\t१॰ नोस्टर धनकोष संयोजन अवलम्बन का सत्यापन करें। दुबारा जाँचें की आपका मनचाहा धनकोष क्रमक नोस्टर धनकोष संयोजन के साथ संगत है। -\n\t२॰ संयोजन बनाएँ। आपके धनकोष क्रमक में जाकर एक नया नोस्टर धनकोष संयोजन बनाएँ। -\n\t३॰ अमेथिस्ट के साथ जोडें। आपका धनकोष क्रमक सामान्यतः तीन विकल्प देगा। -\n\t\t३॰१ स्वचालित जोड। एक घुण्डी जिससे स्वचालित रूप से खोलें तथा अमेथिस्ट से जोडें। यदि हो तो उस घुण्डी को टाँकें तथा अमेथिस्ट खोलें तथा नव्य पटल पर अभिलेखन करें। -\n\t\t३॰२ मनुष्यकृत जोड। एक संयोजन वैश्विकसंसाधनपरिचायक जिसकी अनुकृति करके सीधे इस पटल पर प्रविष्ट कर सकते हैं। -\n\t\t३॰३ क्यूआर॰ चित्र अक्षरराशि। धनकोष क्रमक एक क्यूआर॰ चित्र अक्षरराशि बनएगा जिसे आप परख सकते हैं इस पटल पर क्यूआर॰ क्रमचित्र घुण्डी द्वारा। - + अमेथिस्ट को कोई धनकोष क्रमक प्राप्य नहीं जो नोस्टर धनकोष संयोजन का अवलम्बन करता हो। \n\nआप यह कर सकते हैं : \n\t१॰ नोस्टर धनकोष संयोजन अवलम्बन का सत्यापन करें। दुबारा जाँचें की आपका मनचाहा धनकोष क्रमक नोस्टर धनकोष संयोजन के साथ संगत है। \n\t२॰ संयोजन बनाएँ। आपके धनकोष क्रमक में जाकर एक नया नोस्टर धनकोष संयोजन बनाएँ। \n\t३॰ अमेथिस्ट के साथ जोडें। आपका धनकोष क्रमक सामान्यतः तीन विकल्प देगा। \n\t\t३॰१ स्वचालित जोड। एक घुण्डी जिससे स्वचालित रूप से खोलें तथा अमेथिस्ट से जोडें। यदि हो तो उस घुण्डी को टाँकें तथा अमेथिस्ट खोलें तथा नव्य पटल पर अभिलेखन करें। \n\t\t३॰२ मनुष्यकृत जोड। एक संयोजन वैश्विकसंसाधनपरिचायक जिसकी अनुकृति करके सीधे इस पटल पर प्रविष्ट कर सकते हैं। \n\t\t३॰३ क्यूआर॰ चित्र अक्षरराशि। धनकोष क्रमक एक क्यूआर॰ चित्र अक्षरराशि बनएगा जिसे आप परख सकते हैं इस पटल पर क्यूआर॰ क्रमचित्र घुण्डी द्वारा। बनाएँ - दि॰ + दि॰ संकट क्षेत्र अन्धकारमय विवरण @@ -3791,7 +3774,7 @@ छलनी आवृत क्रमलेखकोश छलनी %1$s वित्तपोषित %2$s साट्स उद्देश्य में से - घं॰ + घं॰ यह विषयवस्तु परिवर्तित हुआ है। हो सकता है लेखक ने परिवर्तन देखा नहीं अथवा अनुमति दिया नहीं। इसका अर्थ क्या है? यह विषयवस्तु वैसे ही है जैसे पत्र प्रकाशन पर @@ -4080,7 +4063,7 @@ आप ख्याप्य कुंचिका का प्रयोग कर रहे हैं जिससे केवल पढ सकते हैं। आरोहण करने के लिए गुप्त कुंचिका के साथ प्रवेशांकन करें आप ख्याप्य कुंचिका का प्रयोग कर रहे हैं जिससे केवल पढ सकते हैं। प्रकाशित पत्रों को चाहने के लिए गुप्त कुंचिका के साथ प्रवेशांकन करें चलचित्र - मि॰ + मि॰ मेरे भावचिह्न पोटलियाँ चित्र जोडें जोडा जा रहा है %1$s… @@ -4711,11 +4694,8 @@ प्रतिनिधि पुनःप्रसारक संग्रहकर्ता पुनःप्रसारक जो क्रमक द्वारा उपयुक्त होना चाहिए आपके सूचनावलियों का अवरोहण करने के लिए। जैसे कि filter.nostr.wine । यह निर्गतपेटिका प्रतिरूप को प्रतिस्थापित करेगा तथा क्रमक आपके सूचियों में पुनःप्रसारकों के साथ ही जुडेगा। सार्वजनिक चर्चा - सार्वजनिक चर्चाएँ सबके लिए दृश्यमान हैं नोस्टर पर तथा सभी - इनमें भाग ले सकते हैं। ये उत्कृष्ट हैं खुले समुदायों के लिए विशिष्ट विषयों के प्रसंग में। - नियमन का नियन्त्रण किया जा सकता है पत्रों को मिटाने से पुनःप्रसारकों में - इस समुदाय के निवास के लिए १ - ३ पुनःप्रसारकों को जोडें। - नोस्टर ग्राहक इस स्थापना विकल्प का प्रयोग करेंगे यह जानने के लिए कि कहाँ से सन्देशों का अवरोहण करना है तथा कहाँ आपके सन्देश भेजने हैं। + सार्वजनिक चर्चाएँ सबके लिए दृश्यमान हैं नोस्टर पर तथा सभी इनमें भाग ले सकते हैं। ये उत्कृष्ट हैं खुले समुदायों के लिए विशिष्ट विषयों के प्रसंग में। नियमन का नियन्त्रण किया जा सकता है पत्रों को मिटाने से पुनःप्रसारकों में + इस समुदाय के निवास के लिए १ - ३ पुनःप्रसारकों को जोडें। नोस्टर ग्राहक इस स्थापना विकल्प का प्रयोग करेंगे यह जानने के लिए कि कहाँ से सन्देशों का अवरोहण करना है तथा कहाँ आपके सन्देश भेजने हैं। पुनःप्रसारक सार्वजनिक चर्चा उपतथ्य सार्वजनिक चर्चा @@ -4780,10 +4760,7 @@ प्रमाण प्रमाणीकरण प्रवेशांकन पुनःप्रसारक हस्ताक्षरण सत्यापन निप॰४२ परिचय पुनःप्रसारक प्रमाणीकरण पुनःप्रसारक चर्चा - पुनःप्रसारक चर्चाएँ आवासी पुनःप्रसारक द्वारा नियन्त्रित चर्चा समुदाएँ हैं। - वे नोस्टर पर सभी के लिए दृश्य हैं तथा उनमें सभी भाग ले सकते हैं। - वे उत्कृष्ट हैं खुले समुदायों के लिए विशिष्ट विषयों पर। इनमें से कुछ समुदाएँ अस्थायी हैं। - तथा इसीलिए समय के साथ चर्चा सन्देश अदृश्य हो जाते हैं + पुनःप्रसारक चर्चाएँ आवासी पुनःप्रसारक द्वारा नियन्त्रित चर्चा समुदाएँ हैं। वे नोस्टर पर सभी के लिए दृश्य हैं तथा उनमें सभी भाग ले सकते हैं। वे उत्कृष्ट हैं खुले समुदायों के लिए विशिष्ट विषयों पर। इनमें से कुछ समुदाएँ अस्थायी हैं। तथा इसीलिए समय के साथ चर्चा सन्देश अदृश्य हो जाते हैं पुनःप्रसारक चर्चाएँ आपके अपने परिचय तथा स्थापना विकल्प तथा पाण्डुलिपियाँ। आपके मुख्य पुनःप्रसारकों पर। पुनःप्रसारक जिनपर प्रत्येक समुदाय अपने समतलों को प्रकाशित करते हैं। @@ -5244,6 +5221,17 @@ %1$d नया सन्देश %1$d नए सन्देश + + + + त्रिमा वस्तु %1$d अस्र। %2$d मुख इस त्रिमा वस्तु का पठन असफल। नियम %1$s @@ -5275,4 +5263,322 @@ हस्ताक्षररहित। इसलिए यह कर्ता केवल एक कथन है प्रकार %1$d का वस्तु। जिसे यह ग्राहक चित्रित नहीं करता %1$d अष्टकों का अमुक जिसे यह ग्राहक पढता नहीं + पुनःबाधित करें %1$s को + रखें %1$s + पुनःजुडें %1$s + पुनःमौन %1$s + पुनःस्थापन %1$s + सूचनावलियाँ पुनःस्थापन + स्थल पुनःस्थापन + परिचय पुनःस्थापध + प्रदाता पुनःस्थापन + पुनःप्रसारक पुनःस्थापन + विषय पुनःस्थापन + धनकोष पुनःस्थापन + स्वीकार + + %1$s तथा %2$d जोडा + %1$s तथा %2$d जोडे + + + %1$s तथा %2$d बाधित किया + %1$s तथा %2$d को बाधित किए + + + %1$s तथा %2$d का अनुगमन किया + %1$s तथा %2$d का अनुगमन किए + + + %1$s तथा %2$d से जुडे + %1$s तथा %2$d से जुडे + + + %1$s तथा %2$d को मौनकृत किया + %1$s तथा %2$d को मौनकृत किए + + आपके अमेथिस्ट समचरणीकृत क्रमक स्थापना विकल्पों को परिवर्तित किया गया एक अन्य क्रमक में और वह अमेथिस्ट नहीं था। इससे समस्याएँ उत्पन्न हो सकते हैं। + आपने अपनी कोंकोर्ड सूची रिक्त की + आपने अपनी कोंकोर्ड सूची का परिवर्तन किया + + आपने %1$d सीधासन्देश पुनःप्रसारक हटाया + आपने %1$d सीधासन्देश पुनःप्रसारकों को हटाए + + + आपने %1$d इष्ट सूचनावली हटाया + आपने %1$d इष्ट सूचनावलियों को हटाए + + + आपने %1$d व्यक्ति का अनुगमन हटाया + आपने %1$d व्यक्तियों का अनुगमन हटाए + + + आपने %1$d विषयसूचक का अनुगमन हटाया + आपने %1$d विषयसूचकों का अनुगमन हटाए + + + आपने %1$d अनुक्रमणीकार पुनःप्रसारक हटाया + आपने %1$d अनुक्रमणीकार पुनःप्रसारकों को हटाए + + + आपने %1$d समूहचर्चा कुंचिका पुनःप्रसारक हटाया + आपने %1$d समूहचर्चा कुंचिका पुनःप्रसारकों को हटाए + + + आपने %1$d सार्वजनिक चर्चा छोड दिया + आपने %1$d सार्वजनिक चर्चाओं को छोड दिए + + + आपने %1$d समुदाय छोड दिया + आपने %1$d समुदायों को छोड दिए + + + आपने %1$d समूह छोड दिया + आपने %1$d समूहों को छोड दिए + + + आपने %1$d चर्चाशाला छोड दिया + आपने %1$d चर्चाशालाओं को छोड दिए + + + आपने %1$d की मौनावस्था हटाया + आपने %1$d की मौनावस्था हटाए + + + आपने %1$d सार्वजनिक पुनःप्रसारक हटाया + आपने %1$d सार्वजनिक पुनःप्रसारकों को हटाए + + आपने अपनी नटज्साप कुंचिका को हटाया + आपने अपनी नटज्साप कुंचिका का प्रतिस्थापन किया + + आपने %1$d नटज्साप टकसाल हटाया + आपने %1$d नटज्साप टकसालों को हटाए + + + आपने %1$d बोल्ट॰बारह प्रस्ताव हटाया + आपने %1$d बोल्ट॰बारह प्रस्तावों को हटाए + + + आपने %1$d भुगतान प्राप्त करने की विधि हटाया + आपने %1$d भुगतान प्राप्त करने के विधियों को हटाए + + + आपने %1$d स्थल का अनुगमन हटाया + आपने %1$d स्थलों का अनुगमन हटाए + + + आपने %1$d निजी पुनःप्रसारक हटाया + आपने %1$d निजी पुनःप्रसारकों को हटाए + + + आपने %1$d परिचय प्रपत्रस्थान हटाया + आपने %1$d परिचय प्रपत्रस्थान हटाए + + + आपने %1$d पुनःप्रसारक सूचनावली हटाया + आपने %1$d पुनःप्रसारक सूचनावलियाँ हटाए + + + आपने %1$d खोज पुनःप्रसारक हटाया + आपने %1$d खोज पुनःप्रसारकों को हटाए + + + आपने %1$d विश्वाससेवा प्रदाता परिवर्तित किया + आपने %1$d विश्वाससेवाओं के प्रदाता को परिवर्तित किया + + + आपने %1$d पुनःप्रसारक पर विश्वास करना रोक दिया + आपने %1$d पुनःप्रसारकों पर विश्वास करना रोक दिए + + + आपने %1$d पुनःप्रसारक अबाधित किया + आपने %1$d पुनःप्रसारकों को अबाधित किए + + आपने अपना काशयू धनकोष रिक्त किया + आपने अपने काशयू धनकोष को परिवर्तित किया + जोडे गए (%1$s) + परिवर्तित (%1$s) + %1$s जोडे गए + %1$s परिवर्तित + %1$s हटाए गए + नया संस्करण का उपयोग करें + आपने निजी (रहस्यीकृत) वस्तुओं को जोडा। + आपने निजी (रहस्यीकृत) वस्तुओं का परिवर्तन भी किया। उनकी तुलना यहाँ नहीं की जा सकती। + आपने सभी निजी (रहस्यीकृत) वस्तुओं को हटा दिया। + पठनेवशक्य + पठन तथा लेखन + लेखनेवशक्य + हटाए गए (%1$s) + सुरक्षित संस्करण पुनःस्थापन + आपने अपना %1$s अन्य क्रमक में परिवर्तित किया + सूचनावलियाँ + चर्चा शालाएँ + समुदाय + विषयसूचक + सम्बन्धित व्यक्तिगण + स्थान + टकसाल + नटज्साप कुंचिकाएँ + प्रस्ताव + अन्य प्रविष्टियाँ + भुगतान पते + लोग + परिचय प्रपत्रस्थान + सार्वजनिक चर्चाएँ + पुनःप्रसारक + समूह + सूत्र + विश्वाससेवा प्रदाता + शब्द + जानकारी + ध्वज + जन्मदिन + क्रमचर चिह्न + क्लिंक॰ प्रस्ताव + प्रदर्शन नाम + लैटनिंग जालपता + लैटनिंग पता + प्रयोक्तानाम + नोस्टर पता + चित्र + सर्वनाम + जालस्थान + अनुमत + अन्य क्रमक में परिवर्तित। %1$s + आपके कोंकोर्ड समुदाय आपके लिए रहस्यीकृत हैं। इसलिए उनकी तुलना यहाँ नहीं की जा सकती। पुनःस्थापन उन्हीं को लौट डालेगा जिनका अभिलेखन इस यन्त्र ने किया। + स्थानान्तरित + %1$s परित्यक्त + परित्यक्त + आपने उसे बनाया + आपने उसे परिवर्तित किया + आपने उसे रिक्त किया + सूचनावली + चला गया + विषयसूचक जिनका आप अनुगमन करते हैं + आगतपेटिका + जहाँ अन्य आप तक अभिगमन करते हैं + जुड गए + रखे गए + %1$s रखे गए + ग्राहक कुंचिका हटाया गया + ग्राहक कुंचिका प्रतिस्थापित + नटज्साप आगे से इस कुंचिका से बद्ध होंगे। यदि इस यन्त्र में वह नहीं है तो आप सम्भाव्यतः उनको यहाँ नहीं चुका सकते। + शेष + शेष + सूचीबद्ध + स्थानान्तरित + %1$s तथा %2$s अधिक + नया संस्करण + %1$s नए + नव्य + नव्य + नव्यतः बाधित + %1$s नए मौनकृत + अब से बाधित नहीं + स्थापित नहीं + यहाँ कुछ नहीं + बोल्ट॰बारह प्रस्ताव + + %1$d प्रस्ताव हटाया गया + %1$d प्रस्ताव हटाए गए + + निर्गतपेटिका + आप जहाँ प्रकाशित करते हैं + + आपको भुगतान करने की %1$d विधि हटाया गया + आपको भुगतान करने की %1$d विधियाँ हटाए गए + + लोग जो आपको भुगतान करते हैं उसी को देखते हैं जो इस सूची में है। + स्थल हटाए गए + स्थल जिनका आप अनुगमन करते हैं + वे अब रहस्यीकृत हैं। जो कि सम्भाव्यतः ऊपर के सार्वजनिक वस्तुओं का गन्तव्य था। + आपने वस्तुओं को निजी किया + वे रहस्यीकृत हैं। इसलिए उनकी तुलना यहाँ नहीं की जा सकती। + आपने निजी वस्तुओं का परिवर्तन किया + वे रहस्यीकृत हैं। इसलिए केवल आप देख सकते हैं कि वे क्या थे। पुनःस्थापन से सब लौटके आ जाएँगे। + आपने सभी निजी वस्तुओं को हटा दिया + ग्रहण मध्य + पुनःनामकृत + पुनःनामकृत + इस यन्त्र पर + %1$s लोग खोजें + खोज मध्य + सेवा + अनुचर + श्रेणी + विषयसूची + %2$s में से %1$s सेवाएँ परिवर्तित + वे अब अन्य व्यक्ति से आते है। अथवा किसी से नहीं + अभी भी बाधित + अभिलेखन मध्य + परित्यक्त। %1$s + सम्पादित। %1$s + नए। %1$s + %1$s पारित + %1$s अद्यतनकृत + विषय हटाए गए + श्रेणीयाँ तथा अनुचर गिनती तथा कचरालेख छालन जो आप देखते हैं इन प्रदाताओं से आते हैं। + विश्वसनीय + %1$s अबाधित + अमेथिस्ट पुनः उनसे जुडेगा + इकाई + %1$s मौनावस्था हटाए गए + ये पुनः आपके सूचनावलियों तक अभिगम्य + अविश्वसनीय + आपके धनकोष के टकसाल तथा कुंचिकाएँ आपके लिए रहस्यीकृत हैं। इसलिए उनकी तुलना वस्तु प्रति वस्तु यहाँ नहीं की जा सकती। पुनःस्थापन उन्हीं को लौट डालेगा जिनका अभिलेखन इस यन्त्र ने किया। + पूर्वतः %1$s था + शब्द तथा विषयसूचक + समचरणीकृत क्रमक स्थापना विकल्प + अमेथिस्ट स्थापना विकल्प समचरणीकृत आपके यन्त्रों में। वे रहस्यीकृत हैं। इसलिए केवल उनकी उपस्थिति की तुलना की जा सकती है। + बाधित पुनःप्रसारक सूची + पुनःप्रसारक जिनसे यह क्रमक कभी भी संयोजन नहीं करता। + बोल्ट॰बारह प्रस्ताव + लैटनिंग प्रस्ताव जिनका उपयोग करके अन्य लोग आपको भुगतान करते हैं। + काशयू धनकोष + आपके ईकाश धनकोष स्थापना विकल्प तथा टकसाल। वे रहस्यीकृत हैं। इसलिए केवल उनकी उपस्थिति की तुलना की जा सकती है। + समुदाय सूची + समुदाय जिनसे आप जुडे। + कोंकोर्ड समुदाय सूची + कोंकोर्ड समुदाय जिनसे आप जुडे। वे रहस्यीकृत हैं। इसलिए केवल उनकी उपस्थिति की तुलना की जा सकती है। + सीधासन्देश पुनःप्रसारक सूची + जहाँ अन्य आपके निजी सन्देश भेजते हैं। + अस्थायी चर्चा सूची + अस्थायी चर्चा शालाएँ जिनसे आप जुडे। + इष्ट सूचनावली सूची + कलनविध्यात्मक सूचनावलियाँ जिनको आपने अभिलेखित किया। + अनुचरण सूची + निर्णय करता है किसके पत्र आपकी मुख्य सूचनावली में प्रकट होंगे। + अनुचरित स्थान + स्थान जहाँ से पत्र आपकी मुख्य सूचनावली में प्रकट होंगे। + अनुचरित विषयसूचक + विषयसूचक जिससे युक्त पत्र आपकी मुख्य सूचनावली में प्रकट होंगे। + अनुक्रमणीकार पुनःप्रसारक सूची + पुनःप्रसारक उपयुक्त अन्य लोगों के परिचय तथा पुनःप्रसारक सूचियों को प्राप्त करने के लिए। + समूहचर्चा कुंचिका पुनःप्रसारक + जहाँ अन्य उन कुंचिकाओं को प्राप्त करते हैं आपको रहस्यीकृत समूहचर्चाओं में जोडने के लिए। + मौन सूची + छिपाता है लोग शब्द विषयसूचक तथा सूत्र उन सब में से जो आप देखते हैं। + नटज्साप प्राप्ती स्थापना विकल्प + टकसाल तथा कुंचिका जिनका उपयोग अन्य करते हैं आपको नटज्साप भेजने के लिए। + सूची + इस यन्त्र पर अभिलेखित सूची। + सार्वजनिक पुनःप्रसारक सूची + सबको बताता है आपके पत्र कहाँ प्राप्त करें तथा आपको उत्तर तथा उल्लेखों को कहाँ भेजें। + भुगतान पते + विधियाँ जिनसे अन्य लोग आपको भुगतान कर सके। + निजी पुनःप्रसारक सूची + जहाँ आपके पाण्डुलिपियाँ तथा निजी जानकारी रखे जाते हैं। + परिचय + आपका नाम चित्र चरित तथा भुगतान पते। जैसे सब को दिखते हैं वैसे। + सार्वजनिक चर्चा सूची + सार्वजनिक चर्चाएँ जिनसे आप जुडे। + इष्ट पुनःप्रसारक सूचनावलियाँ + पुनःप्रसारक जिनका आप वीक्षण करते हैं सूचनावलियों के रूप में। + समूह सूची + पुनःप्रसारक आधारित समूह जिनसे आप जुडे। + खोज पुनःप्रसारक सूची + उपयुक्त पुनःप्रसारक जब आप खोजते हैं। + विश्वाससेवा प्रदाता सूची + सेवाएँ जिनपर आप विश्वास करते है आपके लिए लोगों का सत्यापन तथा श्रेणीकरण करने के लिए। + विश्वसनीय पुनःप्रसारक सूची + पुनःप्रसारक जिनपर आप पर्याप्त विश्वास करते हैं बिना पूछे उनसे संयोजन करने के लिए। diff --git a/commonsUI/src/commonMain/composeResources/values-hu-rHU/strings.xml b/commonsUI/src/commonMain/composeResources/values-hu-rHU/strings.xml index a80b41a6f1..f7872f3f26 100644 --- a/commonsUI/src/commonMain/composeResources/values-hu-rHU/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-hu-rHU/strings.xml @@ -311,7 +311,7 @@ %1$d%% Értéket az értékért Megnyitás - Olvasás + Olvasás Írás Hálózat Típus @@ -461,8 +461,7 @@ Zap-igazolás Közvetlen lightning-fizetés - nem lesz zapigazolás közzétéve a Nostr-on. Egy láncon belüli zapigazolás közzé lesz téve a Nostr-on, így a címzett megtalálhatja a kifizetést. - Fizetés közvetlenül a láncon belüli pénztárcából a profil megadott bitcoin-címére (%1$s) - nem lesz zapigazolás közzétéve. - + Fizetés közvetlenül a láncon belüli pénztárcából a profil megadott bitcoin-címére (%1$s) - nem lesz zapigazolás közzétéve. A nutzap-esemény magát az ecasht kézbesíti, így az mindig közzé lesz téve. Számla kérése… Számla kérése a Nostr hálózaton keresztül… @@ -647,15 +646,8 @@ Az összes ismert megjelölése olvasottként Az összes új üzenet megjelölése olvasottként Összes megjelölése olvasottként - ## Kulcs- és biztonsági mentési tippek - \n\nFiókját egy titkos kulcs védi. A kulcs egy hosszú véletlenszerű karakterlánc, amely **nsec1**-vel kezdődik. Bárki, aki az Ön titkos kulcsához hozzáfér, az Ön személyazonosságának használatával bármilyen tartalmat közzétehet. - \n\n- **Ne** helyezze el a titkos kulcsát olyan webhelyen vagy szoftverben, amelyben nem bízik. - \n- Az Amethyst fejlesztői az Ön titkos kulcsát **soha** nem fogják Öntől elkérni. - \n- A fiók-helyreállításának érdekében, a titkos kulcsáról **mindig** készítsen biztonsági másolatot. Javasoljuk egy jelszókezelő használatát. - - A nagyobb biztonság érdekében titkosítsa egy jelszóval. A jelszóval védett kulcs **ncryptsec1**-vel kezdődik, és a jelszava nélkül nem használható. - \n\nHa elveszíti a jelszavát, nem tudja visszaállítani a kulcsát. - + ## Kulcs- és biztonsági mentési tippek \n\nFiókját egy titkos kulcs védi. A kulcs egy hosszú véletlenszerű karakterlánc, amely **nsec1**-vel kezdődik. Bárki, aki az Ön titkos kulcsához hozzáfér, az Ön személyazonosságának használatával bármilyen tartalmat közzétehet. \n\n- **Ne** helyezze el a titkos kulcsát olyan webhelyen vagy szoftverben, amelyben nem bízik. \n- Az Amethyst fejlesztői az Ön titkos kulcsát **soha** nem fogják Öntől elkérni. \n- A fiók-helyreállításának érdekében, a titkos kulcsáról **mindig** készítsen biztonsági másolatot. Javasoljuk egy jelszókezelő használatát. + A nagyobb biztonság érdekében titkosítsa egy jelszóval. A jelszóval védett kulcs **ncryptsec1**-vel kezdődik, és a jelszava nélkül nem használható. \n\nHa elveszíti a jelszavát, nem tudja visszaállítani a kulcsát. Biztonsági mentés készítése a kulcsokról A titkos kulcs megadása az egyetlen módja a fiók elérésének. Ha elveszíti, akkor soha nem tudja visszaszerezni. Mentse el biztonságos helyre most. Biztonsági mentés most @@ -779,7 +771,7 @@ Eltávolítás Cím nélküli kitűző A kitűzőt %1$d érdemelte ki - Kitűzők · %1$d + Kitűzők · %1$d Válassza ki, hogy a megszerzett kitűzők közül melyek jelenjenek meg a profilban. Ön még nem kapott kitűzőt. Alkalmazások · %1$d @@ -1835,9 +1827,7 @@ Push-értesítések letiltása %1$s alkalmazást használ Push-értesítések beállítása - A push-értesítések fogadásához olyan alkalmazást telepítsen, amely támogatja a [Unified Push](https://unifiedpush.org/), például az [Ntfy](https://ntfy.sh/) alkalmazást. - A telepítés után a beállításokban válassza ki a használni kívánt alkalmazást. - + A push-értesítések fogadásához olyan alkalmazást telepítsen, amely támogatja a [Unified Push](https://unifiedpush.org/), például az [Ntfy](https://ntfy.sh/) alkalmazást. A telepítés után a beállításokban válassza ki a használni kívánt alkalmazást. Bejegyzés Üzenet küldése az eladónak Tisztelt hölgyem / uram! Ez még aktuális? @@ -3124,7 +3114,7 @@ Értesítések a bejövő hang- és videóhívásokhoz Bejövő hívások %1$s elfogadta az Ön sakk-kihívását - Értesítés a sakkjátékkal kapcsolatos eseményekre + Értesítés a sakkjátékkal kapcsolatos eseményekre Sakk Sakkfrissítések %1$s lépett - Ön következik @@ -3586,15 +3576,7 @@ Nem sikerült lekérni a számlát a következőtől: %1$s: %2$s Nem sikerült megnyitni a tömörített fájlt Nem található pénztárca - Az Amethyst nem talált olyan pénztárca-alkalmazást, amely támogatja a Nostr Wallet Connect (NWC) szolgáltatást. -\n\nAmit tehet: -\n\t1. NWC-támogatás ellenőrzése: Ellenőrizze, hogy a kívánt pénztárca-alkalmazás kompatibilis-e a Nostr Wallet Connect szolgáltatással. -\n\t2. Kapcsolat létrehozása: Nyissa meg a pénztárcaalkalmazást, és hozzon létre egy új NWC-kapcsolatot. -\n\t3. Kapcsolódás az Amethysthez: A pénztárcája általában három lehetőséget kínál: -\n\t\t3.1 Automatikus kapcsolódás: Egy gomb az Amethyst automatikus megnyitásához és kapcsolódáshoz. Koppintson erre a gombra, nyissa meg az Amethyst alkalmazást, és mentse el az új képernyőn. -\n\t\t3.2 Kézi kapcsolódás: Egy kapcsolódási cím, amelyet kimásolhat, és a képernyőn található beillesztés gombra koppintva közvetlenül beírhatja. -\n\t\t3.3 QR-kód: A pénztárca létrehoz egy QR-kódot, amelyet a képernyőn található QR-kód gombról olvashat be. - + Az Amethyst nem talált olyan pénztárca-alkalmazást, amely támogatja a Nostr Wallet Connect (NWC) szolgáltatást. \n\nAmit tehet: \n\t1. NWC-támogatás ellenőrzése: Ellenőrizze, hogy a kívánt pénztárca-alkalmazás kompatibilis-e a Nostr Wallet Connect szolgáltatással. \n\t2. Kapcsolat létrehozása: Nyissa meg a pénztárcaalkalmazást, és hozzon létre egy új NWC-kapcsolatot. \n\t3. Kapcsolódás az Amethysthez: A pénztárcája általában három lehetőséget kínál: \n\t\t3.1 Automatikus kapcsolódás: Egy gomb az Amethyst automatikus megnyitásához és kapcsolódáshoz. Koppintson erre a gombra, nyissa meg az Amethyst alkalmazást, és mentse el az új képernyőn. \n\t\t3.2 Kézi kapcsolódás: Egy kapcsolódási cím, amelyet kimásolhat, és a képernyőn található beillesztés gombra koppintva közvetlenül beírhatja. \n\t\t3.3 QR-kód: A pénztárca létrehoz egy QR-kódot, amelyet a képernyőn található QR-kód gombról olvashat be. Létrehozás n Kritikus beállítások @@ -3884,7 +3866,7 @@ Kalandos olvasmány Kalandjelenet Alkalmazáslista - Ajánlott alkalmazások + Ajánlott alkalmazások Találkozó Alkalmazások Időpont-visszaigazolás @@ -4712,11 +4694,8 @@ Proxyzott átjátszók Összesítő átjátszók, amelyeket az alkalmazásnak használnia kell a hírfolyamok letöltéséhez, például filter.nostr.wine. Ez helyettesíti a kimeneti modellt, és az alkalmazás csak a listákban szereplő átjátszókhoz fog csatlakozni. Nyilvános csevegés - A nyilvános csevegések mindenki számára láthatóak a Nostr-on, és bárki - részt vehet bennük. Ezek kiválóan alkalmasak bizonyos témák köré szerveződő nyílt közösségek számára. - A moderálás az átjátszókon lévő hozzászólások törlésével szabályozható - Adjon hozzá 1–3 olyan átjátszót, amelyek kiszolgálják ezt a csoportot. - A Nostr-kliensek ezt a beállítást használják arra, hogy tudják, honnan töltsék le az üzeneteket, és hová küldjék az üzeneteket. + A nyilvános csevegések mindenki számára láthatóak a Nostr-on, és bárki részt vehet bennük. Ezek kiválóan alkalmasak bizonyos témák köré szerveződő nyílt közösségek számára. A moderálás az átjátszókon lévő hozzászólások törlésével szabályozható + Adjon hozzá 1–3 olyan átjátszót, amelyek kiszolgálják ezt a csoportot. A Nostr-kliensek ezt a beállítást használják arra, hogy tudják, honnan töltsék le az üzeneteket, és hová küldjék az üzeneteket. Átjátszók Nyilvános csevegés metaadatai Nyilvános csevegések @@ -4781,10 +4760,7 @@ hitelesítés, átjátszó, aláírás, ellenőrzés, nip-42, azonosság Átjátszó-hitelesítés Átjátszócsevegés - Az átjátszócsevegések olyan csevegőcsoportok, amelyeket az otthoni átjátszó irányít. - Ezek mindenki számára láthatóak a Nostr-on, és bárki részt vehet rajtuk. - Remekül alkalmasak nyitott közösségek kialakítására bizonyos témák körül. Néhány ilyen csoport - rövid élettartamú, ezért a csevegési üzenetek idővel eltűnnek + Az átjátszócsevegések olyan csevegőcsoportok, amelyeket az otthoni átjátszó irányít. Ezek mindenki számára láthatóak a Nostr-on, és bárki részt vehet rajtuk. Remekül alkalmasak nyitott közösségek kialakítására bizonyos témák körül. Néhány ilyen csoport rövid élettartamú, ezért a csevegési üzenetek idővel eltűnnek Átjátszócsevegések Saját profil, -beállítások és -piszkozatok a gazda-átjátszókon. Azok az átjátszók, amelyekre az egyes közösségek közzéteszik a terveiket. @@ -5245,6 +5221,17 @@ %1$d új üzenet %1$d új üzenet + + + + 3D-objektum %1$d csúcs · %2$d lap Ezt a 3D-objektumot nem sikerült beolvasni (%1$s szabály) @@ -5276,4 +5263,322 @@ Aláírás nélküli, így ez a szerző csak állítás Egy %1$d típusú elem, amelyet ez a kliens nem jelenít meg %1$d bájt valamiből, amit ez a kliens nem olvas + %1$s újbóli letiltása + %1$s megtartása + Újbóli csatlakozás ehhez: %1$s + %1$s újbóli némítása + %1$s visszaállítása + Hírfolyamok visszaállítása + Helyek visszaállítása + Profil visszaállítása + Szolgáltatók visszaállítása + Átjátszók visszaállítása + Témák visszaállítása + Pénztárca visszaállítása + Elfogadás + + %1$s és %2$d hozzáadva + %1$s és %2$d hozzáadva + + + %1$s és %2$d letiltva + %1$s és %2$d letiltva + + + %1$s és %2$d követve + %1$s és %2$d követve + + + %1$s és %2$d csatlakozva + %1$s és %2$d csatlakozva + + + %1$s és %2$d némítva + %1$s és %2$d némítva + + A szinkronizált Amethyst alkalmazásbeállításai egy másik, nem Amethyst alkalmazásban módosultak. Ez problémákat okozhat. + Ön kiürítette a Concord listáját + Ön módosította a Concord listáját + + Ön %1$d privát üzenetátjátszót távolított el + %1$d privát üzenet átjátszót távolított el + + + Ön %1$d kedvenc hírfolyamot távolított el + %1$d kedvenc hírfolyamot távolított el + + + %1$d személyt nem követ többé + %1$d személyt nem követ többé + + + Ön %1$d kulcsszót nem követ többé + %1$d hashtaget nem követ többé + + + Ön %1$d indexelő átjátszót távolított el + %1$d indexelő átjátszót távolított el + + + Ön %1$d csoportos csevegési kulcsátjátszót távolított el + %1$d csoportcsevegés kulcs átjátszót távolított el + + + Ön %1$d nyilvános csevegést hagyott el + %1$d nyilvános csevegést hagyott el + + + Ön %1$d közösséget hagyott el + %1$d közösséget hagyott el + + + Ön %1$d csoportot hagyott el + %1$d csoportot hagyott el + + + Ön %1$d csevegőszobát hagyott el + %1$d csevegőszobát hagyott el + + + Ön %1$d dolog némítását oldotta fel + %1$d dolog némítását oldotta fel + + + Ön %1$d nyilvános átjátszót távolított el + %1$d nyilvános átjátszót távolított el + + Ön eltávolította a nutzap kulcsát + Ön lecserélte a nutzap kulcsát + + Ön %1$d nutzap pénzverdét távolított el + %1$d nutzap pénzverdét távolított el + + + Ön %1$d BOLT12 ajánlatot távolított el + %1$d BOLT12 ajánlatot távolított el + + + Ön %1$d fizetési módot távolított el + %1$d fizetési módot távolított el + + + Ön %1$d helyet nem követ többé + %1$d helyet nem követ többé + + + Ön %1$d privát átjátszót távolított el + %1$d privát átjátszót távolított el + + + Ön %1$d profilmezőt távolított el + %1$d profilmezőt távolított el + + + Ön %1$d átjátszó-hírfolyamot távolított el + %1$d átjátszó hírfolyamot távolított el + + + Ön %1$d kereső átjátszót távolított el + %1$d kereső átjátszót távolított el + + + Ön %1$d bizalmi szolgáltatás szolgáltatóját módosította + %1$d bizalmi szolgáltatás szolgáltatóját változtatta meg + + + Ön %1$d átjátszóban nem bízik többé + %1$d átjátszóban nem bízik többé + + + Ön %1$d átjátszó letiltását oldotta fel + %1$d átjátszó blokkolását oldotta fel + + Ön kiürítette a Cashu pénztárcáját + Ön módosította a Cashu pénztárcáját + Hozzáadva (%1$s) + Módosítva (%1$s) + %1$s hozzáadva + %1$s módosítva + %1$s eltávolítva + Új verzió használata + Ön privát (titkosított) elemeket adott hozzá. + Ön privát (titkosított) elemeket is módosított. Ezek itt nem hasonlíthatók össze. + Ön eltávolította az összes privát (titkosított) elemet. + csak olvasható + olvasás és írás + csak írható + Eltávolítva (%1$s) + Mentett verzió visszaállítása + Ön módosította a következőt: %1$s, egy másik alkalmazásban + Hírfolyamok + Csevegőszobák + Közösségek + Kulcsszavak + Kapcsolt identitások + Helyek + Pénzverdék + Nutzap kulcsok + Ajánlatok + Egyéb bejegyzések + Fizetési címek + Személyek + Profilmezők + Nyilvános csevegések + Átjátszók + Csoportok + Beszélgetések + Bizalmi szolgáltatók + Szavak + Névjegy + Borító + Születésnap + Bot jelölő + CLINK-ajánlat + Megjelenített név + LNURL + Lightning-cím + Felhasználónév + Nostr-cím + Kép + Névmások + Weboldal + Engedélyezve + Módosítva egy másik alkalmazásban · %1$s + A Concord közösségei Önhöz vannak titkosítva, így itt nem hasonlíthatók össze. A visszaállítás pontosan azt hozza vissza, amit ez az eszköz mentett. + Áthelyezve + %1$s elvetve + Elvetve + Ön hozta létre + Ön módosította + Ön kiürítette + Hírfolyam + Eltűnt + Ön által követett kulcsszavak + Bejövő + ahol mások elérik Önt + csatlakozott + megtartva + %1$s megtartva + Fogadási kulcs eltávolítva + Fogadási kulcs lecserélve + A nutzapok ehhez az új kulcshoz lesznek kötve. Ha ez az eszköz nem rendelkezik vele, lehet, hogy itt nem tudja beváltani őket. + elhagyta a csoportot + Elhagyva + Listázva + Áthelyezve + %1$s és %2$s további + Új verzió + %1$s új + új + Új + Újonnan letiltva + %1$s újonnan némítva + Már nincs letiltva + Nincs beállítva + Itt nincs semmi + BOLT12 ajánlat + + %1$d ajánlat eltávolítva + %1$d ajánlat eltávolítva + + Kimenő + ahol közzétesz + + %1$d fizetési mód eltávolítva + %1$d fizetési mód eltávolítva + + A fizető emberek csak azt látják, ami ebben a listában van. + eltűnt helyek + Ön által követett helyek + Most már titkosítva vannak, valószínűleg ide kerültek a fenti nyilvános elemek. + Ön priváttá tette az elemeket + Titkosítva vannak, így itt nem hasonlíthatók össze. + Ön módosította a privát elemeket + Titkosítva vannak, így csak Ön láthatja, mik voltak. A visszaállítás mindet visszahozza. + Ön eltávolította az összes privát elemet + Fogadás + átnevezte + Átnevezve + Ezen az eszközön + %1$s személy keresése + Keresés + Szolgáltatás + Követők + Rang + Témák + %2$s szolgáltatásból %1$s módosult + Most már valaki mástól vagy senkitől sem származnak + Továbbra is le van tiltva + Tárolás + Elvetve · %1$s + Szerkesztve · %1$s + Új · %1$s + %1$s törölve + %1$s frissítve + eltűnt témák + A látott rangok, követőszámok és a kéretlen tartalomszűrés ezektől a szolgáltatóktól származnak. + Megbízható + %1$s letiltása feloldva + Az Amethyst újra kapcsolódni fog hozzájuk + Egységek + %1$s némítása feloldva + Ezek újra megjelenhetnek a hírfolyamaiban + Nem megbízható + A saját pénztárca pénzverdéi és kulcsai Önhöz vannak titkosítva, így itt nem hasonlíthatók össze tételesen. A visszaállítás pontosan azt hozza vissza, amit ez az eszköz mentett. + „%1$s” volt + Szavak és kulcsszavak + szinkronizált alkalmazásbeállítások + Az eszközök között szinkronizált Amethyst-beállítások. Titkosítva vannak, így csak a jelenlétük hasonlítható össze. + letiltott átjátszólista + Átjátszók, amelyekhez ez az alkalmazás soha nem kapcsolódik. + BOLT12 ajánlatok + A lightning ajánlatok, amelyeket mások a fizetéshez használnak az Ön számára. + Cashu pénztárca + Az ecash pénztárca beállításai és pénzverdéi. Titkosítva vannak, így csak a jelenlétük hasonlítható össze. + közösséglista + Közösségek, amelyekhez csatlakozott. + Concord közösséglista + A Concord közösségek, amelyekhez csatlakozott. Titkosítva vannak, így csak a jelenlétük hasonlítható össze. + privát üzenet átjátszólista + Ahová mások kézbesítik a privát üzeneteit. + rövid életű csevegési lista + Rövid életű csevegőszobák, amelyekhez csatlakozott. + kedvenc hírfolyamlista + Mentett algoritmus-hírfolyamok. + követési lista + Meghatározza, kinek a bejegyzései jelenjenek meg a kezdőlapon. + követett helyek + Helyek, amelyek bejegyzései megjelennek a hírfolyamaiban. + követett kulcsszavak + Kulcsszavak, amelyek bejegyzései megjelennek a hírfolyamban. + indexelő átjátszólista + Azok az átjátszók, amelyeket mások profiljainak és átjátszólistáinak megtalálásához használnak. + csoportos csevegési kulcsátjátszók + Ahol mások megtalálják a kulcsokat, hogy titkosított csoportos csevegésekhez adhassák Önt. + némítási lista + Elrejti a személyeket, szavakat, kulcsszavakat és beszélgetéseket mindenből, amit lát. + nutzap fogadási beállítások + A pénzverdék és a kulcs, amelyeket mások a nutzapok küldéséhez használnak az Ön számára. + lista + Egy listaelem ezen az eszközön mentve. + nyilvános átjátszólista + Megmondja mindenkinek, hol találhatók a bejegyzései, és hová küldjék a válaszokat és említéseket. + fizetési címek + A módok, ahogy mások fizethetnek Önnek. + privát átjátszólista + Ahol a piszkozatai és privát adatai tárolódnak. + profil + Az Ön neve, képe, bemutatkozása és fizetési címei, ahogy mindenki látja őket. + nyilvános csevegési lista + Nyilvános csevegések, amelyekhez csatlakozott. + kedvenc átjátszóhírfolyamok + Olyan átjátszók, amelyeket hírfolyamként böngész. + csoportlista + Átjátszó-alapú csoportok, amelyekhez csatlakozott. + kereső átjátszólista + Kereséskor használt átjátszók. + bizalmi szolgáltatólista + A szolgáltatások, amelyekben megbízik, hogy rangsorolják és ellenőrizzék Önnek az embereket. + megbízható átjátszólista + Átjátszók, amelyekhez kérdezés nélkül kapcsolódhat. diff --git a/commonsUI/src/commonMain/composeResources/values-in-rID/strings.xml b/commonsUI/src/commonMain/composeResources/values-in-rID/strings.xml index fa3f8fc665..3ce5510f41 100644 --- a/commonsUI/src/commonMain/composeResources/values-in-rID/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-in-rID/strings.xml @@ -266,15 +266,8 @@ Tandai semua yang dikenal sebagai telah dibaca Tandai semua yang baru sebagai telah dibaca Tandai semua sebagai telah dibaca - ## Tips Cadangan Kunci dan Keamanan - \n\nAkun Anda diamankan dengan kunci rahasia. Kunci ini adalah rangkaian karakter panjang yang diawali dengan **nsec1**. Siapa pun yang memiliki akses ke kunci rahasia ini dapat memposting dan mengubah identitas Anda. - \n\n- **Jangan** memasukkan kunci rahasia Anda ke situs web atau perangkat lunak yang tidak Anda percaya. - \n- Pengembang Amethyst **tidak akan pernah** meminta kunci rahasia Anda. - \n- **Lakukan** pencadangan kunci rahasia Anda secara aman untuk pemulihan akun. Kami merekomendasikan penggunaan pengelola kata sandi. - - Untuk keamanan tambahan, Anda dapat mengenkripsi kunci dengan kata sandi. Kunci ini diawali dengan **ncryptsec1** dan tidak dapat digunakan tanpa kata sandi Anda. - \n\nJika Anda kehilangan kata sandi, Anda tidak akan dapat memulihkan kunci Anda. - + ## Tips Cadangan Kunci dan Keamanan \n\nAkun Anda diamankan dengan kunci rahasia. Kunci ini adalah rangkaian karakter panjang yang diawali dengan **nsec1**. Siapa pun yang memiliki akses ke kunci rahasia ini dapat memposting dan mengubah identitas Anda. \n\n- **Jangan** memasukkan kunci rahasia Anda ke situs web atau perangkat lunak yang tidak Anda percaya. \n- Pengembang Amethyst **tidak akan pernah** meminta kunci rahasia Anda. \n- **Lakukan** pencadangan kunci rahasia Anda secara aman untuk pemulihan akun. Kami merekomendasikan penggunaan pengelola kata sandi. + Untuk keamanan tambahan, Anda dapat mengenkripsi kunci dengan kata sandi. Kunci ini diawali dengan **ncryptsec1** dan tidak dapat digunakan tanpa kata sandi Anda. \n\nJika Anda kehilangan kata sandi, Anda tidak akan dapat memulihkan kunci Anda. Enkripsi dan salin kunci rahasia saya Gambar penghargaan lencana untuk %1$s "Gambar penghargaan lencana @@ -853,13 +846,11 @@ DITUTUP TERJADWAL Mulai %1$s - Keluar akan menghapus semua informasi yang disimpan secara lokal. -Pastikan kunci pribadi Anda telah simpan untuk menghindari kehilangan akun Anda. Apakah Anda ingin melanjutkan? + Keluar akan menghapus semua informasi yang disimpan secara lokal. Pastikan kunci pribadi Anda telah simpan untuk menghindari kehilangan akun Anda. Apakah Anda ingin melanjutkan? Tags yang Diikuti Ini adalah daftar pengguna yang Anda rekomendasikan kepada orang lain. Hanya pengguna publik yang diizinkan. Post yang disetujui - Grup ini tidak memiliki deskripsi atau peraturan. -Beri tahu kepada pemilik untuk menambahnya + Grup ini tidak memiliki deskripsi atau peraturan. Beri tahu kepada pemilik untuk menambahnya Komunitas ini tidak memiliki deskripsi. Beri tahu kepada pemilik untuk menambahnya Preferensi Pengguna Cari pengaturan @@ -951,9 +942,7 @@ Beri tahu kepada pemilik untuk menambahnya Matikan Pemberitahuan Push Memakai aplikasi %1$s Pengaturan Pemberitahuan Push - Untuk menerima Pemberitahuan Push, Install aplikasi yang mendukung [Unified Push](https://unifiedpush.org/), seperti [Ntfy](https://ntfy.sh/). - Setelah diinstall, Pilih aplikasi yang ingin digunakan di pengaturan. - + Untuk menerima Pemberitahuan Push, Install aplikasi yang mendukung [Unified Push](https://unifiedpush.org/), seperti [Ntfy](https://ntfy.sh/). Setelah diinstall, Pilih aplikasi yang ingin digunakan di pengaturan. Utas Kirim pesan ke penjual Halo, apakah ini masih tersedia? @@ -1991,15 +1980,7 @@ Beri tahu kepada pemilik untuk menambahnya Tidak dapat mengambil invoice dari %1$s: %2$s Tidak dapat membuka file terkompresi Dompet tidak ditemukan - Amethyst tidak dapat menemukan aplikasi dompet apa pun yang mendukung Nostr Wallet Connect (NWC). -\n\nYang dapat Anda lakukan: -\n\t1. Verifikasi Dukungan NWC: Pastikan aplikasi dompet pilihan Anda kompatibel dengan Nostr Wallet Connect. -\n\t2. Buat Koneksi: Buka aplikasi dompet Anda dan buat koneksi NWC baru. -\n\t3. Hubungkan ke Amethyst: Dompet Anda biasanya akan memberikan tiga pilihan: -\n\t\t3.1 Tautan Otomatis: Tombol untuk secara otomatis membuka dan terhubung ke Amethyst. Jika ada, klik tombol tersebut, buka Amethyst dan tekan simpan di layar baru. -\n\t\t3.2 Tautan Manual: URI Koneksi yang dapat Anda salin dan klik ikon tempel di layar ini untuk memasukkannya secara langsung. -\n\t\t3.3 Kode QR: Dompet membuat kode QR yang dapat Anda baca dari tombol kode QR di layar ini. - + Amethyst tidak dapat menemukan aplikasi dompet apa pun yang mendukung Nostr Wallet Connect (NWC). \n\nYang dapat Anda lakukan: \n\t1. Verifikasi Dukungan NWC: Pastikan aplikasi dompet pilihan Anda kompatibel dengan Nostr Wallet Connect. \n\t2. Buat Koneksi: Buka aplikasi dompet Anda dan buat koneksi NWC baru. \n\t3. Hubungkan ke Amethyst: Dompet Anda biasanya akan memberikan tiga pilihan: \n\t\t3.1 Tautan Otomatis: Tombol untuk secara otomatis membuka dan terhubung ke Amethyst. Jika ada, klik tombol tersebut, buka Amethyst dan tekan simpan di layar baru. \n\t\t3.2 Tautan Manual: URI Koneksi yang dapat Anda salin dan klik ikon tempel di layar ini untuk memasukkannya secara langsung. \n\t\t3.3 Kode QR: Dompet membuat kode QR yang dapat Anda baca dari tombol kode QR di layar ini. Buat h Zona Berbahaya @@ -2183,8 +2164,7 @@ Beri tahu kepada pemilik untuk menambahnya Gambar disimpan ke galeri Identitas palsu Relay Kotak Masuk - Jumlah faktur salah (%1$s sat) dari %2$s. -Seharusnya %3$s + Jumlah faktur salah (%1$s sat) dari %2$s. Seharusnya %3$s Relay Indeks Relay yang berspesialisasi dalam menyimpan metadata dan daftar relay semua orang, seperti purplepag.es. Amethyst akan menggunakan relay ini untuk menemukan pengguna yang tidak ada dalam daftar Anda. Kumpulan Minat Baru @@ -2618,11 +2598,8 @@ Seharusnya %3$s Relay Proxy Relay agregator yang harus digunakan aplikasi untuk mengunduh feed Anda, seperti filter.nostr.wine. Ini menggantikan model outbox dan akan membuat aplikasi hanya terhubung ke relay dalam daftar Anda. Obrolan Publik - Obrolan publik terlihat oleh semua orang di Nostr dan siapa pun - dapat berpartisipasi. Sangat cocok untuk komunitas terbuka seputar topik tertentu. - Moderasi dapat dilakukan dengan menghapus postingan di relay-nya - Masukkan antara 1–3 relay yang menjadi host grup ini. - Klien Nostr menggunakan pengaturan ini untuk mengetahui tempat mengunduh dan mengirim pesan Anda. + Obrolan publik terlihat oleh semua orang di Nostr dan siapa pun dapat berpartisipasi. Sangat cocok untuk komunitas terbuka seputar topik tertentu. Moderasi dapat dilakukan dengan menghapus postingan di relay-nya + Masukkan antara 1–3 relay yang menjadi host grup ini. Klien Nostr menggunakan pengaturan ini untuk mengetahui tempat mengunduh dan mengirim pesan Anda. relay Metadata Obrolan Publik Obrolan Publik @@ -2647,10 +2624,7 @@ Seharusnya %3$s Pengguna hanya-baca Tekan dan tahan untuk merekam pesan Obrolan relay - Obrolan relay adalah grup obrolan yang dikendalikan oleh relay induknya. - Obrolan ini terlihat oleh semua orang di Nostr dan siapa pun dapat berpartisipasi. - Sangat cocok untuk komunitas terbuka seputar topik tertentu. Beberapa grup ini bersifat sementara - sehingga pesan obrolan akan menghilang seiring waktu + Obrolan relay adalah grup obrolan yang dikendalikan oleh relay induknya. Obrolan ini terlihat oleh semua orang di Nostr dan siapa pun dapat berpartisipasi. Sangat cocok untuk komunitas terbuka seputar topik tertentu. Beberapa grup ini bersifat sementara sehingga pesan obrolan akan menghilang seiring waktu Obrolan relay Acara IP @@ -2877,4 +2851,15 @@ Seharusnya %3$s Layanan Lightning penerima di %1$s tidak tersedia. Itu dihitung dari alamat Lightning "%2$s". Kesalahan: %3$s. Periksa apakah server aktif dan apakah alamat petir sudah benar Tidak dapat membuat tagihan Lightning dari %1$s: Elemen pr tidak ditemukan dalam JSON yang dihasilkan. Tidak dapat membuat tagihan Lightning. Pesan dari %1$s: %2$s + + + + diff --git a/commonsUI/src/commonMain/composeResources/values-in/strings.xml b/commonsUI/src/commonMain/composeResources/values-in/strings.xml index 7635510369..2fe9f6457e 100644 --- a/commonsUI/src/commonMain/composeResources/values-in/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-in/strings.xml @@ -172,7 +172,7 @@ Nama pengguna Tentang saya URL Avatar - URL Spanduk/Banner + URL Spanduk/Banner URL Situs Web Kata ganti relay sumber mungkin sudah usang @@ -257,17 +257,8 @@ Tandai semua yg dikenal telah dibaca Tandai pesan baru telah dibaca Tandai semua telah dibaca - - ## Tips Cadangan Kunci dan Keamanan - \n\nAkun Anda diamankan dengan kunci rahasia. Kunci ini adalah rangkaian karakter panjang yang diawali dengan **nsec1**. Siapa pun yang memiliki akses ke kunci rahasia ini dapat memposting dan mengubah identitas Anda. - \n\n- **Jangan** memasukkan kunci rahasia Anda ke situs web atau perangkat lunak yang tidak Anda percaya. - \n- Pengembang Amethyst **tidak akan pernah** meminta kunci rahasia Anda. - \n- **Lakukan** pencadangan kunci rahasia Anda secara aman untuk pemulihan akun. Kami merekomendasikan penggunaan pengelola kata sandi. - - - Untuk keamanan tambahan, Anda dapat mengenkripsi kunci dengan kata sandi. Kunci ini diawali dengan **ncryptsec1** dan tidak dapat digunakan tanpa kata sandi Anda. - \n\nJika Anda kehilangan kata sandi, Anda tidak akan dapat memulihkan kunci Anda. - + ## Tips Cadangan Kunci dan Keamanan \n\nAkun Anda diamankan dengan kunci rahasia. Kunci ini adalah rangkaian karakter panjang yang diawali dengan **nsec1**. Siapa pun yang memiliki akses ke kunci rahasia ini dapat memposting dan mengubah identitas Anda. \n\n- **Jangan** memasukkan kunci rahasia Anda ke situs web atau perangkat lunak yang tidak Anda percaya. \n- Pengembang Amethyst **tidak akan pernah** meminta kunci rahasia Anda. \n- **Lakukan** pencadangan kunci rahasia Anda secara aman untuk pemulihan akun. Kami merekomendasikan penggunaan pengelola kata sandi. + Untuk keamanan tambahan, Anda dapat mengenkripsi kunci dengan kata sandi. Kunci ini diawali dengan **ncryptsec1** dan tidak dapat digunakan tanpa kata sandi Anda. \n\nJika Anda kehilangan kata sandi, Anda tidak akan dapat memulihkan kunci Anda. Enkripsi dan salin kunci rahasia saya Gambar penghargaan lencana untuk %1$s "Gambar penghargaan lencana @@ -960,10 +951,7 @@ Menonaktifkan Notifikasi Push Menggunakan aplikasi %1$s Pengaturan Notifikasi Push - - Untuk menerima notifikasi push, instal aplikasi yang mendukung [Unified Push](https://unifiedpush.org/), seperti [Ntfy](https://ntfy.sh/). - Setelah menginstal, pilih aplikasi yang ingin Anda gunakan di Pengaturan. - + Untuk menerima notifikasi push, instal aplikasi yang mendukung [Unified Push](https://unifiedpush.org/), seperti [Ntfy](https://ntfy.sh/). Setelah menginstal, pilih aplikasi yang ingin Anda gunakan di Pengaturan. Utas Kirim pesan ke penjual Halo, apakah ini masih tersedia? @@ -2048,15 +2036,7 @@ Tidak dapat mengambil invoice dari %1$s: %2$s Tidak dapat membuka file terkompresi Dompet tidak ditemukan - Amethyst tidak dapat menemukan aplikasi dompet apa pun yang mendukung Nostr Wallet Connect (NWC). -\n\nYang dapat Anda lakukan: -\n\t1. Verifikasi Dukungan NWC: Pastikan aplikasi dompet pilihan Anda kompatibel dengan Nostr Wallet Connect. -\n\t2. Buat Koneksi: Buka aplikasi dompet Anda dan buat koneksi NWC baru. -\n\t3. Hubungkan ke Amethyst: Dompet Anda biasanya akan memberikan tiga pilihan: -\n\t\t3.1 Tautan Otomatis: Tombol untuk secara otomatis membuka dan terhubung ke Amethyst. Jika ada, klik tombol tersebut, buka Amethyst dan tekan simpan di layar baru. -\n\t\t3.2 Tautan Manual: URI Koneksi yang dapat Anda salin dan klik ikon tempel di layar ini untuk memasukkannya secara langsung. -\n\t\t3.3 Kode QR: Dompet membuat kode QR yang dapat Anda baca dari tombol kode QR di layar ini. - + Amethyst tidak dapat menemukan aplikasi dompet apa pun yang mendukung Nostr Wallet Connect (NWC). \n\nYang dapat Anda lakukan: \n\t1. Verifikasi Dukungan NWC: Pastikan aplikasi dompet pilihan Anda kompatibel dengan Nostr Wallet Connect. \n\t2. Buat Koneksi: Buka aplikasi dompet Anda dan buat koneksi NWC baru. \n\t3. Hubungkan ke Amethyst: Dompet Anda biasanya akan memberikan tiga pilihan: \n\t\t3.1 Tautan Otomatis: Tombol untuk secara otomatis membuka dan terhubung ke Amethyst. Jika ada, klik tombol tersebut, buka Amethyst dan tekan simpan di layar baru. \n\t\t3.2 Tautan Manual: URI Koneksi yang dapat Anda salin dan klik ikon tempel di layar ini untuk memasukkannya secara langsung. \n\t\t3.3 Kode QR: Dompet membuat kode QR yang dapat Anda baca dari tombol kode QR di layar ini. Buat hari Zona Berbahaya @@ -2723,11 +2703,8 @@ Relay Proxy Relay agregator yang harus digunakan aplikasi untuk mengunduh feed Anda, seperti filter.nostr.wine. Ini menggantikan model outbox dan akan membuat aplikasi hanya terhubung ke relay dalam daftar Anda. Percakapan Publik - Obrolan publik terlihat oleh semua orang di Nostr dan siapa pun - dapat berpartisipasi. Sangat cocok untuk komunitas terbuka seputar topik tertentu. - Moderasi dapat dilakukan dengan menghapus postingan di relay-nya - Masukkan antara 1–3 relay yang menjadi host grup ini. - Klien Nostr menggunakan pengaturan ini untuk mengetahui tempat mengunduh dan mengirim pesan Anda. + Obrolan publik terlihat oleh semua orang di Nostr dan siapa pun dapat berpartisipasi. Sangat cocok untuk komunitas terbuka seputar topik tertentu. Moderasi dapat dilakukan dengan menghapus postingan di relay-nya + Masukkan antara 1–3 relay yang menjadi host grup ini. Klien Nostr menggunakan pengaturan ini untuk mengetahui tempat mengunduh dan mengirim pesan Anda. relay Metadata Obrolan Publik Obrolan Publik @@ -2752,10 +2729,7 @@ Pengguna hanya-baca Tekan dan tahan untuk merekam pesan Obrolan relay - Obrolan relay adalah grup obrolan yang dikendalikan oleh relay induknya. - Obrolan ini terlihat oleh semua orang di Nostr dan siapa pun dapat berpartisipasi. - Sangat cocok untuk komunitas terbuka seputar topik tertentu. Beberapa grup ini bersifat sementara - sehingga pesan obrolan akan menghilang seiring waktu + Obrolan relay adalah grup obrolan yang dikendalikan oleh relay induknya. Obrolan ini terlihat oleh semua orang di Nostr dan siapa pun dapat berpartisipasi. Sangat cocok untuk komunitas terbuka seputar topik tertentu. Beberapa grup ini bersifat sementara sehingga pesan obrolan akan menghilang seiring waktu Obrolan relay Acara IP diff --git a/commonsUI/src/commonMain/composeResources/values-it-rIT/strings.xml b/commonsUI/src/commonMain/composeResources/values-it-rIT/strings.xml index 1a462fbb1f..54245dec39 100644 --- a/commonsUI/src/commonMain/composeResources/values-it-rIT/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-it-rIT/strings.xml @@ -266,15 +266,8 @@ Segna tutti i conosciuti come letti Segna tutti i nuovi come letti Segna tutti come letti - ## Suggerimenti per il backup e la sicurezza della chiave - \n\nIl tuo account è protetto da una chiave segreta. La chiave è una lunga sequenza di caratteri che inizia con **nsec1**. Chiunque abbia accesso a questa chiave segreta può pubblicare e modificare la tua identità. - \n\n- **Non** inserire la tua chiave segreta in nessun sito web o software di cui non ti fidi. - \n- Gli sviluppatori di Amethyst non chiederanno **mai** la tua chiave segreta. - \n- **Conserva** un backup sicuro della tua chiave segreta per il recupero dell'account. Si consiglia l'uso di un gestore di password. - - Per maggiore sicurezza, puoi cifrare la tua chiave con una password. Questa chiave inizia con **ncryptsec1** e non può essere utilizzata senza la tua password. - \n\nSe perdi la password, non potrai recuperare la tua chiave. - + ## Suggerimenti per il backup e la sicurezza della chiave \n\nIl tuo account è protetto da una chiave segreta. La chiave è una lunga sequenza di caratteri che inizia con **nsec1**. Chiunque abbia accesso a questa chiave segreta può pubblicare e modificare la tua identità. \n\n- **Non** inserire la tua chiave segreta in nessun sito web o software di cui non ti fidi. \n- Gli sviluppatori di Amethyst non chiederanno **mai** la tua chiave segreta. \n- **Conserva** un backup sicuro della tua chiave segreta per il recupero dell'account. Si consiglia l'uso di un gestore di password. + Per maggiore sicurezza, puoi cifrare la tua chiave con una password. Questa chiave inizia con **ncryptsec1** e non può essere utilizzata senza la tua password. \n\nSe perdi la password, non potrai recuperare la tua chiave. Cifra e copia la mia chiave segreta Immagine premio badge per %1$s "Immagine del premio badge @@ -947,9 +940,7 @@ Disabilita le notifiche push Usa l'app %1$s Configurazione notifiche push - Per ricevere notifiche push, installa qualsiasi app che supporta [Unified Push](https://unifiedpush.org/), come [Ntfy](https://ntfy.sh/). - Dopo l'installazione, seleziona l'app che desideri usare nelle Impostazioni. - + Per ricevere notifiche push, installa qualsiasi app che supporta [Unified Push](https://unifiedpush.org/), come [Ntfy](https://ntfy.sh/). Dopo l'installazione, seleziona l'app che desideri usare nelle Impostazioni. Invia un messaggio al venditore Ciao, è ancora disponibile? Titolo @@ -1986,15 +1977,7 @@ Impossibile recuperare la fattura da %1$s: %2$s Impossibile aprire il file compresso Wallet non trovato - Amethyst non ha trovato app wallet compatibili con Nostr Wallet Connect (NWC). -\n\nCosa puoi fare: -\n\t1. Verifica il supporto NWC: controlla che la tua app wallet preferita sia compatibile con Nostr Wallet Connect. -\n\t2. Crea connessione: vai nella tua app wallet e crea una nuova connessione NWC. -\n\t3. Collega ad Amethyst: la tua app wallet ti offrirà solitamente tre opzioni: -\n\t\t3.1 Collegamento automatico: un pulsante per aprire e connettersi automaticamente ad Amethyst. In tal caso, clicca quel pulsante, apri Amethyst e premi salva nella nuova schermata. -\n\t\t3.2 Collegamento manuale: un URI di connessione che puoi copiare e incollare tramite l'icona di incolla in questa schermata. -\n\t\t3.3 Codice QR: il wallet crea un codice QR che puoi leggere tramite il pulsante QR in questa schermata. - + Amethyst non ha trovato app wallet compatibili con Nostr Wallet Connect (NWC). \n\nCosa puoi fare: \n\t1. Verifica il supporto NWC: controlla che la tua app wallet preferita sia compatibile con Nostr Wallet Connect. \n\t2. Crea connessione: vai nella tua app wallet e crea una nuova connessione NWC. \n\t3. Collega ad Amethyst: la tua app wallet ti offrirà solitamente tre opzioni: \n\t\t3.1 Collegamento automatico: un pulsante per aprire e connettersi automaticamente ad Amethyst. In tal caso, clicca quel pulsante, apri Amethyst e premi salva nella nuova schermata. \n\t\t3.2 Collegamento manuale: un URI di connessione che puoi copiare e incollare tramite l'icona di incolla in questa schermata. \n\t\t3.3 Codice QR: il wallet crea un codice QR che puoi leggere tramite il pulsante QR in questa schermata. Crea g Zona pericolosa @@ -2645,11 +2628,8 @@ Relay proxy Relay aggregatori che l'app deve usare per scaricare i tuoi feed, come filter.nostr.wine. Questo sostituisce il modello outbox e farà connettere l'app solo ai relay nelle tue liste. Chat Pubblica - Le chat pubbliche sono visibili a tutti su Nostr e chiunque - può parteciparvi. Sono ideali per comunità aperte su argomenti specifici. - La moderazione può essere controllata eliminando i post sui relay associati - Inserisci tra 1 e 3 relay che ospitano questo gruppo. - I client Nostr usano questa impostazione per sapere da dove scaricare i messaggi e dove inviare i tuoi messaggi. + Le chat pubbliche sono visibili a tutti su Nostr e chiunque può parteciparvi. Sono ideali per comunità aperte su argomenti specifici. La moderazione può essere controllata eliminando i post sui relay associati + Inserisci tra 1 e 3 relay che ospitano questo gruppo. I client Nostr usano questa impostazione per sapere da dove scaricare i messaggi e dove inviare i tuoi messaggi. Relay Metadati chat pubblica Chat pubbliche @@ -2674,10 +2654,7 @@ Utente in sola lettura Tieni premuto per registrare un messaggio Chat relay - Le chat relay sono gruppi di chat controllati dal loro relay di riferimento. - Sono visibili a tutti su Nostr e chiunque può parteciparvi. - Sono ideali per comunità aperte su argomenti specifici. Alcuni di questi gruppi sono effimeri - e quindi i messaggi della chat scompaiono nel tempo + Le chat relay sono gruppi di chat controllati dal loro relay di riferimento. Sono visibili a tutti su Nostr e chiunque può parteciparvi. Sono ideali per comunità aperte su argomenti specifici. Alcuni di questi gruppi sono effimeri e quindi i messaggi della chat scompaiono nel tempo Chat relay Eventi IP @@ -2912,4 +2889,15 @@ Il servizio Lightning del destinatario su %1$s non è disponibile. È stato calcolato dall'indirizzo lightning "%2$s". Errore: %3$s. Controlla se il server è attivo e se l'indirizzo lightning è corretto Impossibile creare una fattura lightning da %1$s: elemento pr non trovato nel JSON risultante. Impossibile creare una fattura lightning. Messaggio da %1$s: %2$s + + + + diff --git a/commonsUI/src/commonMain/composeResources/values-ja-rJP/strings.xml b/commonsUI/src/commonMain/composeResources/values-ja-rJP/strings.xml index 4eb3738135..089c9f9857 100644 --- a/commonsUI/src/commonMain/composeResources/values-ja-rJP/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-ja-rJP/strings.xml @@ -69,7 +69,7 @@ QRコードをスキャン それでも表示する このポストは、非表示にしているユーザーまたはワードが含まれているため隠されています - このユーザによって投稿が不適切なものとしてマークされています: + このユーザによって投稿が不適切なものとしてマークされています: 投稿が見つかりません チャンネル画像 参照先のイベントが見つかりません @@ -271,15 +271,8 @@ すべての「知り合い」を既読にする すべての「新規リクエスト」を既読にする すべて既読にする - ## 鍵のバックアップとセキュリティのヒント - \n\nアカウントは秘密鍵によって保護されています。この鍵は **nsec1** で始まる長い文字列です。この秘密鍵にアクセスできる人は誰でも、あなたの名前でポストしたり、プロフィールを変更したりできます。 - \n\n- 信頼していないウェブサイトやアプリに秘密鍵を **入力しないでください**。 - \n- Amethyst の開発者が秘密鍵を **求めることは絶対にありません**。 - \n- アカウントの復元のために、秘密鍵の安全なバックアップを **必ず** 保管してください。パスワードマネージャーの使用をお勧めします。 - - さらにセキュリティを高めるために、鍵をパスワードで暗号化することができます。この鍵は **ncryptsec1** で始まり、パスワードなしでは使用できません。 - \n\nパスワードを忘れると、鍵を復元できなくなります。 - + ## 鍵のバックアップとセキュリティのヒント \n\nアカウントは秘密鍵によって保護されています。この鍵は **nsec1** で始まる長い文字列です。この秘密鍵にアクセスできる人は誰でも、あなたの名前でポストしたり、プロフィールを変更したりできます。 \n\n- 信頼していないウェブサイトやアプリに秘密鍵を **入力しないでください**。 \n- Amethyst の開発者が秘密鍵を **求めることは絶対にありません**。 \n- アカウントの復元のために、秘密鍵の安全なバックアップを **必ず** 保管してください。パスワードマネージャーの使用をお勧めします。 + さらにセキュリティを高めるために、鍵をパスワードで暗号化することができます。この鍵は **ncryptsec1** で始まり、パスワードなしでは使用できません。 \n\nパスワードを忘れると、鍵を復元できなくなります。 秘密鍵を暗号化してコピー %1$sのバッジ授与画像 バッジ授与の画像 @@ -965,9 +958,7 @@ プッシュ通知を無効にします アプリ%1$sを使用 プッシュ通知の設定 - プッシュ通知を受け取るには、[Ntfy](https://ntfy.sh/)など[Unified Push](https://unifiedpush.org/)に対応したアプリをインストールしてください。 - インストール後、設定で使用するアプリを選択してください。 - + プッシュ通知を受け取るには、[Ntfy](https://ntfy.sh/)など[Unified Push](https://unifiedpush.org/)に対応したアプリをインストールしてください。 インストール後、設定で使用するアプリを選択してください。 スレッド 出品者にメッセージを送る こんにちは、こちらはまだ入手可能ですか? @@ -2016,15 +2007,7 @@ %1$s から請求書を取得できませんでした: %2$s 圧縮ファイルを開けませんでした ウォレットが見つかりません - Amethyst は Nostr Wallet Connect(NWC)に対応したウォレットアプリを見つけられませんでした。 -\n\n対処方法: -\n\t1. NWC サポートを確認: お使いのウォレットアプリが Nostr Wallet Connect に対応しているか再確認してください。 -\n\t2. 接続を作成: ウォレットアプリで新しい NWC 接続を作成してください。 -\n\t3. Amethyst にリンク: ウォレットは通常、以下の3つの方法を提供します: -\n\t\t3.1 自動リンク: 自動的に Amethyst を開いて接続するボタン。その場合、ボタンをクリックして Amethyst を開き、新しい画面で保存を押してください。 -\n\t\t3.2 手動リンク: この画面の貼り付けアイコンをクリックして直接入力できる接続 URI。 -\n\t\t3.3 QR コード: ウォレットが作成する QR コード。この画面の QR コードボタンから読み取れます。 - + Amethyst は Nostr Wallet Connect(NWC)に対応したウォレットアプリを見つけられませんでした。 \n\n対処方法: \n\t1. NWC サポートを確認: お使いのウォレットアプリが Nostr Wallet Connect に対応しているか再確認してください。 \n\t2. 接続を作成: ウォレットアプリで新しい NWC 接続を作成してください。 \n\t3. Amethyst にリンク: ウォレットは通常、以下の3つの方法を提供します: \n\t\t3.1 自動リンク: 自動的に Amethyst を開いて接続するボタン。その場合、ボタンをクリックして Amethyst を開き、新しい画面で保存を押してください。 \n\t\t3.2 手動リンク: この画面の貼り付けアイコンをクリックして直接入力できる接続 URI。 \n\t\t3.3 QR コード: ウォレットが作成する QR コード。この画面の QR コードボタンから読み取れます。 作成 日 危険ゾーン @@ -2667,11 +2650,8 @@ Proxy Relay filter.nostr.wine のように、アプリがフィードをダウンロードするために使用するアグリゲーター relay です。これにより outbox モデルが置き換えられ、アプリはリスト内の relay のみに接続するようになります。 公開チャット - パブリックチャットは Nostr 上の全員に公開されており、誰でも - 参加できます。特定のトピックを中心としたオープンなコミュニティに最適です。 - モデレーションは、その relay 上のポストを削除することで管理できます - このグループをホストする relay を 1〜3 件入力してください。 - Nostr クライアントはこの設定を使用して、メッセージのダウンロード先と送信先を決定します。 + パブリックチャットは Nostr 上の全員に公開されており、誰でも 参加できます。特定のトピックを中心としたオープンなコミュニティに最適です。 モデレーションは、その relay 上のポストを削除することで管理できます + このグループをホストする relay を 1〜3 件入力してください。 Nostr クライアントはこの設定を使用して、メッセージのダウンロード先と送信先を決定します。 Relay パブリックチャットのメタデータ パブリックチャット @@ -2696,10 +2676,7 @@ 読み取り専用ユーザー 長押ししてメッセージを録音 relay チャット - relay チャットは、ホスト relay によって管理されるチャットグループです。 - Nostr 上の全員に公開されており、誰でも参加できます。 - 特定のトピックを中心としたオープンなコミュニティに最適です。一部のグループは一時的なもので、 - チャットメッセージは時間とともに消えます + relay チャットは、ホスト relay によって管理されるチャットグループです。 Nostr 上の全員に公開されており、誰でも参加できます。 特定のトピックを中心としたオープンなコミュニティに最適です。一部のグループは一時的なもので、 チャットメッセージは時間とともに消えます relay チャット イベント IP @@ -2932,4 +2909,15 @@ %1$s の受信者の Lightning サービスが利用できません。Lightning アドレス "%2$s" から計算されました。エラー: %3$s。サーバーが稼働しているか、Lightning アドレスが正しいかを確認してください %1$s から Lightning 請求書を作成できませんでした: 結果の JSON に要素 pr が見つかりませんでした。 Lightning 請求書を作成できませんでした。%1$s からのメッセージ: %2$s + + + + diff --git a/commonsUI/src/commonMain/composeResources/values-ja/strings.xml b/commonsUI/src/commonMain/composeResources/values-ja/strings.xml index a9b6f5afcf..8e2365c27e 100644 --- a/commonsUI/src/commonMain/composeResources/values-ja/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-ja/strings.xml @@ -49,7 +49,7 @@ QRコードをスキャン それでも表示する このポストは、非表示にしているユーザーまたはワードが含まれているため隠されています - このユーザによって投稿が不適切なものとしてマークされています: + このユーザによって投稿が不適切なものとしてマークされています: 投稿が見つかりません 👀 チャンネル画像 @@ -257,17 +257,8 @@ すべての「知り合い」を既読にする すべての「新規リクエスト」を既読にする すべて既読にする - - ## 鍵のバックアップとセキュリティのヒント - \n\nアカウントは秘密鍵によって保護されています。この鍵は **nsec1** で始まる長い文字列です。この秘密鍵にアクセスできる人は誰でも、あなたの名前でポストしたり、プロフィールを変更したりできます。 - \n\n- 信頼していないウェブサイトやアプリに秘密鍵を **入力しないでください**。 - \n- Amethyst の開発者が秘密鍵を **求めることは絶対にありません**。 - \n- アカウントの復元のために、秘密鍵の安全なバックアップを **必ず** 保管してください。パスワードマネージャーの使用をお勧めします。 - - - さらにセキュリティを高めるために、鍵をパスワードで暗号化することができます。この鍵は **ncryptsec1** で始まり、パスワードなしでは使用できません。 - \n\nパスワードを忘れると、鍵を復元できなくなります。 - + ## 鍵のバックアップとセキュリティのヒント \n\nアカウントは秘密鍵によって保護されています。この鍵は **nsec1** で始まる長い文字列です。この秘密鍵にアクセスできる人は誰でも、あなたの名前でポストしたり、プロフィールを変更したりできます。 \n\n- 信頼していないウェブサイトやアプリに秘密鍵を **入力しないでください**。 \n- Amethyst の開発者が秘密鍵を **求めることは絶対にありません**。 \n- アカウントの復元のために、秘密鍵の安全なバックアップを **必ず** 保管してください。パスワードマネージャーの使用をお勧めします。 + さらにセキュリティを高めるために、鍵をパスワードで暗号化することができます。この鍵は **ncryptsec1** で始まり、パスワードなしでは使用できません。 \n\nパスワードを忘れると、鍵を復元できなくなります。 秘密鍵を暗号化してコピー %1$sのバッジ授与画像 バッジ授与の画像 @@ -960,10 +951,7 @@ プッシュ通知を無効にします アプリ%1$sを使用 プッシュ通知の設定 - - プッシュ通知を受け取るには、[Ntfy](https://ntfy.sh/)など[Unified Push](https://unifiedpush.org/)に対応したアプリをインストールしてください。 - インストール後、設定で使用するアプリを選択してください。 - + プッシュ通知を受け取るには、[Ntfy](https://ntfy.sh/)など[Unified Push](https://unifiedpush.org/)に対応したアプリをインストールしてください。 インストール後、設定で使用するアプリを選択してください。 スレッド 出品者にメッセージを送る こんにちは、こちらはまだ入手可能ですか? @@ -2048,15 +2036,7 @@ %1$s から請求書を取得できませんでした: %2$s 圧縮ファイルを開けませんでした ウォレットが見つかりません - Amethyst は Nostr Wallet Connect(NWC)に対応したウォレットアプリを見つけられませんでした。 -\n\n対処方法: -\n\t1. NWC サポートを確認: お使いのウォレットアプリが Nostr Wallet Connect に対応しているか再確認してください。 -\n\t2. 接続を作成: ウォレットアプリで新しい NWC 接続を作成してください。 -\n\t3. Amethyst にリンク: ウォレットは通常、以下の3つの方法を提供します: -\n\t\t3.1 自動リンク: 自動的に Amethyst を開いて接続するボタン。その場合、ボタンをクリックして Amethyst を開き、新しい画面で保存を押してください。 -\n\t\t3.2 手動リンク: この画面の貼り付けアイコンをクリックして直接入力できる接続 URI。 -\n\t\t3.3 QR コード: ウォレットが作成する QR コード。この画面の QR コードボタンから読み取れます。 - + Amethyst は Nostr Wallet Connect(NWC)に対応したウォレットアプリを見つけられませんでした。 \n\n対処方法: \n\t1. NWC サポートを確認: お使いのウォレットアプリが Nostr Wallet Connect に対応しているか再確認してください。 \n\t2. 接続を作成: ウォレットアプリで新しい NWC 接続を作成してください。 \n\t3. Amethyst にリンク: ウォレットは通常、以下の3つの方法を提供します: \n\t\t3.1 自動リンク: 自動的に Amethyst を開いて接続するボタン。その場合、ボタンをクリックして Amethyst を開き、新しい画面で保存を押してください。 \n\t\t3.2 手動リンク: この画面の貼り付けアイコンをクリックして直接入力できる接続 URI。 \n\t\t3.3 QR コード: ウォレットが作成する QR コード。この画面の QR コードボタンから読み取れます。 作成 日 危険ゾーン @@ -2723,11 +2703,8 @@ Proxy Relay filter.nostr.wine のように、アプリがフィードをダウンロードするために使用するアグリゲーター relay です。これにより outbox モデルが置き換えられ、アプリはリスト内の relay のみに接続するようになります。 公開チャット - パブリックチャットは Nostr 上の全員に公開されており、誰でも - 参加できます。特定のトピックを中心としたオープンなコミュニティに最適です。 - モデレーションは、その relay 上のポストを削除することで管理できます - このグループをホストする relay を 1〜3 件入力してください。 - Nostr クライアントはこの設定を使用して、メッセージのダウンロード先と送信先を決定します。 + パブリックチャットは Nostr 上の全員に公開されており、誰でも 参加できます。特定のトピックを中心としたオープンなコミュニティに最適です。 モデレーションは、その relay 上のポストを削除することで管理できます + このグループをホストする relay を 1〜3 件入力してください。 Nostr クライアントはこの設定を使用して、メッセージのダウンロード先と送信先を決定します。 Relay パブリックチャットのメタデータ パブリックチャット @@ -2752,10 +2729,7 @@ 読み取り専用ユーザー 長押ししてメッセージを録音 relay チャット - relay チャットは、ホスト relay によって管理されるチャットグループです。 - Nostr 上の全員に公開されており、誰でも参加できます。 - 特定のトピックを中心としたオープンなコミュニティに最適です。一部のグループは一時的なもので、 - チャットメッセージは時間とともに消えます + relay チャットは、ホスト relay によって管理されるチャットグループです。 Nostr 上の全員に公開されており、誰でも参加できます。 特定のトピックを中心としたオープンなコミュニティに最適です。一部のグループは一時的なもので、 チャットメッセージは時間とともに消えます relay チャット イベント IP diff --git a/commonsUI/src/commonMain/composeResources/values-ko-rKR/strings.xml b/commonsUI/src/commonMain/composeResources/values-ko-rKR/strings.xml index 5381b4bb95..4e876d3cf0 100644 --- a/commonsUI/src/commonMain/composeResources/values-ko-rKR/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-ko-rKR/strings.xml @@ -266,15 +266,8 @@ 알려진 항목 모두 읽음 표시 새 항목 모두 읽음 표시 모두 읽음 표시 - ## 키 백업 및 보안 팁 - \n\n계정은 비밀 키로 보호됩니다. 키는 **nsec1**로 시작하는 긴 문자열입니다. 이 비밀 키에 접근할 수 있는 누구든 게시물 작성 및 신원 변경이 가능합니다. - \n\n- 신뢰하지 않는 웹사이트나 소프트웨어에 비밀 키를 **입력하지** 마세요. - \n- Amethyst 개발자는 **절대** 비밀 키를 요청하지 않습니다. - \n- 계정 복구를 위해 비밀 키를 안전하게 백업해 **두세요**. 비밀번호 관리자 사용을 권장합니다. - - 추가 보안을 위해 비밀번호로 키를 암호화할 수 있습니다. 이 키는 **ncryptsec1**로 시작하며 비밀번호 없이는 사용할 수 없습니다. - \n\n비밀번호를 분실하면 키를 복구할 수 없습니다. - + ## 키 백업 및 보안 팁 \n\n계정은 비밀 키로 보호됩니다. 키는 **nsec1**로 시작하는 긴 문자열입니다. 이 비밀 키에 접근할 수 있는 누구든 게시물 작성 및 신원 변경이 가능합니다. \n\n- 신뢰하지 않는 웹사이트나 소프트웨어에 비밀 키를 **입력하지** 마세요. \n- Amethyst 개발자는 **절대** 비밀 키를 요청하지 않습니다. \n- 계정 복구를 위해 비밀 키를 안전하게 백업해 **두세요**. 비밀번호 관리자 사용을 권장합니다. + 추가 보안을 위해 비밀번호로 키를 암호화할 수 있습니다. 이 키는 **ncryptsec1**로 시작하며 비밀번호 없이는 사용할 수 없습니다. \n\n비밀번호를 분실하면 키를 복구할 수 없습니다. 내 비밀 키 암호화 및 복사 %1$s의 배지 수상 이미지 "배지 수상 이미지 @@ -958,9 +951,7 @@ 푸시 알림 비활성화 앱 %1$s 사용 푸시 알림 설정 - 푸시 알림을 받으려면 [Ntfy](https://ntfy.sh/)와 같은 [Unified Push](https://unifiedpush.org/)를 지원하는 앱을 설치하세요. - 설치 후 설정에서 사용할 앱을 선택하세요. - + 푸시 알림을 받으려면 [Ntfy](https://ntfy.sh/)와 같은 [Unified Push](https://unifiedpush.org/)를 지원하는 앱을 설치하세요. 설치 후 설정에서 사용할 앱을 선택하세요. 스레드 판매자에게 메시지 보내기 안녕하세요, 아직 구매 가능한가요? @@ -2005,15 +1996,7 @@ %1$s에서 인보이스를 가져올 수 없습니다: %2$s 압축 파일을 열 수 없습니다 지갑을 찾을 수 없음 - Amethyst에서 Nostr Wallet Connect (NWC)를 지원하는 지갑 앱을 찾을 수 없습니다. -\n\n할 수 있는 작업: -\n\t1. NWC 지원 확인: 사용하려는 지갑 앱이 Nostr Wallet Connect와 호환되는지 다시 확인하세요. -\n\t2. 연결 생성: 지갑 앱에서 새 NWC 연결을 생성하세요. -\n\t3. Amethyst에 연결: 지갑에서 보통 세 가지 옵션을 제공합니다: -\n\t\t3.1 자동 연결: Amethyst를 자동으로 열고 연결하는 버튼. 해당 버튼을 클릭하고 Amethyst를 열어 새 화면에서 저장을 누르세요. -\n\t\t3.2 수동 연결: 연결 URI를 복사하고 이 화면의 붙여넣기 아이콘을 클릭하여 직접 입력하세요. -\n\t\t3.3 QR 코드: 지갑이 QR 코드를 생성하면 이 화면의 QR 코드 버튼으로 읽을 수 있습니다. - + Amethyst에서 Nostr Wallet Connect (NWC)를 지원하는 지갑 앱을 찾을 수 없습니다. \n\n할 수 있는 작업: \n\t1. NWC 지원 확인: 사용하려는 지갑 앱이 Nostr Wallet Connect와 호환되는지 다시 확인하세요. \n\t2. 연결 생성: 지갑 앱에서 새 NWC 연결을 생성하세요. \n\t3. Amethyst에 연결: 지갑에서 보통 세 가지 옵션을 제공합니다: \n\t\t3.1 자동 연결: Amethyst를 자동으로 열고 연결하는 버튼. 해당 버튼을 클릭하고 Amethyst를 열어 새 화면에서 저장을 누르세요. \n\t\t3.2 수동 연결: 연결 URI를 복사하고 이 화면의 붙여넣기 아이콘을 클릭하여 직접 입력하세요. \n\t\t3.3 QR 코드: 지갑이 QR 코드를 생성하면 이 화면의 QR 코드 버튼으로 읽을 수 있습니다. 만들기 일 위험 구역 @@ -2653,11 +2636,8 @@ 프록시 Relay filter.nostr.wine 같이 피드를 다운로드하는 데 앱이 사용해야 하는 집계 relay입니다. 이 설정은 아웃박스 모델을 대체하며 앱이 목록에 있는 relay에만 연결하도록 합니다. 공개 채팅 - 공개 채팅은 Nostr의 모든 사람에게 공개되며 - 누구나 참여할 수 있습니다. 특정 주제에 관한 열린 커뮤니티에 적합합니다. - relay에서 게시물을 삭제하여 관리할 수 있습니다 - 이 그룹을 호스팅하는 relay를 1~3개 입력하세요. - Nostr 클라이언트는 이 설정을 통해 메시지를 다운로드하고 전송할 위치를 파악합니다. + 공개 채팅은 Nostr의 모든 사람에게 공개되며 누구나 참여할 수 있습니다. 특정 주제에 관한 열린 커뮤니티에 적합합니다. relay에서 게시물을 삭제하여 관리할 수 있습니다 + 이 그룹을 호스팅하는 relay를 1~3개 입력하세요. Nostr 클라이언트는 이 설정을 통해 메시지를 다운로드하고 전송할 위치를 파악합니다. relay 공개 채팅 메타데이터 공개 채팅 @@ -2682,10 +2662,7 @@ 읽기 전용 사용자 길게 눌러 메시지를 녹음하세요 relay 채팅 - relay 채팅은 홈 relay가 관리하는 채팅 그룹입니다. - Nostr의 모든 사람에게 공개되며 누구나 참여할 수 있습니다. - 특정 주제에 관한 열린 커뮤니티에 적합합니다. 일부 그룹은 임시적이라 - 채팅 메시지가 시간이 지나면 사라집니다 + relay 채팅은 홈 relay가 관리하는 채팅 그룹입니다. Nostr의 모든 사람에게 공개되며 누구나 참여할 수 있습니다. 특정 주제에 관한 열린 커뮤니티에 적합합니다. 일부 그룹은 임시적이라 채팅 메시지가 시간이 지나면 사라집니다 relay 채팅 이벤트 IP @@ -2916,4 +2893,15 @@ %1$s의 수신자 Lightning 서비스를 사용할 수 없습니다. Lightning 주소 "%2$s"에서 계산된 결과입니다. 오류: %3$s. 서버가 작동 중인지, Lightning 주소가 올바른지 확인하세요 %1$s에서 Lightning 청구서를 생성할 수 없습니다: 결과 JSON에서 pr 요소를 찾을 수 없습니다. Lightning 청구서를 생성할 수 없습니다. %1$s의 메시지: %2$s + + + + diff --git a/commonsUI/src/commonMain/composeResources/values-lv-rLV/strings.xml b/commonsUI/src/commonMain/composeResources/values-lv-rLV/strings.xml index a456aedf1d..2cb28ecf8f 100644 --- a/commonsUI/src/commonMain/composeResources/values-lv-rLV/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-lv-rLV/strings.xml @@ -277,15 +277,8 @@ Atzīmēt visu zināmo kā lasītu Atzīmēt visu jauno kā lasītu Atzīmēt visu kā lasītu - ## Atslēgas dublēšanas un drošības padomi - \n\nJūsu konts ir aizsargāts ar slepeno atslēgu. Atslēga ir gara rakstzīmju secība, kas sākas ar **nsec1**. Ikviens, kam ir piekļuve šai slepenai atslēgai, var publicēt un mainīt jūsu identitāti. - \n\n- **Neievietojiet** savu slepeno atslēgu nevienā tīmekļa vietnē vai programmatūrā, kurai neuzticaties. - \n- Amethyst izstrādātāji **nekad** neprasīs jūsu slepeno atslēgu. - \n- **Glabājiet** drošu slepenas atslēgas dublējumu konta atjaunošanai. Mēs iesakām izmantot paroļu pārvaldnieku. - - Papildu drošībai variet šifrēt atslēgu ar paroli. Šī atslēga sāksies ar **ncryptsec1** virkni un to nevarēs izmantot bez šīs paroles. - \n\nJa jūs aizmirsīsiet paroli, vairs nevarēsiet atgūt savu atslēgu. - + ## Atslēgas dublēšanas un drošības padomi \n\nJūsu konts ir aizsargāts ar slepeno atslēgu. Atslēga ir gara rakstzīmju secība, kas sākas ar **nsec1**. Ikviens, kam ir piekļuve šai slepenai atslēgai, var publicēt un mainīt jūsu identitāti. \n\n- **Neievietojiet** savu slepeno atslēgu nevienā tīmekļa vietnē vai programmatūrā, kurai neuzticaties. \n- Amethyst izstrādātāji **nekad** neprasīs jūsu slepeno atslēgu. \n- **Glabājiet** drošu slepenas atslēgas dublējumu konta atjaunošanai. Mēs iesakām izmantot paroļu pārvaldnieku. + Papildu drošībai variet šifrēt atslēgu ar paroli. Šī atslēga sāksies ar **ncryptsec1** virkni un to nevarēs izmantot bez šīs paroles. \n\nJa jūs aizmirsīsiet paroli, vairs nevarēsiet atgūt savu atslēgu. Jūsu slepenā atslēga ir vienīgais veids, kā piekļūt šim kontam. Ja to pazaudēsiet, tad nekad nevarēsiet atgūt piekļuvi kontam. Saglabājiet atslēgu drošā vietā tagad. Esiet tās saglabājuši Šifrēt un kopēt manu slepeno atslēgu @@ -970,9 +963,7 @@ Atspējo push paziņojumus Izmanto lietotni %1$s Push paziņojumu iestatīšana - Lai saņemtu push paziņojumus, instalējiet jebkuru lietotni, kas atbalsta [Unified Push](https://unifiedpush.org/), piemēram, [Ntfy](https://ntfy.sh/). - Pēc instalēšanas iestatījumos atlasiet lietotni, kuru vēlaties izmantot. - + Lai saņemtu push paziņojumus, instalējiet jebkuru lietotni, kas atbalsta [Unified Push](https://unifiedpush.org/), piemēram, [Ntfy](https://ntfy.sh/). Pēc instalēšanas iestatījumos atlasiet lietotni, kuru vēlaties izmantot. Diskusija Nosūtīt pārdevējam ziņojumu Sveiki, vai tas joprojām ir pieejams? @@ -2037,15 +2028,7 @@ Nevarēja iegūt rēķinu no %1$s: %2$s Nevarēja atvērt saspiestu failu Maks nav atrasts - Amethyst nevarēja atrast nevienu maka lietotni, kas atbalsta Nostr Wallet Connect (NWC). -\n\nKo varat darīt: -\n\t1. Pārbaudiet NWC atbalstu: Pārliecinieties, vai jūsu vēlamā maka lietotne ir saderīga ar Nostr Wallet Connect. -\n\t2. Izveidojiet savienojumu: Atveriet savu maka lietotni un izveidojiet jaunu NWC savienojumu. -\n\t3. Saistiet ar Amethyst: Jūsu maks parasti piedāvās trīs iespējas: -\n\t\t3.1 Automātiska saistīšana: Poga, lai automātiski atvērtu un savienotu ar Amethyst. Ja tā, noklikšķiniet uz šīs pogas, atveriet Amethyst un nospiediet saglabāt jaunajā ekrānā. -\n\t\t3.2 Manuāla saistīšana: Savienojuma URI, ko varat kopēt un noklikšķināt uz ielīmēšanas ikonas šajā ekrānā, lai to ievadītu tieši. -\n\t\t3.3 QR kods: Maks izveido QR kodu, kuru varat nolasīt ar QR koda pogu šajā ekrānā. - + Amethyst nevarēja atrast nevienu maka lietotni, kas atbalsta Nostr Wallet Connect (NWC). \n\nKo varat darīt: \n\t1. Pārbaudiet NWC atbalstu: Pārliecinieties, vai jūsu vēlamā maka lietotne ir saderīga ar Nostr Wallet Connect. \n\t2. Izveidojiet savienojumu: Atveriet savu maka lietotni un izveidojiet jaunu NWC savienojumu. \n\t3. Saistiet ar Amethyst: Jūsu maks parasti piedāvās trīs iespējas: \n\t\t3.1 Automātiska saistīšana: Poga, lai automātiski atvērtu un savienotu ar Amethyst. Ja tā, noklikšķiniet uz šīs pogas, atveriet Amethyst un nospiediet saglabāt jaunajā ekrānā. \n\t\t3.2 Manuāla saistīšana: Savienojuma URI, ko varat kopēt un noklikšķināt uz ielīmēšanas ikonas šajā ekrānā, lai to ievadītu tieši. \n\t\t3.3 QR kods: Maks izveido QR kodu, kuru varat nolasīt ar QR koda pogu šajā ekrānā. Izveidot Bīstamā zona Tumšs @@ -2724,11 +2707,8 @@ Starpniekservera Relay Agregātori relay, ko lietotnei jāizmanto, lai lejupielādētu jūsu plūsmas, piemēram, filter.nostr.wine. Tas aizstāj izejošo ziņojumu modeli un liks lietotnei pieslēgties tikai jūsu sarakstos esošajiem relay. Publiskā tērzēšana - Publiskās tērzēšanas ir redzamas visiem Nostr lietotājiem un ikviens - var tajās piedalīties. Tās ir lieliski piemērotas atvērtām kopienām par konkrētām tēmām. - Moderēšanu var kontrolēt, dzēšot ierakstus tās relay - Ievadiet no 1 līdz 3 relay, kas mitina šo grupu. - Nostr klienti izmanto šo iestatījumu, lai zinātu, no kurienes lejupielādēt ziņojumus un kur nosūtīt jūsu ziņojumus. + Publiskās tērzēšanas ir redzamas visiem Nostr lietotājiem un ikviens var tajās piedalīties. Tās ir lieliski piemērotas atvērtām kopienām par konkrētām tēmām. Moderēšanu var kontrolēt, dzēšot ierakstus tās relay + Ievadiet no 1 līdz 3 relay, kas mitina šo grupu. Nostr klienti izmanto šo iestatījumu, lai zinātu, no kurienes lejupielādēt ziņojumus un kur nosūtīt jūsu ziņojumus. Relay Publiskās tērzēšanas metadati Publiskie čati @@ -2753,10 +2733,7 @@ Tikai lasāms lietotājs Noklikšķiniet un turiet, lai ierakstītu ziņojumu Relay tērzēšana - Relay tērzēšanas ir tērzēšanas grupas, kuras kontrolē to mājas relay. - Tās ir redzamas visiem Nostr lietotājiem un ikviens var tajās piedalīties. - Tās ir lieliski piemērotas atvērtām kopienām par konkrētām tēmām. Dažas no šīm grupām ir īslaicīgas - un tāpēc tērzēšanas ziņojumi laika gaitā pazūd + Relay tērzēšanas ir tērzēšanas grupas, kuras kontrolē to mājas relay. Tās ir redzamas visiem Nostr lietotājiem un ikviens var tajās piedalīties. Tās ir lieliski piemērotas atvērtām kopienām par konkrētām tēmām. Dažas no šīm grupām ir īslaicīgas un tāpēc tērzēšanas ziņojumi laika gaitā pazūd Relay tērzēšanas Notikumi IP adreses @@ -3000,4 +2977,15 @@ Saņēmēja Lightning pakalpojums adresē %1$s nav pieejams. Tas tika aprēķināts no Lightning adreses "%2$s". Kļūda: %3$s. Pārbaudiet, vai serveris darbojas un vai Lightning adrese ir pareiza Nevar izveidot Lightning rēķinu no %1$s: Elements pr nav atrasts iegūtajā JSON. Nevar izveidot Lightning rēķinu. Ziņojums no %1$s: %2$s + + + + diff --git a/commonsUI/src/commonMain/composeResources/values-nl-rNL/strings.xml b/commonsUI/src/commonMain/composeResources/values-nl-rNL/strings.xml index ac0da804f5..3ea2d52fc0 100644 --- a/commonsUI/src/commonMain/composeResources/values-nl-rNL/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-nl-rNL/strings.xml @@ -4621,4 +4621,15 @@ De Lightning-service van de ontvanger op %1$s is niet beschikbaar. Dit werd berekend vanuit Lightning-adres "%2$s". Fout: %3$s. Controleer of de server online is en het adres correct is. Kon geen Lightning-invoice aanmaken van %1$s: element "pr" niet gevonden in JSON. Kon geen Lightning-invoice aanmaken. Bericht van %1$s: %2$s + + + + diff --git a/commonsUI/src/commonMain/composeResources/values-nl/strings.xml b/commonsUI/src/commonMain/composeResources/values-nl/strings.xml index 70ca1190a6..5ddca3ba0e 100644 --- a/commonsUI/src/commonMain/composeResources/values-nl/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-nl/strings.xml @@ -211,7 +211,7 @@ Kopieer gebruiker ID Deblokkeer gebruiker Deze gebruiker is geblokkeerd/gedempt. Hun berichten zijn verborgen. - npub, hex, gebruikersnaam + npub, hex, gebruikersnaam Wissen App logo nsec.. of npub.. @@ -259,15 +259,8 @@ Bekende markeren als gelezen Nieuwe markeren als gelezen Alles markeren als gelezen - ## Sleutel back-up en veiligheidstips - \n\nUw account is beveiligd met een privésleutel. De sleutel is een lange, willekeurige reeks die begint met **nsec1**. Iedereen die toegang heeft tot uw privésleutel kan inhoud publiceren met uw identiteit. - \n\n- Voer uw privésleutel **nooit** in een website of software die u niet vertrouwt. - \n- Amethyst ontwikkelaars zullen u **nooit** om uw privésleutel vragen. - \n- **Bewaar** een veilige back-up van uw privésleutel voor accountherstel. Wij raden u aan een wachtwoordmanager te gebruiken. - - Voor meer veiligheid kan je je sleutel versleutelen met een wachtwoord. Deze sleutel begint met **ncryptsec1** en kan niet worden gebruikt zonder uw wachtwoord. - \n\nAls je je wachtwoord verliest, je kan je sleutel niet herstellen. - + ## Sleutel back-up en veiligheidstips \n\nUw account is beveiligd met een privésleutel. De sleutel is een lange, willekeurige reeks die begint met **nsec1**. Iedereen die toegang heeft tot uw privésleutel kan inhoud publiceren met uw identiteit. \n\n- Voer uw privésleutel **nooit** in een website of software die u niet vertrouwt. \n- Amethyst ontwikkelaars zullen u **nooit** om uw privésleutel vragen. \n- **Bewaar** een veilige back-up van uw privésleutel voor accountherstel. Wij raden u aan een wachtwoordmanager te gebruiken. + Voor meer veiligheid kan je je sleutel versleutelen met een wachtwoord. Deze sleutel begint met **ncryptsec1** en kan niet worden gebruikt zonder uw wachtwoord. \n\nAls je je wachtwoord verliest, je kan je sleutel niet herstellen. Versleutel en kopieer mijn geheime sleutel Badge award afbeelding voor %1$s Badge-awardafbeelding @@ -960,9 +953,7 @@ Push-notificaties uitschakelen Gebruikt app %1$s Instellen Push-notificaties - Om push-notificaties te ontvangen, installeer een app die [Unified Push] ondersteunt](https://unifiedpush.org/), zoals [Ntfy](https://ntfy.sh/). - Na installatie selecteer de app die je wilt gebruiken in de instellingen. - + Om push-notificaties te ontvangen, installeer een app die [Unified Push] ondersteunt](https://unifiedpush.org/), zoals [Ntfy](https://ntfy.sh/). Na installatie selecteer de app die je wilt gebruiken in de instellingen. Draad Stuur de verkoper een bericht Hallo daar, is dit nog steeds beschikbaar? diff --git a/commonsUI/src/commonMain/composeResources/values-pl-rPL/strings.xml b/commonsUI/src/commonMain/composeResources/values-pl-rPL/strings.xml index c307966e68..570f8fc2b1 100644 --- a/commonsUI/src/commonMain/composeResources/values-pl-rPL/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-pl-rPL/strings.xml @@ -174,6 +174,147 @@ Korek Wydarzenie drogowe + Geocache + Tradycyjny + Multi-cache + Tajemniczy + Mikro + Mały + Regularny + Duży + Inny rozmiar + D%1$d + T%1$d + Kto pierwszy znajdzie + Grafika + Kluczowe zadanie + Weryfikowanie znaleziska + Zarezerwowane + Zarchiwizowane + Utknąłeś? Kliknij, aby wyświetlić podpowiedź + Nie da się odczytać? Kliknij, aby wyświetlić inną wersję + W pobliżu + Znaleziono! + Zweryfikowane znalezisko + Sprawdzenie nie powiodło się + Ładowanie bufora… + Ukryty przez %1$s + Nawigacja + Kopiuj współrzędne + Współrzędne skopiowane + Zdjęcia + Misja „Key Quest” + Logi + Nikt jeszcze nie odznaczył tej skrzynki. Bądź pierwszy. + Znalazłem to + Nie znaleziono + Dodaj notatkę + Raport wymaga sprawdzenia + Dodaj do polowania + Nowe polowanie… + Nie dokonałeś jeszcze polowania. + Już podczas tego polowania + %1$d skrzynek tutaj + Usuń to zdjęcie + Ta skrzynka została zarchiwizowana. Nie jest już w grze. + Pierwszy, który znalazł – zgłoszone przez %1$s + Zwycięstwo w kategorii „Pierwszy, który znajdzie” wciąż pozostaje do rozstrzygnięcia + Właściciel + Edytuj tę skrzynkę + Archiwizuj tę skrzynkę + Archiwizuj to. Znajomi zobaczą, że jest poza grą, a lista pozostaje czytelna. + Zaznacz pierwszego zgłaszającego + Publikuje tag F o nazwie %1$s. Jest to zmiana trwała. + Nie ma jeszcze żadnych potwierdzonych znalezisk, więc nie ma nikogo, kogo można by wybrać. + Pokaż kod weryfikacyjny + Wydrukuj to i schowaj w kryjówce. Ametyst tego nie przechowuje — jeśli to zgubisz, osoby, które to znajdą, nie będą już mogły udowodnić, że tam były. + Nie znaleziono + Uwaga + Wymaga przeglądu + Zgłoś znalezisko + Skrzynka + Twój dziennik + Jak poszło? + Dodaj zdjęcie + Twoja odpowiedź w „Key Quest” + Dowód obecności + Zeskanuj kod w skrzynce + Zeskanuj inny kod + Zweryfikowano — byłeś przy skrzynce + Wprowadzono jednorazowo z kodu podanego w polu. Klucz nie jest zapisywany i nie opuszcza tego ekranu. + To nie jest kod weryfikacyjny. + Ten kod należy do innej skrzynki. + W przypadku tej skrzynki nie jest wymagane przedstawienie dowodu, więc wystarczy ją po prostu wpisać do dziennika. + Twoja lokalizacja nie jest dołączona do tego logu. + Wyślij + Dodaj log + W pobliżu + Mapa + Polowania + Znaleziska + Moje + Nieznalezione + D ≤ %1$d + T ≤ %1$d + Ma nagrodę + Wymagany dowód + Użyj mojej lokalizacji + Sortuj według odległości + Od najnowszych + Ukryj skrzynkę + Na razie nie ma tu żadnych wpisów. Spróbuj poszerzyć listę obserwowanych albo ukryj pierwszy wpis. + Nie zarejestrowałeś jeszcze żadnego znaleziska. + Nie ukryłeś jeszcze żadnej skrzynki. + Brak polowań. + Znaleziska + Pierwsze znalezienia + Zweryfikowano + Ukryte + Wyśrodkuj na mnie + Naciśnij i przytrzymaj mapę, aby ukryć skrzynkę + Otwórz + %1$d skrzynek + Gdzie + Co + Dodatki + Dowód + Transmitery + Spowoduje to opublikowanie lokalizacji publicznej z dokładnością do około 5 metrów. Każdy użytkownik serwisu Nostr może ją zobaczyć, a opublikowanego zdarzenia nie da się cofnąć. + Wybierz miejsce + Publikuje %1$s oraz komórki o większej rozdzielczości, aż do 3 znaków, na potrzeby wyszukiwania w pobliżu. + Nazwa + Opis + Typ + Rozmiar + Stopień trudności + Teren + Podpowiedź + Znajdujący zobaczą + Zaszyfruj wskazówkę (ROT13) + Misja „Key Quest” + Modyfikatory + Wymagane potwierdzenie obecności + Klucz weryfikacyjny został wygenerowany dla tej skrzynki. + Gdzie powinni się zgłaszać znalazcy + Publikuj + Podaj nazwę skrzynki. + Wybierz gdzie skrzynka jest ukryta. + Znaleziono %1$d z %2$d + Rozpocznij polowanie + %1$d skrzynek + Tytuł + Opis + Obraz banera + Skrzynki w kolejności + Dodaj skrzynkę + Przenieś wyżej + Przenieś niżej + Usuń + Motyw strony + Styl mapy + Podaj tytuł polowania. + Dodaj co najmniej jedną skrzynkę. + Nie można opublikować. Sprawdź połączenie z transmiterem i spróbuj ponownie. Zaps do tego są podzielone między: %1$d%% Wartość-za-wartość @@ -224,6 +365,18 @@ To jest link do strony internetowej. To jest zwykły tekst, a nie kod Nostra. To jest kod Nostr, ale nie może on zostać otwarty. + Profil + Uwaga + Nostr code + Transmiter + Klucz prywatny + Połączenie z portfelem + Adres zdalnego sygnatariusza + Prośba o nawiązanie połączenia z sygnatariuszem + Faktura Lightning + Token Cashu + Link do strony internetowej + Zwykły tekst Otwórz link Kopiuj Skopiowano @@ -355,8 +508,7 @@ Informacja o grupie… Szukaj i dodaj użytkownika Dodaj Transmiter - Nieprawidłowy adres transmitera. Użyj nazwy hosta lub adresu IP w nawiasach (na przykład -[201:d0e:9ba5:8bbc::1]:8080). + Nieprawidłowy adres transmitera. Użyj nazwy hosta lub adresu IP w nawiasach (na przykład [201:d0e:9ba5:8bbc::1]:8080). Moje imię @tag Nazwa użytkownika Mój nick @@ -504,15 +656,8 @@ Zaznacz wszystkie popularne jako przeczytane Zaznacz wszystkie nowe jako przeczytane Zaznacz wszystkie jako przeczytane - ## Porady dotyczące kopii zapasowej i bezpieczeństwa - \n\nTwoje konto jest zabezpieczone tajnym kluczem. Kluczem jest długa sekwencja znaków, zaczynająca się od **nsec1**. Każdy, kto ma dostęp do tego tajnego klucza może publikować i zmieniać Twoją tożsamość. - \n\n- **nie** umiejszczaj swojego tajnego klucza na żadnej stronie internetowej lub oprogramowaniu, któremu nie ufasz. - \n- Deweloperzy Ametyst **nigdy** nie będą prosić o Twój tajny klucz. - \n- **Zrób ** bezpieczną kopię zapasową swojego tajnego klucza do ewentualnego odzyskania konta. Zalecamy użycie menedżera haseł. - - Dla dodatkowego bezpieczeństwa możesz zaszyfrować swój klucz hasłem. Ten klucz zaczyna się od **ncryptsec1** i nie może być użyty bez hasła. - \n\nJeśli zgubisz hasło, nie będziesz w stanie odzyskać swojego klucza. - + ## Porady dotyczące kopii zapasowej i bezpieczeństwa \n\nTwoje konto jest zabezpieczone tajnym kluczem. Kluczem jest długa sekwencja znaków, zaczynająca się od **nsec1**. Każdy, kto ma dostęp do tego tajnego klucza może publikować i zmieniać Twoją tożsamość. \n\n- **nie** umiejszczaj swojego tajnego klucza na żadnej stronie internetowej lub oprogramowaniu, któremu nie ufasz. \n- Deweloperzy Ametyst **nigdy** nie będą prosić o Twój tajny klucz. \n- **Zrób ** bezpieczną kopię zapasową swojego tajnego klucza do ewentualnego odzyskania konta. Zalecamy użycie menedżera haseł. + Dla dodatkowego bezpieczeństwa możesz zaszyfrować swój klucz hasłem. Ten klucz zaczyna się od **ncryptsec1** i nie może być użyty bez hasła. \n\nJeśli zgubisz hasło, nie będziesz w stanie odzyskać swojego klucza. Kopia zapasowa kluczy Twój tajny klucz jest jedynym sposobem na dostęp do tego konta. Jeśli go zgubisz, nigdy nie będzie można go odzyskać. Zapisz go w bezpiecznym miejscu. Utwórz kopię zapasową @@ -686,14 +831,27 @@ Godziny Minuty Sekundy + Skorzystaj z treningów, które już zapisałeś + Połącz aplikację Connect Health (Samsung Health, Google Fit, Fitbit, Garmin…) z sekcją „Moja kondycja”, aby stworzyć własne podsumowanie treningowe i wprowadzić dane z treningu zarejestrowanego już przez zegarek, zamiast ręcznie wpisywać każdą wartość. Połącz %1$s km Z Health Connect Co czyta Amethyst Health Connect i Amethyst + Amethyst wykorzystuje Health Connect do tworzenia aplikacji „My Fitness” — Twojego osobistego podsumowania treningów, dostępnego na telefonie. + Sekcja „Moja kondycja” (menu → Ty → Mój fitness) pokazuje, jak wyglądał Twój trening: ile ćwiczyłeś w tym tygodniu w porównaniu z poprzednim, jak rozkłada się czas poświęcony na bieganie, jazdę na rowerze, spacery, pływanie i trening na siłowni, Twoje najlepsze wyniki, ile dni trenowałeś oraz aktualną passę. Amethyst tworzy ten obraz na podstawie treningów, które zegarek lub aplikacja fitnessowa zapisały już w Health Connect, wraz z treningami zarejestrowanymi bezpośrednio w aplikacji Amethyst. Dane są przetwarzane na telefonie i wyświetlane użytkownikowi. Co czyta Amethyst i dlaczego + Ćwiczenia – jakie aktywności wykonywałeś, kiedy je rozpocząłeś i jak długo trwały. To właśnie sam trening: decyduje o liczbie treningów, czasie treningu, podziale na poszczególne aktywności, liczbie aktywnych dni oraz serii. + Dystans · jak daleko przebiegłeś – przedstawiony jako dystans w tym tygodniu w porównaniu z poprzednim tygodniem, średni dystans tygodniowy, dystans na jedną sesję treningową oraz najdłuższy dystans. + Kalorie aktywne i całkowite · energia spalona podczas treningu, przedstawiona jako energia w bieżącym tygodniu oraz porównanie z poprzednim tygodniem. Kalorie aktywne są uwzględniane, gdy źródło je rejestruje; kalorie całkowite stanowią wartość zastępczą w przypadku źródeł, które rejestrują jedynie całkowitą energię. + Tętno · średnia i maksymalna wartość z każdego treningu, połączone w średnie i najwyższe tętno dla danego okresu. Dzięki temu można odróżnić tydzień o łagodnym obciążeniu od tygodnia o intensywnym obciążeniu przy tej samej odległości. + Liczba kroków · liczba kroków wykonanych podczas biegu, spaceru lub wędrówki, przedstawiona jako średnia tygodniowa liczba kroków oraz najwyższa liczba kroków. + Przezwyciężona różnica wysokości · o ile się wspiąłeś, przedstawiona jako średnia tygodniowa różnica wysokości oraz największa pokonana różnica wysokości. To właśnie odróżnia tydzień na płaskim terenie od tygodnia w pagórkowatym terenie. Czego Amethyst nie robi + Aplikacja odczytuje wyłącznie treningi z ostatnich 4 tygodni i tylko wtedy, gdy otwarta jest aplikacja Mój Fitness lub edytor treningów. Nigdy nie odczytuje danych w tle. Nigdy nie przesyła żadnych danych do serwisu Health Connect i nigdy nie prosi o podanie trasy ćwiczeń, lokalizacji ani żadnych innych danych dotyczących zdrowia. + Twoje dane dotyczące zdrowia nigdy nie są nigdzie przesyłane. Dane z aplikacji „Mój Fitness” są przetwarzane na Twoim telefonie i pozostają tam. Jeśli później zechcesz opublikować jeden trening, wystarczy, że klikniesz opcję „Udostępnij” przy tym treningu, przeczytasz treść wpisu wypełnioną przez Amethyst i samodzielnie go opublikujesz — jeden trening na raz, nigdy automatycznie. + Cała ta funkcja jest opcjonalna. W dowolnym momencie możesz cofnąć uprawnienia w Health Connect — aplikacja Mój Fitness powróci do poprzedniego ekranu — lub wyłączyć sugestie dotyczące tworzenia treści w sekcji Ustawienia → Opcje tworzenia treści. Pozostałe funkcje aplikacji Amethyst będą nadal działać. Przeczytaj pełną politykę prywatności Moja kondycja Ostatnie 4 tygodnie @@ -1172,6 +1330,8 @@ Data & godzina zakończenia ankiety Ankieta zostanie zamknięta za %1$s Szukam wydarzenia %1$s + Wczytywanie + Czekam na wczytanie Twoich prywatnych wiadomości… Wyślij Zapa Dodaj wiadomość publiczną Dodaj prywatną wiadomość @@ -1447,6 +1607,14 @@ Preferencje użytkownika Ustawienia wyszukiwania Nie znaleziono ustawień dla "%1$s" + Grupa zaktualizowana + Załącznik + Wiadomość + %1$s: %2$s + Utwórz grupę + Zaproszenia z serwisu Marmot są wysyłane na inne urządzenie i nie można ich tutaj otworzyć. + Zignoruj + Opublikuj z tego urządzenia Zresetować stan Marmota? Spowoduje to trwałe usunięcie każdego czatu grupowego Marmot, historii wiadomości i klucza MLS na tym urządzeniu dla bieżącego konta. Uczestnicy nie będą powiadamiani i mogą widzieć Cię w grupach do czasu następnej aktualizacji. Tej operacji nie można cofnąć. Nowy pakiet kluczowy zostanie opublikowany przy następnej synchronizacji aplikacji. Zresetuj @@ -1673,9 +1841,7 @@ Wyłącz powiadomienia Używa aplikacji %1$s Ustawienia powiadomień - Aby otrzymywać powiadomienia, zainstaluj dowolną aplikację, która obsługuje [Unified Push](https://unifiedpush. rg/), np. [Ntfy](https://ntfy.sh/). - Po zainstalowaniu, wybierz w ustawieniach aplikację, którą chcesz użyć. - + Aby otrzymywać powiadomienia, zainstaluj dowolną aplikację, która obsługuje [Unified Push](https://unifiedpush. rg/), np. [Ntfy](https://ntfy.sh/). Po zainstalowaniu, wybierz w ustawieniach aplikację, którą chcesz użyć. Wątek Wyślij sprzedawcy wiadomość Cześć, czy to jest nadal dostępne? @@ -2040,6 +2206,8 @@ Odtwórz wideo w pływającym oknie (opcja ukryta, jeśli nie jest obsługiwana) Przenieś na urządzenie Prześlij film do urządzenia Chromecast podłączonego do sieci Wi-Fi (opcja ukryta w przypadku plików lokalnych) + Napisy + Włącz lub wyłącz napisy (opcja ukryta, jeśli film nie zawiera napisów) Wybierz animację wyświetlaną podczas odtwarzania plików audio. Wyłączone Spektrogram @@ -3026,13 +3194,18 @@ Powiadamia Cię, gdy ktoś zareaguje na twój post Odpowiedzi Nowe odpowiedzi + Nie wysłano Odpowiedz + Ponów + Wysyłanie… + Wciąż wysyłam… Powiadamia Cię, gdy ktoś prześle twój post %1$s ponownie opublikował twój post Reposty Nowe reposty Powiadomienia o opublikowaniu lub braku publikacji zaplanowanego posta. Zaplanowane posty + To konto zostało usunięte z serwisu Amethyst. Powiadamia Cię, gdy ktoś prześle ci zapy %1$s Satoszy dla %1$s @@ -3263,6 +3436,10 @@ Włącz dźwięk Nie znaleziono zwrotnego adresu URL z odpowiedzi %1$s Anuluj + Wył. + Wyłącz napisy + Włącz napisy + Napisy Cashu Token Sprawdzanie minta… Nie można wykupić Cashu @@ -3469,15 +3646,7 @@ Nie można pobrać faktury od %1$s: %2$s Nie można otworzyć skompresowanego pliku Nie znaleziono portfela - Amethyst nie znalazł żadnej aplikacji portfela obsługującej Nostr Wallet Connect (NWC). -\n\nCo możesz zrobić: -\n\t1. Zweryfikuj wsparcie NWC: Sprawdź, czy preferowana aplikacja portfela jest kompatybilna z Nostr Wallet Connect. -\n\t2. Utwórz połączenie: Przejdź do aplikacji portfela i utwórz nowe połączenie NWC. -\n\t3. Połącz z Amethyst: Twój portfel zazwyczaj oferuje trzy opcje: -\n\t\t3. Automatyczne połączenie: przycisk, który automatycznie otwiera i łączy się z Amethyst. Jeśli tak, kliknij ten przycisk, otwórz Ametyst i naciśnij przycisk "Zapisz" na nowym ekranie. -\n\t\t3. Połączenie ręczne: adres URI połączenia, który można skopiować i wkleić, klikając ikonę wklejania na tym ekranie, aby wprowadzić go bezpośrednio. -\n\t\t3. Kod QR: Portfel tworzy kod QR, który można odczytać z przycisku QR na tym ekranie. - + Amethyst nie znalazł żadnej aplikacji portfela obsługującej Nostr Wallet Connect (NWC). \n\nCo możesz zrobić: \n\t1. Zweryfikuj wsparcie NWC: Sprawdź, czy preferowana aplikacja portfela jest kompatybilna z Nostr Wallet Connect. \n\t2. Utwórz połączenie: Przejdź do aplikacji portfela i utwórz nowe połączenie NWC. \n\t3. Połącz z Amethyst: Twój portfel zazwyczaj oferuje trzy opcje: \n\t\t3. Automatyczne połączenie: przycisk, który automatycznie otwiera i łączy się z Amethyst. Jeśli tak, kliknij ten przycisk, otwórz Ametyst i naciśnij przycisk "Zapisz" na nowym ekranie. \n\t\t3. Połączenie ręczne: adres URI połączenia, który można skopiować i wkleić, klikając ikonę wklejania na tym ekranie, aby wprowadzić go bezpośrednio. \n\t\t3. Kod QR: Portfel tworzy kod QR, który można odczytać z przycisku QR na tym ekranie. Utwórz d Niebezpieczna strefa @@ -3784,10 +3953,12 @@ Wstęp do przygody Lektura przygody Scena Przygody + Lista aplikacji Rekomendacje Spotkanie Aplikacje RSVP Spotkania + Lista artykułów Nagłówek audio Ścieżka audio Podcasty autorskie @@ -3800,6 +3971,7 @@ Autentykacja Blossom Serwery Blossom Lista zakładek + Zestaw zakładek Transmitery Nadawcze Kalendarz Określenie kanału @@ -3837,6 +4009,7 @@ Pakiety emotikonów Czat efemeryczny Efemeryczne pokoje czatowe + Ulubione kanały Ulubione Podcasty Ulubione Transmitery Nagłówek pliku @@ -3845,28 +4018,36 @@ Pakiety subskrybentów Obserwacja geohashów GiftWraps + Autorzy Git Problem z Git Łatka Git Wniosek o zmianę Aktualizacja PR Odpowiedź Git Repozytorium Git + Repozytoria Git Status Git: Zastosowano Status Git: Zamknięty Status Git: Szkic Status Git: Otwarty + Zaufani autorzy wiki + Zaufane transmitery Wiki Obserwowane hashtagi Wyróżnienia Autoryzacja http Indeks listy transmiterów + Zainteresowania Czaty na żywo Na żywo + Obserwowane Multimedia + Pakiet startowy multimediów Dane medyczne Pokój spotkań Strefa spotkań Playlista muzyczna Utwór muzyczny Lista zablokowanych + Wyciszono z uwagi na treści typu %1$s Opisane zakładki Serwery Nest NIP @@ -3881,6 +4062,7 @@ Transmitery wysyłające Płatność dla Listy osób + Lista zdjęć Zdjęcia Pineski Odcinek podcastu @@ -3907,6 +4089,7 @@ Szukaj Transmiterów Filmiki Filmiki (odpowiedź) + Grupy Komentarze torrentów Torrenty Zaufani dostawcy @@ -3915,7 +4098,10 @@ Ustawienia użytkownika Status użytkownika Wideo + Współpraca wideo + Lista filmów Wideo (odpowiedź) + Napisy wideo Wiadomość głosowa Odpowiedź głosowa Wybudź @@ -3956,6 +4142,8 @@ Zmień miejsce na mapie Kanały lokalizacji Wybierz miejsce na mapie + Aplikacja Amethyst na Androida nie może zalogować się przy użyciu adresu typu bunker://. Udostępnia ona takie adresy, aby inne aplikacje mogły poprosić ją o podpisanie, ale sama nie może się za ich pomocą zalogować. Należy skorzystać z zewnętrznej aplikacji do podpisywania lub z Amethyst Desktop. + To aplikacja, która prosi o nawiązanie połączenia z Twoim podpisywaczem, a nie sposób na zalogowanie się. Najpierw się zaloguj, a potem otwórz ją z poziomu ekranu podpisywacza NIP-46. Używasz klucza publicznego, a klucze publiczne są tylko do odczytu. Zaloguj się za pomocą klucza prywatnego, aby zwiększyć liczbę postów Używasz klucza publicznego, a klucze publiczne są tylko do odczytu. Zaloguj się za pomocą klucza prywatnego, aby móc obserwować Używasz klucza publicznego, a klucze publiczne są tylko do odczytu. Zaloguj się za pomocą klucza prywatnego, aby móc ukryć słowo lub zdanie @@ -4179,6 +4367,12 @@ Przywróć mówcę Wyjdź Nie udało się oznaczyć pokoju jako zamkniętego. Wychodzę mimo to — pokój zostanie automatycznie zamknięty. + + %1$d słuchacz + %1$d słuchaczy + %1$d słuchaczy + %1$d słuchacze + Opuść rękę Wycisz mikrofon Włącz mikrofon @@ -4344,6 +4538,7 @@ Aplikacja zdalnego logowania Zdalne logowanie + Ta lista jest prywatna – widzi ją wyłącznie jej autor %1$d zestaw aktywów %1$d zestawów aktywów @@ -4753,10 +4948,7 @@ auth uwierzytelnianie transmiter podpis weryfikacja nip-42 tożsamość Uwierzytelnianie transmitera Czat Transmitera - Czaty transmiterów to grupy czatów kontrolowane przez ich macierzyste transmitery. - Są one widoczne dla wszystkich użytkowników Nostr i każdy może w nich uczestniczyć. - Świetnie nadają się do tworzenia otwartych społeczności wokół określonych tematów. Niektóre z tych grup są efemeryczne - i dlatego wiadomości czatu znikają z czasem + Czaty transmiterów to grupy czatów kontrolowane przez ich macierzyste transmitery. Są one widoczne dla wszystkich użytkowników Nostr i każdy może w nich uczestniczyć. Świetnie nadają się do tworzenia otwartych społeczności wokół określonych tematów. Niektóre z tych grup są efemeryczne i dlatego wiadomości czatu znikają z czasem Czaty Transmitera Twój profil, ustawienia i wersje robocze na Twoich domowych transmiterach. Transmitery, na których każda społeczność publikuje swoje plany. @@ -4945,19 +5137,27 @@ Kalendarze Szachy Eksploracja + Edytuj skrzynkę + Edytuj polowanie + Skrzynka + Polowanie + Poszukiwanie skrytki + Geocache Start Import Obserwowanych + Zgłoś znalezisko Multimedia Wiadomości Playlisty Muzyka + Ukryj skrzynkę + Nowe polowanie Powiadomienia Odcinki Podcasty Zaplanuj post Anuluj planowanie - Wpisy zaplanowane mogą nie zostać opublikowane, dopóki nie uruchomisz ponownie aplikacji. -Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest aktywne. Włącz opcję „Zawsze włączone” w menu Ustawienia → Preferencje interfejsu użytkownika, aby zapewnić niezawodne planowanie zadań w tle. + Wpisy zaplanowane mogą nie zostać opublikowane, dopóki nie uruchomisz ponownie aplikacji. Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest aktywne. Włącz opcję „Zawsze włączone” w menu Ustawienia → Preferencje interfejsu użytkownika, aby zapewnić niezawodne planowanie zadań w tle. Zaplanowane posty mogą nie zostać opublikowane, dopóki nie uruchomisz ponownie aplikacji. Aby zapewnić niezawodne planowanie zadań w tle, włącz opcję „Zawsze włączone” w Ustawieniach → Preferencje dotyczące niezawodnego planowania zadań w tle. Zaplanowane posty Wyślij teraz @@ -5153,6 +5353,20 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Wysłano prośbę o usunięcie Lepsza jakość przy mniejszych rozmiarach plików, lecz nie wszystkie urządzenia obsługują odtwarzanie H.265. Użyj kodeka H.265/HEVC + Zaakceptowano kredyt współpracy + Odrzucono kredyt współpracy + Kredyt zaakceptowany + Dźwięk z + Zawiera + Zainspirowany przez + Referencje + Prywatna lista + + %1$d wideo + %1$d wideo + %1$d wideo + %1$d wideo + Rozpoczęto pobieranie wideo… Przyciski odtwarzacza wideo Film zapisany w galerii filmów @@ -5218,4 +5432,435 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Usługa lightning odbiorcy w %1$s jest niedostępna. Została obliczona na podstawie adresu lightning "%2$s". Błąd: %3$s. Sprawdź, czy serwer jest gotowy i czy adres lightning jest poprawny Nie można utworzyć faktury od %1$s: Element pr nie został znaleziony w powstałym JSON. Nie można utworzyć faktury. Wiadomość od %1$s: %2$s + To urządzenie odbiera teraz Twoje zaproszenia z serwisu Marmot. + Nie udało się opublikować KeyPackage: %1$s + Żaden transmiter nie przyjął KeyPackage. Sprawdź ustawienia transmitera i spróbuj ponownie. + Wysyłanie\u2026 + Nie udało się wysłać. Kliknij, aby spróbować ponownie. + + %1$d nowa wiadomość + %1$d nowych wiadomości + %1$d nowych wiadomości + %1$d nowe wiadomości + + + + + + Obiekt 3D + %1$d wierzchołków · %2$d ścian + Nie udało się odczytać tego obiektu 3D (reguła %1$s) + Awatar cyberprzestrzeni + Ten awatar nie został opłacony w odniesieniu do swojego rozmiaru, więc nie został narysowany + Włączone; kliknij, aby narysować obiekt na płasko + Płaski; kliknij, aby podświetlić obiekt + 1 jednostka = %1$s + Ukryte w przestrzeni danych · %1$s + Ukryte w przestrzeni pomysłów · %1$s + Domyślny awatar + Nie opublikowano kształtu, więc wszyscy widzą ten kształt + Ukryte w pewnym miejscu + Ukryte w pudełku zawierającym %1$s regionów + Ukryte w jednym regionie, który wskazówka wymienia z nazwy + Nie ma żadnej wskazówki, więc może to być gdziekolwiek w cyberprzestrzeni + Poza zasięgiem: wskazówka określa 2^%1$d obszarów do przeszukania + Szukaj tego + Stop + Wycena wyszukiwania na tym urządzeniu… + Przeszukano %1$s z %2$s regionów + Poza zasięgiem: około %1$d minut wyszukiwania na tym urządzeniu + Poza zasięgiem: około %1$d godzin wyszukiwania na tym urządzeniu + W pudełku nie ma podpowiedzi o nazwie + Znaleziono region, ale nie udało się odczytać jego zawartości + %1$d element ukryty w tym miejscu nie pasował do swojej sygnatury podpisu i został usunięty + %1$d ukrytych tutaj elementów nie pasowało do swoich sygnatur i zostało usuniętych + Otworzyłem, a w środku nic nie było + Brak podpisu, więc autor to tylko domysł + Element typu %1$d, którego ten klient nie wyświetla + %1$d bajtów danych, których ten klient nie odczytuje + Zablokuj %1$s ponownie + Zachowaj %1$s + Ponów %1$s + Ponownie wycisz %1$s + Przywróć %1$s + Przywróć kanały + Przywróć miejsca + Przywróć profil + Przywróć dostawców + Przywróć transmitery + Przywróć tematy + Przywróć portfel + Zaakceptuj + + %1$s i dodano %2$d + %1$s i dodano %2$d + %1$s i dodano %2$d + %1$s i dodano %2$d + + + %1$s i zablokowano %2$d + %1$s i zablokowano %2$d + %1$s i zablokowano %2$d + %1$s i zablokowano %2$d + + + %1$s i obserwowany %2$d + %1$s i obserwowanych %2$d + %1$s i obserwowanych %2$d + %1$s i obserwowane %2$d + + + %1$s i dołączono do %2$d + %1$s i dołączono do %2$d + %1$s i dołączono do %2$d + %1$s i dołączono do %2$d + + + %1$s i wyciszony %2$d + %1$s i wyciszono %2$d + %1$s i wyciszono %2$d + %1$s i wyciszone %2$d + + Twoje zsynchronizowane ustawienia aplikacji Amethyst zostały zmienione w innej aplikacji, która nie jest Amethystem. To może powodować problemy. + Opróżniłeś swoją listę Concorda + Zmieniłeś swoją listę Concorda + + Usunąłeś %1$d transmiter DM + Usunąłeś %1$d transmiterów DM + Usunąłeś %1$d transmiterów DM + Usunąłeś %1$d transmitery DM + + + Usunąłeś %1$d ulubiony kanał + Usunąłeś %1$d ulubionych kanałów + Usunąłeś %1$d ulubionych kanałów + Usunięto %1$d ulubione kanały + + + Przestałeś obserwować %1$d osobę + Przestałeś obserwować %1$d osób + Przestałeś obserwować %1$d osób + Przestałeś obserwować %1$d osoby + + + Przestałeś obserwować %1$d hashtag + Przestałeś obserwować %1$d hashtagów + Przestałeś obserwować %1$d hashtagów + Przestałeś obserwować %1$d hashtagi + + + Usunięto %1$d transmiter indeksujący + Usunięto %1$d transmiterów indeksujących + Usunięto %1$d transmiterów indeksujących + Usunięto %1$d transmitery indeksujące + + + Usunięto %1$d transmiter klucza grupowego czatu + Usunięto %1$d transmiterów klucza grupowego czatu + Usunięto %1$d transmiterów klucza grupowego czatu + Usunięto %1$d transmitery klucza grupowego czatu + + + Opuściłeś %1$d czat publiczny + Opuściłeś %1$d czatów publicznych + Opuściłeś %1$d czatów publicznych + Opuściłeś %1$d czaty publiczne + + + Opuściłeś %1$d społeczność + Opuściłeś %1$d społeczności + Opuściłeś %1$d społeczności + Opuściłeś %1$d społeczności + + + Opuściłeś %1$d grupę + Opuściłeś %1$d grup + Opuściłeś %1$d grup + Opuszczono %1$d grupy + + + Opuściłeś %1$d pokój czatu + Opuściłeś %1$d pokoi czatu + Opuściłeś %1$d pokoi czatu + Opuściłeś %1$d pokoje czatu + + + Włączyłeś dźwięk dla %1$d elementu + Włączyłeś dźwięk dla %1$d elementów + Włączyłeś dźwięk dla %1$d elementów + %1$d elementy mają włączony dźwięk + + + Usunąłeś %1$d publiczny transmiter + Usunąłeś %1$d publicznych transmiterów + Usunąłeś %1$d publicznych transmiterów + Usunąłeś %1$d publiczne transmitery + + Usunąłeś swój klucz nutzap + Wymieniłeś swój klucz Nutzap + + Usunięto %1$d nutzap mint + Usunięto %1$d nutzap mintów + Usunięto %1$d nutzap mintów + Usunięto %1$d nutzap minty + + + Usunięto %1$d ofertę BOLT12 + Usunięto %1$d ofert BOLT12 + Usunięto %1$d ofert BOLT12 + Usunięto %1$d oferty BOLT12 + + + Usunięto %1$d sposób na otrzymanie zapłaty + Usunięto %1$d sposobów otrzymywania zapłaty + Usunięto %1$d sposobów otrzymywania zapłaty + Usunięto %1$d sposoby otrzymywania zapłaty + + + Przestałeś obserwować %1$d miejsce + Przestałeś obserwować %1$d miejsc + Przestałeś obserwować %1$d miejsc + Przestałeś obserwować %1$d miejsca + + + Usunięto %1$d prywatny transmiter + Usunięto %1$d prywatnych transmiterów + Usunięto %1$d prywatnych transmiterów + Usunięto %1$d prywatne transmitery + + + Usunąłeś %1$d pole profilu + Usunąłeś %1$d pól profilowych + Usunąłeś %1$d pól profilowych + Usunąłeś %1$d pola profilowe + + + Usunięto %1$d kanał transmitera + Usunięto %1$d kanałów transmitera + Usunięto %1$d kanałów transmitera + Usunięto %1$d kanały transmitera + + + Usunięto %1$d transmiter wyszukiwania + Usunięto %1$d transmiterów wyszukiwania + Usunięto %1$d transmiterów wyszukiwania + Usunięto %1$d transmitery wyszukiwania + + + Zmieniłeś dostawcę %1$d zaufanego serwisu + Zmieniłeś dostawcę %1$d zaufanych serwisów + Zmieniłeś dostawcę %1$d zaufanych serwisów + Zmieniłeś dostawcę %1$d zaufanych serwisów + + + Przestałeś ufać %1$d transmiterowi + Przestałeś ufać %1$d transmiterom + Przestałeś ufać %1$d transmiterom + Przestałeś ufać %1$d transmiterom + + + Odblokowałeś %1$d transmiter + Odblokowałeś %1$d transmiterów + Odblokowałeś %1$d transmiterów + Odblokowałeś %1$d transmitery + + Opróżniłeś swój portfel Cashu + Zmieniłeś swój portfel Cashu + Dodano (%1$s) + Zmieniono (%1$s) + Dodano %1$s + %1$s zmieniono + Usunięto %1$s + Użyj nowej wersji + Dodano prywatne (zaszyfrowane) pozycje. + Zmieniłeś również elementy prywatne (zaszyfrowane). Nie można ich tutaj porównać. + Usunąłeś wszystkie prywatne (zaszyfrowane) elementy. + tylko do odczytu + odczyt i zapis + tylko do zapisu + Usunięto (%1$s) + Przywróć zapisaną wersję + Zmieniłeś %1$s w innej aplikacji + Kanały + Czat pokoje + Społeczności + Hashtagi + Powiązane tożsamości + Lokalizacje + Minty + Klucze Nutzap + Oferty + Inne wpisy + Adresy płatności + Osoby + Pola profilu + Czaty publiczne + Transmitery + Grupy + Wątki + Zaufani dostawcy + Słowa + O profilu + Baner + Data urodzin + Flaga bota + Oferta CLINK + Nazwa wyświetlana + LNURL + Lightning Adres + Nazwa użytkownika + Adres Nostr + Zdjęcie + Zaimki + Strona www + Dozwolona + Zmieniono w innej aplikacji · %1$s + Twoje społeczności w Concord są dla Ciebie zaszyfrowane, więc nie można ich tutaj porównać. Przywrócenie danych powoduje przywrócenie dokładnie tego, co zostało zapisane na tym urządzeniu. + Przeniesiona + %1$s odrzuconych + Usunięta + Utworzyłeś to + Zmieniłeś to + Opróżniłeś to + Kanał + Zniknęła + Hashtagi, które obserwujesz + Skrzynka odbiorcza + gdzie inni docierają do Ciebie + dołączył(a) + zachowane + Zachowano %1$s + Usunięto klucz odbierający + Wymieniono klucz odbierający + Nutzapy zostaną przypisane do tego nowego klucza. Jeśli urządzenie nie posiada tego klucza, może się okazać, że nie będzie można ich tutaj zrealizować. + zostało + Zostało + Na Liście + Przeniesiona + %1$s i %2$s więcej + Nowa wersja + %1$s nowych + nowe + Nowa + Nowo zablokowana + %1$s nowo wyciszonych + Nie jest już zablokowana + Nie ustawiono + Nic tu nie ma! + Oferta BOLT12 + + %1$d oferta usunięta + %1$d ofert usunięto + %1$d ofert usunięto + %1$d oferty usunięte + + Skrzynka nadawcza + gdzie publikujesz + + %1$d sposób płatności został usunięty + %1$d sposobów płatności zostało usuniętych + %1$d sposobów płatności zostało usuniętych + %1$d sposoby płatności zostały usunięte + + Osoby, które Ci płacą, widzą tylko to, co znajduje się na tej liście. + miejsca zniknęły + Miejsca które obserwujesz + Są teraz zaszyfrowane, co prawdopodobnie wyjaśnia, gdzie podziały się powyższe elementy publiczne. + Ustawiłeś te pozycje jako prywatne + Są zaszyfrowane, więc nie można ich tu porównać. + Zmieniłeś prywatne elementy + Są zaszyfrowane, więc tylko Ty możesz zobaczyć, co to było. Odtworzenie przywróci je wszystkie. + Usunąłeś wszystkie prywatne elementy + Pobieranie + zmieniono nazwę + Zmieniono nazwę + Na tym urządzeniu + Szukaj %1$s osób + Szukam + Serwis + Obserwujący + Ranking + Tematy + Zmieniono %1$s z %2$s serwisów + Teraz pochodzą od kogoś innego albo od nikogo + Nadal zablokowana + Przechowywanie + Usunięte · %1$s + Edytowane · %1$s + Nowe · %1$s + %1$s wyczyszczono + %1$s zaktualizowano + tematy zniknęły + Rankingi, liczba obserwujących i filtrowanie spamu, które widzisz, pochodzą od tych dostawców. + Zaufane + %1$s odblokowane + Ametyst ponownie połączy się z nimi + Jednostek + %1$s odblokowanych + Mogą one ponownie pojawić się w Twoich informacjach + Niezaufane + Minty i klucze w Twoim portfelu są zaszyfrowane dla Ciebie, więc nie można ich tutaj porównać pozycja po pozycji. Przywrócenie danych powoduje przywrócenie dokładnie tego, co zostało zapisane na tym urządzeniu. + było „%1$s” + Wyrazy & hasztagi + zsynchronizowane ustawienia aplikacji + Ustawienia Amethyst są synchronizowane na wszystkich Twoich urządzeniach. Są one zaszyfrowane, więc można porównać jedynie sam fakt ich obecności. + lista zablokowanych przekaźników + Transmitery, z którymi ta aplikacja nigdy się nie łączy. + Oferta BOLT12 + Ta możliwość pozwala innym płacić Ci za pomocą Lightning. + Portfel Cashu + Ustawienia portfela ecash oraz minty. Są one zaszyfrowane, więc można porównać jedynie sam fakt ich istnienia. + lista społeczności + Społeczności, do których dołączyłeś. + Lista społeczności Concord + Społeczności Concord, do których dołączyłeś. Są one zaszyfrowane, więc można porównać jedynie fakt ich istnienia. + Lista transmiterów DM + Gdzie inni dostarczają Twoje prywatne wiadomości. + lista czatów efemerycznych + Tymczasowe czaty, do których dołączyłeś. + lista ulubionych kanałów + Zapisane przez Ciebie kanały algorytmiczne. + lista obserwowanych + Określa, czyje posty pojawiają się w Twoim kanale głównym. + obserwowane lokalizacje + Miejsca, z których posty pojawiają się na Twoich kanałach. + obserwowane hashtagi + Hashtagi, z których pochodzą posty wyświetlane w Twoich kanałach. + lista transmiterów indeksujących + Transmitery służące do wyszukiwania profili innych osób oraz list transmiterów. + transmitery kluczy czatu grupowego + Gdzie inni znajdą klucze do dodawania cię do zaszyfrowanych czatów grupowych. + lista zablokowanych + Ukrywa osoby, słowa, hashtagi i wątki we wszystkich wyświetlanych treściach. + ustawienia odbioru nutzap + Minty i klucz, które inni używają do wysyłania ci „nutzapów”. + lista + Lista zapisana na tym urządzeniu. + lista publicznych transmiterów + Informuje wszystkich, gdzie mogą znaleźć Twoje posty oraz gdzie mają wysyłać Ci odpowiedzi i wzmianki. + adresy płatności + Sposoby, w jakie inni mogą Ci zapłacić. + lista prywatnych transmiterów + Gdzie przechowywane są Twoje projekty i prywatne dane. + profil + Twoje imię i nazwisko, zdjęcie, biografia oraz adresy do płatności – tak, jak widzą je wszyscy. + lista czatów publicznych + Publiczne czaty, do których dołączyłeś. + ulubione kanały transmitera + Transmitery, które przeglądasz jako kanały informacyjne. + lista grup + Grupy oparte na transmiterach, do których dołączyłeś. + lista transmiterów wyszukiwania + Transmitery używane podczas wyszukiwania. + lista zaufanych dostawców + Serwisy, którym ufasz, a które zajmują się klasyfikowaniem i weryfikacją osób w Twoim imieniu. + lista zaufanych transmiterów + Zaufane transmitery, z którymi bez wahania nawiązujesz połączenie. diff --git a/commonsUI/src/commonMain/composeResources/values-pt-rBR/strings.xml b/commonsUI/src/commonMain/composeResources/values-pt-rBR/strings.xml index c2346d058e..1a156efffd 100644 --- a/commonsUI/src/commonMain/composeResources/values-pt-rBR/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-pt-rBR/strings.xml @@ -361,7 +361,7 @@ Copiar ID do usuário Desbloquear Usuário Este usuário está bloqueado/silenciado. Suas publicações estão ocultas. - npub, hex, usuário + npub, hex, usuário Limpar Logo do aplicativo nsec / npub / hex chave privada @@ -449,8 +449,8 @@ Retransmissor pago Forçar uso do Tor ao conectar postagens recebidas - Autotraduzido - de + Autotraduzido + de para Mostrar em %1$s primeiro Chat público sobre %1$s @@ -463,13 +463,8 @@ Marcar todos conhecidos como lidos Marcar todas novas solicitações como lidas Marcar todas como lidas - ## Dicas de Backup de Chave e Segurança - \n\nSua conta é protegida por uma chave secreta. A chave é uma sequência longa de caracteres que começa com **nsec1**. Qualquer pessoa que tenha acesso a esta chave secreta pode postar e alterar sua identidade. - \n\n- **Não** coloque sua chave secreta em nenhum site ou software que você não confie. - \n- Os desenvolvedores da Amethyst **nunca** pedirão sua chave secreta. - \n- **Mantenha** uma cópia de segurança segura de sua chave secreta para recuperação da conta. Recomendamos o uso de um gerenciador de senhas. - Para segurança adicional, você pode criptografar sua chave com uma senha. Esta chave começa com **ncryptsec1** e não pode ser usada sem sua senha. - \n\nSe você perder sua senha, não será possível recuperar sua chave. + ## Dicas de Backup de Chave e Segurança \n\nSua conta é protegida por uma chave secreta. A chave é uma sequência longa de caracteres que começa com **nsec1**. Qualquer pessoa que tenha acesso a esta chave secreta pode postar e alterar sua identidade. \n\n- **Não** coloque sua chave secreta em nenhum site ou software que você não confie. \n- Os desenvolvedores da Amethyst **nunca** pedirão sua chave secreta. \n- **Mantenha** uma cópia de segurança segura de sua chave secreta para recuperação da conta. Recomendamos o uso de um gerenciador de senhas. + Para segurança adicional, você pode criptografar sua chave com uma senha. Esta chave começa com **ncryptsec1** e não pode ser usada sem sua senha. \n\nSe você perder sua senha, não será possível recuperar sua chave. Faça backup das suas chaves Sua chave secreta é a única forma de acessar esta conta. Se você perdê-la, ela nunca poderá ser recuperada. Guarde-a em um lugar seguro agora. Fazer backup agora @@ -1584,9 +1579,7 @@ Desativa as Notificações Push Usa o aplicativo %1$s Configuração de Notificações Push - Para receber notificações push, instale qualquer aplicativo que suporte [Unified Push](https://unifiedpush.org/), como [Ntfy](https://ntfy.sh/). - Após a instalação, selecione o aplicativo que deseja usar nas configurações. - + Para receber notificações push, instale qualquer aplicativo que suporte [Unified Push](https://unifiedpush.org/), como [Ntfy](https://ntfy.sh/). Após a instalação, selecione o aplicativo que deseja usar nas configurações. Linha Envie uma mensagem para o vendedor Olá, ainda está disponível? @@ -3323,14 +3316,7 @@ Não foi possível obter a fatura de %1$s: %2$s Não foi possível abrir o arquivo compactado Carteira não encontrada - O Amethyst não conseguiu encontrar nenhum aplicativo de carteira que suporte Nostr Wallet Connect (NWC). -\n\nO que você pode fazer: -\n\t1. Verifique o suporte NWC: Confirme se o aplicativo da sua carteira é compatível com o Nostr Wallet Connect. -\n\t2. Criar conexão: Vá até o aplicativo da sua carteira e crie uma nova conexão NWC. -\n\t3. Vincular ao Amethyst: Sua carteira geralmente oferece três opções: -\n\t\t3.1 Link Automático: Um botão para abrir e conectar automaticamente ao Amethyst. Clique nele, abra o Amethyst e salve na nova tela. -\n\t\t3.2 Link Manual: Um URI de conexão que você pode copiar e colar usando o ícone de colar nesta tela. -\n\t\t3.3 Código QR: A carteira gera um código QR que você pode ler usando o botão de código QR nesta tela. + O Amethyst não conseguiu encontrar nenhum aplicativo de carteira que suporte Nostr Wallet Connect (NWC). \n\nO que você pode fazer: \n\t1. Verifique o suporte NWC: Confirme se o aplicativo da sua carteira é compatível com o Nostr Wallet Connect. \n\t2. Criar conexão: Vá até o aplicativo da sua carteira e crie uma nova conexão NWC. \n\t3. Vincular ao Amethyst: Sua carteira geralmente oferece três opções: \n\t\t3.1 Link Automático: Um botão para abrir e conectar automaticamente ao Amethyst. Clique nele, abra o Amethyst e salve na nova tela. \n\t\t3.2 Link Manual: Um URI de conexão que você pode copiar e colar usando o ícone de colar nesta tela. \n\t\t3.3 Código QR: A carteira gera um código QR que você pode ler usando o botão de código QR nesta tela. Criar d Zona de Perigo @@ -4427,11 +4413,8 @@ Relays proxy Relays agregadores que o app usa para baixar seus feeds, como filter.nostr.wine. Isso substitui o modelo outbox e faz o app se conectar apenas aos relays das suas listas. Chat público - Os chats públicos são visíveis para todos no Nostr, e qualquer pessoa - pode participar deles. Eles são ótimos para comunidades abertas em torno de tópicos específicos. - A moderação pode ser controlada excluindo postagens em seus relays - Inserir entre 1–3 relés que hospedam esse grupo. - os clientes Nostr usam essa configuração para saber de onde fazer o download de mensagens e enviar suas mensagens. + Os chats públicos são visíveis para todos no Nostr, e qualquer pessoa pode participar deles. Eles são ótimos para comunidades abertas em torno de tópicos específicos. A moderação pode ser controlada excluindo postagens em seus relays + Inserir entre 1–3 relés que hospedam esse grupo. os clientes Nostr usam essa configuração para saber de onde fazer o download de mensagens e enviar suas mensagens. Relés Metadados do Chat Público Chats públicos @@ -4496,10 +4479,7 @@ auth autenticação relay assinar verificar nip-42 identidade Autenticação de relay Chat Relé - Chat Relés são grupos de chat controlados por seu relé de origem. - Eles são visíveis para todos no Nostr e qualquer pessoa pode participar. - São ótimos para comunidades abertas em torno de tópicos específicos. Alguns desses grupos são efêmeros, - portanto, as mensagens desaparecem com o tempo + Chat Relés são grupos de chat controlados por seu relé de origem. Eles são visíveis para todos no Nostr e qualquer pessoa pode participar. São ótimos para comunidades abertas em torno de tópicos específicos. Alguns desses grupos são efêmeros, portanto, as mensagens desaparecem com o tempo Chat Relés Seu próprio perfil, configurações e rascunhos, nos seus relays de origem. Os relays em que cada comunidade publica seus planos. @@ -4930,4 +4910,15 @@ O serviço Lightning do destinatário em %1$s não está disponível. Foi calculado a partir do endereço Lightning "%2$s". Erro: %3$s. Verifique se o servidor está funcionando e se o endereço Lightning está correto Não foi possível criar uma fatura lightning a partir de %1$s: Elemento pr não encontrado no JSON resultante. Não foi possível criar uma fatura com raios. Mensagem do %1$s: %2$s + + + + diff --git a/commonsUI/src/commonMain/composeResources/values-pt-rPT/strings.xml b/commonsUI/src/commonMain/composeResources/values-pt-rPT/strings.xml index 8995e96af0..d722a0814b 100644 --- a/commonsUI/src/commonMain/composeResources/values-pt-rPT/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-pt-rPT/strings.xml @@ -223,7 +223,7 @@ Copiar ID do usuário Desbloquear Usuário Este usuário está bloqueado/silenciado. Suas publicações estão ocultas. - npub, hex, usuário + npub, hex, usuário Limpar Logo do aplicativo nsec / npub / hex chave privada @@ -257,8 +257,8 @@ Retransmissor pago Forçar uso do Tor ao conectar postagens recebidas - Autotraduzido - de + Autotraduzido + de para Mostrar em %1$s primeiro Chat público sobre %1$s @@ -271,13 +271,8 @@ Marcar todos conhecidos como lidos Marcar todas novas solicitações como lidas Marcar todas como lidas - ## Dicas de Backup de Chave e Segurança - \n\nSua conta é protegida por uma chave secreta. A chave é uma sequência longa de caracteres que começa com **nsec1**. Qualquer pessoa que tenha acesso a esta chave secreta pode postar e alterar sua identidade. - \n\n- **Não** coloque sua chave secreta em nenhum site ou software que você não confie. - \n- Os desenvolvedores da Amethyst **nunca** pedirão sua chave secreta. - \n- **Mantenha** uma cópia de segurança segura de sua chave secreta para recuperação da conta. Recomendamos o uso de um gerenciador de senhas. - Para segurança adicional, você pode criptografar sua chave com uma senha. Esta chave começa com **ncryptsec1** e não pode ser usada sem sua senha. - \n\nSe você perder sua senha, não será possível recuperar sua chave. + ## Dicas de Backup de Chave e Segurança \n\nSua conta é protegida por uma chave secreta. A chave é uma sequência longa de caracteres que começa com **nsec1**. Qualquer pessoa que tenha acesso a esta chave secreta pode postar e alterar sua identidade. \n\n- **Não** coloque sua chave secreta em nenhum site ou software que você não confie. \n- Os desenvolvedores da Amethyst **nunca** pedirão sua chave secreta. \n- **Mantenha** uma cópia de segurança segura de sua chave secreta para recuperação da conta. Recomendamos o uso de um gerenciador de senhas. + Para segurança adicional, você pode criptografar sua chave com uma senha. Esta chave começa com **ncryptsec1** e não pode ser usada sem sua senha. \n\nSe você perder sua senha, não será possível recuperar sua chave. Criptografar e copiar minha chave secreta Imagem da medalha para %1$s Imagem do prêmio @@ -958,9 +953,7 @@ Desativa as Notificações Push Usa o aplicativo %1$s Configuração de Notificações Push - Para receber notificações push, instale qualquer aplicativo que suporte [Unified Push](https://unifiedpush.org/), como [Ntfy](https://ntfy.sh/). - Após a instalação, selecione o aplicativo que deseja usar nas configurações. - + Para receber notificações push, instale qualquer aplicativo que suporte [Unified Push](https://unifiedpush.org/), como [Ntfy](https://ntfy.sh/). Após a instalação, selecione o aplicativo que deseja usar nas configurações. Linha Envie uma mensagem para o vendedor Olá, ainda está disponível? @@ -2003,14 +1996,7 @@ Não foi possível obter a fatura de %1$s: %2$s Não foi possível abrir o arquivo compactado Carteira não encontrada - O Amethyst não conseguiu encontrar nenhum aplicativo de carteira que suporte Nostr Wallet Connect (NWC). -\n\nO que você pode fazer: -\n\t1. Verifique o suporte NWC: Confirme se o aplicativo da sua carteira é compatível com o Nostr Wallet Connect. -\n\t2. Criar conexão: Vá até o aplicativo da sua carteira e crie uma nova conexão NWC. -\n\t3. Vincular ao Amethyst: Sua carteira geralmente oferece três opções: -\n\t\t3.1 Link Automático: Um botão para abrir e conectar automaticamente ao Amethyst. Clique nele, abra o Amethyst e salve na nova tela. -\n\t\t3.2 Link Manual: Um URI de conexão que você pode copiar e colar usando o ícone de colar nesta tela. -\n\t\t3.3 Código QR: A carteira gera um código QR que você pode ler usando o botão de código QR nesta tela. + O Amethyst não conseguiu encontrar nenhum aplicativo de carteira que suporte Nostr Wallet Connect (NWC). \n\nO que você pode fazer: \n\t1. Verifique o suporte NWC: Confirme se o aplicativo da sua carteira é compatível com o Nostr Wallet Connect. \n\t2. Criar conexão: Vá até o aplicativo da sua carteira e crie uma nova conexão NWC. \n\t3. Vincular ao Amethyst: Sua carteira geralmente oferece três opções: \n\t\t3.1 Link Automático: Um botão para abrir e conectar automaticamente ao Amethyst. Clique nele, abra o Amethyst e salve na nova tela. \n\t\t3.2 Link Manual: Um URI de conexão que você pode copiar e colar usando o ícone de colar nesta tela. \n\t\t3.3 Código QR: A carteira gera um código QR que você pode ler usando o botão de código QR nesta tela. Criar Zona de Perigo Escuro @@ -2662,11 +2648,8 @@ Relays proxy Relays agregadores que o app usa para baixar seus feeds, como filter.nostr.wine. Isso substitui o modelo outbox e faz o app se conectar apenas aos relays das suas listas. Chat público - Os chats públicos são visíveis para todos no Nostr, e qualquer pessoa - pode participar deles. Eles são ótimos para comunidades abertas em torno de tópicos específicos. - A moderação pode ser controlada excluindo postagens em seus relays - Inserir entre 1–3 relés que hospedam esse grupo. - os clientes Nostr usam essa configuração para saber de onde fazer o download de mensagens e enviar suas mensagens. + Os chats públicos são visíveis para todos no Nostr, e qualquer pessoa pode participar deles. Eles são ótimos para comunidades abertas em torno de tópicos específicos. A moderação pode ser controlada excluindo postagens em seus relays + Inserir entre 1–3 relés que hospedam esse grupo. os clientes Nostr usam essa configuração para saber de onde fazer o download de mensagens e enviar suas mensagens. Relés Metadados do Chat Público Chats públicos @@ -2691,10 +2674,7 @@ Usuário somente leitura Clique e segure para gravar uma mensagem Chat Relé - Chat Relés são grupos de chat controlados por seu relé de origem. - Eles são visíveis para todos no Nostr e qualquer pessoa pode participar. - São ótimos para comunidades abertas em torno de tópicos específicos. Alguns desses grupos são efêmeros, - portanto, as mensagens desaparecem com o tempo + Chat Relés são grupos de chat controlados por seu relé de origem. Eles são visíveis para todos no Nostr e qualquer pessoa pode participar. São ótimos para comunidades abertas em torno de tópicos específicos. Alguns desses grupos são efêmeros, portanto, as mensagens desaparecem com o tempo Chat Relés Eventos Tipos @@ -2929,4 +2909,15 @@ O serviço Lightning do destinatário em %1$s não está disponível. Foi calculado a partir do endereço Lightning "%2$s". Erro: %3$s. Verifique se o servidor está funcionando e se o endereço Lightning está correto Não foi possível criar uma fatura lightning a partir de %1$s: Elemento pr não encontrado no JSON resultante. Não foi possível criar uma fatura com raios. Mensagem do %1$s: %2$s + + + + diff --git a/commonsUI/src/commonMain/composeResources/values-ru-rRU/strings.xml b/commonsUI/src/commonMain/composeResources/values-ru-rRU/strings.xml index 872282dfb5..d89352fd93 100644 --- a/commonsUI/src/commonMain/composeResources/values-ru-rRU/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-ru-rRU/strings.xml @@ -226,7 +226,7 @@ Копировать ID пользователя Разблокировать Этот пользователь заблокирован/заглушён. Его посты скрыты. - npub, hex, никнейм + npub, hex, никнейм Очистить Логотип приложения nsec / npub / приватный ключ в hex @@ -274,15 +274,8 @@ Отметить все известные прочитанными Отметить все новые прочитанными Отметить всё прочитанным - ## Советы по резервному копированию и безопасности ключей - \n\nВаш аккаунт защищён секретным ключом. Ключ — это длинная последовательность символов, начинающаяся с **nsec1**. Любой, кто получит доступ к этому секретному ключу, сможет публиковать записи и менять вашу личность. - \n\n- **Не** вводите свой секретный ключ на сайтах или в приложениях, которым не доверяете. - \n- Разработчики Amethyst **никогда** не будут запрашивать ваш секретный ключ. - \n- **Обязательно** сохраните надёжную резервную копию секретного ключа для восстановления аккаунта. Рекомендуем использовать менеджер паролей. - - Для дополнительной безопасности вы можете зашифровать ваш ключ с помощью пароля. Этот ключ начинается с **ncryptsec1** и не может быть использован без пароля. - \n\nЕсли вы потеряете свой пароль, вы не сможете восстановить ваш ключ. - + ## Советы по резервному копированию и безопасности ключей \n\nВаш аккаунт защищён секретным ключом. Ключ — это длинная последовательность символов, начинающаяся с **nsec1**. Любой, кто получит доступ к этому секретному ключу, сможет публиковать записи и менять вашу личность. \n\n- **Не** вводите свой секретный ключ на сайтах или в приложениях, которым не доверяете. \n- Разработчики Amethyst **никогда** не будут запрашивать ваш секретный ключ. \n- **Обязательно** сохраните надёжную резервную копию секретного ключа для восстановления аккаунта. Рекомендуем использовать менеджер паролей. + Для дополнительной безопасности вы можете зашифровать ваш ключ с помощью пароля. Этот ключ начинается с **ncryptsec1** и не может быть использован без пароля. \n\nЕсли вы потеряете свой пароль, вы не сможете восстановить ваш ключ. Зашифровать и копировать мой секретный ключ Картинка значка-награды %1$s Изображение награды за значок @@ -970,9 +963,7 @@ Отключает push-уведомления Использует приложение %1$s Настройки push-уведомлений - Для получения push-уведомлений, установите приложение, которое поддерживает [Unified Push](https://unifiedpush.org/), например [Ntfy](https://ntfy.sh/). - После установки выберите приложение, которое хотите использовать, в настройках. - + Для получения push-уведомлений, установите приложение, которое поддерживает [Unified Push](https://unifiedpush.org/), например [Ntfy](https://ntfy.sh/). После установки выберите приложение, которое хотите использовать, в настройках. Ветка Написать продавцу Привет, это ещё в наличии? @@ -2046,15 +2037,7 @@ Не удалось получить счёт от %1$s: %2$s Не удалось открыть сжатый файл Кошелёк не найден - Amethyst не нашёл ни одного приложения кошелька с поддержкой Nostr Wallet Connect (NWC). -\n\nЧто можно сделать: -\n\t1. Проверьте поддержку NWC: убедитесь, что ваш кошелёк совместим с Nostr Wallet Connect. -\n\t2. Создайте подключение: откройте приложение кошелька и создайте новое NWC-подключение. -\n\t3. Привяжите к Amethyst: обычно кошелёк предлагает три варианта: -\n\t\t3.1 Автоматическая привязка: кнопка для автоматического открытия и подключения к Amethyst. Нажмите её, откройте Amethyst и сохраните новый экран. -\n\t\t3.2 Ручная привязка: URI подключения, который можно скопировать и вставить с помощью значка вставки на этом экране. -\n\t\t3.3 QR-код: кошелёк создаёт QR-код, который можно считать кнопкой QR-кода на этом экране. - + Amethyst не нашёл ни одного приложения кошелька с поддержкой Nostr Wallet Connect (NWC). \n\nЧто можно сделать: \n\t1. Проверьте поддержку NWC: убедитесь, что ваш кошелёк совместим с Nostr Wallet Connect. \n\t2. Создайте подключение: откройте приложение кошелька и создайте новое NWC-подключение. \n\t3. Привяжите к Amethyst: обычно кошелёк предлагает три варианта: \n\t\t3.1 Автоматическая привязка: кнопка для автоматического открытия и подключения к Amethyst. Нажмите её, откройте Amethyst и сохраните новый экран. \n\t\t3.2 Ручная привязка: URI подключения, который можно скопировать и вставить с помощью значка вставки на этом экране. \n\t\t3.3 QR-код: кошелёк создаёт QR-код, который можно считать кнопкой QR-кода на этом экране. Создать д Опасная зона @@ -2737,11 +2720,8 @@ Предложить изменение Агрегирующие relay-серверы, которые приложение использует для загрузки ваших лент, например filter.nostr.wine. Это заменяет модель outbox и позволит приложению подключаться только к relay-серверам из ваших списков. Публичный чат - Публичные чаты видны всем в Nostr и открыты - для участия любого желающего. Они отлично подходят для открытых сообществ по определённым темам. - Модерация осуществляется путём удаления постов на relay - Укажите от 1 до 3 relay, на которых размещена эта группа. - Клиенты Nostr используют эту настройку, чтобы знать, откуда загружать сообщения и куда их отправлять. + Публичные чаты видны всем в Nostr и открыты для участия любого желающего. Они отлично подходят для открытых сообществ по определённым темам. Модерация осуществляется путём удаления постов на relay + Укажите от 1 до 3 relay, на которых размещена эта группа. Клиенты Nostr используют эту настройку, чтобы знать, откуда загружать сообщения и куда их отправлять. Relay Метаданные публичного чата Публичные чаты @@ -2766,10 +2746,7 @@ Пользователь только для чтения Нажмите и удерживайте, чтобы записать сообщение Чат relay - Чаты relay — это групповые чаты, управляемые их домашним relay. - Они видны всем в Nostr, и любой может в них участвовать. - Они отлично подходят для открытых сообществ по определённым темам. Некоторые группы эфемерны, - поэтому сообщения исчезают со временем + Чаты relay — это групповые чаты, управляемые их домашним relay. Они видны всем в Nostr, и любой может в них участвовать. Они отлично подходят для открытых сообществ по определённым темам. Некоторые группы эфемерны, поэтому сообщения исчезают со временем Чаты relay События IP-адреса @@ -3019,4 +2996,15 @@ Lightning-сервис получателя по адресу %1$s недоступен. Он был вычислен из Lightning-адреса "%2$s". Ошибка: %3$s. Проверьте, работает ли сервер и правильно ли указан Lightning-адрес Не удалось создать Lightning-счёт от %1$s: элемент pr не найден в полученном JSON. Не удалось создать Lightning-счёт. Сообщение от %1$s: %2$s + + + + diff --git a/commonsUI/src/commonMain/composeResources/values-ru-rUA/strings.xml b/commonsUI/src/commonMain/composeResources/values-ru-rUA/strings.xml index 2e28196e30..4b2c8833b7 100644 --- a/commonsUI/src/commonMain/composeResources/values-ru-rUA/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-ru-rUA/strings.xml @@ -267,15 +267,8 @@ Отметить все известные как прочитанные Отметить все новые как прочитанные Отметить все как прочитанные - ## Советы по резервному копированию и безопасности ключей - \n\nВаш аккаунт защищён секретным ключом. Ключ — это длинная последовательность символов, начинающаяся с **nsec1**. Любой, кто получит доступ к этому секретному ключу, сможет публиковать записи и менять вашу личность. - \n\n- **Не** вводите свой секретный ключ на сайтах или в приложениях, которым не доверяете. - \n- Разработчики Amethyst **никогда** не будут запрашивать ваш секретный ключ. - \n- **Обязательно** сохраните надёжную резервную копию секретного ключа для восстановления аккаунта. Рекомендуем использовать менеджер паролей. - - Для дополнительной безопасности вы можете зашифровать ваш ключ паролем. Этот ключ начинается с **ncryptsec1** и не может быть использован без вашего пароля. - \n\nЕсли вы потеряете пароль, вы не сможете восстановить ключ. - + ## Советы по резервному копированию и безопасности ключей \n\nВаш аккаунт защищён секретным ключом. Ключ — это длинная последовательность символов, начинающаяся с **nsec1**. Любой, кто получит доступ к этому секретному ключу, сможет публиковать записи и менять вашу личность. \n\n- **Не** вводите свой секретный ключ на сайтах или в приложениях, которым не доверяете. \n- Разработчики Amethyst **никогда** не будут запрашивать ваш секретный ключ. \n- **Обязательно** сохраните надёжную резервную копию секретного ключа для восстановления аккаунта. Рекомендуем использовать менеджер паролей. + Для дополнительной безопасности вы можете зашифровать ваш ключ паролем. Этот ключ начинается с **ncryptsec1** и не может быть использован без вашего пароля. \n\nЕсли вы потеряете пароль, вы не сможете восстановить ключ. Зашифровать и скопировать мой секретный ключ Изображение награды для %1$s Изображение награды за значок @@ -960,9 +953,7 @@ Отключает push-уведомления Использует приложение %1$s Настройка push-уведомлений - Для получения push-уведомлений установите любое приложение, поддерживающее [Unified Push](https://unifiedpush.org/), например [Ntfy](https://ntfy.sh/). - После установки выберите нужное приложение в Настройках. - + Для получения push-уведомлений установите любое приложение, поддерживающее [Unified Push](https://unifiedpush.org/), например [Ntfy](https://ntfy.sh/). После установки выберите нужное приложение в Настройках. Тред Написать продавцу Привет, это ещё в наличии? @@ -2032,15 +2023,7 @@ Не удалось получить счёт от %1$s: %2$s Не удалось открыть сжатый файл Кошелёк не найден - Amethyst не нашёл ни одного приложения кошелька с поддержкой Nostr Wallet Connect (NWC). -\n\nЧто можно сделать: -\n\t1. Проверьте поддержку NWC: убедитесь, что ваш кошелёк совместим с Nostr Wallet Connect. -\n\t2. Создайте подключение: откройте приложение кошелька и создайте новое NWC-подключение. -\n\t3. Привяжите к Amethyst: обычно кошелёк предлагает три варианта: -\n\t\t3.1 Автоматическая привязка: кнопка для автоматического открытия и подключения к Amethyst. Нажмите её, откройте Amethyst и сохраните новый экран. -\n\t\t3.2 Ручная привязка: URI подключения, который можно скопировать и вставить с помощью значка вставки на этом экране. -\n\t\t3.3 QR-код: кошелёк создаёт QR-код, который можно считать кнопкой QR-кода на этом экране. - + Amethyst не нашёл ни одного приложения кошелька с поддержкой Nostr Wallet Connect (NWC). \n\nЧто можно сделать: \n\t1. Проверьте поддержку NWC: убедитесь, что ваш кошелёк совместим с Nostr Wallet Connect. \n\t2. Создайте подключение: откройте приложение кошелька и создайте новое NWC-подключение. \n\t3. Привяжите к Amethyst: обычно кошелёк предлагает три варианта: \n\t\t3.1 Автоматическая привязка: кнопка для автоматического открытия и подключения к Amethyst. Нажмите её, откройте Amethyst и сохраните новый экран. \n\t\t3.2 Ручная привязка: URI подключения, который можно скопировать и вставить с помощью значка вставки на этом экране. \n\t\t3.3 QR-код: кошелёк создаёт QR-код, который можно считать кнопкой QR-кода на этом экране. Создать Опасная зона Тёмная @@ -2719,11 +2702,8 @@ Предложить правку Агрегирующие relay-серверы, которые приложение использует для загрузки ваших лент, например filter.nostr.wine. Это заменяет модель outbox и позволит приложению подключаться только к relay-серверам из ваших списков. Публичный чат - Публичные чаты видны всем в Nostr и открыты - для участия любого желающего. Они отлично подходят для открытых сообществ по определённым темам. - Модерация осуществляется путём удаления постов на relay - Укажите от 1 до 3 relay, на которых размещена эта группа. - Клиенты Nostr используют эту настройку, чтобы знать, откуда загружать сообщения и куда их отправлять. + Публичные чаты видны всем в Nostr и открыты для участия любого желающего. Они отлично подходят для открытых сообществ по определённым темам. Модерация осуществляется путём удаления постов на relay + Укажите от 1 до 3 relay, на которых размещена эта группа. Клиенты Nostr используют эту настройку, чтобы знать, откуда загружать сообщения и куда их отправлять. Relay Метаданные публичного чата Публичные чаты @@ -2748,10 +2728,7 @@ Пользователь только для чтения Нажмите и удерживайте для записи сообщения Чат relay - Чаты relay — это групповые чаты, управляемые их домашним relay. - Они видны всем в Nostr, и любой может в них участвовать. - Они отлично подходят для открытых сообществ по определённым темам. Некоторые группы эфемерны, - поэтому сообщения исчезают со временем + Чаты relay — это групповые чаты, управляемые их домашним relay. Они видны всем в Nostr, и любой может в них участвовать. Они отлично подходят для открытых сообществ по определённым темам. Некоторые группы эфемерны, поэтому сообщения исчезают со временем Чаты relay События IP-адреса @@ -3000,4 +2977,15 @@ Lightning-сервис получателя по адресу %1$s недоступен. Он был вычислен из Lightning-адреса "%2$s". Ошибка: %3$s. Проверьте, работает ли сервер и правильно ли указан Lightning-адрес Не удалось создать Lightning-счёт от %1$s: элемент pr не найден в полученном JSON. Не удалось создать Lightning-счёт. Сообщение от %1$s: %2$s + + + + diff --git a/commonsUI/src/commonMain/composeResources/values-ru/strings.xml b/commonsUI/src/commonMain/composeResources/values-ru/strings.xml index 5a3a0ea0eb..9ceaec1f2a 100644 --- a/commonsUI/src/commonMain/composeResources/values-ru/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-ru/strings.xml @@ -215,7 +215,7 @@ Копировать ID пользователя Разблокировать Этот пользователь заблокирован/заглушён. Его посты скрыты. - npub, hex, никнейм + npub, hex, никнейм Очистить Логотип приложения nsec / npub / приватный ключ в hex @@ -263,17 +263,8 @@ Отметить все известные прочитанными Отметить все новые прочитанными Отметить всё прочитанным - - ## Советы по резервному копированию и безопасности ключей - \n\nВаш аккаунт защищён секретным ключом. Ключ — это длинная последовательность символов, начинающаяся с **nsec1**. Любой, кто получит доступ к этому секретному ключу, сможет публиковать записи и менять вашу личность. - \n\n- **Не** вводите свой секретный ключ на сайтах или в приложениях, которым не доверяете. - \n- Разработчики Amethyst **никогда** не будут запрашивать ваш секретный ключ. - \n- **Обязательно** сохраните надёжную резервную копию секретного ключа для восстановления аккаунта. Рекомендуем использовать менеджер паролей. - - - Для дополнительной безопасности вы можете зашифровать ваш ключ паролем. Этот ключ начинается с **ncryptsec1** и не может быть использован без вашего пароля. - \n\nЕсли вы потеряете пароль, вы не сможете восстановить ваш ключ. - + ## Советы по резервному копированию и безопасности ключей \n\nВаш аккаунт защищён секретным ключом. Ключ — это длинная последовательность символов, начинающаяся с **nsec1**. Любой, кто получит доступ к этому секретному ключу, сможет публиковать записи и менять вашу личность. \n\n- **Не** вводите свой секретный ключ на сайтах или в приложениях, которым не доверяете. \n- Разработчики Amethyst **никогда** не будут запрашивать ваш секретный ключ. \n- **Обязательно** сохраните надёжную резервную копию секретного ключа для восстановления аккаунта. Рекомендуем использовать менеджер паролей. + Для дополнительной безопасности вы можете зашифровать ваш ключ паролем. Этот ключ начинается с **ncryptsec1** и не может быть использован без вашего пароля. \n\nЕсли вы потеряете пароль, вы не сможете восстановить ваш ключ. Зашифровать и скопировать секретный ключ Картинка значка-награды %1$s Изображение награды за значок @@ -966,9 +957,7 @@ Отключает push-уведомления Использует приложение %1$s Настройки push-уведомлений - Для получения push-уведомлений, установите приложение, которое поддерживает [Unified Push](https://unifiedpush.org/), например [Ntfy](https://ntfy.sh/). - После установки выберите приложение, которое хотите использовать, в настройках. - + Для получения push-уведомлений, установите приложение, которое поддерживает [Unified Push](https://unifiedpush.org/), например [Ntfy](https://ntfy.sh/). После установки выберите приложение, которое хотите использовать, в настройках. Ветка Написать продавцу Привет, это ещё в наличии? @@ -2077,15 +2066,7 @@ Не удалось получить счёт от %1$s: %2$s Не удалось открыть сжатый файл Кошелёк не найден - Amethyst не нашёл ни одного приложения кошелька с поддержкой Nostr Wallet Connect (NWC). -\n\nЧто можно сделать: -\n\t1. Проверьте поддержку NWC: убедитесь, что ваш кошелёк совместим с Nostr Wallet Connect. -\n\t2. Создайте подключение: откройте приложение кошелька и создайте новое NWC-подключение. -\n\t3. Привяжите к Amethyst: обычно кошелёк предлагает три варианта: -\n\t\t3.1 Автоматическая привязка: кнопка для автоматического открытия и подключения к Amethyst. Нажмите её, откройте Amethyst и сохраните новый экран. -\n\t\t3.2 Ручная привязка: URI подключения, который можно скопировать и вставить с помощью значка вставки на этом экране. -\n\t\t3.3 QR-код: кошелёк создаёт QR-код, который можно считать кнопкой QR-кода на этом экране. - + Amethyst не нашёл ни одного приложения кошелька с поддержкой Nostr Wallet Connect (NWC). \n\nЧто можно сделать: \n\t1. Проверьте поддержку NWC: убедитесь, что ваш кошелёк совместим с Nostr Wallet Connect. \n\t2. Создайте подключение: откройте приложение кошелька и создайте новое NWC-подключение. \n\t3. Привяжите к Amethyst: обычно кошелёк предлагает три варианта: \n\t\t3.1 Автоматическая привязка: кнопка для автоматического открытия и подключения к Amethyst. Нажмите её, откройте Amethyst и сохраните новый экран. \n\t\t3.2 Ручная привязка: URI подключения, который можно скопировать и вставить с помощью значка вставки на этом экране. \n\t\t3.3 QR-код: кошелёк создаёт QR-код, который можно считать кнопкой QR-кода на этом экране. Создать д Опасная зона @@ -2815,11 +2796,8 @@ Proxy Relays Агрегирующие relay-серверы, которые приложение использует для загрузки ваших лент, например filter.nostr.wine. Это заменяет модель outbox и позволит приложению подключаться только к relay-серверам из ваших списков. Публичный чат - Публичные чаты видны всем в Nostr и открыты - для участия любого желающего. Они отлично подходят для открытых сообществ по определённым темам. - Модерация осуществляется путём удаления постов на relay - Укажите от 1 до 3 relay, на которых размещена эта группа. - Клиенты Nostr используют эту настройку, чтобы знать, откуда загружать сообщения и куда их отправлять. + Публичные чаты видны всем в Nostr и открыты для участия любого желающего. Они отлично подходят для открытых сообществ по определённым темам. Модерация осуществляется путём удаления постов на relay + Укажите от 1 до 3 relay, на которых размещена эта группа. Клиенты Nostr используют эту настройку, чтобы знать, откуда загружать сообщения и куда их отправлять. Relay Метаданные публичного чата Публичные чаты @@ -2844,10 +2822,7 @@ Пользователь только для чтения Нажмите и удерживайте для записи сообщения Чат relay - Чаты relay — это групповые чаты, управляемые их домашним relay. - Они видны всем в Nostr, и любой может в них участвовать. - Они отлично подходят для открытых сообществ по определённым темам. Некоторые группы эфемерны, - поэтому сообщения исчезают со временем + Чаты relay — это групповые чаты, управляемые их домашним relay. Они видны всем в Nostr, и любой может в них участвовать. Они отлично подходят для открытых сообществ по определённым темам. Некоторые группы эфемерны, поэтому сообщения исчезают со временем Чаты relay События IP-адреса diff --git a/commonsUI/src/commonMain/composeResources/values-sl-rSI/strings.xml b/commonsUI/src/commonMain/composeResources/values-sl-rSI/strings.xml index 2f49657cbf..8ae6e95125 100644 --- a/commonsUI/src/commonMain/composeResources/values-sl-rSI/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-sl-rSI/strings.xml @@ -472,16 +472,8 @@ Označi vse znane kot prebrane Označi vse nove kot prebrane Označi vse kot prebrane - - ## Varnostna kopija ključev in varnostni nasveti - \n\nVaš račun je zaščiten z privatnim ključem. Ta ključ ima dolgo sekvenco znakov kateri se začnejo z **nsec1**. Kdorkoli ima dostop, do tega privatnega ključa, lahko pošiljajo zapiske in spremenijo vašo digitalno identiteto. - \n\n- Nikoli in **Nikdar** ne vpisuj svojega privatnega ključa v spletne strani in aplikacije katerim ne zaupaš. - \n- Amethyst ustvarjalci vas ne bojo **nikoli** vprašali po vašem privatnem ključu. - \n- Ustvari **varnostno kopijo** svojega privatnega ključa, za obnovitev računa. Priporoča se uporaba upravljalnika gesel. - - Za dodatno varnost, lahko šifriraš svoj ključ z dodatnim geslom. Ta ključ se začne z **ncryptsec1** in ne more bit uporabljen brez dodatnega gesla. - \n\nČe izgubiš geslo, obnova ključa ni več mogoča. - + ## Varnostna kopija ključev in varnostni nasveti \n\nVaš račun je zaščiten z privatnim ključem. Ta ključ ima dolgo sekvenco znakov kateri se začnejo z **nsec1**. Kdorkoli ima dostop, do tega privatnega ključa, lahko pošiljajo zapiske in spremenijo vašo digitalno identiteto. \n\n- Nikoli in **Nikdar** ne vpisuj svojega privatnega ključa v spletne strani in aplikacije katerim ne zaupaš. \n- Amethyst ustvarjalci vas ne bojo **nikoli** vprašali po vašem privatnem ključu. \n- Ustvari **varnostno kopijo** svojega privatnega ključa, za obnovitev računa. Priporoča se uporaba upravljalnika gesel. + Za dodatno varnost, lahko šifriraš svoj ključ z dodatnim geslom. Ta ključ se začne z **ncryptsec1** in ne more bit uporabljen brez dodatnega gesla. \n\nČe izgubiš geslo, obnova ključa ni več mogoča. Varno shrani ključe Vaš zasebni ključ je edini način za dostop do tega računa. Če ga izgubite, ga ne boste mogli več obnoviti. Shranite ga na varno mesto. Varno shrani zdaj @@ -1593,9 +1585,7 @@ Onemogoči potisna obvestila Uporablja aplikacijo %1$s Nastavitev potisnih obvestil - Za prejemanje potisnih obvestil namestite aplikacijo, ki podpira [Unified Push](https://unifiedpush.org/), na primer [Ntfy](https://ntfy.sh/). - Po namestitvi izberite aplikacijo, ki jo želite uporabljati, v nastavitvah. - + Za prejemanje potisnih obvestil namestite aplikacijo, ki podpira [Unified Push](https://unifiedpush.org/), na primer [Ntfy](https://ntfy.sh/). Po namestitvi izberite aplikacijo, ki jo želite uporabljati, v nastavitvah. Niz objav Pošlji prodajalcu sporočilo Živjo, je to še na voljo? @@ -1856,7 +1846,7 @@ danes izbrano Ustvari nov koledarski dogodek - Ustvari novo zbirko koledarjev + Ustvari novo zbirko koledarjev Ustvari novo zbirko koledarjev (neimenovano) Ves dan @@ -2042,7 +2032,7 @@ Več Dodaj člana Dodaj nekoga v to ZS - Neveljaven npub ali hex ključ + Neveljaven npub ali hex ključ Samo vi Delovni prostor Za @@ -2091,8 +2081,7 @@ Nastavite releje za predal zasebnih sporočil Ta nastavitev omogoča vsem, da vedo, katere releje uporabiti, ko vam pošiljajo sporočila. Brez njih bi lahko zgrešili nekatera sporočila. Dobre možnosti so:\n - auth.nostr1.com (brezplačen)\n - inbox.nostr.wine (plačljiv)\n - relay.0xchat.com (brezplačen) - Vnesite 1–3 releje, ki bodo služili kot vaš predal - za zasebna sporočila. Releji za zasebna sporočila bi morali sprejeti katero koli sporočilo od kogar koli, vendar samo vam dovoliti njihov prenos. + Vnesite 1–3 releje, ki bodo služili kot vaš predal za zasebna sporočila. Releji za zasebna sporočila bi morali sprejeti katero koli sporočilo od kogar koli, vendar samo vam dovoliti njihov prenos. Nastavi zdaj Releji za prejeto pošto (ZS) niso bili najdeni. Sporočil ni mogoče dostaviti, dokler uporabnik ne nastavi seznama svojih relejev. Nastavi iskalne releje @@ -2339,7 +2328,7 @@ Nova partija šaha Nalagam partijo\u2026 Partija ni najdena - Ta partija je zaključena ali pa, čaka na nasprotnika + Ta partija je zaključena ali pa, čaka na nasprotnika ID Partije: %1$s\u2026 in %1$d ostalih Odstrani %1$s @@ -2664,9 +2653,7 @@ committed the change, %2$s the member it concerns, %3$s the new name. --> Odstrani fotografijo KeyPackage releji niso nastavljeni - Še nimate seznama relejev za KeyPackage (MIP-00). Ta seznam drugim sporoča, kje je objavljen vaš KeyPackage, da vas lahko povabijo v skupinske klepete. - -Ali želite za to uporabiti trenutne releje iz odhodnega predala? + Še nimate seznama relejev za KeyPackage (MIP-00). Ta seznam drugim sporoča, kje je objavljen vaš KeyPackage, da vas lahko povabijo v skupinske klepete. Ali želite za to uporabiti trenutne releje iz odhodnega predala? Uporabi odhodne releje Zaenkrat preskoči Podatki o skupini @@ -3337,15 +3324,7 @@ Ali želite za to uporabiti trenutne releje iz odhodnega predala? Ni bilo mogoče pridobiti fakture od %1$s: %2$s Stisnjene datoteke ni bilo mogoče odpreti Ne najdem denarnice - Amethyst ni našel nobene denarnice, ki podpira Nostr Wallet Connect (NWC). -\n\nKaj lahko storiš: -\n\t1. Preverite podporo za NWC: Ponovno\t preverite, ali vaša priljubljena denarnica podpira Nostr Wallet Connect. -\n\t2. Ustvari povezavo: Odprite svojo denarnico in ustvarite novo NWC povezavo. -\n\t3. Povežite z Amethyst: Vaša denarnica vam bo ponavadi ponudila tri možnosti: -\n\t\t3.1 Samodejna povezava: Gumb za samodejno odprtje in povezavo z Amethyst. Če je na voljo, kliknite ta gumb, odprite Amethyst in na novem zaslonu pritisnite shrani. -\n\t\tRočna povezava: Povezovalni URI, ki ga lahko kopirate, nato pa na tem zaslonu kliknite ikono za lepljenje, da ga neposredno vnesete. -\n\t\tQR koda: Denarnica ustvari QR-kodo, ki jo skenirajte z gumbom za QR na tem zaslonu. - + Amethyst ni našel nobene denarnice, ki podpira Nostr Wallet Connect (NWC). \n\nKaj lahko storiš: \n\t1. Preverite podporo za NWC: Ponovno\t preverite, ali vaša priljubljena denarnica podpira Nostr Wallet Connect. \n\t2. Ustvari povezavo: Odprite svojo denarnico in ustvarite novo NWC povezavo. \n\t3. Povežite z Amethyst: Vaša denarnica vam bo ponavadi ponudila tri možnosti: \n\t\t3.1 Samodejna povezava: Gumb za samodejno odprtje in povezavo z Amethyst. Če je na voljo, kliknite ta gumb, odprite Amethyst in na novem zaslonu pritisnite shrani. \n\t\tRočna povezava: Povezovalni URI, ki ga lahko kopirate, nato pa na tem zaslonu kliknite ikono za lepljenje, da ga neposredno vnesete. \n\t\tQR koda: Denarnica ustvari QR-kodo, ki jo skenirajte z gumbom za QR na tem zaslonu. Ustvari d Nevarno območje @@ -3804,26 +3783,16 @@ Ali želite za to uporabiti trenutne releje iz odhodnega predala? Spremeni lokacijo na karti Lokacijski kanali Izberi lokacijo na karti - Uporabljaš javni ključ in javni ključi omogočajo le branje. -Prijavi se s privatnim ključem za posredovanje sporočila - Uporabljaš javni ključ in javni ključi omogočajo le branje. -Prijavi se s privatnim ključem, za sledenje uporabnika - Uporabljaš javni ključ in javni ključi omogočajo le branje. -Prijavi se s privatnim ključem, za cenzuro besed ali stavkov - Uporabljaš javni ključ in javni ključi omogočajo le branje. -Prijavi se s privatnim ključem, da omogočiš tudi pisanje - Uporabljaš javni ključ in javni ključi omogočajo le branje. -Prijavite se s privatnim ključem, da boste lahko pošiljali Zape - Uporabljaš javni ključ in javni ključi omogočajo le branje. -Prijavi se s privatnim ključem, za prikaz skritih besed in stavkov - Uporabljaš javni ključ in javni ključi omogočajo le branje. -Za podpisovanje se je potrebno prijaviti s privatnim ključem - Uporabljaš javni ključ in javni ključi omogočajo le branje. -Prijavi se s privatnim ključem, za odstranitev iz seznama sledljivih - Uporabljaš javni ključ in javni ključi omogočajo le branje. -Za nalaganje se je potrebno prijaviti s privatnim ključem - Uporabljaš javni ključ in javni ključi omogočajo le branje. -Prijavi se s privatnim ključem za všečkanje sporočila + Uporabljaš javni ključ in javni ključi omogočajo le branje. Prijavi se s privatnim ključem za posredovanje sporočila + Uporabljaš javni ključ in javni ključi omogočajo le branje. Prijavi se s privatnim ključem, za sledenje uporabnika + Uporabljaš javni ključ in javni ključi omogočajo le branje. Prijavi se s privatnim ključem, za cenzuro besed ali stavkov + Uporabljaš javni ključ in javni ključi omogočajo le branje. Prijavi se s privatnim ključem, da omogočiš tudi pisanje + Uporabljaš javni ključ in javni ključi omogočajo le branje. Prijavite se s privatnim ključem, da boste lahko pošiljali Zape + Uporabljaš javni ključ in javni ključi omogočajo le branje. Prijavi se s privatnim ključem, za prikaz skritih besed in stavkov + Uporabljaš javni ključ in javni ključi omogočajo le branje. Za podpisovanje se je potrebno prijaviti s privatnim ključem + Uporabljaš javni ključ in javni ključi omogočajo le branje. Prijavi se s privatnim ključem, za odstranitev iz seznama sledljivih + Uporabljaš javni ključ in javni ključi omogočajo le branje. Za nalaganje se je potrebno prijaviti s privatnim ključem + Uporabljaš javni ključ in javni ključi omogočajo le branje. Prijavi se s privatnim ključem za všečkanje sporočila Videoposnetki m Moji emoji paketi @@ -4122,7 +4091,7 @@ Prijavi se s privatnim ključem za všečkanje sporočila Brez moderacije Ustvari nov javen klepet Pridruži se lahko kdorkoli - Preprosto in zlahka dosegljivo + Preprosto in zlahka dosegljivo Javna sporočila na releju Javni klepet Skupine, ki jih moderira njihov gostiteljski rele @@ -4531,11 +4500,8 @@ Prijavi se s privatnim ključem za všečkanje sporočila Proxy releji Agregatorji, ki jih mora aplikacija uporabljati za prenos vaših vsebin, na primer filter.nostr.wine. To nadomesti model odhodne pošte (outbox model) in bo prisililo aplikacijo k povezovanju prek relejev iz vašega seznama. Javni pogovor - Javni klepeti so vidni vsem na Nostru in vsakdo - se lahko pridruži. So odlični za odprte skupnosti o specifičnih temah. - Moderiranje je možno z brisanjem objav na njihovih relejih - Vnesi 1–3 releje, ki gostijo to skupino. - Nostr odjemalci uporabljajo to nastavitev, da vedo, od kod prenesti vaša sporočila in kam jih poslati. + Javni klepeti so vidni vsem na Nostru in vsakdo se lahko pridruži. So odlični za odprte skupnosti o specifičnih temah. Moderiranje je možno z brisanjem objav na njihovih relejih + Vnesi 1–3 releje, ki gostijo to skupino. Nostr odjemalci uporabljajo to nastavitev, da vedo, od kod prenesti vaša sporočila in kam jih poslati. Releji Metapodatki Javnega Klepeta Javni klepeti @@ -4595,10 +4561,7 @@ Prijavi se s privatnim ključem za všečkanje sporočila Preverjanje pristnosti (Auth), rele (Relay), podpis (Sign), preverjanje (Verify), NIP-42, identiteta Avtentikacija releja Rele klepet - Pogovori prek relejev so pogovorne skupine, ki jih nadzoruje njihov domači rele. - Vidne so vsem na Nostru in v njih lahko sodeluje kdorkoli. - So odlične za odprte skupnosti, povezane s specifičnimi temami. Nekatere od teh skupin so kratkotrajne - zato sporočila v klepetu sčasoma izginejo + Pogovori prek relejev so pogovorne skupine, ki jih nadzoruje njihov domači rele. Vidne so vsem na Nostru in v njih lahko sodeluje kdorkoli. So odlične za odprte skupnosti, povezane s specifičnimi temami. Nekatere od teh skupin so kratkotrajne zato sporočila v klepetu sčasoma izginejo Rele klepeti Vaš profil, nastavitve in osnutki na vaših domačih relejih. Releji, kamor vsaka skupnost objavlja svoje načrte. @@ -4732,7 +4695,7 @@ Prijavi se s privatnim ključem za všečkanje sporočila Lokalni pomnilnik Zapiski v pomnilniku Trenutni pomnilnik - Profili v pomnilniku + Profili v pomnilniku Čas v zvočnih sobah Rudarjenje PoW Obuditve radijskega modula za prenose (ocena) @@ -5033,4 +4996,15 @@ Prijavi se s privatnim ključem za všečkanje sporočila Prejemnikova "lightning" storitev na %1$s ni na voljo. Izračunana je bila iz "lightning" naslova '%2$s'. Napaka: %3$s. Preverite, ali strežnik deluje in ali je "lightning" naslov pravilen Ni mogoče ustvariti lightning fakture od %1$s: Element 'pr' ni bil najden v JSON-u. Ni mogoče ustvariti lightning fakture. Sporočilo od %1$s: %2$s + + + + diff --git a/commonsUI/src/commonMain/composeResources/values-sr-rSP/strings.xml b/commonsUI/src/commonMain/composeResources/values-sr-rSP/strings.xml index 7912cce970..9aee9b1e4d 100644 --- a/commonsUI/src/commonMain/composeResources/values-sr-rSP/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-sr-rSP/strings.xml @@ -271,15 +271,8 @@ Označi sve poznate kao pročitano Označi sve novo kao pročitano Označi sve kao pročitano - ## Saveti za bekap ključa i bezbednost - \n\nVaš nalog je zaštićen tajnim ključem. Ključ je dugačak niz znakova koji počinje sa **nsec1**. Svako ko ima pristup ovom tajnom ključu može da objavljuje i menja vaš identitet. - \n\n- **Ne** unosite vaš tajni ključ ni na jedan veb-sajt ili softver kojemu ne verujete. - \n- Amethyst programeri **nikada** neće tražiti vaš tajni ključ. - \n- **Čuvajte** bezbednu kopiju vašeg tajnog ključa radi oporavka naloga. Preporučujemo korišćenje menadžera lozinki. - - Za dodatnu bezbednost, možete šifrovati vaš ključ lozinkom. Ovaj ključ počinje sa **ncryptsec1** i ne može se koristiti bez vaše lozinke. - \n\nAko izgubite lozinku, nećete moći da obnovite ključ. - + ## Saveti za bekap ključa i bezbednost \n\nVaš nalog je zaštićen tajnim ključem. Ključ je dugačak niz znakova koji počinje sa **nsec1**. Svako ko ima pristup ovom tajnom ključu može da objavljuje i menja vaš identitet. \n\n- **Ne** unosite vaš tajni ključ ni na jedan veb-sajt ili softver kojemu ne verujete. \n- Amethyst programeri **nikada** neće tražiti vaš tajni ključ. \n- **Čuvajte** bezbednu kopiju vašeg tajnog ključa radi oporavka naloga. Preporučujemo korišćenje menadžera lozinki. + Za dodatnu bezbednost, možete šifrovati vaš ključ lozinkom. Ovaj ključ počinje sa **ncryptsec1** i ne može se koristiti bez vaše lozinke. \n\nAko izgubite lozinku, nećete moći da obnovite ključ. Šifruj i kopiraj moj tajni ključ Slika značke za %1$s "Slika značke @@ -959,9 +952,7 @@ Onemogućava push obaveštenja Koristi aplikaciju %1$s Podešavanje push obaveštenja - Da biste primali push obaveštenja, instalirajte bilo koju aplikaciju koja podržava [Unified Push](https://unifiedpush.org/), kao što je [Ntfy](https://ntfy.sh/). - Nakon instalacije, izaberite aplikaciju koju želite da koristite u Postavkama. - + Da biste primali push obaveštenja, instalirajte bilo koju aplikaciju koja podržava [Unified Push](https://unifiedpush.org/), kao što je [Ntfy](https://ntfy.sh/). Nakon instalacije, izaberite aplikaciju koju želite da koristite u Postavkama. Nit Pošaljite prodavcu poruku Zdravo, da li je ovo još uvek dostupno? @@ -2014,15 +2005,7 @@ Nije moguće dohvatiti fakturu od %1$s: %2$s Komprimovana datoteka se ne može otvoriti Novčanik nije pronađen - Amethyst nije mogao da pronađe nijednu aplikaciju novčanika koja podržava Nostr Wallet Connect (NWC). -\n\nŠta možete da uradite: -\n\t1. Proverite NWC podršku: Proverite da li vaša preferovana aplikacija novčanika podržava Nostr Wallet Connect. -\n\t2. Kreirajte vezu: Idite na aplikaciju novčanika i kreirajte novu NWC vezu. -\n\t3. Povežite sa Amethyst: Vaš novčanik će obično ponuditi tri opcije: -\n\t\t3.1 Automatska veza: Dugme za automatsko otvaranje i povezivanje sa Amethyst. Ako postoji, kliknite to dugme, otvorite Amethyst i pritisnite sačuvaj na novom ekranu. -\n\t\t3.2 Ručna veza: URI veze koji možete kopirati i kliknuti ikonu za nalepljivanje na ovom ekranu da ga direktno unesete. -\n\t\t3.3 QR kod: Novčanik kreira QR kod koji možete očitati dugmetom za QR kod na ovom ekranu. - + Amethyst nije mogao da pronađe nijednu aplikaciju novčanika koja podržava Nostr Wallet Connect (NWC). \n\nŠta možete da uradite: \n\t1. Proverite NWC podršku: Proverite da li vaša preferovana aplikacija novčanika podržava Nostr Wallet Connect. \n\t2. Kreirajte vezu: Idite na aplikaciju novčanika i kreirajte novu NWC vezu. \n\t3. Povežite sa Amethyst: Vaš novčanik će obično ponuditi tri opcije: \n\t\t3.1 Automatska veza: Dugme za automatsko otvaranje i povezivanje sa Amethyst. Ako postoji, kliknite to dugme, otvorite Amethyst i pritisnite sačuvaj na novom ekranu. \n\t\t3.2 Ručna veza: URI veze koji možete kopirati i kliknuti ikonu za nalepljivanje na ovom ekranu da ga direktno unesete. \n\t\t3.3 QR kod: Novčanik kreira QR kod koji možete očitati dugmetom za QR kod na ovom ekranu. Napravi Opasna zona Tamno @@ -2703,11 +2686,8 @@ Proxy Relay-i Agregatorni relay-i koje aplikacija mora koristiti za preuzimanje vaših feedova, poput filter.nostr.wine. Ovo zamenjuje outbox model i čini da se aplikacija povezuje samo na relay-e u vašim listama. Javni čet - Javni četovi vidljivi su svima na Nostr-u i svako - može da učestvuje. Odlični su za otvorene zajednice oko specifičnih tema. - Moderacija se može kontrolisati brisanjem objava na relay-ima - Unesite između 1 i 3 relay-a koji hostuju ovu grupu. - Nostr klijenti koriste ovo podešavanje da znaju odakle da preuzmu poruke i kuda da ih šalju. + Javni četovi vidljivi su svima na Nostr-u i svako može da učestvuje. Odlični su za otvorene zajednice oko specifičnih tema. Moderacija se može kontrolisati brisanjem objava na relay-ima + Unesite između 1 i 3 relay-a koji hostuju ovu grupu. Nostr klijenti koriste ovo podešavanje da znaju odakle da preuzmu poruke i kuda da ih šalju. Relay-i Metapodaci javnog četa Javni razgovori @@ -2732,10 +2712,7 @@ Korisnik samo za čitanje Kliknite i držite za snimanje poruke Relay čet - Relay četovi su grupe za razgovor kojima upravlja njihov matični relay. - Vidljivi su svima na Nostr-u i svako može da učestvuje. - Odlični su za otvorene zajednice oko specifičnih tema. Neke od ovih grupa su efemeralne - i stoga poruke nestaju s vremenom + Relay četovi su grupe za razgovor kojima upravlja njihov matični relay. Vidljivi su svima na Nostr-u i svako može da učestvuje. Odlični su za otvorene zajednice oko specifičnih tema. Neke od ovih grupa su efemeralne i stoga poruke nestaju s vremenom Relay četovi Događaji IP adrese @@ -2977,4 +2954,15 @@ Lightning usluga primaoca na %1$s nije dostupna. Izračunata je iz lightning adrese „%2$s". Greška: %3$s. Proverite da li je server aktivan i da li je lightning adresa ispravna Nije moguće kreirati lightning fakturu od %1$s: Element pr nije pronađen u rezultujućem JSON-u. Nije moguće kreirati lightning fakturu. Poruka od %1$s: %2$s + + + + diff --git a/commonsUI/src/commonMain/composeResources/values-sv-rSE/strings.xml b/commonsUI/src/commonMain/composeResources/values-sv-rSE/strings.xml index daca2e136f..0b434776f0 100644 --- a/commonsUI/src/commonMain/composeResources/values-sv-rSE/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-sv-rSE/strings.xml @@ -361,7 +361,7 @@ Kopiera Användarens ID Sluta blockera Användare Den här användaren är blockerad/tystad. Deras inlägg är dolda. - npub, hex, användarnamn + npub, hex, användarnamn Rensa Appens logo nsec / npub / hex privat nyckel @@ -463,13 +463,8 @@ Markera alla kända som lästa Markera alla nya som lästa Markera allt som läst - ## Nyckelsäkerhetsråd - \n\nDitt konto är säkrat med en hemlig nyckel. Nyckeln är en lång följd av tecken som börjar med **nsec1**. Den som har tillgång till denna hemliga nyckel kan posta och ändra din identitet. - \n\n- **Lägg inte** din hemliga nyckel på någon webbplats eller i någon mjukvara som du inte litar på. - \n- Utvecklarna av Amethyst kommer **aldrig** att be om din hemliga nyckel. - \n- **Spara** en säkerhetskopia av din hemliga nyckel för återställning av kontot. Vi rekommenderar att du använder en lösenordshanterare. - För extra säkerhet kan du kryptera din nyckel med ett lösenord. Denna nyckel börjar med **ncryptsec1** och kan inte användas utan ditt lösenord. - \n\nOm du tappar bort ditt lösenord kommer du inte att kunna återställa din nyckel. + ## Nyckelsäkerhetsråd \n\nDitt konto är säkrat med en hemlig nyckel. Nyckeln är en lång följd av tecken som börjar med **nsec1**. Den som har tillgång till denna hemliga nyckel kan posta och ändra din identitet. \n\n- **Lägg inte** din hemliga nyckel på någon webbplats eller i någon mjukvara som du inte litar på. \n- Utvecklarna av Amethyst kommer **aldrig** att be om din hemliga nyckel. \n- **Spara** en säkerhetskopia av din hemliga nyckel för återställning av kontot. Vi rekommenderar att du använder en lösenordshanterare. + För extra säkerhet kan du kryptera din nyckel med ett lösenord. Denna nyckel börjar med **ncryptsec1** och kan inte användas utan ditt lösenord. \n\nOm du tappar bort ditt lösenord kommer du inte att kunna återställa din nyckel. Säkerhetskopiera dina nycklar Din hemliga nyckel är enda sättet att komma åt det här kontot. Om du förlorar den kan den aldrig återskapas. Spara den på ett säkert ställe nu. Säkerhetskopiera nu @@ -478,7 +473,7 @@ Kryptera och kopiera min hemliga nyckel Tilldelad Badge för %1$s Bild på utmärkelse - Du har blivit tilldelad en ny Badge + Du har blivit tilldelad en ny Badge Badge utmärkelse tilldelas Lägg till nytt konto Välj konto @@ -1584,9 +1579,7 @@ Inaktivera Push-meddelande Använder appen %1$s Inställning av Push-aviseringar - För att ta emot push-aviseringar, installera vilken app som helst som stöder [Unified Push](https://unifiedpush.org), till exempel [Ntfy](https://ntfy.sh/). - Efter installationen väljer du den app du vill använda i inställningarna. - + För att ta emot push-aviseringar, installera vilken app som helst som stöder [Unified Push](https://unifiedpush.org), till exempel [Ntfy](https://ntfy.sh/). Efter installationen väljer du den app du vill använda i inställningarna. Tråd Skicka ett meddelande till säljaren Hallå där, är detta fortfarande tillgängligt? @@ -3323,14 +3316,7 @@ Kunde inte hämta faktura från %1$s: %2$s Kunde inte öppna den komprimerade filen Plånbok hittades inte - Amethyst kunde inte hitta några plånboksappar som stöder Nostr Wallet Connect (NWC). -\n\nVad du kan göra: -\n\t1. Verifiera NWC-stöd: Kontrollera att din föredragna plånbok stöder Nostr Wallet Connect. -\n\t2. Skapa anslutning: Gå till din plånbok och skapa en ny NWC-anslutning. -\n\t3. Länka till Amethyst: Din plånbok erbjuder vanligtvis tre alternativ: -\n\t\t3.1 Automatisk länk: En knapp för att automatiskt öppna och ansluta till Amethyst. Klicka på den, öppna Amethyst och spara på den nya skärmen. -\n\t\t3.2 Manuell länk: En anslutnings-URI som du kan kopiera och klistra in via klistra-ikonen på den här skärmen. -\n\t\t3.3 QR-kod: Plånboken skapar en QR-kod som du kan skanna med QR-knappens funktion på den här skärmen. + Amethyst kunde inte hitta några plånboksappar som stöder Nostr Wallet Connect (NWC). \n\nVad du kan göra: \n\t1. Verifiera NWC-stöd: Kontrollera att din föredragna plånbok stöder Nostr Wallet Connect. \n\t2. Skapa anslutning: Gå till din plånbok och skapa en ny NWC-anslutning. \n\t3. Länka till Amethyst: Din plånbok erbjuder vanligtvis tre alternativ: \n\t\t3.1 Automatisk länk: En knapp för att automatiskt öppna och ansluta till Amethyst. Klicka på den, öppna Amethyst och spara på den nya skärmen. \n\t\t3.2 Manuell länk: En anslutnings-URI som du kan kopiera och klistra in via klistra-ikonen på den här skärmen. \n\t\t3.3 QR-kod: Plånboken skapar en QR-kod som du kan skanna med QR-knappens funktion på den här skärmen. Skapa d Riskzon @@ -4427,11 +4413,8 @@ Proxyreläer Aggregeringsreläer som används för att hämta flöden, t.ex. filter.nostr.wine Publik Chat - Offentliga chattar är synliga för alla på Nostr och alla - kan delta på dem. De är bra för öppna samhällen kring specifika ämnen. - Moderation kan kontrolleras genom att ta bort inlägg på reläerna - Infoga mellan 1–3 reläer som är värd för denna grupp. - Nostr klienter använder denna inställning för att veta var du kan ladda ner meddelanden från och skicka dina meddelanden till. + Offentliga chattar är synliga för alla på Nostr och alla kan delta på dem. De är bra för öppna samhällen kring specifika ämnen. Moderation kan kontrolleras genom att ta bort inlägg på reläerna + Infoga mellan 1–3 reläer som är värd för denna grupp. Nostr klienter använder denna inställning för att veta var du kan ladda ner meddelanden från och skicka dina meddelanden till. Reläer Metadata för offentlig chatt Offentliga chattar @@ -4496,10 +4479,7 @@ auth autentisering relä signera verifiera nip-42 identitet Reläautentisering Relä Chatt - Relä chattar är chattgrupper som kontrolleras av deras hem relä. - De är synliga för alla på Nostr och vem som helst kan delta på dem. - De är bra för öppna gemenskaper kring specifika ämnen. Några av dessa grupper är kortlivade - och därmed försvinner chattmeddelanden över tiden + Relä chattar är chattgrupper som kontrolleras av deras hem relä. De är synliga för alla på Nostr och vem som helst kan delta på dem. De är bra för öppna gemenskaper kring specifika ämnen. Några av dessa grupper är kortlivade och därmed försvinner chattmeddelanden över tiden Relä Chattar Din egen profil, dina inställningar och utkast, på dina hemreläer. Reläerna som varje gemenskap publicerar sina plan till. @@ -4930,4 +4910,15 @@ Mottagarens Lightning-tjänst på %1$s är inte tillgänglig. Den beräknades från Lightning Address "%2$s". Fel: %3$s. Kontrollera om servern är uppe och om Lightning Addressen är korrekt Det gick inte att skapa en blixtfaktura från %1$s: Elementet pr hittades inte i den resulterande JSON. Det gick inte att skapa en blixt faktura. Meddelande från %1$s: %2$s + + + + diff --git a/commonsUI/src/commonMain/composeResources/values-sw/strings.xml b/commonsUI/src/commonMain/composeResources/values-sw/strings.xml index 86678c3975..439b9c7be0 100644 --- a/commonsUI/src/commonMain/composeResources/values-sw/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-sw/strings.xml @@ -268,15 +268,8 @@ Funga kama Zimejulikana Funga kama Mpya Funga kama Zimejulikana - ## Vidokezo vya Nakala Rudufu ya Ufunguo na Usalama - \n\nAkaunti yako inalindwa na ufunguo wa siri. Ufunguo ni mfululizo mrefu wa herufi unaoanza na **nsec1**. Mtu yeyote anayepata ufunguo huu wa siri anaweza kuchapisha na kubadilisha utambulisho wako. - \n\n- **Usiweke** ufunguo wako wa siri katika tovuti yoyote au programu unayoitembelewa. - \n- Watengenezaji wa Amethyst **hawatawahi** kuomba ufunguo wako wa siri. - \n- **Hifadhi** nakala salama ya ufunguo wako wa siri kwa ajili ya kurejesha akaunti. Tunapendekeza kutumia kidhibiti cha nenosiri. - - Kwa usalama zaidi, unaweza kusimba ufunguo wako kwa nenosiri. Ufunguo huu unaanza na **ncryptsec1** na hauwezi kutumika bila nenosiri lako. - \n\nUkipoteza nenosiri lako, hutaweza kurejesha ufunguo wako. - + ## Vidokezo vya Nakala Rudufu ya Ufunguo na Usalama \n\nAkaunti yako inalindwa na ufunguo wa siri. Ufunguo ni mfululizo mrefu wa herufi unaoanza na **nsec1**. Mtu yeyote anayepata ufunguo huu wa siri anaweza kuchapisha na kubadilisha utambulisho wako. \n\n- **Usiweke** ufunguo wako wa siri katika tovuti yoyote au programu unayoitembelewa. \n- Watengenezaji wa Amethyst **hawatawahi** kuomba ufunguo wako wa siri. \n- **Hifadhi** nakala salama ya ufunguo wako wa siri kwa ajili ya kurejesha akaunti. Tunapendekeza kutumia kidhibiti cha nenosiri. + Kwa usalama zaidi, unaweza kusimba ufunguo wako kwa nenosiri. Ufunguo huu unaanza na **ncryptsec1** na hauwezi kutumika bila nenosiri lako. \n\nUkipoteza nenosiri lako, hutaweza kurejesha ufunguo wako. Simba na nakili ufunguo wangu wa siri Picha ya tuzo ya alama kwa %1$s "Picha ya tuzo ya beji @@ -961,9 +954,7 @@ Inazima Arifa za Push Inatumia programu %1$s Mpangilio wa Arifa za Push - Ili kupokea arifa za kusukuma, sakinisha programu yoyote inayounga mkono [Unified Push](https://unifiedpush.org/), kama vile [Ntfy](https://ntfy.sh/). - Baada ya kusakinisha, chagua programu unayotaka kutumia katika Mipangilio. - + Ili kupokea arifa za kusukuma, sakinisha programu yoyote inayounga mkono [Unified Push](https://unifiedpush.org/), kama vile [Ntfy](https://ntfy.sh/). Baada ya kusakinisha, chagua programu unayotaka kutumia katika Mipangilio. Nyuzi Tuma ujumbe kwa muuzaji Habari, je, hii bado inapatikana? @@ -2016,15 +2007,7 @@ Haikuweza kupata ankara kutoka kwa %1$s: %2$s Haikuweza kufungua faili lililosinyazwa Mkoba haukupatikana - Amethyst haikupata programu yoyote ya mkoba inayounga mkono Nostr Wallet Connect (NWC). -\n\nUnachoweza kufanya: -\n\t1. Thibitisha Usaidizi wa NWC: Hakikisha kuwa programu yako ya mkoba inaoana na Nostr Wallet Connect. -\n\t2. Unda Muunganisho: Nenda kwenye programu yako ya mkoba na unda muunganisho mpya wa NWC. -\n\t3. Unganisha na Amethyst: Mkoba wako kwa kawaida utakupa chaguo tatu: -\n\t\t3.1 Kiungo cha Kiotomatiki: Kitufe cha kufungua na kuunganisha moja kwa moja na Amethyst. Ikiwa ndivyo, bonyeza kitufe hicho, fungua Amethyst na bonyeza hifadhi kwenye skrini mpya. -\n\t\t3.2 Kiungo cha Mkono: URI ya Muunganisho unayoweza kunakili na kubonyeza ikoni ya kubandika kwenye skrini hii kuiingiza moja kwa moja. -\n\t\t3.3 Msimbo wa QR: Mkoba unaunda msimbo wa QR ambao unaweza kuusoma kutoka kwa kitufe cha msimbo wa QR kwenye skrini hii. - + Amethyst haikupata programu yoyote ya mkoba inayounga mkono Nostr Wallet Connect (NWC). \n\nUnachoweza kufanya: \n\t1. Thibitisha Usaidizi wa NWC: Hakikisha kuwa programu yako ya mkoba inaoana na Nostr Wallet Connect. \n\t2. Unda Muunganisho: Nenda kwenye programu yako ya mkoba na unda muunganisho mpya wa NWC. \n\t3. Unganisha na Amethyst: Mkoba wako kwa kawaida utakupa chaguo tatu: \n\t\t3.1 Kiungo cha Kiotomatiki: Kitufe cha kufungua na kuunganisha moja kwa moja na Amethyst. Ikiwa ndivyo, bonyeza kitufe hicho, fungua Amethyst na bonyeza hifadhi kwenye skrini mpya. \n\t\t3.2 Kiungo cha Mkono: URI ya Muunganisho unayoweza kunakili na kubonyeza ikoni ya kubandika kwenye skrini hii kuiingiza moja kwa moja. \n\t\t3.3 Msimbo wa QR: Mkoba unaunda msimbo wa QR ambao unaweza kuusoma kutoka kwa kitufe cha msimbo wa QR kwenye skrini hii. Unda siku Eneo Hatari @@ -2686,11 +2669,8 @@ Relay za Wakala Relay za mkusanyaji ambazo programu lazima itumie kupakua malisho yako, kama filter.nostr.wine. Hii inabadilisha mfano wa sanduku la nje na itafanya programu iunganike na relay zilizo kwenye orodha zako tu. Mazungumzo ya Umma - Mazungumzo ya umma yanaweza kuonekana na kila mtu kwenye Nostr na mtu yeyote - anaweza kushiriki. Ni mazuri kwa jamii wazi kuhusu mada maalum. - Udhibiti unaweza kufanywa kwa kufuta machapisho kwenye relay zake - Ingiza kati ya relay 1–3 zinazohifadhi kikundi hiki. - Wateja wa Nostr hutumia mipangilio hii kujua mahali pa kupakua ujumbe na kutuma ujumbe wako. + Mazungumzo ya umma yanaweza kuonekana na kila mtu kwenye Nostr na mtu yeyote anaweza kushiriki. Ni mazuri kwa jamii wazi kuhusu mada maalum. Udhibiti unaweza kufanywa kwa kufuta machapisho kwenye relay zake + Ingiza kati ya relay 1–3 zinazohifadhi kikundi hiki. Wateja wa Nostr hutumia mipangilio hii kujua mahali pa kupakua ujumbe na kutuma ujumbe wako. Metadata ya Mazungumzo ya Umma Mazungumzo ya Umma Relay za Sanduku la Nje/Nyumbani za Umma @@ -2714,10 +2694,7 @@ Mtumiaji wa kusoma tu Bonyeza na ushikilie kurekodi ujumbe Mazungumzo ya Relay - Mazungumzo ya relay ni vikundi vya mazungumzo vinavyodhibitiwa na relay yao ya nyumbani. - Yanaweza kuonekana na kila mtu kwenye Nostr na mtu yeyote anaweza kushiriki. - Ni mazuri kwa jamii wazi kuhusu mada maalum. Baadhi ya vikundi hivi ni vya muda mfupi - na hivyo ujumbe wa mazungumzo huisha baada ya muda + Mazungumzo ya relay ni vikundi vya mazungumzo vinavyodhibitiwa na relay yao ya nyumbani. Yanaweza kuonekana na kila mtu kwenye Nostr na mtu yeyote anaweza kushiriki. Ni mazuri kwa jamii wazi kuhusu mada maalum. Baadhi ya vikundi hivi ni vya muda mfupi na hivyo ujumbe wa mazungumzo huisha baada ya muda Mazungumzo ya Relay Matukio Aina @@ -2952,4 +2929,15 @@ Huduma ya Lightning ya mpokeaji katika %1$s haipatikani. Ilihesabiwa kutoka kwa anwani ya lightning "%2$s". Kosa: %3$s. Angalia kama seva iko juu na kama anwani ya lightning ni sahihi Imeshindwa kuunda ankara ya lightning kutoka %1$s: Kipengele pr hakikupatikana katika JSON inayotokea. Imeshindwa kuunda ankara ya lightning. Ujumbe kutoka %1$s: %2$s + + + + diff --git a/commonsUI/src/commonMain/composeResources/values-ta-rIN/strings.xml b/commonsUI/src/commonMain/composeResources/values-ta-rIN/strings.xml index 015cf2f36c..106be142e3 100644 --- a/commonsUI/src/commonMain/composeResources/values-ta-rIN/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-ta-rIN/strings.xml @@ -224,7 +224,7 @@ பயனர் IDயை நகலெடுக்கவும் பயனரைத் தடைநீக்கு இந்த பயனர் தடுக்கப்பட்டு/முடக்கப்பட்டுள்ளார். அவர்களின் இடுகைகள் மறைக்கப்பட்டுள்ளன. - npub, hex, பயனர்பெயர் + npub, hex, பயனர்பெயர் அழி பயன்பாட்டு இலச்சினை nsec / npub / hex தனிப்பட்ட சாவி @@ -272,17 +272,10 @@ தெரிந்தவற்றைப் படித்ததாகக் குறி புதியனவற்றைப் படித்ததாகக் குறி அனைத்தையும் படித்ததாகக் குறி - ## திறவுகோல் காப்புப்பிரதி மற்றும் பாதுகாப்பு குறிப்புகள் - \n\nஉங்கள் கணக்கு ஒரு இரகசிய திறவுகோலால் பாதுகாக்கப்படுகிறது. இந்த திறவுகோல் **nsec1** என்று தொடங்கும் நீண்ட எழுத்துத் தொடராகும். இந்த இரகசிய திறவுகோலை யாராவது பெற்றால் அவர்கள் உங்கள் அடையாளத்தை பயன்படுத்தி இடுகையிடலாம் மற்றும் மாற்றலாம். - \n\n- உங்கள் இரகசிய திறவுகோலை நீங்கள் நம்பாத எந்த வலைத்தளத்திலும் அல்லது மென்பொருளிலும் வைக்க **வேண்டாம்**. - \n- Amethyst டெவலப்பர்கள் உங்கள் இரகசிய திறவுகோலை **ஒருபோதும்** கேட்கமாட்டார்கள். - \n- கணக்கு மீட்டெடுப்புக்காக உங்கள் இரகசிய திறவுகோலை பாதுகாப்பாக காப்புப்பிரதி எடுங்கள். கடவுச்சொல் மேலாளரை பயன்படுத்த பரிந்துரைக்கிறோம். - - கூடுதல் பாதுகாப்புக்காக, உங்கள் திறவுகோலை கடவுச்சொல்லால் குறியாக்கலாம். இந்த திறவுகோல் **ncryptsec1** என்று தொடங்கும் மற்றும் உங்கள் கடவுச்சொல்லின்றி பயன்படுத்த முடியாது. - \n\nகடவுச்சொல்லை மறந்தால், உங்கள் திறவுகோலை மீட்டெடுக்க முடியாது. - + ## திறவுகோல் காப்புப்பிரதி மற்றும் பாதுகாப்பு குறிப்புகள் \n\nஉங்கள் கணக்கு ஒரு இரகசிய திறவுகோலால் பாதுகாக்கப்படுகிறது. இந்த திறவுகோல் **nsec1** என்று தொடங்கும் நீண்ட எழுத்துத் தொடராகும். இந்த இரகசிய திறவுகோலை யாராவது பெற்றால் அவர்கள் உங்கள் அடையாளத்தை பயன்படுத்தி இடுகையிடலாம் மற்றும் மாற்றலாம். \n\n- உங்கள் இரகசிய திறவுகோலை நீங்கள் நம்பாத எந்த வலைத்தளத்திலும் அல்லது மென்பொருளிலும் வைக்க **வேண்டாம்**. \n- Amethyst டெவலப்பர்கள் உங்கள் இரகசிய திறவுகோலை **ஒருபோதும்** கேட்கமாட்டார்கள். \n- கணக்கு மீட்டெடுப்புக்காக உங்கள் இரகசிய திறவுகோலை பாதுகாப்பாக காப்புப்பிரதி எடுங்கள். கடவுச்சொல் மேலாளரை பயன்படுத்த பரிந்துரைக்கிறோம். + கூடுதல் பாதுகாப்புக்காக, உங்கள் திறவுகோலை கடவுச்சொல்லால் குறியாக்கலாம். இந்த திறவுகோல் **ncryptsec1** என்று தொடங்கும் மற்றும் உங்கள் கடவுச்சொல்லின்றி பயன்படுத்த முடியாது. \n\nகடவுச்சொல்லை மறந்தால், உங்கள் திறவுகோலை மீட்டெடுக்க முடியாது. என் இரகசிய விசையை மறைகுறியாக்கி நகல் எடுக்கவும் - %1$s க்கான பேட்ஜ் விருது படம் + %1$s க்கான பேட்ஜ் விருது படம் "பேட்ஜ் விருது படம் நீங்கள் ஒரு புதிய பேட்ஜ் விருதைப் பெற்றீர்கள் பேட்ஜ் விருது வழங்கப்பட்டது. பெறுநர்: @@ -304,7 +297,7 @@ தடைசெய் அழி தடைசெய் - புகார் + புகார் மீண்டும் காட்டாதே எரிதம் அல்லது மோசடிகள் அவதூறு அல்லது வெறுக்கத்தக்க நடத்தை @@ -871,7 +864,7 @@ பின்பற்றப்படும் சிட்டைகள் இவை நீங்கள் மற்றவர்களுக்கு பரிந்துரைக்கும் பயனர்களின் பட்டியல்கள். பொது பயனர்கள் மட்டுமே அனுமதிக்கப்படுவர். அங்கீகரிக்கப்பட்ட குறிப்பு - இந்த அரட்டைக் குழுவின் விளக்கமும் விதிகளும் இன்னும் சேர்க்கபடவில்லை. உரிமையாளரை அணுகி அவற்றை சேர்க்க கோரவும் + இந்த அரட்டைக் குழுவின் விளக்கமும் விதிகளும் இன்னும் சேர்க்கபடவில்லை. உரிமையாளரை அணுகி அவற்றை சேர்க்க கோரவும் இந்த சமூகத்தின் விளக்கமும் விதிகளும் இன்னும் சேர்க்கபடவில்லை. உரிமையாளரை அணுகி அவற்றை சேர்க்க கோரவும் பயனர் விருப்பங்கள் அமைப்புகளை தேடு @@ -965,9 +958,7 @@ புஷ் அறிவிப்புகளை முடக்கும் %1$s ஆப்பை பயன்படுத்துகிறது புஷ் அறிவிப்பு அமைவு - Push அறிவிப்புகளை பெற, [Unified Push](https://unifiedpush.org/) ஆதரிக்கும் எந்த பயன்பாட்டையும் நிறுவுங்கள், எடுத்துக்காட்டாக [Ntfy](https://ntfy.sh/). - நிறுவிய பின், அமைப்புகளில் நீங்கள் பயன்படுத்த விரும்பும் பயன்பாட்டை தேர்ந்தெடுங்கள். - + Push அறிவிப்புகளை பெற, [Unified Push](https://unifiedpush.org/) ஆதரிக்கும் எந்த பயன்பாட்டையும் நிறுவுங்கள், எடுத்துக்காட்டாக [Ntfy](https://ntfy.sh/). நிறுவிய பின், அமைப்புகளில் நீங்கள் பயன்படுத்த விரும்பும் பயன்பாட்டை தேர்ந்தெடுங்கள். நூல் விற்பனையாளருக்கு ஒரு செய்தி அனுப்பவும் வணக்கம், இது இன்னும் கிடைக்கிறதா? @@ -2016,15 +2007,7 @@ %1$s இலிருந்து இன்வாய்ஸை பெற முடியவில்லை: %2$s சுருக்கப்பட்ட கோப்பை திறக்க முடியவில்லை வாலட் கிடைக்கவில்லை - Nostr Wallet Connect (NWC)-ஐ ஆதரிக்கும் எந்த பணப்பை பயன்பாடுகளையும் Amethyst கண்டுபிடிக்கவில்லை. -\n\nநீங்கள் செய்யக்கூடியவை: -\n\t1. NWC ஆதரவை சரிபாருங்கள்: உங்கள் விருப்பமான பணப்பை பயன்பாடு Nostr Wallet Connect-உடன் இணக்கமானதா என இரட்டிப்பு சரிபாருங்கள். -\n\t2. இணைப்பை உருவாக்குங்கள்: உங்கள் பணப்பை பயன்பாட்டிற்கு சென்று புதிய NWC இணைப்பை உருவாக்குங்கள். -\n\t3. Amethyst-உடன் இணைக்குங்கள்: உங்கள் பணப்பை பொதுவாக மூன்று விருப்பங்களை வழங்கும்: -\n\t\t3.1 தானியங்கி இணைப்பு: Amethyst-ஐ தானாக திறந்து இணைக்க ஒரு பொத்தான். அவ்வாறிருந்தால், அந்த பொத்தானை அழுத்தி Amethyst-ஐ திறந்து புதிய திரையில் சேமி அழுத்துங்கள். -\n\t\t3.2 கைமுறை இணைப்பு: நீங்கள் நகலெடுக்கக்கூடிய Connection URI, இந்த திரையில் ஒட்டு ஐகானை அழுத்தி நேரடியாக உள்ளிடலாம். -\n\t\t3.3 QR குறியீடு: பணப்பை ஒரு QR குறியீட்டை உருவாக்கும், இந்த திரையில் QR குறியீடு பொத்தானிலிருந்து படிக்கலாம். - + Nostr Wallet Connect (NWC)-ஐ ஆதரிக்கும் எந்த பணப்பை பயன்பாடுகளையும் Amethyst கண்டுபிடிக்கவில்லை. \n\nநீங்கள் செய்யக்கூடியவை: \n\t1. NWC ஆதரவை சரிபாருங்கள்: உங்கள் விருப்பமான பணப்பை பயன்பாடு Nostr Wallet Connect-உடன் இணக்கமானதா என இரட்டிப்பு சரிபாருங்கள். \n\t2. இணைப்பை உருவாக்குங்கள்: உங்கள் பணப்பை பயன்பாட்டிற்கு சென்று புதிய NWC இணைப்பை உருவாக்குங்கள். \n\t3. Amethyst-உடன் இணைக்குங்கள்: உங்கள் பணப்பை பொதுவாக மூன்று விருப்பங்களை வழங்கும்: \n\t\t3.1 தானியங்கி இணைப்பு: Amethyst-ஐ தானாக திறந்து இணைக்க ஒரு பொத்தான். அவ்வாறிருந்தால், அந்த பொத்தானை அழுத்தி Amethyst-ஐ திறந்து புதிய திரையில் சேமி அழுத்துங்கள். \n\t\t3.2 கைமுறை இணைப்பு: நீங்கள் நகலெடுக்கக்கூடிய Connection URI, இந்த திரையில் ஒட்டு ஐகானை அழுத்தி நேரடியாக உள்ளிடலாம். \n\t\t3.3 QR குறியீடு: பணப்பை ஒரு QR குறியீட்டை உருவாக்கும், இந்த திரையில் QR குறியீடு பொத்தானிலிருந்து படிக்கலாம். உருவாக்கு நா ஆபத்து மண்டலம் @@ -2416,7 +2399,7 @@ மீடியாவிலிருந்து தனிப்பட்ட மெட்டாடேட்டாவை நீக்கத் தோல்வியடைந்தது. பதிவேற்றம் ரத்துசெய்யப்பட்டது. புக்மார்க்குகள் வெற்றிகரமாக இடம்பெயர்க்கப்பட்டன Lightning அமைப்பு இல்லை - மேலும் + மேலும் தனிப்பட்டதிற்கு நகர்த்து பொதுவிற்கு நகர்த்து அட்டை படத்தை பதிவேற்று @@ -2648,7 +2631,7 @@ இந்த இடுகையில் %1$d hashtag-ஐ விட அதிகம் உள்ளது இந்த இடுகையில் %1$d hashtags-ஐ விட அதிகம் உள்ளது - %1$s க்கான முன்னோட்ட அட்டைப் படம் + %1$s க்கான முன்னோட்ட அட்டைப் படம் தனியுரிமை விருப்பங்கள் தனியுரிமைக் கொள்கை தனிப்பட்ட செய்தியை மறைக்குறியாக்க இயலவில்லை \n \n %1$s மற்றும் %2$s க்கு இடையே தனிப்பட்ட/ குறியாக்கப்பட்ட உரையாடலில் நீங்கள் மேற்கோள் காட்டப்பட்டீர்கள். @@ -2673,11 +2656,8 @@ திருத்தத்தை முன்மொழிக filter.nostr.wine போன்ற ஒருங்கிணைப்பு relays, பயன்பாடு உங்கள் ஊட்டங்களை பதிவிறக்க பயன்படுத்த வேண்டும். இது அவுட்பாக்ஸ் மாதிரியை மாற்றுகிறது, பயன்பாட்டை உங்கள் பட்டியல்களில் உள்ள relays மட்டுமே இணைக்கும். பொது அரட்டை - பொது அரட்டைகள் Nostr-ல் அனைவருக்கும் தெரியும் மற்றும் யாரும் - அவற்றில் பங்கேற்கலாம். அவை குறிப்பிட்ட தலைப்புகளை சுற்றிய திறந்த சமூகங்களுக்கு மிகவும் ஏற்றவை. - அதன் relays-ல் இடுகைகளை நீக்குவதன் மூலம் மிதவாண்மையை கட்டுப்படுத்தலாம் - இந்த குழுவை ஹோஸ்ட் செய்யும் 1–3 relays-ஐ உள்ளிடவும். - Nostr கிளையன்கள் செய்திகளை எங்கிருந்து பதிவிறக்கம் செய்வது மற்றும் உங்கள் செய்திகளை எங்கே அனுப்புவது என்பதை அறிய இந்த அமைப்பை பயன்படுத்துகின்றன. + பொது அரட்டைகள் Nostr-ல் அனைவருக்கும் தெரியும் மற்றும் யாரும் அவற்றில் பங்கேற்கலாம். அவை குறிப்பிட்ட தலைப்புகளை சுற்றிய திறந்த சமூகங்களுக்கு மிகவும் ஏற்றவை. அதன் relays-ல் இடுகைகளை நீக்குவதன் மூலம் மிதவாண்மையை கட்டுப்படுத்தலாம் + இந்த குழுவை ஹோஸ்ட் செய்யும் 1–3 relays-ஐ உள்ளிடவும். Nostr கிளையன்கள் செய்திகளை எங்கிருந்து பதிவிறக்கம் செய்வது மற்றும் உங்கள் செய்திகளை எங்கே அனுப்புவது என்பதை அறிய இந்த அமைப்பை பயன்படுத்துகின்றன. Relay கள் பொது அரட்டை மெட்டாடேட்டா பொது அரட்டைகள் @@ -2702,10 +2682,7 @@ படிக்க மட்டும் பயனர் செய்தி பதிவு செய்ய கிளிக் செய்து பிடிக்கவும் Relay அரட்டை - Relay அரட்டைகள் என்பவை அவற்றின் முகப்பு relay-ஆல் கட்டுப்படுத்தப்படும் அரட்டை குழுக்கள். - அவை Nostr-ல் அனைவருக்கும் தெரியும் மற்றும் யாரும் அவற்றில் பங்கேற்கலாம். - அவை குறிப்பிட்ட தலைப்புகளை சுற்றிய திறந்த சமூகங்களுக்கு மிகவும் ஏற்றவை. இந்த குழுக்களில் சில தற்காலிகமானவை - எனவே அரட்டை செய்திகள் காலப்போக்கில் மறைந்துவிடும் + Relay அரட்டைகள் என்பவை அவற்றின் முகப்பு relay-ஆல் கட்டுப்படுத்தப்படும் அரட்டை குழுக்கள். அவை Nostr-ல் அனைவருக்கும் தெரியும் மற்றும் யாரும் அவற்றில் பங்கேற்கலாம். அவை குறிப்பிட்ட தலைப்புகளை சுற்றிய திறந்த சமூகங்களுக்கு மிகவும் ஏற்றவை. இந்த குழுக்களில் சில தற்காலிகமானவை எனவே அரட்டை செய்திகள் காலப்போக்கில் மறைந்துவிடும் Relay அரட்டைகள் நிகழ்வுகள் வகைகள் @@ -2941,4 +2918,15 @@ %1$s-ல் பெறுநரின் lightning சேவை கிடைக்கவில்லை. இது lightning முகவரி "%2$s" இலிருந்து கணக்கிடப்பட்டது. பிழை: %3$s. சர்வர் இயங்குகிறதா மற்றும் lightning முகவரி சரியானதா என சரிபாருங்கள் %1$s-இலிருந்து lightning invoice உருவாக்க முடியவில்லை: முடிவு JSON-ல் pr உறுப்பு கிடைக்கவில்லை. Lightning invoice உருவாக்க முடியவில்லை. %1$s-இலிருந்து செய்தி: %2$s + + + + diff --git a/commonsUI/src/commonMain/composeResources/values-ta/strings.xml b/commonsUI/src/commonMain/composeResources/values-ta/strings.xml index 18a9e71315..210f73485f 100644 --- a/commonsUI/src/commonMain/composeResources/values-ta/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-ta/strings.xml @@ -211,7 +211,7 @@ பயனர் IDயை நகலெடுக்கவும் பயனரைத் தடைநீக்கு இந்த பயனர் தடுக்கப்பட்டு/முடக்கப்பட்டுள்ளார். அவர்களின் இடுகைகள் மறைக்கப்பட்டுள்ளன. - npub, hex, பயனர்பெயர் + npub, hex, பயனர்பெயர் அழி பயன்பாட்டு இலச்சினை nsec / npub / hex தனிப்பட்ட சாவி @@ -259,19 +259,10 @@ தெரிந்தவற்றைப் படித்ததாகக் குறி புதியனவற்றைப் படித்ததாகக் குறி அனைத்தையும் படித்ததாகக் குறி - - ## திறவுகோல் காப்புப்பிரதி மற்றும் பாதுகாப்பு குறிப்புகள் - \n\nஉங்கள் கணக்கு ஒரு இரகசிய திறவுகோலால் பாதுகாக்கப்படுகிறது. இந்த திறவுகோல் **nsec1** என்று தொடங்கும் நீண்ட எழுத்துத் தொடராகும். இந்த இரகசிய திறவுகோலை யாராவது பெற்றால் அவர்கள் உங்கள் அடையாளத்தை பயன்படுத்தி இடுகையிடலாம் மற்றும் மாற்றலாம். - \n\n- உங்கள் இரகசிய திறவுகோலை நீங்கள் நம்பாத எந்த வலைத்தளத்திலும் அல்லது மென்பொருளிலும் வைக்க **வேண்டாம்**. - \n- Amethyst டெவலப்பர்கள் உங்கள் இரகசிய திறவுகோலை **ஒருபோதும்** கேட்கமாட்டார்கள். - \n- கணக்கு மீட்டெடுப்புக்காக உங்கள் இரகசிய திறவுகோலை பாதுகாப்பாக காப்புப்பிரதி எடுங்கள். கடவுச்சொல் மேலாளரை பயன்படுத்த பரிந்துரைக்கிறோம். - - - கூடுதல் பாதுகாப்புக்காக, உங்கள் திறவுகோலை கடவுச்சொல்லால் குறியாக்கலாம். இந்த திறவுகோல் **ncryptsec1** என்று தொடங்கும் மற்றும் உங்கள் கடவுச்சொல்லின்றி பயன்படுத்த முடியாது. - \n\nகடவுச்சொல்லை மறந்தால், உங்கள் திறவுகோலை மீட்டெடுக்க முடியாது. - + ## திறவுகோல் காப்புப்பிரதி மற்றும் பாதுகாப்பு குறிப்புகள் \n\nஉங்கள் கணக்கு ஒரு இரகசிய திறவுகோலால் பாதுகாக்கப்படுகிறது. இந்த திறவுகோல் **nsec1** என்று தொடங்கும் நீண்ட எழுத்துத் தொடராகும். இந்த இரகசிய திறவுகோலை யாராவது பெற்றால் அவர்கள் உங்கள் அடையாளத்தை பயன்படுத்தி இடுகையிடலாம் மற்றும் மாற்றலாம். \n\n- உங்கள் இரகசிய திறவுகோலை நீங்கள் நம்பாத எந்த வலைத்தளத்திலும் அல்லது மென்பொருளிலும் வைக்க **வேண்டாம்**. \n- Amethyst டெவலப்பர்கள் உங்கள் இரகசிய திறவுகோலை **ஒருபோதும்** கேட்கமாட்டார்கள். \n- கணக்கு மீட்டெடுப்புக்காக உங்கள் இரகசிய திறவுகோலை பாதுகாப்பாக காப்புப்பிரதி எடுங்கள். கடவுச்சொல் மேலாளரை பயன்படுத்த பரிந்துரைக்கிறோம். + கூடுதல் பாதுகாப்புக்காக, உங்கள் திறவுகோலை கடவுச்சொல்லால் குறியாக்கலாம். இந்த திறவுகோல் **ncryptsec1** என்று தொடங்கும் மற்றும் உங்கள் கடவுச்சொல்லின்றி பயன்படுத்த முடியாது. \n\nகடவுச்சொல்லை மறந்தால், உங்கள் திறவுகோலை மீட்டெடுக்க முடியாது. என் இரகசிய விசையை மறைகுறியாக்கி நகல் எடுக்கவும் - %1$s க்கான பேட்ஜ் விருது படம் + %1$s க்கான பேட்ஜ் விருது படம் "பேட்ஜ் விருது படம் நீங்கள் ஒரு புதிய பேட்ஜ் விருதைப் பெற்றீர்கள் பேட்ஜ் விருது வழங்கப்பட்டது. பெறுநர்: @@ -293,7 +284,7 @@ தடைசெய் அழி தடைசெய் - புகார் + புகார் மீண்டும் காட்டாதே எரிதம் அல்லது மோசடிகள் அவதூறு அல்லது வெறுக்கத்தக்க நடத்தை @@ -867,7 +858,7 @@ பின்பற்றப்படும் சிட்டைகள் இவை நீங்கள் மற்றவர்களுக்கு பரிந்துரைக்கும் பயனர்களின் பட்டியல்கள். பொது பயனர்கள் மட்டுமே அனுமதிக்கப்படுவர். அங்கீகரிக்கப்பட்ட குறிப்பு - இந்த அரட்டைக் குழுவின் விளக்கமும் விதிகளும் இன்னும் சேர்க்கபடவில்லை. உரிமையாளரை அணுகி அவற்றை சேர்க்க கோரவும் + இந்த அரட்டைக் குழுவின் விளக்கமும் விதிகளும் இன்னும் சேர்க்கபடவில்லை. உரிமையாளரை அணுகி அவற்றை சேர்க்க கோரவும் இந்த சமூகத்தின் விளக்கமும் விதிகளும் இன்னும் சேர்க்கபடவில்லை. உரிமையாளரை அணுகி அவற்றை சேர்க்க கோரவும் பயனர் விருப்பங்கள் அமைப்புகளை தேடு @@ -962,10 +953,7 @@ புஷ் அறிவிப்புகளை முடக்கும் %1$s ஆப்பை பயன்படுத்துகிறது புஷ் அறிவிப்பு அமைவு - - Push அறிவிப்புகளை பெற, [Unified Push](https://unifiedpush.org/) ஆதரிக்கும் எந்த பயன்பாட்டையும் நிறுவுங்கள், எடுத்துக்காட்டாக [Ntfy](https://ntfy.sh/). - நிறுவிய பின், அமைப்புகளில் நீங்கள் பயன்படுத்த விரும்பும் பயன்பாட்டை தேர்ந்தெடுங்கள். - + Push அறிவிப்புகளை பெற, [Unified Push](https://unifiedpush.org/) ஆதரிக்கும் எந்த பயன்பாட்டையும் நிறுவுங்கள், எடுத்துக்காட்டாக [Ntfy](https://ntfy.sh/). நிறுவிய பின், அமைப்புகளில் நீங்கள் பயன்படுத்த விரும்பும் பயன்பாட்டை தேர்ந்தெடுங்கள். நூல் விற்பனையாளருக்கு ஒரு செய்தி அனுப்பவும் வணக்கம், இது இன்னும் கிடைக்கிறதா? @@ -2058,15 +2046,7 @@ %1$s இலிருந்து இன்வாய்ஸை பெற முடியவில்லை: %2$s சுருக்கப்பட்ட கோப்பை திறக்க முடியவில்லை வாலட் கிடைக்கவில்லை - Nostr Wallet Connect (NWC)-ஐ ஆதரிக்கும் எந்த பணப்பை பயன்பாடுகளையும் Amethyst கண்டுபிடிக்கவில்லை. -\n\nநீங்கள் செய்யக்கூடியவை: -\n\t1. NWC ஆதரவை சரிபாருங்கள்: உங்கள் விருப்பமான பணப்பை பயன்பாடு Nostr Wallet Connect-உடன் இணக்கமானதா என இரட்டிப்பு சரிபாருங்கள். -\n\t2. இணைப்பை உருவாக்குங்கள்: உங்கள் பணப்பை பயன்பாட்டிற்கு சென்று புதிய NWC இணைப்பை உருவாக்குங்கள். -\n\t3. Amethyst-உடன் இணைக்குங்கள்: உங்கள் பணப்பை பொதுவாக மூன்று விருப்பங்களை வழங்கும்: -\n\t\t3.1 தானியங்கி இணைப்பு: Amethyst-ஐ தானாக திறந்து இணைக்க ஒரு பொத்தான். அவ்வாறிருந்தால், அந்த பொத்தானை அழுத்தி Amethyst-ஐ திறந்து புதிய திரையில் சேமி அழுத்துங்கள். -\n\t\t3.2 கைமுறை இணைப்பு: நீங்கள் நகலெடுக்கக்கூடிய Connection URI, இந்த திரையில் ஒட்டு ஐகானை அழுத்தி நேரடியாக உள்ளிடலாம். -\n\t\t3.3 QR குறியீடு: பணப்பை ஒரு QR குறியீட்டை உருவாக்கும், இந்த திரையில் QR குறியீடு பொத்தானிலிருந்து படிக்கலாம். - + Nostr Wallet Connect (NWC)-ஐ ஆதரிக்கும் எந்த பணப்பை பயன்பாடுகளையும் Amethyst கண்டுபிடிக்கவில்லை. \n\nநீங்கள் செய்யக்கூடியவை: \n\t1. NWC ஆதரவை சரிபாருங்கள்: உங்கள் விருப்பமான பணப்பை பயன்பாடு Nostr Wallet Connect-உடன் இணக்கமானதா என இரட்டிப்பு சரிபாருங்கள். \n\t2. இணைப்பை உருவாக்குங்கள்: உங்கள் பணப்பை பயன்பாட்டிற்கு சென்று புதிய NWC இணைப்பை உருவாக்குங்கள். \n\t3. Amethyst-உடன் இணைக்குங்கள்: உங்கள் பணப்பை பொதுவாக மூன்று விருப்பங்களை வழங்கும்: \n\t\t3.1 தானியங்கி இணைப்பு: Amethyst-ஐ தானாக திறந்து இணைக்க ஒரு பொத்தான். அவ்வாறிருந்தால், அந்த பொத்தானை அழுத்தி Amethyst-ஐ திறந்து புதிய திரையில் சேமி அழுத்துங்கள். \n\t\t3.2 கைமுறை இணைப்பு: நீங்கள் நகலெடுக்கக்கூடிய Connection URI, இந்த திரையில் ஒட்டு ஐகானை அழுத்தி நேரடியாக உள்ளிடலாம். \n\t\t3.3 QR குறியீடு: பணப்பை ஒரு QR குறியீட்டை உருவாக்கும், இந்த திரையில் QR குறியீடு பொத்தானிலிருந்து படிக்கலாம். உருவாக்கு நா ஆபத்து மண்டலம் @@ -2486,7 +2466,7 @@ மீடியாவிலிருந்து தனிப்பட்ட மெட்டாடேட்டாவை நீக்கத் தோல்வியடைந்தது. பதிவேற்றம் ரத்துசெய்யப்பட்டது. புக்மார்க்குகள் வெற்றிகரமாக இடம்பெயர்க்கப்பட்டன Lightning அமைப்பு இல்லை - மேலும் + மேலும் தனிப்பட்டதிற்கு நகர்த்து பொதுவிற்கு நகர்த்து அட்டை படத்தை பதிவேற்று @@ -2728,7 +2708,7 @@ இந்த இடுகையில் %1$d hashtag-ஐ விட அதிகம் உள்ளது இந்த இடுகையில் %1$d hashtags-ஐ விட அதிகம் உள்ளது - %1$s க்கான முன்னோட்ட அட்டைப் படம் + %1$s க்கான முன்னோட்ட அட்டைப் படம் தனியுரிமை விருப்பங்கள் தனியுரிமைக் கொள்கை தனிப்பட்ட செய்தியை மறைக்குறியாக்க இயலவில்லை \n \n %1$s மற்றும் %2$s க்கு இடையே தனிப்பட்ட/ குறியாக்கப்பட்ட உரையாடலில் நீங்கள் மேற்கோள் காட்டப்பட்டீர்கள். @@ -2754,11 +2734,8 @@ Proxy Relays filter.nostr.wine போன்ற ஒருங்கிணைப்பு relays, பயன்பாடு உங்கள் ஊட்டங்களை பதிவிறக்க பயன்படுத்த வேண்டும். இது அவுட்பாக்ஸ் மாதிரியை மாற்றுகிறது, பயன்பாட்டை உங்கள் பட்டியல்களில் உள்ள relays மட்டுமே இணைக்கும். பொது அரட்டை - பொது அரட்டைகள் Nostr-ல் அனைவருக்கும் தெரியும் மற்றும் யாரும் - அவற்றில் பங்கேற்கலாம். அவை குறிப்பிட்ட தலைப்புகளை சுற்றிய திறந்த சமூகங்களுக்கு மிகவும் ஏற்றவை. - அதன் relays-ல் இடுகைகளை நீக்குவதன் மூலம் மிதவாண்மையை கட்டுப்படுத்தலாம் - இந்த குழுவை ஹோஸ்ட் செய்யும் 1–3 relays-ஐ உள்ளிடவும். - Nostr கிளையன்கள் செய்திகளை எங்கிருந்து பதிவிறக்கம் செய்வது மற்றும் உங்கள் செய்திகளை எங்கே அனுப்புவது என்பதை அறிய இந்த அமைப்பை பயன்படுத்துகின்றன. + பொது அரட்டைகள் Nostr-ல் அனைவருக்கும் தெரியும் மற்றும் யாரும் அவற்றில் பங்கேற்கலாம். அவை குறிப்பிட்ட தலைப்புகளை சுற்றிய திறந்த சமூகங்களுக்கு மிகவும் ஏற்றவை. அதன் relays-ல் இடுகைகளை நீக்குவதன் மூலம் மிதவாண்மையை கட்டுப்படுத்தலாம் + இந்த குழுவை ஹோஸ்ட் செய்யும் 1–3 relays-ஐ உள்ளிடவும். Nostr கிளையன்கள் செய்திகளை எங்கிருந்து பதிவிறக்கம் செய்வது மற்றும் உங்கள் செய்திகளை எங்கே அனுப்புவது என்பதை அறிய இந்த அமைப்பை பயன்படுத்துகின்றன. Relay கள் பொது அரட்டை மெட்டாடேட்டா பொது அரட்டைகள் @@ -2783,10 +2760,7 @@ படிக்க மட்டும் பயனர் செய்தி பதிவு செய்ய கிளிக் செய்து பிடிக்கவும் Relay அரட்டை - Relay அரட்டைகள் என்பவை அவற்றின் முகப்பு relay-ஆல் கட்டுப்படுத்தப்படும் அரட்டை குழுக்கள். - அவை Nostr-ல் அனைவருக்கும் தெரியும் மற்றும் யாரும் அவற்றில் பங்கேற்கலாம். - அவை குறிப்பிட்ட தலைப்புகளை சுற்றிய திறந்த சமூகங்களுக்கு மிகவும் ஏற்றவை. இந்த குழுக்களில் சில தற்காலிகமானவை - எனவே அரட்டை செய்திகள் காலப்போக்கில் மறைந்துவிடும் + Relay அரட்டைகள் என்பவை அவற்றின் முகப்பு relay-ஆல் கட்டுப்படுத்தப்படும் அரட்டை குழுக்கள். அவை Nostr-ல் அனைவருக்கும் தெரியும் மற்றும் யாரும் அவற்றில் பங்கேற்கலாம். அவை குறிப்பிட்ட தலைப்புகளை சுற்றிய திறந்த சமூகங்களுக்கு மிகவும் ஏற்றவை. இந்த குழுக்களில் சில தற்காலிகமானவை எனவே அரட்டை செய்திகள் காலப்போக்கில் மறைந்துவிடும் Relay அரட்டைகள் நிகழ்வுகள் IPs diff --git a/commonsUI/src/commonMain/composeResources/values-th-rTH/strings.xml b/commonsUI/src/commonMain/composeResources/values-th-rTH/strings.xml index 48e478f26f..ae6a8e1778 100644 --- a/commonsUI/src/commonMain/composeResources/values-th-rTH/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-th-rTH/strings.xml @@ -158,7 +158,7 @@ การ zap แบบ on-chain ต้องใช้อย่างน้อย %1$s sats ยอดคงเหลือใน mint ที่ผู้รับยอมรับไม่เพียงพอ เติมเงินในกระเป๋า Cashu ก่อน ค่าธรรมเนียมเครือข่าย - ส่งทิปให้เบา ๆ + ส่งทิปให้เบา ๆ โน้ตถึงผู้รับ ขอบคุณมาก! จำนวนในหน่วย sat @@ -269,14 +269,8 @@ ทำเครื่องหมายอ่านแล้วในแชทที่รู้จักทั้งหมด ทำเครื่องหมายว่าอ่านแล้วทั้งหมดสำหรับข้อความใหม่ ทพเครื่องหมายว่าอ่านแล้วทั้งหมด - ## เคล็ดลับการสำรองข้อมูลและความปลอดภัยที่สำคัญ - \n\nบัญชีของคุณได้รับการรักษาความปลอดภัยด้วยคีย์ลับ คีย์เป็นรหัสชุดตัวอักษรยาวขึ้นต้นด้วย **nsec1** ใครก็ตามที่สามารถเข้าถึงรหัสชุดนี้ได้ จะสามารถโพสต์และเปลี่ยนแปลงข้อมูลประจำตัวของคุณได้ - - \n\n- **ห้าม** ใส่รหัสชุดนี้ในเว็บไซต์หรือซอฟต์แวร์ที่คุณไม่ไว้ใจ - \n- ทีมพัฒนา Amethyst **จะไม่มีวัน** ขอรหัสชุดนี้ของคุณ - \n- **ควร** สำรองข้อมูลรหัสชุดนี้ไว้เพื่อกู้คืนบัญชีของคุณ เราแนะนำให้ใช้โปรแกรมจัดการรหัสผ่าน - สำหรับการรักษาความปลอดภัยเพิ่มเติม คุณสามารถเข้ารหัสคีย์ของคุณด้วยรหัสผ่าน คีย์นี้ขึ้นต้นด้วย **ncryptsec1** และไม่สามารถใช้งานได้หากไม่มีรหัสผ่านของคุณ - \n\n**แต่** หากคุณสูญหายรหัสผ่าน คุณจะไม่สามารถกู้คืนคีย์ของคุณได้ + ## เคล็ดลับการสำรองข้อมูลและความปลอดภัยที่สำคัญ \n\nบัญชีของคุณได้รับการรักษาความปลอดภัยด้วยคีย์ลับ คีย์เป็นรหัสชุดตัวอักษรยาวขึ้นต้นด้วย **nsec1** ใครก็ตามที่สามารถเข้าถึงรหัสชุดนี้ได้ จะสามารถโพสต์และเปลี่ยนแปลงข้อมูลประจำตัวของคุณได้ \n\n- **ห้าม** ใส่รหัสชุดนี้ในเว็บไซต์หรือซอฟต์แวร์ที่คุณไม่ไว้ใจ \n- ทีมพัฒนา Amethyst **จะไม่มีวัน** ขอรหัสชุดนี้ของคุณ \n- **ควร** สำรองข้อมูลรหัสชุดนี้ไว้เพื่อกู้คืนบัญชีของคุณ เราแนะนำให้ใช้โปรแกรมจัดการรหัสผ่าน + สำหรับการรักษาความปลอดภัยเพิ่มเติม คุณสามารถเข้ารหัสคีย์ของคุณด้วยรหัสผ่าน คีย์นี้ขึ้นต้นด้วย **ncryptsec1** และไม่สามารถใช้งานได้หากไม่มีรหัสผ่านของคุณ \n\n**แต่** หากคุณสูญหายรหัสผ่าน คุณจะไม่สามารถกู้คืนคีย์ของคุณได้ เข้ารหัสและคัดลอกคีย์ความปลอดภัยของฉัน ภาพเหรียญตราสําหรับ %1$s "รูปภาพรางวัลป้าย @@ -1831,7 +1825,7 @@ ย้อนกลับ สำรองข้อมูลรหัสลับ แบดจ์ - เกิดข้อผิดพลาดในการตรวจสอบ + เกิดข้อผิดพลาดในการตรวจสอบ เกิดข้อผิดพลาดจากการยืนยันตนด้วยชีวภาพ เกิดข้อผิดพลาดจากการยืนยันตนด้วยชีวภาพ: Error: %1$s บล๊อก @@ -1894,7 +1888,7 @@ โทเคน Cashu กำลังตรวจสอบ mint… ไม่สามารถใช้งาน Cashu - ผู้ให้บริการ Mint เกิดข้อผิดพลาด : %1$s + ผู้ให้บริการ Mint เกิดข้อผิดพลาด : %1$s รับแล้ว ส่งแล้ว Mints ที่ตรงกัน @@ -1986,15 +1980,7 @@ ไม่สามารถดึงใบแจ้งหนี้จาก %1$s: %2$s ไม่สามารถเปิดดูข้อมูลที่ถูกบีบอัดได้ ไม่พบกระเป๋าเงิน - Amethyst ไม่พบแอปกระเป๋าเงินที่รองรับ Nostr Wallet Connect (NWC) -\n\nสิ่งที่คุณทำได้: -\n\t1. ตรวจสอบการรองรับ NWC: ตรวจสอบอีกครั้งว่าแอปกระเป๋าเงินที่คุณต้องการใช้รองรับ Nostr Wallet Connect หรือไม่ -\n\t2. สร้างการเชื่อมต่อ: ไปที่แอปกระเป๋าเงินของคุณและสร้างการเชื่อมต่อ NWC ใหม่ -\n\t3. เชื่อมกับ Amethyst: กระเป๋าเงินของคุณมักจะให้ตัวเลือกสามแบบ: -\n\t\t3.1 ลิงก์อัตโนมัติ: ปุ่มเพื่อเปิดและเชื่อมต่อกับ Amethyst โดยอัตโนมัติ หากมี ให้กดปุ่มนั้น เปิด Amethyst และกดบันทึกที่หน้าจอใหม่ -\n\t\t3.2 ลิงก์ด้วยตนเอง: URI การเชื่อมต่อที่คุณสามารถคัดลอกและกดไอคอนวางบนหน้าจอนี้เพื่อป้อนโดยตรง -\n\t\t3.3 QR Code: กระเป๋าเงินสร้าง QR Code ที่คุณสามารถอ่านได้จากปุ่ม QR code ในหน้าจอนี้ - + Amethyst ไม่พบแอปกระเป๋าเงินที่รองรับ Nostr Wallet Connect (NWC) \n\nสิ่งที่คุณทำได้: \n\t1. ตรวจสอบการรองรับ NWC: ตรวจสอบอีกครั้งว่าแอปกระเป๋าเงินที่คุณต้องการใช้รองรับ Nostr Wallet Connect หรือไม่ \n\t2. สร้างการเชื่อมต่อ: ไปที่แอปกระเป๋าเงินของคุณและสร้างการเชื่อมต่อ NWC ใหม่ \n\t3. เชื่อมกับ Amethyst: กระเป๋าเงินของคุณมักจะให้ตัวเลือกสามแบบ: \n\t\t3.1 ลิงก์อัตโนมัติ: ปุ่มเพื่อเปิดและเชื่อมต่อกับ Amethyst โดยอัตโนมัติ หากมี ให้กดปุ่มนั้น เปิด Amethyst และกดบันทึกที่หน้าจอใหม่ \n\t\t3.2 ลิงก์ด้วยตนเอง: URI การเชื่อมต่อที่คุณสามารถคัดลอกและกดไอคอนวางบนหน้าจอนี้เพื่อป้อนโดยตรง \n\t\t3.3 QR Code: กระเป๋าเงินสร้าง QR Code ที่คุณสามารถอ่านได้จากปุ่ม QR code ในหน้าจอนี้ สร้าง วัน เขตอันตราย @@ -2773,4 +2759,15 @@ บริการ Lightning ของ %1$s แลจะไม่พร้อมใช้งานจาก "%2$s". Error: %3$s. โปรดตรวจสอบผู้ให้บริการ หรือที่อยู่ Lightning ให้ถูกตต้อง ไม่สามารถสร้างบิลชำระในการส่ง zap จาก %1$s เนื่องจากไม่พบ PR บน JSON ไม่สามารถสร้าง lightning invoice ข้อความจาก %1$s: %2$s + + + + diff --git a/commonsUI/src/commonMain/composeResources/values-th/strings.xml b/commonsUI/src/commonMain/composeResources/values-th/strings.xml index 60a0ddb5c5..a4d606f00b 100644 --- a/commonsUI/src/commonMain/composeResources/values-th/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-th/strings.xml @@ -144,7 +144,7 @@ การ zap แบบ on-chain ต้องใช้อย่างน้อย %1$s sats ยอดคงเหลือใน mint ที่ผู้รับยอมรับไม่เพียงพอ เติมเงินในกระเป๋า Cashu ก่อน ค่าธรรมเนียมเครือข่าย - ส่งทิปให้เบา ๆ + ส่งทิปให้เบา ๆ โน้ตถึงผู้รับ ขอบคุณมาก! จำนวนในหน่วย sat @@ -257,14 +257,8 @@ ทำเครื่องหมายอ่านแล้วในแชทที่รู้จักทั้งหมด ทำเครื่องหมายว่าอ่านแล้วทั้งหมดสำหรับข้อความใหม่ ทพเครื่องหมายว่าอ่านแล้วทั้งหมด - ## เคล็ดลับการสำรองข้อมูลและความปลอดภัยที่สำคัญ - \n\nบัญชีของคุณได้รับการรักษาความปลอดภัยด้วยคีย์ลับ คีย์เป็นรหัสชุดตัวอักษรยาวขึ้นต้นด้วย **nsec1** ใครก็ตามที่สามารถเข้าถึงรหัสชุดนี้ได้ จะสามารถโพสต์และเปลี่ยนแปลงข้อมูลประจำตัวของคุณได้ - - \n\n- **ห้าม** ใส่รหัสชุดนี้ในเว็บไซต์หรือซอฟต์แวร์ที่คุณไม่ไว้ใจ - \n- ทีมพัฒนา Amethyst **จะไม่มีวัน** ขอรหัสชุดนี้ของคุณ - \n- **ควร** สำรองข้อมูลรหัสชุดนี้ไว้เพื่อกู้คืนบัญชีของคุณ เราแนะนำให้ใช้โปรแกรมจัดการรหัสผ่าน - สำหรับการรักษาความปลอดภัยเพิ่มเติม คุณสามารถเข้ารหัสคีย์ของคุณด้วยรหัสผ่าน คีย์นี้ขึ้นต้นด้วย **ncryptsec1** และไม่สามารถใช้งานได้หากไม่มีรหัสผ่านของคุณ - \n\n**แต่** หากคุณสูญหายรหัสผ่าน คุณจะไม่สามารถกู้คืนคีย์ของคุณได้ + ## เคล็ดลับการสำรองข้อมูลและความปลอดภัยที่สำคัญ \n\nบัญชีของคุณได้รับการรักษาความปลอดภัยด้วยคีย์ลับ คีย์เป็นรหัสชุดตัวอักษรยาวขึ้นต้นด้วย **nsec1** ใครก็ตามที่สามารถเข้าถึงรหัสชุดนี้ได้ จะสามารถโพสต์และเปลี่ยนแปลงข้อมูลประจำตัวของคุณได้ \n\n- **ห้าม** ใส่รหัสชุดนี้ในเว็บไซต์หรือซอฟต์แวร์ที่คุณไม่ไว้ใจ \n- ทีมพัฒนา Amethyst **จะไม่มีวัน** ขอรหัสชุดนี้ของคุณ \n- **ควร** สำรองข้อมูลรหัสชุดนี้ไว้เพื่อกู้คืนบัญชีของคุณ เราแนะนำให้ใช้โปรแกรมจัดการรหัสผ่าน + สำหรับการรักษาความปลอดภัยเพิ่มเติม คุณสามารถเข้ารหัสคีย์ของคุณด้วยรหัสผ่าน คีย์นี้ขึ้นต้นด้วย **ncryptsec1** และไม่สามารถใช้งานได้หากไม่มีรหัสผ่านของคุณ \n\n**แต่** หากคุณสูญหายรหัสผ่าน คุณจะไม่สามารถกู้คืนคีย์ของคุณได้ เข้ารหัสและคัดลอกคีย์ความปลอดภัยของฉัน ภาพเหรียญตราสําหรับ %1$s "รูปภาพรางวัลป้าย @@ -1883,7 +1877,7 @@ ย้อนกลับ สำรองข้อมูลรหัสลับ แบดจ์ - เกิดข้อผิดพลาดในการตรวจสอบ + เกิดข้อผิดพลาดในการตรวจสอบ เกิดข้อผิดพลาดจากการยืนยันตนด้วยชีวภาพ เกิดข้อผิดพลาดจากการยืนยันตนด้วยชีวภาพ: Error: %1$s บล๊อก @@ -1950,7 +1944,7 @@ โทเคน Cashu กำลังตรวจสอบ mint… ไม่สามารถใช้งาน Cashu - ผู้ให้บริการ Mint เกิดข้อผิดพลาด : %1$s + ผู้ให้บริการ Mint เกิดข้อผิดพลาด : %1$s รับแล้ว ส่งแล้ว Mints ที่ตรงกัน @@ -2042,15 +2036,7 @@ ไม่สามารถดึงใบแจ้งหนี้จาก %1$s: %2$s ไม่สามารถเปิดดูข้อมูลที่ถูกบีบอัดได้ ไม่พบกระเป๋าเงิน - Amethyst ไม่พบแอปกระเป๋าเงินที่รองรับ Nostr Wallet Connect (NWC) -\n\nสิ่งที่คุณทำได้: -\n\t1. ตรวจสอบการรองรับ NWC: ตรวจสอบอีกครั้งว่าแอปกระเป๋าเงินที่คุณต้องการใช้รองรับ Nostr Wallet Connect หรือไม่ -\n\t2. สร้างการเชื่อมต่อ: ไปที่แอปกระเป๋าเงินของคุณและสร้างการเชื่อมต่อ NWC ใหม่ -\n\t3. เชื่อมกับ Amethyst: กระเป๋าเงินของคุณมักจะให้ตัวเลือกสามแบบ: -\n\t\t3.1 ลิงก์อัตโนมัติ: ปุ่มเพื่อเปิดและเชื่อมต่อกับ Amethyst โดยอัตโนมัติ หากมี ให้กดปุ่มนั้น เปิด Amethyst และกดบันทึกที่หน้าจอใหม่ -\n\t\t3.2 ลิงก์ด้วยตนเอง: URI การเชื่อมต่อที่คุณสามารถคัดลอกและกดไอคอนวางบนหน้าจอนี้เพื่อป้อนโดยตรง -\n\t\t3.3 QR Code: กระเป๋าเงินสร้าง QR Code ที่คุณสามารถอ่านได้จากปุ่ม QR code ในหน้าจอนี้ - + Amethyst ไม่พบแอปกระเป๋าเงินที่รองรับ Nostr Wallet Connect (NWC) \n\nสิ่งที่คุณทำได้: \n\t1. ตรวจสอบการรองรับ NWC: ตรวจสอบอีกครั้งว่าแอปกระเป๋าเงินที่คุณต้องการใช้รองรับ Nostr Wallet Connect หรือไม่ \n\t2. สร้างการเชื่อมต่อ: ไปที่แอปกระเป๋าเงินของคุณและสร้างการเชื่อมต่อ NWC ใหม่ \n\t3. เชื่อมกับ Amethyst: กระเป๋าเงินของคุณมักจะให้ตัวเลือกสามแบบ: \n\t\t3.1 ลิงก์อัตโนมัติ: ปุ่มเพื่อเปิดและเชื่อมต่อกับ Amethyst โดยอัตโนมัติ หากมี ให้กดปุ่มนั้น เปิด Amethyst และกดบันทึกที่หน้าจอใหม่ \n\t\t3.2 ลิงก์ด้วยตนเอง: URI การเชื่อมต่อที่คุณสามารถคัดลอกและกดไอคอนวางบนหน้าจอนี้เพื่อป้อนโดยตรง \n\t\t3.3 QR Code: กระเป๋าเงินสร้าง QR Code ที่คุณสามารถอ่านได้จากปุ่ม QR code ในหน้าจอนี้ สร้าง วัน เขตอันตราย diff --git a/commonsUI/src/commonMain/composeResources/values-tr-rTR/strings.xml b/commonsUI/src/commonMain/composeResources/values-tr-rTR/strings.xml index 7657d945f3..59fd97ae80 100644 --- a/commonsUI/src/commonMain/composeResources/values-tr-rTR/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-tr-rTR/strings.xml @@ -71,7 +71,7 @@ QR Tara Yine De Göster Bu gönderi, gizlediğiniz kullanıcıları veya kelimeleri içerdiği için gizlendi - Gönderi uygun değil olarak işaretlendi tarafından: + Gönderi uygun değil olarak işaretlendi tarafından: Gönderi bulunamadı Kanal Resmi Referanslı etkinlik bulunamadı @@ -272,15 +272,8 @@ Tüm bilinenleri okundu olarak işaretle Tüm yenileri okundu olarak işaretle Tümünü okundu olarak işaretle - ## Anahtar Yedekleme ve Güvenlik İpuçları - \n\nHesabınız bir gizli anahtarla korunmaktadır. Anahtar, **nsec1** ile başlayan uzun bir karakter dizisidir. Bu gizli anahtara erişimi olan herkes kimliğiniz adına gönderi yapabilir ve kimliğinizi değiştirebilir. - \n\n- Gizli anahtarınızı güvenmediğiniz herhangi bir web sitesine veya yazılıma **koymayın**. - \n- Amethyst geliştiricileri gizli anahtarınızı **asla** istemez. - \n- Hesap kurtarma için gizli anahtarınızın güvenli bir yedeğini **alın**. Bir parola yöneticisi kullanmanızı öneririz. - - Ek güvenlik için anahtarınızı bir parola ile şifreleyebilirsiniz. Bu anahtar **ncryptsec1** ile başlar ve parolanız olmadan kullanılamaz. - \n\nParolanızı kaybederseniz anahtarınızı kurtaramazsınız. - + ## Anahtar Yedekleme ve Güvenlik İpuçları \n\nHesabınız bir gizli anahtarla korunmaktadır. Anahtar, **nsec1** ile başlayan uzun bir karakter dizisidir. Bu gizli anahtara erişimi olan herkes kimliğiniz adına gönderi yapabilir ve kimliğinizi değiştirebilir. \n\n- Gizli anahtarınızı güvenmediğiniz herhangi bir web sitesine veya yazılıma **koymayın**. \n- Amethyst geliştiricileri gizli anahtarınızı **asla** istemez. \n- Hesap kurtarma için gizli anahtarınızın güvenli bir yedeğini **alın**. Bir parola yöneticisi kullanmanızı öneririz. + Ek güvenlik için anahtarınızı bir parola ile şifreleyebilirsiniz. Bu anahtar **ncryptsec1** ile başlar ve parolanız olmadan kullanılamaz. \n\nParolanızı kaybederseniz anahtarınızı kurtaramazsınız. Gizli anahtarımı şifrele ve kopyala %1$s için rozet ödülü görseli "Rozet ödülü görseli @@ -966,9 +959,7 @@ Push Bildirimlerini Devre Dışı Bırakır %1$s uygulamasını kullanır Push Bildirim Kurulumu - Push bildirimleri almak için [Ntfy](https://ntfy.sh/) gibi [Unified Push](https://unifiedpush.org/) destekleyen herhangi bir uygulama yükleyin. - Yükledikten sonra, Ayarlar'da kullanmak istediğiniz uygulamayı seçin. - + Push bildirimleri almak için [Ntfy](https://ntfy.sh/) gibi [Unified Push](https://unifiedpush.org/) destekleyen herhangi bir uygulama yükleyin. Yükledikten sonra, Ayarlar'da kullanmak istediğiniz uygulamayı seçin. Konu Satıcıya mesaj gönderin Merhaba, bu hâlâ mevcut mu? @@ -2024,15 +2015,7 @@ %1$s'den fatura alınamadı: %2$s Sıkıştırılmış dosya açılamadı Cüzdan bulunamadı - Amethyst, Nostr Wallet Connect (NWC) destekleyen herhangi bir cüzdan uygulaması bulamadı. -\n\nYapabilecekleriniz: -\n\t1. NWC Desteğini Doğrulayın: Tercih ettiğiniz cüzdan uygulamasının Nostr Wallet Connect ile uyumlu olup olmadığını iki kez kontrol edin. -\n\t2. Bağlantı Oluşturun: Cüzdan uygulamanıza gidin ve yeni bir NWC bağlantısı oluşturun. -\n\t3. Amethyst'e Bağlayın: Cüzdanınız genellikle size üç seçenek sunar: -\n\t\t3.1 Otomatik Bağlantı: Amethyst'i otomatik olarak açıp bağlamak için bir düğme. Öyleyse o düğmeye tıklayın, Amethyst'i açın ve yeni ekranda kaydet'e basın. -\n\t\t3.2 Manuel Bağlantı: Doğrudan girmek için bu ekrandaki yapıştır simgesine tıklayabileceğiniz bir Bağlantı URI'si kopyalayın. -\n\t\t3.3 QR Kodu: Cüzdan, bu ekrandaki QR kodu düğmesiyle okuyabileceğiniz bir QR kodu oluşturur. - + Amethyst, Nostr Wallet Connect (NWC) destekleyen herhangi bir cüzdan uygulaması bulamadı. \n\nYapabilecekleriniz: \n\t1. NWC Desteğini Doğrulayın: Tercih ettiğiniz cüzdan uygulamasının Nostr Wallet Connect ile uyumlu olup olmadığını iki kez kontrol edin. \n\t2. Bağlantı Oluşturun: Cüzdan uygulamanıza gidin ve yeni bir NWC bağlantısı oluşturun. \n\t3. Amethyst'e Bağlayın: Cüzdanınız genellikle size üç seçenek sunar: \n\t\t3.1 Otomatik Bağlantı: Amethyst'i otomatik olarak açıp bağlamak için bir düğme. Öyleyse o düğmeye tıklayın, Amethyst'i açın ve yeni ekranda kaydet'e basın. \n\t\t3.2 Manuel Bağlantı: Doğrudan girmek için bu ekrandaki yapıştır simgesine tıklayabileceğiniz bir Bağlantı URI'si kopyalayın. \n\t\t3.3 QR Kodu: Cüzdan, bu ekrandaki QR kodu düğmesiyle okuyabileceğiniz bir QR kodu oluşturur. Oluştur g Tehlike Bölgesi @@ -2677,11 +2660,8 @@ Proxy Relay'leri Uygulamanın akışlarınızı indirmek için kullanması gereken filter.nostr.wine gibi toplayıcı relay'ler. Bu, giden kutusu modelinin yerini alır ve uygulamanın yalnızca listelerinizdeki relay'lere bağlanmasını sağlar. Herkese Açık Chat - Genel sohbetler Nostr'daki herkese görünürdür ve herkes - katılabilir. Belirli konular etrafında açık topluluklar için idealdirler. - Moderasyon, relay'lerindeki gönderilerin silinmesiyle kontrol edilebilir - Bu grubu barındıran 1–3 relay ekleyin. - Nostr istemcileri mesajları nereden indireceğini ve mesajlarınızı nereye göndereceğini öğrenmek için bu ayarı kullanır. + Genel sohbetler Nostr'daki herkese görünürdür ve herkes katılabilir. Belirli konular etrafında açık topluluklar için idealdirler. Moderasyon, relay'lerindeki gönderilerin silinmesiyle kontrol edilebilir + Bu grubu barındıran 1–3 relay ekleyin. Nostr istemcileri mesajları nereden indireceğini ve mesajlarınızı nereye göndereceğini öğrenmek için bu ayarı kullanır. relay'ler Genel Sohbet Meta Verisi Genel Sohbetler @@ -2706,10 +2686,7 @@ Salt okunur kullanıcı Sesli mesaj kaydetmek için tıklayıp basılı tutun relay Sohbeti - relay sohbetleri, ana relay'leri tarafından kontrol edilen sohbet gruplarıdır. - Nostr'daki herkese görünürdür ve herkes katılabilir. - Belirli konular etrafında açık topluluklar için idealdirler. Bu grupların bir kısmı geçici niteliktedir - ve bu nedenle sohbet mesajları zamanla silinir + relay sohbetleri, ana relay'leri tarafından kontrol edilen sohbet gruplarıdır. Nostr'daki herkese görünürdür ve herkes katılabilir. Belirli konular etrafında açık topluluklar için idealdirler. Bu grupların bir kısmı geçici niteliktedir ve bu nedenle sohbet mesajları zamanla silinir relay Sohbetleri Etkinlikler IP'ler @@ -2948,4 +2925,15 @@ %1$s adresindeki alıcının Lightning hizmeti kullanılamıyor. Bu adres "%2$s" Lightning adresinden hesaplandı. Hata: %3$s. Sunucunun çalışıp çalışmadığını ve Lightning adresinin doğru olup olmadığını kontrol edin. %1$s adresinden Lightning faturası oluşturulamadı: Sonuçtaki JSON'da pr öğesi bulunamadı. Lightning faturası oluşturulamadı. %1$s'den gelen mesaj: %2$s + + + + diff --git a/commonsUI/src/commonMain/composeResources/values-tr/strings.xml b/commonsUI/src/commonMain/composeResources/values-tr/strings.xml index 989465fdb4..7716fa9b4b 100644 --- a/commonsUI/src/commonMain/composeResources/values-tr/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-tr/strings.xml @@ -51,7 +51,7 @@ QR Tara Yine De Göster Bu gönderi, gizlediğiniz kullanıcıları veya kelimeleri içerdiği için gizlendi - Gönderi uygun değil olarak işaretlendi tarafından: + Gönderi uygun değil olarak işaretlendi tarafından: Gönderi bulunamadı 👀 Kanal Resmi @@ -259,17 +259,8 @@ Tüm bilinenleri okundu olarak işaretle Tüm yenileri okundu olarak işaretle Tümünü okundu olarak işaretle - - ## Anahtar Yedekleme ve Güvenlik İpuçları - \n\nHesabınız bir gizli anahtarla korunmaktadır. Anahtar, **nsec1** ile başlayan uzun bir karakter dizisidir. Bu gizli anahtara erişimi olan herkes kimliğiniz adına gönderi yapabilir ve kimliğinizi değiştirebilir. - \n\n- Gizli anahtarınızı güvenmediğiniz herhangi bir web sitesine veya yazılıma **koymayın**. - \n- Amethyst geliştiricileri gizli anahtarınızı **asla** istemez. - \n- Hesap kurtarma için gizli anahtarınızın güvenli bir yedeğini **alın**. Bir parola yöneticisi kullanmanızı öneririz. - - - Ek güvenlik için anahtarınızı bir parola ile şifreleyebilirsiniz. Bu anahtar **ncryptsec1** ile başlar ve parolanız olmadan kullanılamaz. - \n\nParolanızı kaybederseniz anahtarınızı kurtaramazsınız. - + ## Anahtar Yedekleme ve Güvenlik İpuçları \n\nHesabınız bir gizli anahtarla korunmaktadır. Anahtar, **nsec1** ile başlayan uzun bir karakter dizisidir. Bu gizli anahtara erişimi olan herkes kimliğiniz adına gönderi yapabilir ve kimliğinizi değiştirebilir. \n\n- Gizli anahtarınızı güvenmediğiniz herhangi bir web sitesine veya yazılıma **koymayın**. \n- Amethyst geliştiricileri gizli anahtarınızı **asla** istemez. \n- Hesap kurtarma için gizli anahtarınızın güvenli bir yedeğini **alın**. Bir parola yöneticisi kullanmanızı öneririz. + Ek güvenlik için anahtarınızı bir parola ile şifreleyebilirsiniz. Bu anahtar **ncryptsec1** ile başlar ve parolanız olmadan kullanılamaz. \n\nParolanızı kaybederseniz anahtarınızı kurtaramazsınız. Gizli anahtarımı şifrele ve kopyala %1$s için rozet ödülü görseli "Rozet ödülü görseli @@ -962,10 +953,7 @@ Push Bildirimlerini Devre Dışı Bırakır %1$s uygulamasını kullanır Push Bildirim Kurulumu - - Push bildirimleri almak için [Ntfy](https://ntfy.sh/) gibi [Unified Push](https://unifiedpush.org/) destekleyen herhangi bir uygulama yükleyin. - Yükledikten sonra, Ayarlar'da kullanmak istediğiniz uygulamayı seçin. - + Push bildirimleri almak için [Ntfy](https://ntfy.sh/) gibi [Unified Push](https://unifiedpush.org/) destekleyen herhangi bir uygulama yükleyin. Yükledikten sonra, Ayarlar'da kullanmak istediğiniz uygulamayı seçin. Konu Satıcıya mesaj gönderin Merhaba, bu hâlâ mevcut mu? @@ -2058,15 +2046,7 @@ %1$s'den fatura alınamadı: %2$s Sıkıştırılmış dosya açılamadı Cüzdan bulunamadı - Amethyst, Nostr Wallet Connect (NWC) destekleyen herhangi bir cüzdan uygulaması bulamadı. -\n\nYapabilecekleriniz: -\n\t1. NWC Desteğini Doğrulayın: Tercih ettiğiniz cüzdan uygulamasının Nostr Wallet Connect ile uyumlu olup olmadığını iki kez kontrol edin. -\n\t2. Bağlantı Oluşturun: Cüzdan uygulamanıza gidin ve yeni bir NWC bağlantısı oluşturun. -\n\t3. Amethyst'e Bağlayın: Cüzdanınız genellikle size üç seçenek sunar: -\n\t\t3.1 Otomatik Bağlantı: Amethyst'i otomatik olarak açıp bağlamak için bir düğme. Öyleyse o düğmeye tıklayın, Amethyst'i açın ve yeni ekranda kaydet'e basın. -\n\t\t3.2 Manuel Bağlantı: Doğrudan girmek için bu ekrandaki yapıştır simgesine tıklayabileceğiniz bir Bağlantı URI'si kopyalayın. -\n\t\t3.3 QR Kodu: Cüzdan, bu ekrandaki QR kodu düğmesiyle okuyabileceğiniz bir QR kodu oluşturur. - + Amethyst, Nostr Wallet Connect (NWC) destekleyen herhangi bir cüzdan uygulaması bulamadı. \n\nYapabilecekleriniz: \n\t1. NWC Desteğini Doğrulayın: Tercih ettiğiniz cüzdan uygulamasının Nostr Wallet Connect ile uyumlu olup olmadığını iki kez kontrol edin. \n\t2. Bağlantı Oluşturun: Cüzdan uygulamanıza gidin ve yeni bir NWC bağlantısı oluşturun. \n\t3. Amethyst'e Bağlayın: Cüzdanınız genellikle size üç seçenek sunar: \n\t\t3.1 Otomatik Bağlantı: Amethyst'i otomatik olarak açıp bağlamak için bir düğme. Öyleyse o düğmeye tıklayın, Amethyst'i açın ve yeni ekranda kaydet'e basın. \n\t\t3.2 Manuel Bağlantı: Doğrudan girmek için bu ekrandaki yapıştır simgesine tıklayabileceğiniz bir Bağlantı URI'si kopyalayın. \n\t\t3.3 QR Kodu: Cüzdan, bu ekrandaki QR kodu düğmesiyle okuyabileceğiniz bir QR kodu oluşturur. Oluştur g Tehlike Bölgesi @@ -2754,11 +2734,8 @@ Proxy Relay'leri Uygulamanın akışlarınızı indirmek için kullanması gereken filter.nostr.wine gibi toplayıcı relay'ler. Bu, giden kutusu modelinin yerini alır ve uygulamanın yalnızca listelerinizdeki relay'lere bağlanmasını sağlar. Herkese Açık Chat - Genel sohbetler Nostr'daki herkese görünürdür ve herkes - katılabilir. Belirli konular etrafında açık topluluklar için idealdirler. - Moderasyon, relay'lerindeki gönderilerin silinmesiyle kontrol edilebilir - Bu grubu barındıran 1–3 relay ekleyin. - Nostr istemcileri mesajları nereden indireceğini ve mesajlarınızı nereye göndereceğini öğrenmek için bu ayarı kullanır. + Genel sohbetler Nostr'daki herkese görünürdür ve herkes katılabilir. Belirli konular etrafında açık topluluklar için idealdirler. Moderasyon, relay'lerindeki gönderilerin silinmesiyle kontrol edilebilir + Bu grubu barındıran 1–3 relay ekleyin. Nostr istemcileri mesajları nereden indireceğini ve mesajlarınızı nereye göndereceğini öğrenmek için bu ayarı kullanır. relay'ler Genel Sohbet Meta Verisi Genel Sohbetler @@ -2783,10 +2760,7 @@ Salt okunur kullanıcı Sesli mesaj kaydetmek için tıklayıp basılı tutun relay Sohbeti - relay sohbetleri, ana relay'leri tarafından kontrol edilen sohbet gruplarıdır. - Nostr'daki herkese görünürdür ve herkes katılabilir. - Belirli konular etrafında açık topluluklar için idealdirler. Bu grupların bir kısmı geçici niteliktedir - ve bu nedenle sohbet mesajları zamanla silinir + relay sohbetleri, ana relay'leri tarafından kontrol edilen sohbet gruplarıdır. Nostr'daki herkese görünürdür ve herkes katılabilir. Belirli konular etrafında açık topluluklar için idealdirler. Bu grupların bir kısmı geçici niteliktedir ve bu nedenle sohbet mesajları zamanla silinir relay Sohbetleri Etkinlikler IP'ler diff --git a/commonsUI/src/commonMain/composeResources/values-uk-rUA/strings.xml b/commonsUI/src/commonMain/composeResources/values-uk-rUA/strings.xml index 42b3580e01..5e60c8e1da 100644 --- a/commonsUI/src/commonMain/composeResources/values-uk-rUA/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-uk-rUA/strings.xml @@ -225,7 +225,7 @@ Скопіювати ID користувача Розблокувати користувача Цього користувача заблоковано/заглушено. Їхні публікації приховано. - npub, hex, нікнейм + npub, hex, нікнейм Очистити Логотип додатка nsec / npub / приватний ключ в hex @@ -273,15 +273,8 @@ Позначити всі відомі прочитаними Позначити всі нові прочитаними Позначити все прочитаним - ## Поради щодо резервного копіювання та безпеки ключа - \n\nВаш обліковий запис захищений секретним ключем. Ключ — це довга послідовність символів, що починається з **nsec1**. Будь-хто, хто має доступ до цього секретного ключа, може публікувати та змінювати вашу особу. - \n\n- **Не** вводьте свій секретний ключ на жодному сайті чи в програмі, якій ви не довіряєте. - \n- Розробники Amethyst **ніколи** не запитуватимуть ваш секретний ключ. - \n- **Зробіть** надійне резервне копіювання секретного ключа для відновлення облікового запису. Рекомендуємо використовувати менеджер паролів. - - Для додаткової безпеки ви можете зашифрувати ключ паролем. Цей ключ починається з **ncryptsec1** і не може бути використаний без вашого пароля. - \n\nЯкщо ви забудете пароль, ви не зможете відновити ключ. - + ## Поради щодо резервного копіювання та безпеки ключа \n\nВаш обліковий запис захищений секретним ключем. Ключ — це довга послідовність символів, що починається з **nsec1**. Будь-хто, хто має доступ до цього секретного ключа, може публікувати та змінювати вашу особу. \n\n- **Не** вводьте свій секретний ключ на жодному сайті чи в програмі, якій ви не довіряєте. \n- Розробники Amethyst **ніколи** не запитуватимуть ваш секретний ключ. \n- **Зробіть** надійне резервне копіювання секретного ключа для відновлення облікового запису. Рекомендуємо використовувати менеджер паролів. + Для додаткової безпеки ви можете зашифрувати ключ паролем. Цей ключ починається з **ncryptsec1** і не може бути використаний без вашого пароля. \n\nЯкщо ви забудете пароль, ви не зможете відновити ключ. Зашифрувати та скопіювати мій секретний ключ Картинка значка-нагороди %1$s "Зображення нагороди значком @@ -970,9 +963,7 @@ Вимикає push-сповіщення Використовує додаток %1$s Налаштування push-сповіщень - Щоб отримувати push-сповіщення, встановіть будь-який додаток з підтримкою [Unified Push](https://unifiedpush.org/), наприклад [Ntfy](https://ntfy.sh/). - Після встановлення виберіть потрібний додаток у налаштуваннях. - + Щоб отримувати push-сповіщення, встановіть будь-який додаток з підтримкою [Unified Push](https://unifiedpush.org/), наприклад [Ntfy](https://ntfy.sh/). Після встановлення виберіть потрібний додаток у налаштуваннях. Тема Надіслати продавцю повідомлення Привіт, це ще доступно? @@ -2037,15 +2028,7 @@ Не вдалося отримати інвойс від %1$s: %2$s Не вдалося відкрити стиснутий файл Гаманець не знайдено - Amethyst не знайшов жодного гаманця, який підтримує Nostr Wallet Connect (NWC). -\n\nЩо можна зробити: -\n\t1. Перевірте підтримку NWC: переконайтеся, що ваш гаманець сумісний з Nostr Wallet Connect. -\n\t2. Створіть підключення: відкрийте гаманець і створіть нове NWC-підключення. -\n\t3. Прив'яжіть до Amethyst: зазвичай гаманець надає три варіанти: -\n\t\t3.1 Автоматичне підключення: кнопка для автоматичного відкриття та підключення до Amethyst. Якщо так — натисніть її, відкрийте Amethyst і збережіть налаштування на новому екрані. -\n\t\t3.2 Ручне підключення: URI підключення, який можна скопіювати та вставити за допомогою значка вставки на цьому екрані. -\n\t\t3.3 QR-код: гаманець створює QR-код, який можна зчитати за допомогою кнопки QR-коду на цьому екрані. - + Amethyst не знайшов жодного гаманця, який підтримує Nostr Wallet Connect (NWC). \n\nЩо можна зробити: \n\t1. Перевірте підтримку NWC: переконайтеся, що ваш гаманець сумісний з Nostr Wallet Connect. \n\t2. Створіть підключення: відкрийте гаманець і створіть нове NWC-підключення. \n\t3. Прив'яжіть до Amethyst: зазвичай гаманець надає три варіанти: \n\t\t3.1 Автоматичне підключення: кнопка для автоматичного відкриття та підключення до Amethyst. Якщо так — натисніть її, відкрийте Amethyst і збережіть налаштування на новому екрані. \n\t\t3.2 Ручне підключення: URI підключення, який можна скопіювати та вставити за допомогою значка вставки на цьому екрані. \n\t\t3.3 QR-код: гаманець створює QR-код, який можна зчитати за допомогою кнопки QR-коду на цьому екрані. Створити дн Небезпечна зона @@ -2726,11 +2709,8 @@ Proxy relay-сервери Агрегаторні relay-сервери, які застосунок має використовувати для завантаження ваших стрічок, наприклад filter.nostr.wine. Це замінює модель outbox і змусить застосунок підключатися лише до relay-серверів у ваших списках. Публічний чат - Публічні чати видимі всім у Nostr, і кожен - може брати в них участь. Вони чудово підходять для відкритих спільнот за певними темами. - Модерацію можна здійснювати, видаляючи публікації на відповідних relay - Вкажіть від 1 до 3 relay, що розміщують цю групу. - Клієнти Nostr використовують це налаштування, щоб знати, звідки завантажувати повідомлення та куди їх надсилати. + Публічні чати видимі всім у Nostr, і кожен може брати в них участь. Вони чудово підходять для відкритих спільнот за певними темами. Модерацію можна здійснювати, видаляючи публікації на відповідних relay + Вкажіть від 1 до 3 relay, що розміщують цю групу. Клієнти Nostr використовують це налаштування, щоб знати, звідки завантажувати повідомлення та куди їх надсилати. Relay Метадані публічного чату Публічні чати @@ -2755,10 +2735,7 @@ Користувач лише для читання Натисніть і утримуйте, щоб записати повідомлення Чат relay - Чати relay — це групи чатів, керовані їхнім домашнім relay. - Вони видимі всім у Nostr, і кожен може брати в них участь. - Вони чудово підходять для відкритих спільнот за певними темами. Деякі з цих груп є тимчасовими, - і тому повідомлення чату зникають з часом + Чати relay — це групи чатів, керовані їхнім домашнім relay. Вони видимі всім у Nostr, і кожен може брати в них участь. Вони чудово підходять для відкритих спільнот за певними темами. Деякі з цих груп є тимчасовими, і тому повідомлення чату зникають з часом Чати relay Події IP-адреси @@ -3006,4 +2983,15 @@ Lightning-сервіс отримувача за адресою %1$s недоступний. Він був розрахований із lightning-адреси "%2$s". Помилка: %3$s. Перевірте, чи працює сервер і чи правильна lightning-адреса Неможливо створити lightning-рахунок від %1$s: Елемент pr не знайдено у відповідному JSON. Неможливо створити lightning-рахунок. Повідомлення від %1$s: %2$s + + + + diff --git a/commonsUI/src/commonMain/composeResources/values-uk/strings.xml b/commonsUI/src/commonMain/composeResources/values-uk/strings.xml index 53159e447e..bc7b9935b3 100644 --- a/commonsUI/src/commonMain/composeResources/values-uk/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-uk/strings.xml @@ -215,7 +215,7 @@ Скопіювати ID користувача Розблокувати користувача Цього користувача заблоковано/заглушено. Їхні публікації приховано. - npub, hex, нікнейм + npub, hex, нікнейм Очистити Логотип додатка nsec / npub / приватний ключ в hex @@ -263,17 +263,8 @@ Позначити всі відомі прочитаними Позначити всі нові прочитаними Позначити все прочитаним - - ## Поради щодо резервного копіювання та безпеки ключа - \n\nВаш обліковий запис захищений секретним ключем. Ключ — це довга послідовність символів, що починається з **nsec1**. Будь-хто, хто має доступ до цього секретного ключа, може публікувати та змінювати вашу особу. - \n\n- **Не** вводьте свій секретний ключ на жодному сайті чи в програмі, якій ви не довіряєте. - \n- Розробники Amethyst **ніколи** не запитуватимуть ваш секретний ключ. - \n- **Зробіть** надійне резервне копіювання секретного ключа для відновлення облікового запису. Рекомендуємо використовувати менеджер паролів. - - - Для додаткової безпеки ви можете зашифрувати ключ паролем. Цей ключ починається з **ncryptsec1** і не може бути використаний без вашого пароля. - \n\nЯкщо ви забудете пароль, ви не зможете відновити ключ. - + ## Поради щодо резервного копіювання та безпеки ключа \n\nВаш обліковий запис захищений секретним ключем. Ключ — це довга послідовність символів, що починається з **nsec1**. Будь-хто, хто має доступ до цього секретного ключа, може публікувати та змінювати вашу особу. \n\n- **Не** вводьте свій секретний ключ на жодному сайті чи в програмі, якій ви не довіряєте. \n- Розробники Amethyst **ніколи** не запитуватимуть ваш секретний ключ. \n- **Зробіть** надійне резервне копіювання секретного ключа для відновлення облікового запису. Рекомендуємо використовувати менеджер паролів. + Для додаткової безпеки ви можете зашифрувати ключ паролем. Цей ключ починається з **ncryptsec1** і не може бути використаний без вашого пароля. \n\nЯкщо ви забудете пароль, ви не зможете відновити ключ. Зашифрувати та скопіювати мій секретний ключ Картинка значка-нагороди %1$s "Зображення нагороди значком @@ -966,10 +957,7 @@ Вимикає push-сповіщення Використовує додаток %1$s Налаштування push-сповіщень - - Щоб отримувати push-сповіщення, встановіть будь-який додаток з підтримкою [Unified Push](https://unifiedpush.org/), наприклад [Ntfy](https://ntfy.sh/). - Після встановлення виберіть потрібний додаток у налаштуваннях. - + Щоб отримувати push-сповіщення, встановіть будь-який додаток з підтримкою [Unified Push](https://unifiedpush.org/), наприклад [Ntfy](https://ntfy.sh/). Після встановлення виберіть потрібний додаток у налаштуваннях. Тема Надіслати продавцю повідомлення Привіт, це ще доступно? @@ -2078,15 +2066,7 @@ Не вдалося отримати інвойс від %1$s: %2$s Не вдалося відкрити стиснутий файл Гаманець не знайдено - Amethyst не знайшов жодного гаманця, який підтримує Nostr Wallet Connect (NWC). -\n\nЩо можна зробити: -\n\t1. Перевірте підтримку NWC: переконайтеся, що ваш гаманець сумісний з Nostr Wallet Connect. -\n\t2. Створіть підключення: відкрийте гаманець і створіть нове NWC-підключення. -\n\t3. Прив'яжіть до Amethyst: зазвичай гаманець надає три варіанти: -\n\t\t3.1 Автоматичне підключення: кнопка для автоматичного відкриття та підключення до Amethyst. Якщо так — натисніть її, відкрийте Amethyst і збережіть налаштування на новому екрані. -\n\t\t3.2 Ручне підключення: URI підключення, який можна скопіювати та вставити за допомогою значка вставки на цьому екрані. -\n\t\t3.3 QR-код: гаманець створює QR-код, який можна зчитати за допомогою кнопки QR-коду на цьому екрані. - + Amethyst не знайшов жодного гаманця, який підтримує Nostr Wallet Connect (NWC). \n\nЩо можна зробити: \n\t1. Перевірте підтримку NWC: переконайтеся, що ваш гаманець сумісний з Nostr Wallet Connect. \n\t2. Створіть підключення: відкрийте гаманець і створіть нове NWC-підключення. \n\t3. Прив'яжіть до Amethyst: зазвичай гаманець надає три варіанти: \n\t\t3.1 Автоматичне підключення: кнопка для автоматичного відкриття та підключення до Amethyst. Якщо так — натисніть її, відкрийте Amethyst і збережіть налаштування на новому екрані. \n\t\t3.2 Ручне підключення: URI підключення, який можна скопіювати та вставити за допомогою значка вставки на цьому екрані. \n\t\t3.3 QR-код: гаманець створює QR-код, який можна зчитати за допомогою кнопки QR-коду на цьому екрані. Створити дн Небезпечна зона @@ -2816,11 +2796,8 @@ Proxy relay-сервери Агрегаторні relay-сервери, які застосунок має використовувати для завантаження ваших стрічок, наприклад filter.nostr.wine. Це замінює модель outbox і змусить застосунок підключатися лише до relay-серверів у ваших списках. Публічний чат - Публічні чати видимі всім у Nostr, і кожен - може брати в них участь. Вони чудово підходять для відкритих спільнот за певними темами. - Модерацію можна здійснювати, видаляючи публікації на відповідних relay - Вкажіть від 1 до 3 relay, що розміщують цю групу. - Клієнти Nostr використовують це налаштування, щоб знати, звідки завантажувати повідомлення та куди їх надсилати. + Публічні чати видимі всім у Nostr, і кожен може брати в них участь. Вони чудово підходять для відкритих спільнот за певними темами. Модерацію можна здійснювати, видаляючи публікації на відповідних relay + Вкажіть від 1 до 3 relay, що розміщують цю групу. Клієнти Nostr використовують це налаштування, щоб знати, звідки завантажувати повідомлення та куди їх надсилати. Relay Метадані публічного чату Публічні чати @@ -2845,10 +2822,7 @@ Користувач лише для читання Натисніть і утримуйте, щоб записати повідомлення Чат relay - Чати relay — це групи чатів, керовані їхнім домашнім relay. - Вони видимі всім у Nostr, і кожен може брати в них участь. - Вони чудово підходять для відкритих спільнот за певними темами. Деякі з цих груп є тимчасовими, - і тому повідомлення чату зникають з часом + Чати relay — це групи чатів, керовані їхнім домашнім relay. Вони видимі всім у Nostr, і кожен може брати в них участь. Вони чудово підходять для відкритих спільнот за певними темами. Деякі з цих груп є тимчасовими, і тому повідомлення чату зникають з часом Чати relay Події IP-адреси diff --git a/commonsUI/src/commonMain/composeResources/values-uz-rUZ/strings.xml b/commonsUI/src/commonMain/composeResources/values-uz-rUZ/strings.xml index 3e13b5f327..3c8e9daa2a 100644 --- a/commonsUI/src/commonMain/composeResources/values-uz-rUZ/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-uz-rUZ/strings.xml @@ -273,15 +273,8 @@ Barcha ma'lumlarni o'qilgan deb belgilash Barcha yangilarni o'qilgan deb belgilash Barchasini o'qilgan deb belgilash - ## Kalit zaxiralash va xavfsizlik maslahatlari - \n\nHisobingiz maxfiy kalit bilan himoyalangan. Kalit **nsec1** bilan boshlanadigan uzun belgilar ketma-ketligidir. Ushbu maxfiy kalitga kirish huquqiga ega bo'lgan har kim sizning nomingizdan post qo'yishi va identifikatoringizni o'zgartirishi mumkin. - \n\n- Maxfiy kalitingizni ishonmagan veb-sayt yoki dasturga **kiritmang**. - \n- Amethyst ishlab chiquvchilari maxfiy kalitingizni hech **qachon** so'ramaydi. - \n- Hisob tiklash uchun maxfiy kalitingizning xavfsiz zaxira nusxasini saqlang. Parol menejeridan foydalanishni tavsiya qilamiz. - - Qo'shimcha xavfsizlik uchun kalitingizni parol bilan shifrlashingiz mumkin. Bu kalit **ncryptsec1** bilan boshlanadi va parolsiz ishlatib bo'lmaydi. - \n\nParolingizni unutsangiz, kalitingizni tiklay olmaysiz. - + ## Kalit zaxiralash va xavfsizlik maslahatlari \n\nHisobingiz maxfiy kalit bilan himoyalangan. Kalit **nsec1** bilan boshlanadigan uzun belgilar ketma-ketligidir. Ushbu maxfiy kalitga kirish huquqiga ega bo'lgan har kim sizning nomingizdan post qo'yishi va identifikatoringizni o'zgartirishi mumkin. \n\n- Maxfiy kalitingizni ishonmagan veb-sayt yoki dasturga **kiritmang**. \n- Amethyst ishlab chiquvchilari maxfiy kalitingizni hech **qachon** so'ramaydi. \n- Hisob tiklash uchun maxfiy kalitingizning xavfsiz zaxira nusxasini saqlang. Parol menejeridan foydalanishni tavsiya qilamiz. + Qo'shimcha xavfsizlik uchun kalitingizni parol bilan shifrlashingiz mumkin. Bu kalit **ncryptsec1** bilan boshlanadi va parolsiz ishlatib bo'lmaydi. \n\nParolingizni unutsangiz, kalitingizni tiklay olmaysiz. Maxfiy kalitni shifrlash va nusxalash %1$s uchun nishon mukofoti rasmi "Nishon mukofoti rasmi @@ -962,9 +955,7 @@ Push bildirishnomalarini o'chiradi %1$s ilovasidan foydalanadi Push bildirishnoma sozlamasi - Push bildirishnomalarni olish uchun [Ntfy](https://ntfy.sh/) kabi [Unified Push](https://unifiedpush.org/) ni qo'llab-quvvatlaydigan ilovani o'rnating. - O'rnatgandan so'ng, Sozlamalarda foydalanmoqchi bo'lgan ilovani tanlang. - + Push bildirishnomalarni olish uchun [Ntfy](https://ntfy.sh/) kabi [Unified Push](https://unifiedpush.org/) ni qo'llab-quvvatlaydigan ilovani o'rnating. O'rnatgandan so'ng, Sozlamalarda foydalanmoqchi bo'lgan ilovani tanlang. Ip Sotuvchiga xabar yuborish Salom, bu hali ham mavjudmi? @@ -2012,15 +2003,7 @@ %1$s dan hisob-faktura olishda xato: %2$s Siqilgan faylni ochib bo'lmadi Hamyon topilmadi - Amethyst Nostr Wallet Connect (NWC) ni qo'llab-quvvatlaydigan hech qanday hamyon ilovasini topa olmadi. -\n\nNima qilishingiz mumkin: -\n\t1. NWC qo'llab-quvvatlashini tekshirish: Tanlagan hamyon ilovangiz Nostr Wallet Connect bilan mos kelishini ikki marta tekshiring. -\n\t2. Ulanish yaratish: Hamyon ilovangizga o'ting va yangi NWC ulanishini yarating. -\n\t3. Amethyst ga bog'lash: Hamyoningiz odatda uchta variant taqdim etadi: -\n\t\t3.1 Avtomatik bog'lash: Amethyst ni avtomatik ochish va ulash tugmasi. Agar shunday bo'lsa, ushbu tugmani bosing, Amethyst ni oching va yangi ekranda saqlash tugmasini bosing. -\n\t\t3.2 Qo'lda bog'lash: Nusxa olishingiz va to'g'ridan-to'g'ri kiritish uchun ushbu ekrandagi joylashtirish belgisini bosishingiz mumkin bo'lgan Ulanish URI si. -\n\t\t3.3 QR kod: Hamyon ushbu ekrandagi QR kod tugmasidan o'qishingiz mumkin bo'lgan QR kod yaratadi. - + Amethyst Nostr Wallet Connect (NWC) ni qo'llab-quvvatlaydigan hech qanday hamyon ilovasini topa olmadi. \n\nNima qilishingiz mumkin: \n\t1. NWC qo'llab-quvvatlashini tekshirish: Tanlagan hamyon ilovangiz Nostr Wallet Connect bilan mos kelishini ikki marta tekshiring. \n\t2. Ulanish yaratish: Hamyon ilovangizga o'ting va yangi NWC ulanishini yarating. \n\t3. Amethyst ga bog'lash: Hamyoningiz odatda uchta variant taqdim etadi: \n\t\t3.1 Avtomatik bog'lash: Amethyst ni avtomatik ochish va ulash tugmasi. Agar shunday bo'lsa, ushbu tugmani bosing, Amethyst ni oching va yangi ekranda saqlash tugmasini bosing. \n\t\t3.2 Qo'lda bog'lash: Nusxa olishingiz va to'g'ridan-to'g'ri kiritish uchun ushbu ekrandagi joylashtirish belgisini bosishingiz mumkin bo'lgan Ulanish URI si. \n\t\t3.3 QR kod: Hamyon ushbu ekrandagi QR kod tugmasidan o'qishingiz mumkin bo'lgan QR kod yaratadi. Yaratish k Xavfli zona @@ -2686,11 +2669,8 @@ Proksi Relay lar Ilovaning tasmalangizni yuklab olish uchun foydalanishi kerak bo'lgan agregator relay lar, masalan filter.nostr.wine. Bu chiqib ketish modelini almashtiradi va ilovani faqat ro'yxatlaringizdagi relay larga ulanishga majbur qiladi. Ommaviy chat - Ommaviy chatlar Nostr dagi hamma uchun ko'rinadi va har kim - ularda ishtirok eta oladi. Ular muayyan mavzular bo'yicha ochiq jamoalar uchun juda mos keladi. - Moderatsiya relay'larida postlarni o'chirish orqali boshqarilishi mumkin - Ushbu guruhni joylashtiradigan 1-3 ta relay kiriting. - Nostr klientlari xabarlarni qayerdan yuklab olishini va xabarlaringizni qayerga yuborishini bilish uchun ushbu sozlamadan foydalanadi. + Ommaviy chatlar Nostr dagi hamma uchun ko'rinadi va har kim ularda ishtirok eta oladi. Ular muayyan mavzular bo'yicha ochiq jamoalar uchun juda mos keladi. Moderatsiya relay'larida postlarni o'chirish orqali boshqarilishi mumkin + Ushbu guruhni joylashtiradigan 1-3 ta relay kiriting. Nostr klientlari xabarlarni qayerdan yuklab olishini va xabarlaringizni qayerga yuborishini bilish uchun ushbu sozlamadan foydalanadi. Relay lar Ommaviy chat metama'lumotlari Ommaviy chatlar @@ -2715,10 +2695,7 @@ Faqat o'qish foydalanuvchisi Xabar yozib olish uchun bosib ushlab turing Relay chati - Relay chatlari o'z uy relay'lari tomonidan boshqariladigan chat guruhlaridir. - Ular Nostr dagi hamma uchun ko'rinadi va har kim ularda ishtirok eta oladi. - Ular muayyan mavzular bo'yicha ochiq jamoalar uchun juda mos keladi. Bu guruhlarning bir qismi vaqtinchalik bo'lib, - shuning uchun chat xabarlari vaqt o'tishi bilan yo'qoladi + Relay chatlari o'z uy relay'lari tomonidan boshqariladigan chat guruhlaridir. Ular Nostr dagi hamma uchun ko'rinadi va har kim ularda ishtirok eta oladi. Ular muayyan mavzular bo'yicha ochiq jamoalar uchun juda mos keladi. Bu guruhlarning bir qismi vaqtinchalik bo'lib, shuning uchun chat xabarlari vaqt o'tishi bilan yo'qoladi Relay chatlari Hodisalar IP lar @@ -2957,4 +2934,15 @@ %1$s manzilidagi qabul qiluvchining lightning xizmati mavjud emas. U "%2$s" lightning manzilidan hisoblangan. Xato: %3$s. Server ishlayotganini va lightning manzili to'g'riligini tekshiring %1$s dan lightning hisob-fakturasi yaratib bo'lmadi: Natija JSON da pr elementi topilmadi. Lightning hisob-fakturasi yaratib bo'lmadi. %1$s dan xabar: %2$s + + + + diff --git a/commonsUI/src/commonMain/composeResources/values-vi-rVN/strings.xml b/commonsUI/src/commonMain/composeResources/values-vi-rVN/strings.xml index cb8ea4881d..e39e556680 100644 --- a/commonsUI/src/commonMain/composeResources/values-vi-rVN/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-vi-rVN/strings.xml @@ -267,15 +267,8 @@ Đánh dấu tất cả đã biết là đã đọc Đánh dấu tất cả mới là đã đọc Đánh dấu tất cả là đã đọc - ## Mẹo sao lưu khóa và bảo mật - \n\nTài khoản của bạn được bảo mật bằng một khóa bí mật. Khóa là một chuỗi ký tự dài bắt đầu bằng **nsec1**. Bất kỳ ai có quyền truy cập vào khóa bí mật này đều có thể đăng bài và thay đổi danh tính của bạn. - \n\n- **Không** đưa khóa bí mật của bạn vào bất kỳ trang web hoặc phần mềm nào mà bạn không tin tưởng. - \n- Các nhà phát triển Amethyst sẽ **không bao giờ** yêu cầu khóa bí mật của bạn. - \n- **Hãy** giữ bản sao lưu an toàn của khóa bí mật để khôi phục tài khoản. Chúng tôi khuyến nghị sử dụng trình quản lý mật khẩu. - - Để bảo mật thêm, bạn có thể mã hóa khóa của mình bằng mật khẩu. Khóa này bắt đầu bằng **ncryptsec1** và không thể sử dụng nếu không có mật khẩu của bạn. - \n\nNếu bạn mất mật khẩu, bạn sẽ không thể khôi phục khóa của mình. - + ## Mẹo sao lưu khóa và bảo mật \n\nTài khoản của bạn được bảo mật bằng một khóa bí mật. Khóa là một chuỗi ký tự dài bắt đầu bằng **nsec1**. Bất kỳ ai có quyền truy cập vào khóa bí mật này đều có thể đăng bài và thay đổi danh tính của bạn. \n\n- **Không** đưa khóa bí mật của bạn vào bất kỳ trang web hoặc phần mềm nào mà bạn không tin tưởng. \n- Các nhà phát triển Amethyst sẽ **không bao giờ** yêu cầu khóa bí mật của bạn. \n- **Hãy** giữ bản sao lưu an toàn của khóa bí mật để khôi phục tài khoản. Chúng tôi khuyến nghị sử dụng trình quản lý mật khẩu. + Để bảo mật thêm, bạn có thể mã hóa khóa của mình bằng mật khẩu. Khóa này bắt đầu bằng **ncryptsec1** và không thể sử dụng nếu không có mật khẩu của bạn. \n\nNếu bạn mất mật khẩu, bạn sẽ không thể khôi phục khóa của mình. Mã hóa và sao chép khóa bí mật của tôi Hình ảnh huy hiệu được trao cho %1$s "Hình ảnh huy hiệu được trao @@ -954,9 +947,7 @@ Tắt thông báo Push Sử dụng ứng dụng %1$s Cài đặt thông báo Push - Để nhận thông báo push, hãy cài đặt ứng dụng hỗ trợ [Unified Push](https://unifiedpush.org/), chẳng hạn như [Ntfy](https://ntfy.sh/). - Sau khi cài đặt, hãy chọn ứng dụng bạn muốn sử dụng trong Cài đặt. - + Để nhận thông báo push, hãy cài đặt ứng dụng hỗ trợ [Unified Push](https://unifiedpush.org/), chẳng hạn như [Ntfy](https://ntfy.sh/). Sau khi cài đặt, hãy chọn ứng dụng bạn muốn sử dụng trong Cài đặt. Luồng Gửi tin nhắn cho người bán Xin chào, mặt hàng này còn không? @@ -2000,15 +1991,7 @@ Không thể tải hóa đơn từ %1$s: %2$s Không thể mở tệp nén Không tìm thấy ví - Amethyst không tìm thấy ứng dụng ví nào hỗ trợ Nostr Wallet Connect (NWC). -\n\nBạn có thể thực hiện: -\n\t1. Xác minh hỗ trợ NWC: Kiểm tra lại xem ứng dụng ví của bạn có tương thích với Nostr Wallet Connect không. -\n\t2. Tạo kết nối: Vào ứng dụng ví của bạn và tạo kết nối NWC mới. -\n\t3. Liên kết với Amethyst: Ví của bạn thường cung cấp ba tùy chọn: -\n\t\t3.1 Liên kết tự động: Nút để tự động mở và kết nối với Amethyst. Nếu có, nhấn nút đó, mở Amethyst và nhấn lưu trên màn hình mới. -\n\t\t3.2 Liên kết thủ công: URI kết nối mà bạn có thể sao chép và nhấn biểu tượng dán trên màn hình này để nhập trực tiếp. -\n\t\t3.3 Mã QR: Ví tạo mã QR mà bạn có thể đọc bằng nút mã QR trên màn hình này. - + Amethyst không tìm thấy ứng dụng ví nào hỗ trợ Nostr Wallet Connect (NWC). \n\nBạn có thể thực hiện: \n\t1. Xác minh hỗ trợ NWC: Kiểm tra lại xem ứng dụng ví của bạn có tương thích với Nostr Wallet Connect không. \n\t2. Tạo kết nối: Vào ứng dụng ví của bạn và tạo kết nối NWC mới. \n\t3. Liên kết với Amethyst: Ví của bạn thường cung cấp ba tùy chọn: \n\t\t3.1 Liên kết tự động: Nút để tự động mở và kết nối với Amethyst. Nếu có, nhấn nút đó, mở Amethyst và nhấn lưu trên màn hình mới. \n\t\t3.2 Liên kết thủ công: URI kết nối mà bạn có thể sao chép và nhấn biểu tượng dán trên màn hình này để nhập trực tiếp. \n\t\t3.3 Mã QR: Ví tạo mã QR mà bạn có thể đọc bằng nút mã QR trên màn hình này. Tạo n Vùng nguy hiểm @@ -2642,11 +2625,8 @@ Relay Proxy Các relay tổng hợp mà ứng dụng phải sử dụng để tải feed của bạn, như filter.nostr.wine. Tùy chọn này thay thế mô hình outbox và sẽ khiến ứng dụng chỉ kết nối đến các relay trong danh sách của bạn. Trò chuyện công khai - Trò chuyện công khai hiển thị với mọi người trên Nostr và bất kỳ ai - cũng có thể tham gia. Chúng rất phù hợp cho các cộng đồng mở xung quanh các chủ đề cụ thể. - Kiểm duyệt có thể được kiểm soát bằng cách xóa bài đăng trên các relay của nó - Nhập từ 1–3 relay lưu trữ nhóm này. - Các ứng dụng Nostr sử dụng cài đặt này để biết nơi tải xuống tin nhắn và gửi tin nhắn của bạn. + Trò chuyện công khai hiển thị với mọi người trên Nostr và bất kỳ ai cũng có thể tham gia. Chúng rất phù hợp cho các cộng đồng mở xung quanh các chủ đề cụ thể. Kiểm duyệt có thể được kiểm soát bằng cách xóa bài đăng trên các relay của nó + Nhập từ 1–3 relay lưu trữ nhóm này. Các ứng dụng Nostr sử dụng cài đặt này để biết nơi tải xuống tin nhắn và gửi tin nhắn của bạn. Relay Siêu dữ liệu trò chuyện công khai Trò chuyện công khai @@ -2670,10 +2650,7 @@ Lượng dữ liệu tính bằng byte đã nhận được từ relay này, bao gồm các bộ lọc và sự kiện Người dùng chỉ đọc Nhấn và giữ để ghi âm tin nhắn - Relay chat là các nhóm trò chuyện được kiểm soát bởi relay chủ quản. - Chúng hiển thị với mọi người trên Nostr và bất kỳ ai cũng có thể tham gia. - Chúng rất phù hợp cho các cộng đồng mở xung quanh các chủ đề cụ thể. Một số nhóm này là tạm thời - và do đó tin nhắn trò chuyện sẽ biến mất theo thời gian + Relay chat là các nhóm trò chuyện được kiểm soát bởi relay chủ quản. Chúng hiển thị với mọi người trên Nostr và bất kỳ ai cũng có thể tham gia. Chúng rất phù hợp cho các cộng đồng mở xung quanh các chủ đề cụ thể. Một số nhóm này là tạm thời và do đó tin nhắn trò chuyện sẽ biến mất theo thời gian Relay Chat Sự kiện IP @@ -2902,4 +2879,15 @@ Dịch vụ Lightning của người nhận tại %1$s không khả dụng. Địa chỉ này được tính từ địa chỉ Lightning "%2$s". Lỗi: %3$s. Hãy kiểm tra xem máy chủ có hoạt động và địa chỉ Lightning có đúng không Không thể tạo hóa đơn Lightning từ %1$s: Không tìm thấy phần tử pr trong JSON kết quả. Không thể tạo hóa đơn Lightning. Thông báo từ %1$s: %2$s + + + + diff --git a/commonsUI/src/commonMain/composeResources/values-zh-rCN/strings.xml b/commonsUI/src/commonMain/composeResources/values-zh-rCN/strings.xml index 1e2c4c9148..36527eeeaf 100644 --- a/commonsUI/src/commonMain/composeResources/values-zh-rCN/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-zh-rCN/strings.xml @@ -389,15 +389,8 @@ 将所有已知内容标记为已读 将所有新内容标记为已读 将所有内容标记为已读 - ## 备份与安全提示 - \n\n你的帐户由一个私钥保护。私钥是以**nsec1**开头的随机字符串。任何拥有你的私钥的人都可以使用你的身份发布内容。 - \n\n- **不要**将你的私钥添加到任何你不信任的网站或软件,亦不要在网上公开。 - \n- Amethyst 开发人员**永远不会**要求你提供私钥。 - \n- **请**保留你的私钥的安全备份,以备帐户恢复。我们建议使用密码管理器。 - - 为了额外的安全性,你可以用密码加密你的密钥。 此密钥以 **ncryptsec1** 开头,没有密码就不能使用。 - \n\n如果你丢失密码, 你将无法恢复你的密钥。 - + ## 备份与安全提示 \n\n你的帐户由一个私钥保护。私钥是以**nsec1**开头的随机字符串。任何拥有你的私钥的人都可以使用你的身份发布内容。 \n\n- **不要**将你的私钥添加到任何你不信任的网站或软件,亦不要在网上公开。 \n- Amethyst 开发人员**永远不会**要求你提供私钥。 \n- **请**保留你的私钥的安全备份,以备帐户恢复。我们建议使用密码管理器。 + 为了额外的安全性,你可以用密码加密你的密钥。 此密钥以 **ncryptsec1** 开头,没有密码就不能使用。 \n\n如果你丢失密码, 你将无法恢复你的密钥。 加密并复制我的密钥。 颁发给 %1$s 的徽章图片 "徽章图片 @@ -478,7 +471,7 @@ 轻触以允许一种类型。长按一种类型来设置每个事件的大小或每日帖子的限制。 自定义类型 添加类型 - %1$d 类型的限制 + %1$d 类型的限制 最大事件大小(字节) 每位作者每天最多发帖数 留空为无限制 @@ -1356,9 +1349,7 @@ 关闭推送通知 使用应用 %1$s 推送通知设置 - 若要接收推送通知,请安装任何支持 [Unified Push](https://unifiedpus)的应用, 例如 [Ntfy](https://ntfy.sh/)。 - 安装后,请在设置中选择你想要使用的应用程序。 - + 若要接收推送通知,请安装任何支持 [Unified Push](https://unifiedpus)的应用, 例如 [Ntfy](https://ntfy.sh/)。 安装后,请在设置中选择你想要使用的应用程序。 主题帖 向卖方发送消息 你好,这个还能买到吗? @@ -2640,15 +2631,7 @@ 无法从 %1$s 获取发票:%2$s 无法打开压缩文件 未找到钱包 - Amethyst 找不到任何支持 Nostr Wallet Connect (NWC) 的钱包应用. -\n\n你可以做的事情: -\n\t1. 验证 NWC 支持:仔细确认偏好的钱包应用和 Nostr Wallet Connect 兼容。 -\n\t2. 创建连接:前往钱包应用并创建新的 NWC 连接。 -\n\t3.链接到 Amethyst: 你的钱包通常会给你三个选项: -\n\t\t3.1 自动链接:自动打开并连接到 Amethyst 的按钮。如果是这样, 单击那个按钮,打开 Amethyst 并在新屏上按保存。 -\n\t\t3.2 手动链接: 你可以复制的连接 URI,单击这个屏幕上的粘贴图标直接输入它。 -\n\t\t3.3 二维码: 钱包会创建一个你可以从这个屏幕中的二维码按钮那里读取的二维码。 - + Amethyst 找不到任何支持 Nostr Wallet Connect (NWC) 的钱包应用. \n\n你可以做的事情: \n\t1. 验证 NWC 支持:仔细确认偏好的钱包应用和 Nostr Wallet Connect 兼容。 \n\t2. 创建连接:前往钱包应用并创建新的 NWC 连接。 \n\t3.链接到 Amethyst: 你的钱包通常会给你三个选项: \n\t\t3.1 自动链接:自动打开并连接到 Amethyst 的按钮。如果是这样, 单击那个按钮,打开 Amethyst 并在新屏上按保存。 \n\t\t3.2 手动链接: 你可以复制的连接 URI,单击这个屏幕上的粘贴图标直接输入它。 \n\t\t3.3 二维码: 钱包会创建一个你可以从这个屏幕中的二维码按钮那里读取的二维码。 创建 天 危险区 @@ -3552,11 +3535,8 @@ 代理中继 聚合器中继是应用获取资讯源时使用的中继(例如:filter.nostr.wine),设置这个列表将覆盖信箱模型的行为,使得应用只会连接到这个列表中的中继以获取内容。 公共聊天 - 公开聊天对所有 Nostr 用户都是可见的,任何人都可以: - 参与这些聊天。这对特定话题建立的开放式社区来说很重要 - 通过删除中继上的帖子实现内容审核控制 - 使用 1~3 个中继托管这个群组。 - 让 Nostr 客户端知道应该使用这些中继配置来发送和下载消息。 + 公开聊天对所有 Nostr 用户都是可见的,任何人都可以: 参与这些聊天。这对特定话题建立的开放式社区来说很重要 通过删除中继上的帖子实现内容审核控制 + 使用 1~3 个中继托管这个群组。 让 Nostr 客户端知道应该使用这些中继配置来发送和下载消息。 中继器 公开聊天元数据 公共聊天 @@ -3589,10 +3569,7 @@ auth authentication relay sign verify nip-42 identity 中继身份认证 中继聊天 - 中继聊天是由聊天所属中继控制的聊天群组。 - Nostr 上的每个人都可以看到这些群组,任何人都可以加入群组。 - 这非常适合围绕特定主题而建立的开放社区。其中一些群组是临时的, - 因此聊天信息会随着时间的推移而消失。 + 中继聊天是由聊天所属中继控制的聊天群组。 Nostr 上的每个人都可以看到这些群组,任何人都可以加入群组。 这非常适合围绕特定主题而建立的开放社区。其中一些群组是临时的, 因此聊天信息会随着时间的推移而消失。 中继聊天 建群 编辑群 @@ -3918,4 +3895,15 @@ %1$s 的接收者闪电服务不可用。它是根据雷电地址"%2$s"计算的。 错误: %3$s。请检查服务器是否已经上线,闪电地址是否正确 无法从 %1$s 创建闪电发票:在生成的 JSON 中找不到付款要求元素。 无法创建闪电发票。来自 %1$s 的消息:%2$s + + + + diff --git a/commonsUI/src/commonMain/composeResources/values-zh-rHK/strings.xml b/commonsUI/src/commonMain/composeResources/values-zh-rHK/strings.xml index def7f1824e..4ee4682e0a 100644 --- a/commonsUI/src/commonMain/composeResources/values-zh-rHK/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-zh-rHK/strings.xml @@ -228,7 +228,7 @@ 複製用户ID 隱藏該用户 此用户被屏蔽/静音。他们的帖子被隐藏。 - npub,hex,用户名 + npub,hex,用户名 清除 應用標誌 nsec/ npub/ hex 私鑰 @@ -276,15 +276,8 @@ 將所有已知內容標記為已讀 將所有新內容標記為已讀 將所有內容標記為已讀 - ## 备份与安全提示 - \n\n你的帐户由一个私钥保护。私钥是以**nsec1**开头的随机字符串。任何拥有你的私钥的人都可以使用你的身份发布内容。 - \n\n- **不要**将你的私钥添加到任何你不信任的网站或软件,亦不要在网上公开。 - \n- Amethyst 开发人员**永远不会**要求你提供私钥。 - \n- **请**保留你的私钥的安全备份,以备帐户恢复。我们建议使用密码管理器。 - - 为了额外的安全性,你可以用密码加密你的密钥。 此密钥以 **ncryptsec1** 开头,没有密码就不能使用。 - \n\n如果你丢失密码, 你将无法恢复你的密钥。 - + ## 备份与安全提示 \n\n你的帐户由一个私钥保护。私钥是以**nsec1**开头的随机字符串。任何拥有你的私钥的人都可以使用你的身份发布内容。 \n\n- **不要**将你的私钥添加到任何你不信任的网站或软件,亦不要在网上公开。 \n- Amethyst 开发人员**永远不会**要求你提供私钥。 \n- **请**保留你的私钥的安全备份,以备帐户恢复。我们建议使用密码管理器。 + 为了额外的安全性,你可以用密码加密你的密钥。 此密钥以 **ncryptsec1** 开头,没有密码就不能使用。 \n\n如果你丢失密码, 你将无法恢复你的密钥。 加密并复制我的密钥。 頒發給 %1$s 的徽章圖片 "徽章图片 @@ -365,7 +358,7 @@ 轻触以允许一种类型。长按一种类型来设置每个事件的大小或每日帖子的限制。 自定义类型 添加类型 - %1$d 类型的限制 + %1$d 类型的限制 最大事件大小(字节) 每位作者每天最多发帖数 留空为无限制 @@ -976,9 +969,7 @@ 关闭推送通知 使用应用 %1$s 推送通知设置 - 若要接收推送通知,请安装任何支持 [Unified Push](https://unifiedpus)的应用, 例如 [Ntfy](https://ntfy.sh/)。 - 安装后,请在设置中选择你想要使用的应用程序。 - + 若要接收推送通知,请安装任何支持 [Unified Push](https://unifiedpus)的应用, 例如 [Ntfy](https://ntfy.sh/)。 安装后,请在设置中选择你想要使用的应用程序。 主题帖 向卖方发送消息 你好,这个还能买到吗? @@ -2037,15 +2028,7 @@ 无法从 %1$s 获取发票:%2$s 无法打开压缩文件 未找到钱包 - Amethyst 找不到任何支持 Nostr Wallet Connect (NWC) 的钱包应用. -\n\n你可以做的事情: -\n\t1. 验证 NWC 支持:仔细确认偏好的钱包应用和 Nostr Wallet Connect 兼容。 -\n\t2. 创建连接:前往钱包应用并创建新的 NWC 连接。 -\n\t3.链接到 Amethyst: 你的钱包通常会给你三个选项: -\n\t\t3.1 自动链接:自动打开并连接到 Amethyst 的按钮。如果是这样, 单击那个按钮,打开 Amethyst 并在新屏上按保存。 -\n\t\t3.2 手动链接: 你可以复制的连接 URI,单击这个屏幕上的粘贴图标直接输入它。 -\n\t\t3.3 二维码: 钱包会创建一个你可以从这个屏幕中的二维码按钮那里读取的二维码。 - + Amethyst 找不到任何支持 Nostr Wallet Connect (NWC) 的钱包应用. \n\n你可以做的事情: \n\t1. 验证 NWC 支持:仔细确认偏好的钱包应用和 Nostr Wallet Connect 兼容。 \n\t2. 创建连接:前往钱包应用并创建新的 NWC 连接。 \n\t3.链接到 Amethyst: 你的钱包通常会给你三个选项: \n\t\t3.1 自动链接:自动打开并连接到 Amethyst 的按钮。如果是这样, 单击那个按钮,打开 Amethyst 并在新屏上按保存。 \n\t\t3.2 手动链接: 你可以复制的连接 URI,单击这个屏幕上的粘贴图标直接输入它。 \n\t\t3.3 二维码: 钱包会创建一个你可以从这个屏幕中的二维码按钮那里读取的二维码。 創建 天 危险区 @@ -2700,11 +2683,8 @@ 代理中继 聚合器中继是应用获取资讯源时使用的中继(例如:filter.nostr.wine),设置这个列表将覆盖信箱模型的行为,使得应用只会连接到这个列表中的中继以获取内容。 公共聊天 - 公开聊天对所有 Nostr 用户都是可见的,任何人都可以: - 参与这些聊天。这对特定话题建立的开放式社区来说很重要 - 通过删除中继上的帖子实现内容审核控制 - 使用 1~3 个中继托管这个群组。 - 让 Nostr 客户端知道应该使用这些中继配置来发送和下载消息。 + 公开聊天对所有 Nostr 用户都是可见的,任何人都可以: 参与这些聊天。这对特定话题建立的开放式社区来说很重要 通过删除中继上的帖子实现内容审核控制 + 使用 1~3 个中继托管这个群组。 让 Nostr 客户端知道应该使用这些中继配置来发送和下载消息。 中继器 公开聊天元数据 公共聊天 @@ -2729,10 +2709,7 @@ 仅读用户 点按以录制消息 中继聊天 - 中继聊天是由聊天所属中继控制的聊天群组。 - Nostr 上的每个人都可以看到这些群组,任何人都可以加入群组。 - 这非常适合围绕特定主题而建立的开放社区。其中一些群组是临时的, - 因此聊天信息会随着时间的推移而消失。 + 中继聊天是由聊天所属中继控制的聊天群组。 Nostr 上的每个人都可以看到这些群组,任何人都可以加入群组。 这非常适合围绕特定主题而建立的开放社区。其中一些群组是临时的, 因此聊天信息会随着时间的推移而消失。 中继聊天 事件 IP地址 @@ -2967,4 +2944,15 @@ %1$s 的接收者闪电服务不可用。它是根据雷电地址"%2$s"计算的。 错误: %3$s。请检查服务器是否已经上线,闪电地址是否正确 无法从 %1$s 创建闪电发票:在生成的 JSON 中找不到付款要求元素。 无法创建闪电发票。来自 %1$s 的消息:%2$s + + + + diff --git a/commonsUI/src/commonMain/composeResources/values-zh-rSG/strings.xml b/commonsUI/src/commonMain/composeResources/values-zh-rSG/strings.xml index 5c55e3b527..0d0a9f7f9e 100644 --- a/commonsUI/src/commonMain/composeResources/values-zh-rSG/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-zh-rSG/strings.xml @@ -276,15 +276,8 @@ 将所有已知内容标记为已读 将所有新内容标记为已读 将所有内容标记为已读 - ## 备份与安全提示 - \n\n你的帐户由一个私钥保护。私钥是以**nsec1**开头的随机字符串。任何拥有你的私钥的人都可以使用你的身份发布内容。 - \n\n- **不要**将你的私钥添加到任何你不信任的网站或软件,亦不要在网上公开。 - \n- Amethyst 开发人员**永远不会**要求你提供私钥。 - \n- **请**保留你的私钥的安全备份,以备帐户恢复。我们建议使用密码管理器。 - - 为了额外的安全性,你可以用密码加密你的密钥。 此密钥以 **ncryptsec1** 开头,没有密码就不能使用。 - \n\n如果你丢失密码, 你将无法恢复你的密钥。 - + ## 备份与安全提示 \n\n你的帐户由一个私钥保护。私钥是以**nsec1**开头的随机字符串。任何拥有你的私钥的人都可以使用你的身份发布内容。 \n\n- **不要**将你的私钥添加到任何你不信任的网站或软件,亦不要在网上公开。 \n- Amethyst 开发人员**永远不会**要求你提供私钥。 \n- **请**保留你的私钥的安全备份,以备帐户恢复。我们建议使用密码管理器。 + 为了额外的安全性,你可以用密码加密你的密钥。 此密钥以 **ncryptsec1** 开头,没有密码就不能使用。 \n\n如果你丢失密码, 你将无法恢复你的密钥。 加密并复制我的密钥。 颁发给 %1$s 的徽章图片 "徽章图片 @@ -365,7 +358,7 @@ 轻触以允许一种类型。长按一种类型来设置每个事件的大小或每日帖子的限制。 自定义类型 添加类型 - %1$d 类型的限制 + %1$d 类型的限制 最大事件大小(字节) 每位作者每天最多发帖数 留空为无限制 @@ -976,9 +969,7 @@ 关闭推送通知 使用应用 %1$s 推送通知设置 - 若要接收推送通知,请安装任何支持 [Unified Push](https://unifiedpus)的应用, 例如 [Ntfy](https://ntfy.sh/)。 - 安装后,请在设置中选择你想要使用的应用程序。 - + 若要接收推送通知,请安装任何支持 [Unified Push](https://unifiedpus)的应用, 例如 [Ntfy](https://ntfy.sh/)。 安装后,请在设置中选择你想要使用的应用程序。 主题帖 向卖方发送消息 你好,这个还能买到吗? @@ -2037,15 +2028,7 @@ 无法从 %1$s 获取发票:%2$s 无法打开压缩文件 未找到钱包 - Amethyst 找不到任何支持 Nostr Wallet Connect (NWC) 的钱包应用. -\n\n你可以做的事情: -\n\t1. 验证 NWC 支持:仔细确认偏好的钱包应用和 Nostr Wallet Connect 兼容。 -\n\t2. 创建连接:前往钱包应用并创建新的 NWC 连接。 -\n\t3.链接到 Amethyst: 你的钱包通常会给你三个选项: -\n\t\t3.1 自动链接:自动打开并连接到 Amethyst 的按钮。如果是这样, 单击那个按钮,打开 Amethyst 并在新屏上按保存。 -\n\t\t3.2 手动链接: 你可以复制的连接 URI,单击这个屏幕上的粘贴图标直接输入它。 -\n\t\t3.3 二维码: 钱包会创建一个你可以从这个屏幕中的二维码按钮那里读取的二维码。 - + Amethyst 找不到任何支持 Nostr Wallet Connect (NWC) 的钱包应用. \n\n你可以做的事情: \n\t1. 验证 NWC 支持:仔细确认偏好的钱包应用和 Nostr Wallet Connect 兼容。 \n\t2. 创建连接:前往钱包应用并创建新的 NWC 连接。 \n\t3.链接到 Amethyst: 你的钱包通常会给你三个选项: \n\t\t3.1 自动链接:自动打开并连接到 Amethyst 的按钮。如果是这样, 单击那个按钮,打开 Amethyst 并在新屏上按保存。 \n\t\t3.2 手动链接: 你可以复制的连接 URI,单击这个屏幕上的粘贴图标直接输入它。 \n\t\t3.3 二维码: 钱包会创建一个你可以从这个屏幕中的二维码按钮那里读取的二维码。 创建 天 危险区 @@ -2700,11 +2683,8 @@ 代理中继 聚合器中继是应用获取资讯源时使用的中继(例如:filter.nostr.wine),设置这个列表将覆盖信箱模型的行为,使得应用只会连接到这个列表中的中继以获取内容。 公共聊天 - 公开聊天对所有 Nostr 用户都是可见的,任何人都可以: - 参与这些聊天。这对特定话题建立的开放式社区来说很重要 - 通过删除中继上的帖子实现内容审核控制 - 使用 1~3 个中继托管这个群组。 - 让 Nostr 客户端知道应该使用这些中继配置来发送和下载消息。 + 公开聊天对所有 Nostr 用户都是可见的,任何人都可以: 参与这些聊天。这对特定话题建立的开放式社区来说很重要 通过删除中继上的帖子实现内容审核控制 + 使用 1~3 个中继托管这个群组。 让 Nostr 客户端知道应该使用这些中继配置来发送和下载消息。 中继器 公开聊天元数据 公共聊天 @@ -2729,10 +2709,7 @@ 仅读用户 点按以录制消息 中继聊天 - 中继聊天是由聊天所属中继控制的聊天群组。 - Nostr 上的每个人都可以看到这些群组,任何人都可以加入群组。 - 这非常适合围绕特定主题而建立的开放社区。其中一些群组是临时的, - 因此聊天信息会随着时间的推移而消失。 + 中继聊天是由聊天所属中继控制的聊天群组。 Nostr 上的每个人都可以看到这些群组,任何人都可以加入群组。 这非常适合围绕特定主题而建立的开放社区。其中一些群组是临时的, 因此聊天信息会随着时间的推移而消失。 中继聊天 事件 IP地址 @@ -2967,4 +2944,15 @@ %1$s 的接收者闪电服务不可用。它是根据雷电地址"%2$s"计算的。 错误: %3$s。请检查服务器是否已经上线,闪电地址是否正确 无法从 %1$s 创建闪电发票:在生成的 JSON 中找不到付款要求元素。 无法创建闪电发票。来自 %1$s 的消息:%2$s + + + + diff --git a/commonsUI/src/commonMain/composeResources/values-zh-rTW/strings.xml b/commonsUI/src/commonMain/composeResources/values-zh-rTW/strings.xml index 4c9e594f1e..65c10efd5d 100644 --- a/commonsUI/src/commonMain/composeResources/values-zh-rTW/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-zh-rTW/strings.xml @@ -223,7 +223,7 @@ 複製用户ID 隱藏該用户 此使用者已被封鎖/靜音。其貼文已隱藏。 - npub,hex,用户名 + npub,hex,用户名 清除 應用標誌 nsec/ npub/ hex 私鑰 @@ -271,15 +271,8 @@ 將所有已知內容標記為已讀 將所有新內容標記為已讀 將所有內容標記為已讀 - ## 備份與安全提示 - \n\n你的帳戶由一個私鑰保護。私鑰是以 **nsec1** 開頭的隨機字符串。任何擁有你的私鑰的人都可以使用你的身份發佈內容。 - \n\n- **不要**將你的私鑰添加到任何你不信任的網站或軟件,亦不要在網上公開。 - \n- Amethyst 開發人員**永遠不會**要求你提供私鑰。 - \n- **請**保留你的私鑰的安全備份,以備帳戶恢復。我們建議使用密碼管理器。 - - 爲了額外的安全性,你可以用密碼加密你的密鑰。此密鑰以 **ncryptsec1** 開頭,沒有密碼就不能使用。 - \n\n如果你丟失密碼,你將無法恢復你的密鑰。 - + ## 備份與安全提示 \n\n你的帳戶由一個私鑰保護。私鑰是以 **nsec1** 開頭的隨機字符串。任何擁有你的私鑰的人都可以使用你的身份發佈內容。 \n\n- **不要**將你的私鑰添加到任何你不信任的網站或軟件,亦不要在網上公開。 \n- Amethyst 開發人員**永遠不會**要求你提供私鑰。 \n- **請**保留你的私鑰的安全備份,以備帳戶恢復。我們建議使用密碼管理器。 + 爲了額外的安全性,你可以用密碼加密你的密鑰。此密鑰以 **ncryptsec1** 開頭,沒有密碼就不能使用。 \n\n如果你丟失密碼,你將無法恢復你的密鑰。 加密並複製我的密鑰 頒發給 %1$s 的徽章圖片 "徽章獎勵圖片 @@ -968,9 +961,7 @@ 關閉推送通知 使用應用 %1$s 推送通知設置 - 若要接收推送通知,請安裝任何支持 [Unified Push](https://unifiedpush.org/)的應用,例如 [Ntfy](https://ntfy.sh/)。 - 安裝後,請在設備中選擇你想要使用的應用程式。 - + 若要接收推送通知,請安裝任何支持 [Unified Push](https://unifiedpush.org/)的應用,例如 [Ntfy](https://ntfy.sh/)。 安裝後,請在設備中選擇你想要使用的應用程式。 主題帖 向賣方發送消息 你好,這個還能買到嗎? @@ -2022,7 +2013,7 @@ 無法從 %1$s 取得發票:%2$s 無法打開壓縮文件 找不到錢包 - Amethyst 找不到任何支援 Nostr Wallet Connect (NWC) 的錢包應用程式。\n\n您可以這樣做:\n\t1. 確認 NWC 支援:請再次確認您偏好的錢包應用程式是否與 Nostr Wallet Connect 相容。\n\t2. 建立連線:前往您的錢包應用程式並建立新的 NWC 連線。\n\t3. 連結至 Amethyst:您的錢包通常會提供三種選項:\n\t\t3.1 自動連結:點擊按鈕自動開啟並連接到 Amethyst。若有此選項,請點擊該按鈕,開啟 Amethyst 並在新畫面上點擊儲存。\n\t\t3.2 手動連結:複製連線 URI,然後點擊此畫面上的貼上圖示直接輸入。\n\t\t3.3 QR 碼:錢包會產生 QR 碼,您可透過此畫面上的 QR 碼按鈕掃描。\n + Amethyst 找不到任何支援 Nostr Wallet Connect (NWC) 的錢包應用程式。\n\n您可以這樣做:\n\t1. 確認 NWC 支援:請再次確認您偏好的錢包應用程式是否與 Nostr Wallet Connect 相容。\n\t2. 建立連線:前往您的錢包應用程式並建立新的 NWC 連線。\n\t3. 連結至 Amethyst:您的錢包通常會提供三種選項:\n\t\t3.1 自動連結:點擊按鈕自動開啟並連接到 Amethyst。若有此選項,請點擊該按鈕,開啟 Amethyst 並在新畫面上點擊儲存。\n\t\t3.2 手動連結:複製連線 URI,然後點擊此畫面上的貼上圖示直接輸入。\n\t\t3.3 QR 碼:錢包會產生 QR 碼,您可透過此畫面上的 QR 碼按鈕掃描。\n 創建 天 危險區域 @@ -2664,11 +2655,8 @@ 代理 Relay 應用程式必須用來下載您的動態的聚合器 relay,例如 filter.nostr.wine。這將取代發件匣模式,使應用程式僅連線至您清單中的 relay。 公共聊天 - 公開聊天室對所有 Nostr 使用者可見, - 任何人皆可參與。非常適合圍繞特定主題的開放社群。 - 可透過在其 relay 上刪除貼文來進行內容管理 - 請輸入 1–3 個託管此群組的 relay。 - Nostr 客戶端使用此設定來確認下載訊息及傳送訊息的位置。 + 公開聊天室對所有 Nostr 使用者可見, 任何人皆可參與。非常適合圍繞特定主題的開放社群。 可透過在其 relay 上刪除貼文來進行內容管理 + 請輸入 1–3 個託管此群組的 relay。 Nostr 客戶端使用此設定來確認下載訊息及傳送訊息的位置。 Relay 公開聊天室元資料 公開聊天室 @@ -2693,10 +2681,7 @@ 只讀用戶 按住以錄製訊息 Relay 聊天 - Relay 聊天室是由其所屬 relay 管控的聊天群組。 - 所有 Nostr 使用者均可看見,任何人皆可參與。 - 非常適合圍繞特定主題的開放社群。部分群組為臨時性質, - 因此聊天訊息會隨時間消失 + Relay 聊天室是由其所屬 relay 管控的聊天群組。 所有 Nostr 使用者均可看見,任何人皆可參與。 非常適合圍繞特定主題的開放社群。部分群組為臨時性質, 因此聊天訊息會隨時間消失 Relay 聊天室 事件 IP @@ -2930,4 +2915,15 @@ %1$s 的接收方閃電服務不可用。它是根據閃電地址“%2$s”計算的。錯誤:%3$s。請檢查伺服器是否已上線,閃電地址是否正確 無法從 %1$s 創建閃電發票:在生成的 JSON 中找不到付款要求元素。 無法創建閃電發票。來自 %1$s 的消息:%2$s + + + + diff --git a/commonsUI/src/commonMain/composeResources/values-zh/strings.xml b/commonsUI/src/commonMain/composeResources/values-zh/strings.xml index 643d37c01f..17be4c460b 100644 --- a/commonsUI/src/commonMain/composeResources/values-zh/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-zh/strings.xml @@ -337,7 +337,7 @@ 轻触以允许一种类型。长按一种类型来设置每个事件的大小或每日帖子的限制。 自定义类型 添加类型 - %1$d 类型的限制 + %1$d 类型的限制 最大事件大小(字节) 每位作者每天最多发帖数 留空为无限制 @@ -949,9 +949,7 @@ 关闭推送通知 使用应用 %1$s 推送通知设置 - 若要接收推送通知,请安装任何支持 [Unified Push](https://unifiedpus)的应用, 例如 [Ntfy](https://ntfy.sh/)。 - 安装后,请在设置中选择你想要使用的应用程序。 - + 若要接收推送通知,请安装任何支持 [Unified Push](https://unifiedpus)的应用, 例如 [Ntfy](https://ntfy.sh/)。 安装后,请在设置中选择你想要使用的应用程序。 主题帖 向卖方发送消息 你好,这个还能买到吗? @@ -1801,15 +1799,8 @@ Explorer API 基础URL 支付来自DVM的发票 支付 %1$s sats 到 DVM - ## 备份与安全提示 - \n\n你的帐户由一个私钥保护。私钥是以**nsec1**开头的随机字符串。任何拥有你的私钥的人都可以使用你的身份发布内容。 - \n\n- **不要**将你的私钥添加到任何你不信任的网站或软件,亦不要在网上公开。 - \n- Amethyst 开发人员**永远不会**要求你提供私钥。 - \n- **请**保留你的私钥的安全备份,以备帐户恢复。我们建议使用密码管理器。 - - 为了额外的安全性,你可以用密码加密你的密钥。 此密钥以 **ncryptsec1** 开头,没有密码就不能使用。 - \n\n如果你丢失密码, 你将无法恢复你的密钥。 - + ## 备份与安全提示 \n\n你的帐户由一个私钥保护。私钥是以**nsec1**开头的随机字符串。任何拥有你的私钥的人都可以使用你的身份发布内容。 \n\n- **不要**将你的私钥添加到任何你不信任的网站或软件,亦不要在网上公开。 \n- Amethyst 开发人员**永远不会**要求你提供私钥。 \n- **请**保留你的私钥的安全备份,以备帐户恢复。我们建议使用密码管理器。 + 为了额外的安全性,你可以用密码加密你的密钥。 此密钥以 **ncryptsec1** 开头,没有密码就不能使用。 \n\n如果你丢失密码, 你将无法恢复你的密钥。 使用直接链接 静态网站: %1$s nApple: %1$s @@ -2054,15 +2045,7 @@ 无法从 %1$s 获取发票:%2$s 无法打开压缩文件 未找到钱包 - Amethyst 找不到任何支持 Nostr Wallet Connect (NWC) 的钱包应用. -\n\n你可以做的事情: -\n\t1. 验证 NWC 支持:仔细确认偏好的钱包应用和 Nostr Wallet Connect 兼容。 -\n\t2. 创建连接:前往钱包应用并创建新的 NWC 连接。 -\n\t3.链接到 Amethyst: 你的钱包通常会给你三个选项: -\n\t\t3.1 自动链接:自动打开并连接到 Amethyst 的按钮。如果是这样, 单击那个按钮,打开 Amethyst 并在新屏上按保存。 -\n\t\t3.2 手动链接: 你可以复制的连接 URI,单击这个屏幕上的粘贴图标直接输入它。 -\n\t\t3.3 二维码: 钱包会创建一个你可以从这个屏幕中的二维码按钮那里读取的二维码。 - + Amethyst 找不到任何支持 Nostr Wallet Connect (NWC) 的钱包应用. \n\n你可以做的事情: \n\t1. 验证 NWC 支持:仔细确认偏好的钱包应用和 Nostr Wallet Connect 兼容。 \n\t2. 创建连接:前往钱包应用并创建新的 NWC 连接。 \n\t3.链接到 Amethyst: 你的钱包通常会给你三个选项: \n\t\t3.1 自动链接:自动打开并连接到 Amethyst 的按钮。如果是这样, 单击那个按钮,打开 Amethyst 并在新屏上按保存。 \n\t\t3.2 手动链接: 你可以复制的连接 URI,单击这个屏幕上的粘贴图标直接输入它。 \n\t\t3.3 二维码: 钱包会创建一个你可以从这个屏幕中的二维码按钮那里读取的二维码。 创建 天 危险区 @@ -2745,11 +2728,8 @@ 代理中继 聚合器中继是应用获取资讯源时使用的中继(例如:filter.nostr.wine),设置这个列表将覆盖信箱模型的行为,使得应用只会连接到这个列表中的中继以获取内容。 公共聊天 - 公开聊天对所有 Nostr 用户都是可见的,任何人都可以: - 参与这些聊天。这对特定话题建立的开放式社区来说很重要 - 通过删除中继上的帖子实现内容审核控制 - 使用 1~3 个中继托管这个群组。 - 让 Nostr 客户端知道应该使用这些中继配置来发送和下载消息。 + 公开聊天对所有 Nostr 用户都是可见的,任何人都可以: 参与这些聊天。这对特定话题建立的开放式社区来说很重要 通过删除中继上的帖子实现内容审核控制 + 使用 1~3 个中继托管这个群组。 让 Nostr 客户端知道应该使用这些中继配置来发送和下载消息。 中继器 公开聊天元数据 公共聊天 @@ -2776,10 +2756,7 @@ 仅读用户 点按以录制消息 中继聊天 - 中继聊天是由聊天所属中继控制的聊天群组。 - Nostr 上的每个人都可以看到这些群组,任何人都可以加入群组。 - 这非常适合围绕特定主题而建立的开放社区。其中一些群组是临时的, - 因此聊天信息会随着时间的推移而消失。 + 中继聊天是由聊天所属中继控制的聊天群组。 Nostr 上的每个人都可以看到这些群组,任何人都可以加入群组。 这非常适合围绕特定主题而建立的开放社区。其中一些群组是临时的, 因此聊天信息会随着时间的推移而消失。 中继聊天 事件 IP地址 diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index 49b19ed171..e397bbe75d 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -660,17 +660,8 @@ Mark all Known as read Mark all New as read Mark all as read - - ## Key Backup and Safety Tips - \n\nYour account is secured by a secret key. The key is a long sequence of characters starting with **nsec1**. Anyone who has access to this secret key can post and change your identity. - \n\n- Do **not** put your secret key in any website or software you do not trust. - \n- Amethyst developers will **never** ask for your secret key. - \n- **Do** keep a secure backup of your secret key for account recovery. We recommend using a password manager. - - - For additional security, you can encrypt your key with a password. This key starts with **ncryptsec1** and cannot be used without your password. - \n\nIf you lose your password, you will not be able to recover your key. - + ## Key Backup and Safety Tips \n\nYour account is secured by a secret key. The key is a long sequence of characters starting with **nsec1**. Anyone who has access to this secret key can post and change your identity. \n\n- Do **not** put your secret key in any website or software you do not trust. \n- Amethyst developers will **never** ask for your secret key. \n- **Do** keep a secure backup of your secret key for account recovery. We recommend using a password manager. + For additional security, you can encrypt your key with a password. This key starts with **ncryptsec1** and cannot be used without your password. \n\nIf you lose your password, you will not be able to recover your key. Back up your keys Your secret key is the only way to access this account. If you lose it, it can never be recovered. Save it somewhere safe now. Back up now @@ -1853,10 +1844,7 @@ Disables Push Notifications Uses app %1$s Push Notification Setup - - To receive push notifications, install any app that supports [Unified Push](https://unifiedpush.org/), such as [Ntfy](https://ntfy.sh/). - After installing, select the app you want to use in the Settings. - + To receive push notifications, install any app that supports [Unified Push](https://unifiedpush.org/), such as [Ntfy](https://ntfy.sh/). After installing, select the app you want to use in the Settings. Thread Send the seller a message Hi there, is this still available? @@ -2911,6 +2899,7 @@ You don't pass this community's web-of-trust requirements. The latest community rules document was rejected as stale. Marmot Groups + Cordn Groups Create Group Create Marmot Group A new MLS group will be created. You can add members after. @@ -3628,15 +3617,7 @@ Could not fetch invoice from %1$s: %2$s Could not open the compressed file Wallet not found - Amethyst couldn't find any wallet apps that support Nostr Wallet Connect (NWC). -\n\nWhat you can do: -\n\t1. Verify NWC Support: Double-check that your preferred wallet app is compatible with Nostr Wallet Connect. -\n\t2. Create Connection: Go to your wallet app and create a new NWC connection. -\n\t3. Link to Amethyst: Your wallet will usually give you three options: -\n\t\t3.1 Automatic Link: A button to automatically open and connect to Amethyst. If so, click that button, open Amethyst and hit save on the new screen. -\n\t\t3.2 Manual Link: A Connection URI you can copy and click the paste icon on this screen to directly input it. -\n\t\t3.3 QR Code: The wallet creates a QR code which you can read from the QR code button in this screen. - + Amethyst couldn't find any wallet apps that support Nostr Wallet Connect (NWC). \n\nWhat you can do: \n\t1. Verify NWC Support: Double-check that your preferred wallet app is compatible with Nostr Wallet Connect. \n\t2. Create Connection: Go to your wallet app and create a new NWC connection. \n\t3. Link to Amethyst: Your wallet will usually give you three options: \n\t\t3.1 Automatic Link: A button to automatically open and connect to Amethyst. If so, click that button, open Amethyst and hit save on the new screen. \n\t\t3.2 Manual Link: A Connection URI you can copy and click the paste icon on this screen to directly input it. \n\t\t3.3 QR Code: The wallet creates a QR code which you can read from the QR code button in this screen. Create d Danger Zone @@ -4772,11 +4753,8 @@ Proxy Relays Aggregator relays the app must use to download your feeds from, like filter.nostr.wine. This replaces the outbox model and will make the app connect to only the relays in your lists. Public Chat - Public chats are visible to everyone on Nostr and anyone - can participate on them. They are great for open communities around specific topics. - Moderation can be controlled by deleting posts on its relays - Insert between 1–3 relays that host this group. - Nostr clients use this setting to know where to download messages from and send your messages to. + Public chats are visible to everyone on Nostr and anyone can participate on them. They are great for open communities around specific topics. Moderation can be controlled by deleting posts on its relays + Insert between 1–3 relays that host this group. Nostr clients use this setting to know where to download messages from and send your messages to. Relays Public Chat Metadata Public Chats @@ -4843,10 +4821,7 @@ auth authentication login relay sign verify nip-42 identity Relay login Relay Chat - Relay chats are chat groups controlled by their home relay. - They are visible to everyone on Nostr and anyone can participate on them. - They are great for open communities around specific topics. Some of these groups are ephemeral - and thus chat messages disappear over time + Relay chats are chat groups controlled by their home relay. They are visible to everyone on Nostr and anyone can participate on them. They are great for open communities around specific topics. Some of these groups are ephemeral and thus chat messages disappear over time Relay Chats Your own profile, settings and drafts, on your home relays. The relays each community publishes its planes to. @@ -5320,6 +5295,136 @@ %1$d new message %1$d new messages + + + Coordinator group + Delivered by a coordinator, not by relays + + What this coordinator can see + A coordinator is one server that carries every message in this group, in order. That is a different trade from relays — worth knowing before you treat this like any other chat. + Message contents + Who is in the group + Who sends what + Unreadable + Under a throwaway key + Tied to your real account + Details + Coordinator %1$s + This is not the same exposure as a Marmot group, where no single operator holds the whole conversation. + + Joining names real accounts at both ends, so the coordinator knows who is in this group. + One throwaway key sends and fetches for every group you have here, so the coordinator can tell they are all you. + One operator holds the complete, ordered history of every group it carries. + The key package you published is a signed record that this account uses cordn, and the coordinator can hand it to anyone. + Messages are not padded, so the coordinator sees how long each one is. + This client is not encrypting to the coordinator. That is a bug — please report it. + + Not contacted yet + Responding + Not responding + %1$d failed attempts in a row + + Paste a cordn1… link someone shared with you to see which coordinator carries that group, and what that operator would learn about you if you joined. + cordn1… + Inspect + Paste + Clear + That is not a cordn group link: %1$s + Group id + Coordinator + Reachable through + This link names no coordinator, so it cannot be followed on its own. Ask whoever sent it which coordinator carries the group. + Inspecting a link does not join anything, and holding one does not make you a member. + + + Cordn group link + cordn coordinator group link invite mls chat metadata privacy exposure + Cordn coordinators + cordn coordinator add remove purge health server mls chat ordering + Cordn key packages + cordn key package publish invite last resort coordinator mls + Cordn backup + cordn backup restore export import passphrase groups recovery + Cordn groups + End-to-end encrypted groups ordered by a coordinator you choose, instead of by relays. + + + Group %1$s + No messages yet + Cordn group, carried by %1$s + Message deleted + Voice note + Photo + Video + File + Cordn group + Encrypted group chat ordered by a coordinator you pick. + Coordinated + Best for teams that want one reliable ordering of the conversation. + Create Cordn group + End-to-end encrypted with MLS — the coordinator can never read a message. + One agreed order for everyone, so history cannot be reshuffled by a bad clock. + The coordinator learns who is in the group and when you talk, even though it cannot read what you say. + Invited to a cordn group? + Review invitations + + + Cordn + cordn coordinator group mls chat migrate backup key package link move device + Groups delivered by a coordinator you choose, instead of by relays. + Inspect a group link + See what a cordn1… link points at before you act on it + Move off this phone + Take over from another phone + Published key packages + The link + Coordinators you use + Announcing themselves + Add one by hand + Passphrase + Make a backup + Restore from a backup + The coordinator + This device + Coordinators + The servers that carry your groups + Key packages + What lets people add you to a group + Backup and restore + A passphrase-encrypted file you keep + Move to a new phone + Hand your groups to a device you are switching to + + Move to a new phone + cordn migrate move new phone device transfer handoff switch qr + Your cordn groups live on this device. Moving them takes one code: this phone publishes them, the new phone reads them, and this phone stops. + Sign in with the same account on the new phone first. This moves your groups, never your key. + Start the handoff + Publishing… + Scan this on the new phone + The code is only a pointer. Anyone who photographs it learns nothing — what it points at is encrypted to you. + This device has handed its groups over + It has stopped sending and receiving, because two phones sending from one group would break it for everyone in it. Your groups are still on this device and nothing was deleted. + Take this device back + Use this if the move did not finish. Only do it if the new phone has not started sending. + Receive from another phone + Scan the code + or paste it + Bring the groups here + Fetching… + This replaces any cordn groups already on this phone. They cannot be merged. + %1$d groups moved + There are no cordn groups on this device to move. + Set a media server first — the encrypted documents need somewhere to sit while the other phone fetches them. + What leaves this device + Your group state is encrypted and uploaded to your media server so the other phone can fetch it. It is unreadable to that server, which sees only a size and a time. Delete the blobs afterwards if you want nothing left behind. 3D object %1$d vertices · %2$d faces This 3D object could not be read (rule %1$s) @@ -5457,4 +5562,574 @@ Logins and preferences this site saved for this account Clear site data Sign out of this site and delete its data? + Block %1$s again + Keep %1$s + Rejoin %1$s + Re-mute %1$s + Restore %1$s + Restore feeds + Restore places + Restore profile + Restore providers + Restore relays + Restore topics + Restore wallet + Accept + + %1$s and added %2$d + %1$s and added %2$d + + + %1$s and blocked %2$d + %1$s and blocked %2$d + + + %1$s and followed %2$d + %1$s and followed %2$d + + + %1$s and joined %2$d + %1$s and joined %2$d + + + %1$s and muted %2$d + %1$s and muted %2$d + + Your Amethyst synced app settings changed in another app that is not Amethyst. This can create problems. + You emptied your Concord list + You changed your Concord list + + You removed %1$d DM relay + You removed %1$d DM relays + + + You removed %1$d favorite feed + You removed %1$d favorite feeds + + + You unfollowed %1$d person + You unfollowed %1$d people + + + You unfollowed %1$d hashtag + You unfollowed %1$d hashtags + + + You removed %1$d indexer relay + You removed %1$d indexer relays + + + You removed %1$d group-chat key relay + You removed %1$d group-chat key relays + + + You left %1$d public chat + You left %1$d public chats + + + You left %1$d community + You left %1$d communities + + + You left %1$d group + You left %1$d groups + + + You left %1$d chat room + You left %1$d chat rooms + + + You unmuted %1$d thing + You unmuted %1$d things + + + You removed %1$d public relay + You removed %1$d public relays + + You removed your nutzap key + You replaced your nutzap key + + You removed %1$d nutzap mint + You removed %1$d nutzap mints + + + You removed %1$d BOLT12 offer + You removed %1$d BOLT12 offers + + + You removed %1$d way to get paid + You removed %1$d ways to get paid + + + You unfollowed %1$d place + You unfollowed %1$d places + + + You removed %1$d private relay + You removed %1$d private relays + + + You removed %1$d profile field + You removed %1$d profile fields + + + You removed %1$d relay feed + You removed %1$d relay feeds + + + You removed %1$d search relay + You removed %1$d search relays + + + You changed the provider of %1$d trust service + You changed the provider of %1$d trust services + + + You stopped trusting %1$d relay + You stopped trusting %1$d relays + + + You unblocked %1$d relay + You unblocked %1$d relays + + You emptied your Cashu wallet + You changed your Cashu wallet + Added (%1$s) + Changed (%1$s) + %1$s added + %1$s changed + %1$s removed + Use new version + You added private (encrypted) items. + You also changed private (encrypted) items. They can't be compared here. + You removed all private (encrypted) items. + read only + read and write + write only + Removed (%1$s) + Restore saved version + You changed your %1$s in another app + Feeds + Chat rooms + Communities + Hashtags + Linked identities + Locations + Mints + Nutzap keys + Offers + Other entries + Payment addresses + People + Profile fields + Public chats + Relays + Groups + Threads + Trust providers + Words + About + Banner + Birthday + Bot flag + CLINK offer + Display name + LNURL + Lightning address + Username + Nostr address + Picture + Pronouns + Website + Allowed + Changed in another app · %1$s + Your Concord communities are encrypted to you, so they can't be compared here. Restoring puts back exactly what this device saved. + Moved + %1$s dropped + Dropped + You created it + You changed it + You emptied it + Feed + Gone + Hashtags you follow + Inbox + where others reach you + joined + kept + %1$s kept + Receiving key removed + Receiving key replaced + Nutzaps will be locked to this new key. If this device doesn't have it, you may not be able to redeem them here. + left + Left + Listed + Moved + %1$s and %2$s more + New version + %1$s new + new + New + Newly blocked + %1$s newly muted + No longer blocked + Not set + Nothing here + BOLT12 offer + + %1$d offer removed + %1$d offers removed + + Outbox + where you publish + + %1$d way to pay you removed + %1$d ways to pay you removed + + People paying you only see what's in this list. + places gone + Places you follow + They're encrypted now, which is likely where the public items above went. + You made items private + They're encrypted, so they can't be compared here. + You changed private items + They're encrypted, so only you can see what they were. Restoring brings them all back. + You removed all private items + Receiving + renamed + Renamed + On this device + Search %1$s people + Searching + Service + Followers + Rank + Topics + %1$s of %2$s services changed + They now come from someone else, or no one + Still blocked + Storing + Dropped · %1$s + Edited · %1$s + New · %1$s + %1$s cleared + %1$s updated + topics gone + Ranks, follower counts and spam filtering you see come from these providers. + Trusted + %1$s unblocked + Amethyst will connect to them again + Units + %1$s unmuted + These can reach your feeds again + Untrusted + Your wallet's mints and keys are encrypted to you, so they can't be compared item by item here. Restoring puts back exactly what this device saved. + was “%1$s” + Words & hashtags + synced app settings + Amethyst settings synced across your devices. They are encrypted, so only their presence can be compared. + blocked relay list + Relays this app never connects to. + BOLT12 offers + The lightning offers others use to pay you. + Cashu wallet + Your ecash wallet settings and mints. They are encrypted, so only their presence can be compared. + community list + The communities you joined. + Concord community list + The Concord communities you joined. They are encrypted, so only their presence can be compared. + DM relay list + Where others deliver your private messages. + ephemeral chat list + The ephemeral chat rooms you joined. + favorite feeds list + The algorithmic feeds you saved. + follow list + Decides whose posts appear in your home feed. + followed locations + Locations whose posts appear in your feeds. + followed hashtags + Hashtags whose posts appear in your feeds. + indexer relay list + The relays used to find other people's profiles and relay lists. + group-chat key relays + Where others find the keys to add you to encrypted group chats. + mute list + Hides people, words, hashtags and threads from everything you see. + nutzap receiving settings + The mints and key others use to send you nutzaps. + list + A list saved on this device. + public relay list + Tells everyone where to find your posts and where to send you replies and mentions. + payment addresses + The ways other people can pay you. + private relay list + Where your drafts and private data are stored. + profile + Your name, picture, bio and payment addresses, as everyone sees them. + public chat list + The public chats you joined. + favorite relay feeds + The relays you browse as feeds. + group list + The relay-based groups you joined. + search relay list + The relays used when you search. + trust provider list + The services you trust to rank and verify people for you. + trusted relay list + Relays you trust to connect to without asking. + Delete + Edit + Editing your message + Go to message + Pin + Reply + Unpin + That change could not be made. + The coordinator did not answer, so nothing was changed. Try again in a moment. + %1$s has not published a key package to this coordinator, so they cannot be added yet. Ask them to open cordn on this coordinator first. + You cannot remove yourself from a cordn group. + %1$s could not be given a way into the group, so the add was abandoned. + %1$s is not in this group. + That person + This coordinator served a key package that belongs to someone else, so %1$s was not added. Nothing was changed. + Your group encryption keys, your key packages, and every message in those groups. Anyone who opens it can read the conversations. The passphrase is the only thing protecting it once it leaves Amethyst. + What is in the file + Your nsec cannot bring cordn groups back. A cordn group lives as encryption state on this device and an ordered stream on a coordinator that cannot read it — lose the device without a backup and the group is gone, including everything already said in it. + Export + Backup saved. + That did not work. Check the passphrase and the file. + Passphrase + Restore + Restoring replaces the cordn groups on this device with the ones in the file. It is for a phone that has taken over from another — not a way to use the same groups on two devices at once. + Every cordn group now on this device will be removed and replaced by the ones in the file.\n\nDo not restore onto a second phone while the first is still in use. Both would hold the same group keys, and once both have sent a message the group breaks for everyone — silently, and with no way back. + Replace this device's cordn groups? + Restore + Restored. + Anything sent here is encrypted for this group. The coordinator relays it without being able to read it. + No messages yet + New messages + Yesterday + Message + Add + Could not open that coordinator. + Copy the coordinator's npub + Look for coordinators + Add + Reads announcements your relays already carry. No coordinator is contacted, so none of them learns you looked. + Could not read announcements + Nobody is announcing on your relays. + Announced %1$s ago + Last announced %1$s + Nothing found, and %1$d of your relays did not answer. + A coordinator orders one group's messages and is the only thing that can. Losing it loses the ordering, and a second one does not mirror the first — so these are named things you choose, not interchangeable relays. + Not answering — %1$d calls in a row have failed. Groups on it are not syncing. + Answering. + A call failed; retrying. + Nothing asked of it yet. + Hide the ones that stopped announcing + Ask who it is + Coordinator key + A name for it (yours, optional) + This name is yours and stays on this device. A coordinator cannot prove a name, so it is never asked for one. + More actions + No coordinators yet. + Purge + Removing keeps the groups on this device, so adding the coordinator back brings them with it. Purging deletes them: the group keys, the read positions and your key packages all go, and those groups can only be re-entered by a fresh invitation.\n\nThe coordinator is not told and keeps whatever it already had. + Purge this coordinator? + %1$s + Answers on + %1$s +%2$d more + Remove + Rename + Says it is: %1$s + A coordinator's name and version are strings it chose. Its public key is the only thing that identifies it. + Answered, but named nothing. + Show all %1$d + Show fewer + Show %1$d that stopped announcing + These have not announced in over a month. Creating a group on one that has gone away will fail. + Create group + + Create and invite %1$d person + Create and invite %1$d people + + Add someone + Admin + + %1$d person can add and remove + %1$d people can add and remove + + Only I manage this group + Only you can invite, remove members or change the group's details. You can hand that to someone else later. + Coordinator + Change + Find coordinators + Done + Another coordinator + Coordinator public key (hex or npub) + Relays it answers on, one per line + Reaches all %1$d of your people + Reaches %1$d of %2$d — the rest would be left out + Not checked — picking it will ask + Description (optional) + Everyone can add and remove + Everyone in the group can invite, remove members and change its details. Any member can name admins later, which ends that. + A cordn group is ordered by one coordinator you choose. Creating the group happens on this device — the coordinator only learns the group exists once you invite someone or send a message. + The group could not be created. + Group name + No coordinator chosen + Each invitation is a Welcome left on the coordinator, so they can fail one at a time. Nobody has been notified: they see it next time they open their app. + The coordinator refused the invitation + Send a link + Added to the group, but the invitation did not reach them — try again from the group screen + Open the chat + No key here, so no Welcome could be left + The group exists + Waiting for them + Done + Nobody added yet. A group can be created empty and invitations sent later, but naming people now is what shows which coordinator can reach them. + Nobody yet — tap to add people + Naming people now is what tells us which coordinator can reach them. Each one gets a Welcome left on that coordinator, which they pick up next time they open their app. + + Reachable on %1$d coordinator + Reachable on %1$d coordinators + + No key on any coordinator you use + Not checked yet + Who can add and remove + Name it + Who is in it + Where it lives + New Cordn group + + %1$d person cannot be reached by any coordinator you use + %1$d people cannot be reached by any coordinator you use + + You, always an admin + Everyone in the group will see this message as withdrawn, and Amethyst will stop showing its text. The coordinator still holds the encrypted copy it was sent as, and anyone who already read it has already read it. + Withdraw message + Accepted by the coordinator + Got it + What this coordinator can see + Group info + This group is not available. Its coordinator may have been removed. + Add a coordinator in Settings to start using cordn groups. + + %1$d group + %1$d groups + + Nothing is arriving from these groups. Amethyst keeps retrying. + Coordinators + No cordn groups yet + A cordn group lives on one coordinator that holds its membership and passes messages along, without ever seeing what anyone writes. + Start one + Add someone + Nobody found by that name. + They can only be added if they published a key package to this coordinator. + Name, npub or name@domain + Admin + + The group's %1$d admin stays as it is. + The group's %1$d admins stay as they are. + + Coordinator + Coordinator key + Coordinator link + Copy the coordinator's nprofile + Edit details + This group has no admins: everyone can add, remove and rename, permanently. + Epoch + Group id + Can be added on this coordinator + Members + %1$s stops receiving messages from here. They keep what they have already read, and this cannot be undone — they would have to be invited again. + Remove from group? + Remove + Save + Technical details + Join + Decline + Declining is permanent. Getting back in means being invited again. + Checked when you open this screen, and at no other time — asking a coordinator whether anyone invited you tells it you are here, so Amethyst does not do it in the background. + Could not read invitations. + + %1$d person is already in this group + %1$d people are already in this group + + +%1$d more + You have no coordinator open. cordn invitations arrive through a coordinator, so there is nowhere to look yet. + No invitations waiting. + Check again + An invitation was left for another device + Cordn invitations + %1$s did not answer + Through %1$s + Publishing signs a record under your own account key. That coordinator then knows this account exists and is invitable — permanently, and whether or not anyone invites you. + Others can only add you to a cordn group by taking a key package you published to that coordinator. cordn has no other place to keep one, so with none published nobody can invite you — and nothing tells them why. + Could not read the key packages. + No last-resort package. Once the single-use ones run out, invitations fail. + A last-resort package is published, so an invitation can still be made once the single-use ones run out. + + %1$d package this device cannot open + %1$d packages this device cannot open + + The coordinator will hand these to anyone inviting you, but the private half is not on this device — it belongs to another install. If that install is gone, an invitation using one of these produces a welcome nobody can ever open. + Publish one + Publish last-resort + + %1$d single-use package available. + %1$d single-use packages available. + + Once you have published here, Amethyst quietly replaces packages as they are used up. It never publishes the first one for you. + Withdraw all + Withdraw those + Ask to join this group + This publishes a key package under your own account key and tells the coordinator you want into this group — whether or not anyone answers. + Could not ask to join. + Asked. A member of the group has to add you; the invitation will show up under cordn invitations. + Scan + Could not open that file. + No media server is set for this account, so there is nowhere to put the file. Pick one in Settings. + That file could not be read. + Could not send that file. + + %1$d member + %1$d members + + Message deleted + Coordinator + Cursor + Sent + Message details + edited + Pinned by %1$s + + %1$d pinned message in this group + %1$d pinned messages in this group + + Next pinned message + Previous pinned message + Show all pinned messages + Pinned messages + Reactions + Add + Only an admin can accept. In a group with no admins, that is everyone. + Check for requests + Dismiss + Could not read the requests. + Nobody is waiting to join. + Requests to join + Send + That did not send. + This group's coordinator is not open, so nothing can be sent yet. + Copied + Copy link + Anyone with this link can ask to join. An admin still has to let them in. + Share this group + Play voice note + Record a voice note + Stop and send diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chats/ui/ChatDivisor.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chats/ui/ChatDivisor.kt index d6061a875f..a9988fb4cd 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chats/ui/ChatDivisor.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chats/ui/ChatDivisor.kt @@ -21,11 +21,14 @@ package com.vitorpamplona.amethyst.commons.chats.ui import androidx.compose.foundation.layout.Row +import androidx.compose.material3.DividerDefaults import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.isSpecified import androidx.compose.ui.text.font.FontWeight import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness import com.vitorpamplona.amethyst.commons.ui.theme.Font14SP @@ -33,21 +36,32 @@ import com.vitorpamplona.amethyst.commons.ui.theme.HalfPadding import com.vitorpamplona.amethyst.commons.ui.theme.StdPadding @Composable -fun ChatDivisor(info: String) { +fun ChatDivisor( + info: String, + /** + * Tints both rules and the label. Unspecified keeps the default, which is every + * date divisor; an unread marker passes the accent so the line it draws reads as a + * status rather than another date. + */ + color: Color = Color.Unspecified, +) { Row(verticalAlignment = Alignment.CenterVertically, modifier = StdPadding) { HorizontalDivider( modifier = Modifier.weight(1f), thickness = DividerThickness, + color = if (color.isSpecified) color else DividerDefaults.color, ) Text( text = info, fontWeight = FontWeight.Bold, fontSize = Font14SP, + color = color, modifier = HalfPadding, ) HorizontalDivider( modifier = Modifier.weight(1f), thickness = DividerThickness, + color = if (color.isSpecified) color else DividerDefaults.color, ) } } diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chats/ui/NewConversationScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chats/ui/NewConversationScreen.kt index 013d758a4a..96af13638e 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chats/ui/NewConversationScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chats/ui/NewConversationScreen.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.commons.chats.ui import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.clickable import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -63,6 +64,8 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.navigation.Route import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.cordn_invitations_entry +import com.vitorpamplona.amethyst.commons.resources.cordn_invitations_entry_action import com.vitorpamplona.amethyst.commons.resources.new_conversation_best_for import com.vitorpamplona.amethyst.commons.resources.new_conversation_concord_best import com.vitorpamplona.amethyst.commons.resources.new_conversation_concord_chip @@ -73,6 +76,14 @@ import com.vitorpamplona.amethyst.commons.resources.new_conversation_concord_pro import com.vitorpamplona.amethyst.commons.resources.new_conversation_concord_tagline import com.vitorpamplona.amethyst.commons.resources.new_conversation_concord_title import com.vitorpamplona.amethyst.commons.resources.new_conversation_cons +import com.vitorpamplona.amethyst.commons.resources.new_conversation_cordn_best +import com.vitorpamplona.amethyst.commons.resources.new_conversation_cordn_chip +import com.vitorpamplona.amethyst.commons.resources.new_conversation_cordn_con_1 +import com.vitorpamplona.amethyst.commons.resources.new_conversation_cordn_cta +import com.vitorpamplona.amethyst.commons.resources.new_conversation_cordn_pro_1 +import com.vitorpamplona.amethyst.commons.resources.new_conversation_cordn_pro_2 +import com.vitorpamplona.amethyst.commons.resources.new_conversation_cordn_tagline +import com.vitorpamplona.amethyst.commons.resources.new_conversation_cordn_title import com.vitorpamplona.amethyst.commons.resources.new_conversation_dm_best import com.vitorpamplona.amethyst.commons.resources.new_conversation_dm_chip import com.vitorpamplona.amethyst.commons.resources.new_conversation_dm_con_1 @@ -143,6 +154,7 @@ import org.jetbrains.compose.resources.StringResource private val ColorPrivate = Color(0xFF7C3AED) private val ColorMarmot = Color(0xFF4F46E5) private val ColorConcord = Color(0xFF0F766E) +private val ColorCordn = Color(0xFF14B8A6) private val ColorPublic = Color(0xFFB45309) private val ColorRelay = Color(0xFF2563EB) private val ColorEphemeral = Color(0xFFC2410C) @@ -224,6 +236,24 @@ private val conversationSections = cons = listOf(Res.string.new_conversation_concord_con_1), route = Route.ConcordCreate, ), + ConversationType( + icon = MaterialSymbols.Dns, + color = ColorCordn, + title = Res.string.new_conversation_cordn_title, + tagline = Res.string.new_conversation_cordn_tagline, + chip = Res.string.new_conversation_cordn_chip, + bestFor = Res.string.new_conversation_cordn_best, + cta = Res.string.new_conversation_cordn_cta, + pros = listOf(Res.string.new_conversation_cordn_pro_1, Res.string.new_conversation_cordn_pro_2), + // The §8 exposure, stated where the choice is made + // rather than discovered later: a coordinator learns + // who is in which group and when they talk, even though + // it can never read a word. The whole point of the + // exposure work was to say this before someone commits + // to it, and this is the first place it can be said. + cons = listOf(Res.string.new_conversation_cordn_con_1), + route = Route.CordnCreateGroup, + ), ), ), ConversationSection( @@ -325,6 +355,46 @@ fun NewConversationScreen(nav: INav) { } } } + + // Being invited is the other half of "start a conversation", so it + // belongs on the screen people reach for when they want one -- + // not buried in settings. The count is deliberately absent: it + // would take a call to every coordinator, and every call to a + // coordinator is metadata (spec/00.md §8). + item(key = "cordn-invitations") { + CordnInvitationsEntry(onClick = { nav.nav(Route.CordnInvitations) }) + } + } + } +} + +@Composable +private fun CordnInvitationsEntry(onClick: () -> Unit) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 6.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Icon( + symbol = MaterialSymbols.Dns, + contentDescription = null, + tint = ColorCordn, + modifier = Modifier.size(20.dp), + ) + Column(Modifier.weight(1f)) { + Text( + text = stringRes(Res.string.cordn_invitations_entry), + style = MaterialTheme.typography.bodyMedium, + ) + Text( + text = stringRes(Res.string.cordn_invitations_entry_action), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.grayText, + ) } } } diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/ui/CoordinatorHealthRow.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/ui/CoordinatorHealthRow.kt new file mode 100644 index 0000000000..a6770124d3 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/ui/CoordinatorHealthRow.kt @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.cordn.CoordinatorHealth +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.cordn_health_down +import com.vitorpamplona.amethyst.commons.resources.cordn_health_failures +import com.vitorpamplona.amethyst.commons.resources.cordn_health_ok +import com.vitorpamplona.amethyst.commons.resources.cordn_health_unknown +import org.jetbrains.compose.resources.stringResource + +/** + * Whether a coordinator is answering. + * + * Losing a coordinator is not like losing a relay: relays are redundant and the + * next one has the same events, while a coordinator is the single authority for + * the groups it carries. So "not responding" means those conversations have + * stopped, not that they are slower — which is why it gets a row of its own + * rather than a dot. + * + * Three states, not two. [CoordinatorHealth.State.isUnknown] (nothing tried + * yet) reads as neutral, because showing a fresh session a red marker for a + * coordinator that is probably fine trains people to ignore the marker that + * matters. + */ +@Composable +fun CoordinatorHealthRow( + state: CoordinatorHealth.State, + modifier: Modifier = Modifier, +) { + val tint = + when { + state.isUnknown -> MaterialTheme.colorScheme.onSurfaceVariant + state.isDown -> MaterialTheme.colorScheme.error + else -> MaterialTheme.colorScheme.primary + } + + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + symbol = + when { + state.isUnknown -> MaterialSymbols.HourglassEmpty + state.isDown -> MaterialSymbols.SyncProblem + else -> MaterialSymbols.CheckCircle + }, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = tint, + ) + Text( + text = + stringResource( + when { + state.isUnknown -> Res.string.cordn_health_unknown + state.isDown -> Res.string.cordn_health_down + else -> Res.string.cordn_health_ok + }, + ), + style = MaterialTheme.typography.bodySmall, + color = tint, + ) + // Only once it is actually down: one failed call is a network blip and + // deserves no words at all. + if (state.isDown) { + Text( + text = stringResource(Res.string.cordn_health_failures, state.consecutiveFailures), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/ui/CordnExposureCard.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/ui/CordnExposureCard.kt new file mode 100644 index 0000000000..558e7de2dd --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/ui/CordnExposureCard.kt @@ -0,0 +1,287 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.cordn.ExposureLevel +import com.vitorpamplona.amethyst.commons.cordn.ExposureNote +import com.vitorpamplona.amethyst.commons.cordn.GroupExposure +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.cordn_exposure_coordinator +import com.vitorpamplona.amethyst.commons.resources.cordn_exposure_details +import com.vitorpamplona.amethyst.commons.resources.cordn_exposure_differs +import com.vitorpamplona.amethyst.commons.resources.cordn_exposure_dimension_content +import com.vitorpamplona.amethyst.commons.resources.cordn_exposure_dimension_membership +import com.vitorpamplona.amethyst.commons.resources.cordn_exposure_dimension_messaging +import com.vitorpamplona.amethyst.commons.resources.cordn_exposure_level_identified +import com.vitorpamplona.amethyst.commons.resources.cordn_exposure_level_none +import com.vitorpamplona.amethyst.commons.resources.cordn_exposure_level_pseudonymous +import com.vitorpamplona.amethyst.commons.resources.cordn_exposure_subtitle +import com.vitorpamplona.amethyst.commons.resources.cordn_exposure_title +import com.vitorpamplona.amethyst.commons.resources.cordn_note_encryption_bug +import com.vitorpamplona.amethyst.commons.resources.cordn_note_history +import com.vitorpamplona.amethyst.commons.resources.cordn_note_linked +import com.vitorpamplona.amethyst.commons.resources.cordn_note_membership +import com.vitorpamplona.amethyst.commons.resources.cordn_note_publication +import com.vitorpamplona.amethyst.commons.resources.cordn_note_sizes +import com.vitorpamplona.amethyst.commons.util.toShortDisplay +import com.vitorpamplona.quartz.nip19Bech32.toNpub +import com.vitorpamplona.quartz.utils.Hex +import org.jetbrains.compose.resources.stringResource + +/** + * What the coordinator behind a cordn group learns, as a panel. + * + * §8 of `quartz/plans/2026-09-17-cordn-interop.md` ends with a requirement: + * the exposure analysis "should be surfaced in the UI if we ship this, not + * buried. A Marmot group and a cordn group have materially different metadata + * exposure and users cannot infer that from either one looking like a group + * chat." This is that surface. + * + * Two deliberate choices about how it reads: + * + * - **It does not rank the two.** cordn is weaker against the operator and + * stronger against the network — the coordinator never sees an IP (§8.5). + * Which trade is right depends on who runs the coordinator, which is the + * user's call. So the card states facts and stops. + * - **The notes come from [GroupExposure.notes], not from this file.** They are + * computed from the group's real state, so a group linked to five others says + * so and a lone group does not. A hand-written paragraph would drift from the + * truth the moment either changed. + */ +@Composable +fun CordnExposureCard( + exposure: GroupExposure, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + symbol = MaterialSymbols.PrivacyTip, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = stringResource(Res.string.cordn_exposure_title), + style = MaterialTheme.typography.titleMedium, + ) + } + + Text( + text = stringResource(Res.string.cordn_exposure_subtitle), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + ExposureRow(stringResource(Res.string.cordn_exposure_dimension_content), exposure.content) + ExposureRow(stringResource(Res.string.cordn_exposure_dimension_membership), exposure.membership) + ExposureRow(stringResource(Res.string.cordn_exposure_dimension_messaging), exposure.messaging) + + HorizontalDivider() + + Text( + text = stringResource(Res.string.cordn_exposure_details), + style = MaterialTheme.typography.labelLarge, + ) + exposure.notes().forEach { NoteRow(it) } + + if (exposure.differsFromMarmot()) { + Text( + text = stringResource(Res.string.cordn_exposure_differs), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Text( + // An npub, not a hex head. What somebody checks this against is + // the npub they were sent, and a hex prefix cannot be compared + // with one at all -- different encoding, different alphabet. + // + // Name and face are deliberately absent: this card is shared + // code with no AccountViewModel to read a profile through, and + // it is a footnote under a disclosure rather than an identity + // surface. Every screen that embeds it names the coordinator + // properly nearby. + text = stringResource(Res.string.cordn_exposure_coordinator, remember(exposure.coordinator) { shortNpub(exposure.coordinator) }), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +/** + * The coordinator key as a short npub. + * + * Shortened with the same prefix `User.pubkeyDisplayHex` uses, so a coordinator + * key reads identically here and on every other screen that prints one. + * + * Falls back to the hex if the key is not decodable, which no stored exposure + * should carry -- a label that is wrong is still better than a card that + * crashes on one malformed group. Decoded with the total `decode64OrNull` + * rather than `Hex.decode`, which validates no characters at all: it indexes a + * 256-entry table by char code, so anything above U+00FF throws an + * ArrayIndexOutOfBounds that no IllegalArgumentException guard would catch. + */ +private const val NPUB_PREFIX = 5 + +private fun shortNpub(pubKeyHex: String): String = Hex.decode64OrNull(pubKeyHex)?.toNpub()?.toShortDisplay(NPUB_PREFIX) ?: pubKeyHex.take(16) + +@Composable +private fun ExposureRow( + dimension: String, + level: ExposureLevel, +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + symbol = level.symbol(), + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = level.tint(), + ) + Text( + text = dimension, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + ) + Text( + text = level.label(), + style = MaterialTheme.typography.labelMedium, + fontWeight = level.weight(), + color = level.tint(), + ) + } +} + +@Composable +private fun NoteRow(note: ExposureNote) { + val isBug = note == ExposureNote.ENCRYPTION_NOT_PINNED + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + symbol = if (isBug) MaterialSymbols.Warning else MaterialSymbols.Info, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = if (isBug) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = note.label(), + style = MaterialTheme.typography.bodySmall, + color = if (isBug) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +/** + * The colour of a level, chosen so the scale reads without the words. + * + * It has to be **monotonic**, and getting that wrong is easy: an earlier + * version used `tertiary` for the middle level, which rendered pink against a + * dark `onSurface` for the worst one — so the row a user should worry about + * least of the two looked like the alarming one. Severity now runs + * affirmative → muted → emphatic, and emphasis for the worst case comes from + * [weight] rather than another hue. + * + * Nothing uses `error`. Everything on this card is how cordn works, not a + * fault, and painting normal operation red teaches people to ignore red. + */ +@Composable +private fun ExposureLevel.tint(): Color = + when (this) { + ExposureLevel.NONE -> MaterialTheme.colorScheme.primary + ExposureLevel.PSEUDONYMOUS -> MaterialTheme.colorScheme.onSurfaceVariant + ExposureLevel.IDENTIFIED -> MaterialTheme.colorScheme.onSurface + } + +/** The other half of the scale: only the worst level is emphasised. */ +private fun ExposureLevel.weight(): FontWeight = + when (this) { + ExposureLevel.IDENTIFIED -> FontWeight.SemiBold + else -> FontWeight.Normal + } + +private fun ExposureLevel.symbol(): MaterialSymbol = + when (this) { + ExposureLevel.NONE -> MaterialSymbols.Lock + ExposureLevel.PSEUDONYMOUS -> MaterialSymbols.NoAccounts + ExposureLevel.IDENTIFIED -> MaterialSymbols.Person + } + +@Composable +private fun ExposureLevel.label(): String = + stringResource( + when (this) { + ExposureLevel.NONE -> Res.string.cordn_exposure_level_none + ExposureLevel.PSEUDONYMOUS -> Res.string.cordn_exposure_level_pseudonymous + ExposureLevel.IDENTIFIED -> Res.string.cordn_exposure_level_identified + }, + ) + +@Composable +private fun ExposureNote.label(): String = + stringResource( + when (this) { + ExposureNote.MEMBERSHIP_IS_IDENTIFIED -> Res.string.cordn_note_membership + ExposureNote.GROUPS_LINKED_BY_SESSION -> Res.string.cordn_note_linked + ExposureNote.SINGLE_OPERATOR_HOLDS_HISTORY -> Res.string.cordn_note_history + ExposureNote.PUBLICATION_IS_A_SIGNED_RECORD -> Res.string.cordn_note_publication + ExposureNote.MESSAGE_SIZES_UNPADDED -> Res.string.cordn_note_sizes + ExposureNote.ENCRYPTION_NOT_PINNED -> Res.string.cordn_note_encryption_bug + }, + ) diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/ui/CordnGroupBadge.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/ui/CordnGroupBadge.kt new file mode 100644 index 0000000000..7ed9488337 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cordn/ui/CordnGroupBadge.kt @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.cordn_badge_coordinator +import com.vitorpamplona.amethyst.commons.resources.cordn_badge_coordinator_desc +import org.jetbrains.compose.resources.stringResource + +/** + * Marks a group as coordinator-delivered, wherever groups are listed together. + * + * The whole §8 problem in one line: a cordn group and a Marmot group look + * identical in a list, and their metadata exposure is not. Somebody scanning a + * list of conversations will not open a disclosure panel — so the list itself + * has to say which ones are which, and the panel is where they go to find out + * what it means. + * + * Carries its own content description rather than leaning on the label, because + * "Coordinator group" read aloud with no context is a noise, not a warning. + */ +@Composable +fun CordnGroupBadge(modifier: Modifier = Modifier) { + val description = stringResource(Res.string.cordn_badge_coordinator_desc) + + Surface( + modifier = modifier.semantics { contentDescription = description }, + shape = MaterialTheme.shapes.small, + color = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + ) { + Row( + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Icon( + symbol = MaterialSymbols.Dns, + contentDescription = null, + modifier = Modifier.size(12.dp), + ) + Text( + text = stringResource(Res.string.cordn_badge_coordinator), + style = MaterialTheme.typography.labelSmall, + ) + } + } +} diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip17Dm/ui/IncognitoBadge.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip17Dm/ui/IncognitoBadge.kt index 2c7269e112..d0af55cff6 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip17Dm/ui/IncognitoBadge.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip17Dm/ui/IncognitoBadge.kt @@ -29,11 +29,11 @@ import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.ui.theme.IncognitoIconModifier import com.vitorpamplona.amethyst.commons.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText -import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip04Dm.messages.EncryptedDmEvent @Composable fun IncognitoBadge(baseNote: Note) { - if (baseNote.event is PrivateDmEvent) { + if (baseNote.event is EncryptedDmEvent) { Icon( symbol = MaterialSymbols.NoEncryption, contentDescription = null, diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip51Lists/ui/BookmarkGroupItem.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip51Lists/ui/BookmarkGroupItem.kt index 6d2ea0334b..cb27ce2c4a 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip51Lists/ui/BookmarkGroupItem.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip51Lists/ui/BookmarkGroupItem.kt @@ -50,7 +50,7 @@ import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.nip51Lists.BookmarkType -import com.vitorpamplona.amethyst.commons.model.nip51Lists.labeledBookmarkLists.LabeledBookmarkList +import com.vitorpamplona.amethyst.commons.model.nip51Lists.bookmarkSets.BookmarkSet import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.bookmark_list_articles_btn_label import com.vitorpamplona.amethyst.commons.resources.bookmark_list_clone_btn_label @@ -85,7 +85,7 @@ import com.vitorpamplona.amethyst.commons.ui.theme.StdVertSpacer @Composable fun BookmarkGroupItem( modifier: Modifier = Modifier, - bookmarkList: LabeledBookmarkList, + bookmarkList: BookmarkSet, onClick: (bookmarkItemType: BookmarkType) -> Unit, onRename: () -> Unit, onDescriptionChange: () -> Unit, diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip51Lists/ui/ListOfBookmarkGroupsFeedView.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip51Lists/ui/ListOfBookmarkGroupsFeedView.kt index fe891a2366..d208a1694f 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip51Lists/ui/ListOfBookmarkGroupsFeedView.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip51Lists/ui/ListOfBookmarkGroupsFeedView.kt @@ -46,7 +46,7 @@ import com.vitorpamplona.amethyst.commons.model.nip51Lists.BookmarkType import com.vitorpamplona.amethyst.commons.model.nip51Lists.GitRepositoryListState import com.vitorpamplona.amethyst.commons.model.nip51Lists.OldBookmarkListState import com.vitorpamplona.amethyst.commons.model.nip51Lists.PinListState -import com.vitorpamplona.amethyst.commons.model.nip51Lists.labeledBookmarkLists.LabeledBookmarkList +import com.vitorpamplona.amethyst.commons.model.nip51Lists.bookmarkSets.BookmarkSet import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.bookmark_list_icon_label import com.vitorpamplona.amethyst.commons.resources.bookmarks_explainer @@ -73,17 +73,17 @@ fun ListOfBookmarkGroupsFeedView( oldBookmarks: OldBookmarkListState, pinnedNotes: PinListState, repositories: GitRepositoryListState, - groupListFeedSource: StateFlow>, + groupListFeedSource: StateFlow>, openDefaultBookmarks: () -> Unit, openOldBookmarks: () -> Unit, openPinnedNotes: () -> Unit, openRepositories: () -> Unit, openPodcasts: () -> Unit, onOpenItem: (String, BookmarkType) -> Unit, - onRenameItem: (targetBookmarkGroup: LabeledBookmarkList) -> Unit, - onItemDescriptionChange: (bookmarkGroup: LabeledBookmarkList) -> Unit, - onItemClone: (bookmarkGroup: LabeledBookmarkList, customName: String?, customDesc: String?) -> Unit, - onDeleteItem: (bookmarkGroup: LabeledBookmarkList) -> Unit, + onRenameItem: (targetBookmarkGroup: BookmarkSet) -> Unit, + onItemDescriptionChange: (bookmarkGroup: BookmarkSet) -> Unit, + onItemClone: (bookmarkGroup: BookmarkSet, customName: String?, customDesc: String?) -> Unit, + onDeleteItem: (bookmarkGroup: BookmarkSet) -> Unit, listState: LazyListState = rememberLazyListState(), ) { val bookmarkGroupFeedState by groupListFeedSource.collectAsStateWithLifecycle() @@ -119,7 +119,7 @@ fun ListOfBookmarkGroupsFeedView( itemsIndexed( bookmarkGroupFeedState, - key = { _: Int, item: LabeledBookmarkList -> item.identifier }, + key = { _: Int, item: BookmarkSet -> item.identifier }, ) { _, groupItem -> BookmarkGroupItem( modifier = Modifier.fillMaxSize().animateItem(), diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip51Lists/ui/PeopleListItem.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip51Lists/ui/PeopleListItem.kt index 2cee4e8d6a..c73b4f9c9a 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip51Lists/ui/PeopleListItem.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip51Lists/ui/PeopleListItem.kt @@ -51,7 +51,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.amethyst.commons.model.cache.LocalCache -import com.vitorpamplona.amethyst.commons.model.nip51Lists.peopleList.PeopleList +import com.vitorpamplona.amethyst.commons.model.nip51Lists.followSets.PeopleList import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.cancel import com.vitorpamplona.amethyst.commons.resources.follow_set_copy_action_btn_label diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip85TrustedAssertions/ui/EditNicknameDialog.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip85TrustedAssertions/ui/EditNicknameDialog.kt index b24c98cbb5..400f75d209 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip85TrustedAssertions/ui/EditNicknameDialog.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip85TrustedAssertions/ui/EditNicknameDialog.kt @@ -41,7 +41,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiSuggestionState -import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.ContactCardsState +import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.UserAssertionsState import com.vitorpamplona.amethyst.commons.nip30CustomEmojis.ui.ShowEmojiSuggestionList import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.nickname_cancel @@ -62,31 +62,31 @@ import org.jetbrains.compose.resources.stringResource * Typing `:` offers the account's NIP-30 custom emojis; the mappings for any * shortcode used are embedded (also encrypted) so the nickname renders with them. * - * Shared by every front end: the caller supplies the account's [contactCards] + * Shared by every front end: the caller supplies the account's [userAssertions] * and publishes the result in [onSave] (e.g. through its outbox relays). On * Android, compose `WatchAndLoadMyEmojiList` alongside so the emoji packs load. */ @Composable fun EditNicknameDialog( user: User, - contactCards: ContactCardsState, + userAssertions: UserAssertionsState, onSave: (petName: String?, summary: String?) -> Unit, onDismiss: () -> Unit, ) { val nickname = rememberTextFieldState() val summary = rememberTextFieldState() - val emojiSuggestions = remember(contactCards) { EmojiSuggestionState(contactCards.emojiPacks) } + val emojiSuggestions = remember(userAssertions) { EmojiSuggestionState(userAssertions.emojiPacks) } // which field the emoji autocomplete should insert into: the last one edited val emojiTarget = remember { mutableStateOf(null) } // Prefill with the card's current encrypted values, if any. Decryption can // be slow on external signers, so don't clobber anything already typed. LaunchedEffect(user) { - contactCards + userAssertions .petName(user.pubkeyHex) ?.takeIf { nickname.text.isEmpty() } ?.let { nickname.setTextAndPlaceCursorAtEnd(it) } - contactCards + userAssertions .summary(user.pubkeyHex) ?.takeIf { summary.text.isEmpty() } ?.let { summary.setTextAndPlaceCursorAtEnd(it) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorSettings.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/tor/TorSettingsLabels.kt similarity index 88% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorSettings.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/tor/TorSettingsLabels.kt index 4443d4bbdc..e9fdf4b476 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorSettings.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/tor/TorSettingsLabels.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.tor +package com.vitorpamplona.amethyst.commons.tor import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.tor_custom @@ -34,16 +34,17 @@ import com.vitorpamplona.amethyst.commons.resources.tor_small_payloads import com.vitorpamplona.amethyst.commons.resources.tor_small_payloads_explainer import com.vitorpamplona.amethyst.commons.resources.tor_when_needed import com.vitorpamplona.amethyst.commons.resources.tor_when_needed_explainer -import com.vitorpamplona.amethyst.commons.tor.TorPresetType -import com.vitorpamplona.amethyst.commons.tor.TorType import org.jetbrains.compose.resources.StringResource -// Re-export shared types so existing Android imports continue to work -// The canonical types now live in commons/commonMain -@Suppress("unused") -private const val RE_EXPORTS = 0 - -// Catalog keys for TorType (shared types live in commons/commonMain) +/** + * The display labels for the Tor settings enums. + * + * Same split as UiSettingsLabels: the data ([TorType], [TorPresetType], + * [com.vitorpamplona.amethyst.commons.tor.TorSettings]) is in `commons`, where + * the CLI and any headless front end can read it, and the [StringResource]s are + * here because they come from the generated `Res` class that only `commonsUI` + * has. + */ val TorType.resourceId: StringResource get() = when (this) { @@ -52,7 +53,6 @@ val TorType.resourceId: StringResource TorType.EXTERNAL -> Res.string.tor_external } -// Catalog keys for TorPresetType val TorPresetType.resourceId: StringResource get() = when (this) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableBuzzInviteLink.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ClickableBuzzInviteLink.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableBuzzInviteLink.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ClickableBuzzInviteLink.kt index 7779422d4b..089ff137a3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableBuzzInviteLink.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ClickableBuzzInviteLink.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.components +package com.vitorpamplona.amethyst.commons.ui.components import androidx.compose.foundation.combinedClickable import androidx.compose.material3.MaterialTheme diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableConcordInviteLink.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ClickableConcordInviteLink.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableConcordInviteLink.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ClickableConcordInviteLink.kt index 3212e52163..e18ff60ff8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableConcordInviteLink.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ClickableConcordInviteLink.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.components +package com.vitorpamplona.amethyst.commons.ui.components import androidx.compose.foundation.combinedClickable import androidx.compose.material3.MaterialTheme diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ClickableLinks.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ClickableLinks.kt index beedb404fc..c50279a930 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ClickableLinks.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ClickableLinks.kt @@ -87,7 +87,32 @@ fun ClickableEmail( textDecoration = if (underline) TextDecoration.Underline else null, modifier = modifier.pointerHoverIcon(PointerIcon.Hand).clickable { - runCatching { uriHandler.openUri("mailto:$display") } + // '%' is legal in an address but starts an escape in a mailto: URI (RFC 6068). + runCatching { uriHandler.openUri("mailto:" + display.replace("%", "%25")) } + }, + ) +} + +/** + * A primary-colored clickable phone number that opens the platform dialer via + * [LocalUriHandler] (`tel:`). Strips a leading `tel:` from the display text. + * [underline] + hover cursor are the Desktop mouse-first affordances. + */ +@Composable +fun ClickablePhone( + number: String, + modifier: Modifier = Modifier, + underline: Boolean = false, +) { + val uriHandler = LocalUriHandler.current + val display = remember(number) { number.removePrefix("tel:") } + Text( + text = display, + color = MaterialTheme.colorScheme.primary, + textDecoration = if (underline) TextDecoration.Underline else null, + modifier = + modifier.pointerHoverIcon(PointerIcon.Hand).clickable { + runCatching { uriHandler.openUri("tel:$display") } }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRelayGroupLink.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ClickableRelayGroupLink.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRelayGroupLink.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ClickableRelayGroupLink.kt index d968ab300b..72a1104bed 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRelayGroupLink.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ClickableRelayGroupLink.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.components +package com.vitorpamplona.amethyst.commons.ui.components import androidx.compose.foundation.combinedClickable import androidx.compose.material3.MaterialTheme diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRelayUrl.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ClickableRelayUrl.kt similarity index 97% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRelayUrl.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ClickableRelayUrl.kt index 303fd67251..60e3668e5c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRelayUrl.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ClickableRelayUrl.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.components +package com.vitorpamplona.amethyst.commons.ui.components import androidx.compose.foundation.combinedClickable import androidx.compose.material3.MaterialTheme diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/NewItemsBubble.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/NewItemsBubble.kt new file mode 100644 index 0000000000..0d7b070240 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/NewItemsBubble.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import com.vitorpamplona.amethyst.commons.ui.theme.newItemBubbleModifier + +/** The small dot that marks a row with unread items. */ +@Composable +fun NewItemsBubble() { + Box(MaterialTheme.colorScheme.newItemBubbleModifier) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/OutlinedThinPaddingTextField.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/OutlinedThinPaddingTextField.kt similarity index 99% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/OutlinedThinPaddingTextField.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/OutlinedThinPaddingTextField.kt index 3ee7f3cc0a..d7e7a537ec 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/OutlinedThinPaddingTextField.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/OutlinedThinPaddingTextField.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.components +package com.vitorpamplona.amethyst.commons.ui.components import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.interaction.MutableInteractionSource diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/StickToTopOnPrepend.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/StickToTopOnPrepend.kt index 9074a494dd..7ba0fb5eed 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/StickToTopOnPrepend.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/StickToTopOnPrepend.kt @@ -24,10 +24,10 @@ import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.grid.LazyGridState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.snapshotFlow +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.commons.feeds.FeedContentState import com.vitorpamplona.amethyst.commons.feeds.FeedState import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -59,11 +59,14 @@ import kotlinx.coroutines.flow.map * with an instant (non-animated) scroll so the prepend appears as * in-place growth rather than a visible jump-then-scroll. * - * Commons port of `amethyst/.../WatchScrollToTop.kt` so Desktop and any - * other multiplatform front-end can use the same auto-stick behavior. - * Uses plain `collectAsState` instead of the Android-only - * `collectAsStateWithLifecycle` — equivalent here because the effect's - * lifecycle is already bound to composition via `LaunchedEffect`. + * Most callers should not invoke this directly: `SaveableFeedContentState`, + * `SaveableGridFeedContentState`, and the analogous wrappers in the app's + * `ui/screen/FeedView.kt` already apply auto-stick to every feed they + * own. Invoke the explicit overload only when the listState is + * constructed outside one of those wrappers, or when the key that + * should trigger the snap is not the default `items.list[0].idHex` + * (e.g. notifications, chats, or feeds keyed on something other than a + * Note's hex id). */ @Composable fun StickToTopOnPrepend( @@ -142,7 +145,7 @@ private fun rememberFirstItemIdHex(feedContentState: FeedContentState): String? } } } - val key by flow.collectAsState(initial = null) + val key by flow.collectAsStateWithLifecycle(initialValue = null) return key } diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/WatchScrollToTop.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/WatchScrollToTop.kt new file mode 100644 index 0000000000..5bd267e8f5 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/WatchScrollToTop.kt @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.feeds + +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.foundation.pager.PagerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.feeds.FeedContentState + +@Composable +fun WatchScrollToTop( + feedContentState: FeedContentState, + listState: LazyListState, +) { + val scrollToTop by feedContentState.scrollToTop.collectAsStateWithLifecycle() + + LaunchedEffect(scrollToTop) { + if (scrollToTop > 0 && feedContentState.scrollToTopPending) { + listState.scrollToItem(index = 0) + feedContentState.sentToTop() + } + } +} + +@Composable +fun WatchScrollToTop( + feedContentState: FeedContentState, + listState: LazyGridState, +) { + val scrollToTop by feedContentState.scrollToTop.collectAsStateWithLifecycle() + + LaunchedEffect(scrollToTop) { + if (scrollToTop > 0 && feedContentState.scrollToTopPending) { + listState.scrollToItem(index = 0) + feedContentState.sentToTop() + } + } +} + +@Composable +fun WatchScrollToTop( + videoFeedContentState: FeedContentState, + pagerState: PagerState, +) { + val scrollToTop by videoFeedContentState.scrollToTop.collectAsStateWithLifecycle() + + LaunchedEffect(scrollToTop) { + if (scrollToTop > 0 && videoFeedContentState.scrollToTopPending) { + pagerState.scrollToPage(page = 0) + videoFeedContentState.sentToTop() + } + } +} diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/ChatHeaderLayout.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/ChatHeaderLayout.kt new file mode 100644 index 0000000000..76fc5b6425 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/ChatHeaderLayout.kt @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.layouts + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.ui.theme.DoubleHorzSpacer +import com.vitorpamplona.amethyst.commons.ui.theme.Height4dpModifier +import com.vitorpamplona.amethyst.commons.ui.theme.Size55Modifier + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun ChatHeaderLayout( + channelPicture: @Composable () -> Unit, + firstRow: @Composable RowScope.() -> Unit, + secondRow: @Composable RowScope.() -> Unit, + onClick: () -> Unit, + onLongClick: (() -> Unit)? = null, +) { + Row( + modifier = + Modifier + .combinedClickable(onClick = onClick, onLongClick = onLongClick) + .padding(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(Size55Modifier) { channelPicture() } + + Spacer(modifier = DoubleHorzSpacer) + + Column( + modifier = Modifier.fillMaxWidth(), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + firstRow() + } + + Spacer(modifier = Height4dpModifier) + + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + secondRow() + } + } + } +} diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/LeftPictureLayout.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/LeftPictureLayout.kt new file mode 100644 index 0000000000..2d7102877c --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/LeftPictureLayout.kt @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.layouts + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import com.vitorpamplona.amethyst.commons.ui.theme.DoubleHorzSpacer + +@Composable +fun LeftPictureLayout( + onImage: @Composable () -> Unit, + onTitleRow: @Composable RowScope.() -> Unit, + onDescription: @Composable () -> Unit, + onBottomRow: @Composable RowScope.() -> Unit, + imageFraction: Float = 0.25f, +) { + Row(Modifier.aspectRatio(ratio = 1 / imageFraction)) { + Column( + modifier = Modifier.fillMaxWidth(imageFraction).aspectRatio(ratio = 1f), + ) { + onImage() + } + + Spacer(modifier = DoubleHorzSpacer) + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.SpaceBetween, + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + onTitleRow() + } + + Row( + modifier = Modifier.fillMaxWidth().weight(1f), + ) { + onDescription() + } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + onBottomRow() + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/listItem/SlimListItemLayout.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/listItem/SlimListItemLayout.kt similarity index 81% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/listItem/SlimListItemLayout.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/listItem/SlimListItemLayout.kt index ed6f0b1a5d..b7ac7505f8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/listItem/SlimListItemLayout.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/listItem/SlimListItemLayout.kt @@ -18,31 +18,22 @@ * 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.layouts.listItem +package com.vitorpamplona.amethyst.commons.ui.layouts.listItem -import androidx.annotation.VisibleForTesting -import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.padding -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.ListItem import androidx.compose.material3.ListItemColors import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.remember import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.FirstBaseline import androidx.compose.ui.layout.IntrinsicMeasurable import androidx.compose.ui.layout.IntrinsicMeasureScope @@ -55,117 +46,20 @@ import androidx.compose.ui.layout.MultiContentMeasurePolicy import androidx.compose.ui.layout.Placeable import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.offset import androidx.compose.ui.unit.sp -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.commons.resources.Res -import com.vitorpamplona.amethyst.commons.resources.profile_banner -import com.vitorpamplona.amethyst.commons.ui.stringRes -import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness -import com.vitorpamplona.amethyst.commons.ui.theme.Height4dpModifier -import com.vitorpamplona.amethyst.commons.ui.theme.Size55Modifier -import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn -import com.vitorpamplona.amethyst.ui.layouts.ChatHeaderLayout -import com.vitorpamplona.amethyst.ui.layouts.listItem.ListTokens.ListItemContainerElevation -import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo -import com.vitorpamplona.amethyst.ui.painterRes -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.NewItemsBubble -import com.vitorpamplona.quartz.utils.TimeUtils +import com.vitorpamplona.amethyst.commons.ui.layouts.listItem.ListTokens.ListItemContainerElevation +import kotlin.jvm.JvmInline import kotlin.math.max /** * This is a copy of Material3's ListItemLayout.kt file, with the only change being the padding change from 16.dp to 10.dp */ -private const val PREVIEW_AUTHOR = "This is my author" -private const val PREVIEW_MESSAGE = "This is a message from this person" - -@Composable -@Preview -fun ChannelNamePreview() { - ThemeComparisonColumn { - Column { - ChatHeaderLayout( - channelPicture = { - Image( - painter = painterRes(R.drawable.github, 1), - contentDescription = stringRes(id = Res.string.profile_banner), - contentScale = ContentScale.FillWidth, - ) - }, - firstRow = { - Text(PREVIEW_AUTHOR, Modifier.weight(1f)) - TimeAgo(TimeUtils.now()) - }, - secondRow = { - Text(PREVIEW_MESSAGE, Modifier.weight(1f)) - NewItemsBubble() - }, - onClick = {}, - ) - - HorizontalDivider(thickness = DividerThickness) - - SlimListItem( - headlineContent = { - Row(verticalAlignment = CenterVertically) { - Text(PREVIEW_AUTHOR, Modifier.weight(1f)) - TimeAgo(TimeUtils.now()) - } - }, - supportingContent = { - Row(verticalAlignment = CenterVertically) { - Text(PREVIEW_MESSAGE, Modifier.weight(1f)) - Spacer(modifier = Height4dpModifier) - NewItemsBubble() - } - }, - leadingContent = { - Image( - painter = painterRes(R.drawable.github, 2), - contentDescription = stringRes(id = Res.string.profile_banner), - contentScale = ContentScale.FillWidth, - modifier = Size55Modifier, - ) - }, - ) - - HorizontalDivider(thickness = DividerThickness) - - ListItem( - headlineContent = { - Row(verticalAlignment = CenterVertically) { - Text(PREVIEW_AUTHOR, Modifier.weight(1f)) - TimeAgo(TimeUtils.now()) - } - }, - supportingContent = { - Row(verticalAlignment = CenterVertically) { - Text(PREVIEW_MESSAGE, Modifier.weight(1f)) - Spacer(modifier = Height4dpModifier) - NewItemsBubble() - } - }, - leadingContent = { - Image( - painter = painterRes(R.drawable.github, 2), - contentDescription = stringRes(id = Res.string.profile_banner), - contentScale = ContentScale.FillWidth, - modifier = Size55Modifier, - ) - }, - ) - - HorizontalDivider(thickness = DividerThickness) - } - } -} - @Composable fun SlimListItem( headlineContent: @Composable () -> Unit, @@ -688,22 +582,22 @@ object ListTokens { // Container related defaults // TODO: Make sure these values stay up to date until replaced with tokens. -@VisibleForTesting internal val ListItemVerticalPadding = 8.dp +internal val ListItemVerticalPadding = 8.dp -@VisibleForTesting internal val ListItemThreeLineVerticalPadding = 12.dp +internal val ListItemThreeLineVerticalPadding = 12.dp -@VisibleForTesting internal val ListItemStartPadding = 10.dp +internal val ListItemStartPadding = 10.dp -@VisibleForTesting internal val ListItemEndPadding = 10.dp +internal val ListItemEndPadding = 10.dp // Icon related defaults. // TODO: Make sure these values stay up to date until replaced with tokens. -@VisibleForTesting internal val LeadingContentEndPadding = Modifier.padding(end = 10.dp) +internal val LeadingContentEndPadding = Modifier.padding(end = 10.dp) // Icon related defaults. // TODO: Make sure these values stay up to date until replaced with tokens. -@VisibleForTesting internal val SupportingContentTopPadding = Modifier.padding(top = 2.dp) +internal val SupportingContentTopPadding = Modifier.padding(top = 2.dp) // Trailing related defaults. // TODO: Make sure these values stay up to date until replaced with tokens. -@VisibleForTesting internal val TrailingContentStartPadding = Modifier.padding(start = 10.dp) +internal val TrailingContentStartPadding = Modifier.padding(start = 10.dp) diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/CalendarCollectionCard.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/CalendarCollectionCard.kt index ba22fa6639..6066ad2cab 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/CalendarCollectionCard.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/CalendarCollectionCard.kt @@ -41,12 +41,12 @@ import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.calendar_collection_count import com.vitorpamplona.amethyst.commons.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.commons.ui.theme.replyModifier -import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent +import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarCollectionEvent import org.jetbrains.compose.resources.pluralStringResource /** Card for a NIP-52 calendar collection (kind 31924): title, description, and event count. */ @Composable -fun CalendarCollectionCard(event: CalendarEvent) { +fun CalendarCollectionCard(event: CalendarCollectionEvent) { val title = remember(event) { event.title() } val eventCount = remember(event) { event.calendarEventAddresses().size } diff --git a/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/ui/CordnExposureRenderTest.kt b/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/ui/CordnExposureRenderTest.kt new file mode 100644 index 0000000000..af4de6b3cf --- /dev/null +++ b/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/cordn/ui/CordnExposureRenderTest.kt @@ -0,0 +1,191 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.cordn.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.ImageComposeScene +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.cordn.CoordinatorHealth +import com.vitorpamplona.amethyst.commons.cordn.GroupExposure +import org.jetbrains.skia.EncodedImageFormat +import java.awt.image.BufferedImage +import java.io.ByteArrayInputStream +import javax.imageio.ImageIO +import kotlin.test.Test +import kotlin.test.assertTrue + +/** + * Renders the cordn disclosure surface headlessly and looks at the pixels. + * + * Compiling a composable proves almost nothing about it. Two failure modes it + * cannot catch, and this can: + * + * - **A missing string resource throws at render, not at build.** The generated + * `Res.string.*` accessors compile whether or not `strings.xml` has the entry, + * so a typo ships and crashes the screen the first time someone opens it. + * This suite opens it. + * - **A composable that draws nothing** — a zero-height container, a colour that + * equals its background — still builds and still "renders". + * + * It asserts structure rather than exact pixels, so it does not become a + * screenshot test that has to be re-blessed on every font or Material bump. It + * does not write files either; what it checks is what it can check honestly + * without a human looking. `ImageComposeScene` rasterises in software, so this + * needs no display and runs in CI. + */ +class CordnExposureRenderTest { + private val width = 900 + private val height = 1500 + + // Inside the card and clear of text: the card starts at the Column's 8dp + // padding and this sits in the gutter to the right of the title row. + private val cardInteriorX = width - 40 + private val cardInteriorY = 40 + + private fun exposure(linked: Int = 3) = + GroupExposure( + coordinator = "cc".repeat(32), + linkedGroupCount = linked, + joinedFromShareLink = true, + publishedKeyPackage = true, + encryptionPinned = true, + ) + + /** Renders [content] under a light or dark Material theme and decodes it. */ + private fun render( + dark: Boolean, + content: @Composable () -> Unit, + ): BufferedImage { + val scene = + ImageComposeScene(width = width, height = height, density = Density(2f)) { + MaterialTheme(colorScheme = if (dark) darkColorScheme() else lightColorScheme()) { + Column( + modifier = + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .padding(8.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + content() + } + } + } + return try { + val png = scene.render().encodeToData(EncodedImageFormat.PNG)!!.bytes + ImageIO.read(ByteArrayInputStream(png)) + } finally { + scene.close() + } + } + + /** Every composable on the surface, in one pass. */ + private val wholeSurface: @Composable () -> Unit = { + CordnExposureCard(exposure()) + CordnGroupBadge() + CoordinatorHealthRow(CoordinatorHealth.State()) + CoordinatorHealthRow(CoordinatorHealth.State(lastSuccessAt = 1L)) + CoordinatorHealthRow( + CoordinatorHealth.State(lastFailureAt = 1L, consecutiveFailures = 4, lastFailure = "timeout"), + ) + } + + private fun BufferedImage.distinctColours(): Int { + val seen = mutableSetOf() + for (x in 0 until width step 3) { + for (y in 0 until height step 3) { + seen += getRGB(x, y) + } + } + return seen.size + } + + @Test + fun `the whole surface renders in both themes`() { + // The string-resource check: any missing entry throws here. + listOf(false, true).forEach { dark -> + val image = render(dark, wholeSurface) + assertTrue( + image.distinctColours() > 20, + "the ${if (dark) "dark" else "light"} surface drew ${image.distinctColours()} colours, which is not text on a card", + ) + } + } + + @Test + fun `the surface follows the theme rather than hardcoding colours`() { + // A composable that paints its own background survives a theme switch + // looking identical, and is unreadable in one of the two. + val light = render(false, wholeSurface) + val dark = render(true, wholeSurface) + + val brightness = { rgb: Int -> ((rgb shr 16 and 0xFF) + (rgb shr 8 and 0xFF) + (rgb and 0xFF)) / 3 } + + // Two samples, because they catch different mistakes. The page + // background catches a screen that ignores the theme; a point inside + // the card, clear of any glyph, catches a *component* that paints its + // own colour — which the outer sample cannot see at all. + listOf( + "page background" to (2 to 2), + "card surface" to (cardInteriorX to cardInteriorY), + ).forEach { (what, point) -> + val (x, y) = point + val lightPixel = light.getRGB(x, y) + val darkPixel = dark.getRGB(x, y) + assertTrue(lightPixel != darkPixel, "the $what ignored the theme") + assertTrue(brightness(lightPixel) > brightness(darkPixel), "light and dark are swapped on the $what") + } + } + + @Test + fun `an unlinked group draws less than a linked one`() { + // The §8.2 note is conditional, and a conditional that never fires is + // indistinguishable from one that is broken. Fewer notes means a + // shorter card, so the ink below the fold differs. + val linked = render(false) { CordnExposureCard(exposure(linked = 3)) } + val alone = render(false) { CordnExposureCard(exposure(linked = 1)) } + + val inkBelow = { image: BufferedImage -> + var count = 0 + for (x in 0 until width step 3) { + for (y in height / 2 until height step 3) { + if (image.getRGB(x, y) != image.getRGB(2, 2)) count++ + } + } + count + } + + assertTrue( + inkBelow(linked) > inkBelow(alone), + "a group linked to others must show the extra disclosure: ${inkBelow(linked)} vs ${inkBelow(alone)}", + ) + } +} diff --git a/desktopApp/plans/2026-05-12-feat-desktop-wallet-zapping-test-coverage-plan.md b/desktopApp/plans/2026-05-12-feat-desktop-wallet-zapping-test-coverage-plan.md index 13e2a9b82d..d3a4161b94 100644 --- a/desktopApp/plans/2026-05-12-feat-desktop-wallet-zapping-test-coverage-plan.md +++ b/desktopApp/plans/2026-05-12-feat-desktop-wallet-zapping-test-coverage-plan.md @@ -17,7 +17,7 @@ origin: desktopApp/plans/2026-05-12-wallet-zapping-phase1-2-plan.md 1. **mockk callback capture**: Use `slot<(Event, NormalizedRelayUrl) -> Unit>()` + `capture(slot)` to intercept relay subscription callbacks and feed simulated wallet responses 2. **GlobalScope.launch problem**: NwcPaymentHandler uses `GlobalScope.launch(Dispatchers.IO)` inside callbacks — tests need real time via `withTimeout(5.seconds)` to await (or refactor to inject scope) -3. **LnZapPaymentResponseEvent.createResponse()** exists — build real encrypted test responses with deterministic keys, no need to mock encryption +3. **NwcResponseEvent.createResponse()** exists — build real encrypted test responses with deterministic keys, no need to mock encryption 4. **runTest auto-advances virtual time** — timeout tests resolve instantly without real delays 5. **Codebase prefers anonymous objects** over mockk for complex interfaces (see RelayConnectionManagerTest) @@ -35,7 +35,7 @@ methods, wallet column state management, and zap dialog logic. | NIP-47 URI parsing | quartz | `Nip47WalletConnectTest.kt` | Complete | | NWC request serialization | quartz | `RequestTest.kt` (50+ cases) | Complete | | NWC response parsing | quartz | `ResponseTest.kt` (50+ cases) | Complete | -| NWC event encryption | quartz | `LnZapPaymentRequestEventTest.kt` | Complete | +| NWC event encryption | quartz | `NwcRequestEventTest.kt` | Complete | | Alby interop | quartz | `AlbyInteropTest.kt` (60+ cases) | Complete | | Account management | desktopApp | `AccountManager*Test.kt` (5 files) | Complete | | Cache pipeline | desktopApp | `DesktopCachePipelineTest.kt` | Complete | @@ -187,9 +187,9 @@ class NwcPaymentHandlerTest { **Challenge:** The `subscribeOnRelay` callback runs asynchronously. Tests must capture the `onEvent` lambda and invoke it with a simulated -`LnZapPaymentResponseEvent`. This requires building encrypted response +`NwcResponseEvent`. This requires building encrypted response events using the wallet's signer — similar to -`quartz/nip47WalletConnect/LnZapPaymentRequestEventTest.kt`. +`quartz/nip47WalletConnect/NwcRequestEventTest.kt`. **Alternative (simpler):** If capturing the relay callback is too complex, extract the response processing logic into a testable pure function and @@ -349,7 +349,7 @@ class NwcRpcIntegrationTest { val requestEvent = client.getBalance() // 2. Verify request is properly encrypted - assertEquals(LnZapPaymentRequestEvent.KIND, requestEvent.kind) + assertEquals(NwcRequestEvent.KIND, requestEvent.kind) assertTrue(requestEvent.content.isNotBlank()) // 3. Wallet decrypts and verifies method @@ -357,7 +357,7 @@ class NwcRpcIntegrationTest { assertIs(decryptedRequest) // 4. Wallet builds encrypted response - val responseEvent = LnZapPaymentResponseEvent.create( + val responseEvent = NwcResponseEvent.create( request = requestEvent, response = GetBalanceSuccessResponse( result = GetBalanceSuccessResponse.GetBalanceResult(balance = 125000), @@ -429,7 +429,7 @@ extraction needed). | Mocking relay callback is complex | Medium | Extract response processing into pure function, test that directly | | Compose state testing without Compose | Medium | Extract state holder class (Option A) | | `formatSats` is private | Low | Change to `internal` visibility | -| Encrypted response building may need quartz test utils | Low | Use same pattern as `LnZapPaymentRequestEventTest.kt` | +| Encrypted response building may need quartz test utils | Low | Use same pattern as `NwcRequestEventTest.kt` | ## Sources @@ -437,4 +437,4 @@ extraction needed). - NIP-47 protocol tests: `quartz/src/commonTest/.../nip47WalletConnect/` (13 files) - Test framework: kotlin.test + mockk 1.14.9 + kotlinx-coroutines-test 1.10.2 - Mock pattern: `AccountManagerBunkerLoginTest.kt` (relaxed mockk + coEvery) -- Crypto test pattern: `LnZapPaymentRequestEventTest.kt` (deterministic KeyPair) +- Crypto test pattern: `NwcRequestEventTest.kt` (deterministic KeyPair) diff --git a/desktopApp/plans/2026-06-21-napplet-desktop-host.md b/desktopApp/plans/2026-06-21-napplet-desktop-host.md index b7b53f4ce8..03be3b0bc8 100644 --- a/desktopApp/plans/2026-06-21-napplet-desktop-host.md +++ b/desktopApp/plans/2026-06-21-napplet-desktop-host.md @@ -19,7 +19,7 @@ doc is the map: what already works for free, what desktop must build, and the de `NappletIdentity`, `NappletRequest`/`NappletResponse`, the permissions `Ledger`/`Store`/`GrantState`, and the gateway **interfaces** (`NappletRelayGateway`, `NappletStorage`, `NappletWalletGateway`, `NappletResourceGateway`, `NappletUploadGateway`, `NappletIdentityGateway`, `NappletConsentPrompt`). -- **`NappletProtocolJson`** (`commons/jvmAndroid`) — the wire codec. Desktop's host marshals through +- **`NappletProtocolJson`** (`commons/commonMain`) — the wire codec. Desktop's host marshals through the same object, so request/result/push shapes can never drift between platforms. **The web contract (`commons/commonMain/composeResources/files/napplet/` + `NappletWebContract`)** — diff --git a/desktopApp/plans/2026-07-23-feat-desktop-moderation-safety-plan.md b/desktopApp/plans/2026-07-23-feat-desktop-moderation-safety-plan.md index 62f1e5fc80..a2fc90ef6a 100644 --- a/desktopApp/plans/2026-07-23-feat-desktop-moderation-safety-plan.md +++ b/desktopApp/plans/2026-07-23-feat-desktop-moderation-safety-plan.md @@ -30,7 +30,7 @@ cruxes**, de-risking the whole feature: → Crux 2 resolved: reuse `invalidateData()`, don't invent a bus. 3. **Concrete APIs confirmed** (see Research Insights under Technical Approach): `MuteListEvent.create/add/remove(MuteTag, isPrivate, signer)` where `MuteTag ∈ - {UserTag, HashtagTag(word), EventTag(thread)}`; `PeopleListEvent.addUser/ + {UserTag, HashtagTag(word), EventTag(thread)}`; `FollowSetEvent.addUser/ removeUser` for blocks; publish via `signer.sign(template)` + `relayManager.broadcastToAll(signed)` (precedent `reactToNote`/`repostNote` in `desktopApp/.../ui/NoteActions.kt`). @@ -85,7 +85,7 @@ enforcement, reusing quartz protocol and the existing spam-collapse reveal: `isAcceptable`, so DMs get filtered for free once the predicate is real. - **Actions:** `hideUser`/`showUser`/`blockUser`/`hideWord`/`hideThread` on `DesktopIAccount` build → sign → publish an updated `MuteListEvent`/ - `PeopleListEvent`. + `FollowSetEvent`. - **Report:** `report(note|user, type, comment)` reuses quartz `ReportEvent.build` (kind 1984) + `ReportType`, published to the account's public + private outbox (match Android). New `ReportNoteDialog` ported to desktop Compose, wired into @@ -103,7 +103,7 @@ enforcement, reusing quartz protocol and the existing spam-collapse reveal: flowchart TD subgraph relays[Relays] K10000[kind 10000 MuteListEvent - encrypted] - K10001[kind 10001 PeopleListEvent - block] + K10001[kind 10001 FollowSetEvent - block] K1068[content notes, some content-warning tagged] end K10000 & K10001 --> SUB[Main.kt account subscription] @@ -130,7 +130,7 @@ flowchart TD | Report types | `ReportType` enum (SPAM/PROFANITY/IMPERSONATION/NUDITY/ILLEGAL/MALWARE/VIOLENCE/…) | `quartz/.../nip56Reports/ReportType.kt` | | CW detection | `Event.isSensitiveOrNSFW()`, `contentWarningReason()` | `quartz/.../nip36SensitiveContent/EventExt.kt` | | Hidden check | `Note.isHiddenFor(liveHiddenUsers)` (users+words+threads) | `quartz/.../Note.kt` | -| Mute/block events | `MuteListEvent` (10000), `PeopleListEvent` (10001) | `quartz/.../nip51Lists/…` | +| Mute/block events | `MuteListEvent` (10000), `FollowSetEvent` (10001) | `quartz/.../nip51Lists/…` | | Reveal primitive | `SpamCheckedNoteRender` `forceReveal` + `rememberSaveable(id)` | `desktopApp/.../ui/note/SpamCheckedNoteRender.kt` | ### Android references to port @@ -183,7 +183,7 @@ MuteListEvent.remove(earlierVersion, mute, signer) // reading private entries requires the signer: earlierVersion.privateTags(signer) // throws SignerExceptions.UnauthorizedDecryptionException on read-only // block (kind 10001) -PeopleListEvent.addUser(...) / removeUser(...) +FollowSetEvent.addUser(...) / removeUser(...) ``` MuteTag subtypes: `UserTag` (pubkey), `HashtagTag` (word), `EventTag` (thread root). Enforcement then reads these via `Note.isHiddenFor(liveHiddenUsers)`. diff --git a/desktopApp/plans/2026-07-27-feat-desktop-profile-actions-plan.md b/desktopApp/plans/2026-07-27-feat-desktop-profile-actions-plan.md index 5cdbf395ce..11e777743e 100644 --- a/desktopApp/plans/2026-07-27-feat-desktop-profile-actions-plan.md +++ b/desktopApp/plans/2026-07-27-feat-desktop-profile-actions-plan.md @@ -44,13 +44,13 @@ Open Desktop Messages for this pubkey via the existing deck-column pattern. (`ShareMenu.kt:56`) shape. No auth required; own + others. ## Action 3 — Add to follow-pack (kind 39089) — mostly REUSE -- **Picker source:** `FollowPacksState.allPacks: StateFlow>` +- **Picker source:** `FollowPacksState.allPacks: StateFlow>` (`followpacks/FollowPacksState.kt:84`) — zero-packs returns `emptyList()`. -- **Add member:** `FollowListEvent.add(earlierVersion, person = UserTag(pubKey, relayHint), signer)` - (`quartz nip51Lists/followList/FollowListEvent.kt:116`; `UserTag` from `muteList/tags/UserTag.kt:35`). +- **Add member:** `StarterPackEvent.add(earlierVersion, person = UserTag(pubKey, relayHint), signer)` + (`quartz nip51Lists/starterPack/StarterPackEvent.kt:116`; `UserTag` from `muteList/tags/UserTag.kt:35`). Publish via `relayManager.broadcastToAll(event)` + `cache.consume(event, relay, wasVerified=false)` (pattern in `FollowPackDetailScreen.kt:178-192`). -- **Thin `commons/actions/FollowPackActions.kt`** = pure builder wrapping `FollowListEvent.add` +- **Thin `commons/actions/FollowPackActions.kt`** = pure builder wrapping `StarterPackEvent.add` (returns the signed event; desktop layer publishes). No hand-rolled tag assembly. - **Zero-packs → offer "Create new pack"** (reuse `FollowPackEditor`); no dead-end modal. - **Stale-list guard:** disable until the target pack's current 39089 has loaded (`add` onto a stale @@ -58,33 +58,33 @@ Open Desktop Messages for this pubkey via the existing deck-column pattern. - **Modal caveat:** the picker must NOT use `rememberSubscription` (broken in AlertDialog); the `allPacks` StateFlow is already hoisted, so read it directly. -## Action 4 — Block (`PeopleListEvent` kind 30000, d=`mute`) +## Action 4 — Block (`FollowSetEvent` kind 30000, d=`mute`) **Much of this already exists on moderation-safety — Block is mostly wiring, mirroring mute.** > **Revision of parent's "extract to commons" default:** moderation-safety keeps the *mute* write > **inline** on `DesktopIAccount.updateMuteList` (not a commons builder). To avoid the architecture > reviewer's "two parallel patterns" smell, Block should be **inline and symmetric with mute** — NOT -> a `commons/actions/BlockActions.kt`. The quartz `PeopleListEvent.addUser` IS the shared builder; a +> a `commons/actions/BlockActions.kt`. The quartz `FollowSetEvent.addUser` IS the shared builder; a > commons wrapper adds nothing. (If a commons home is later wanted, extract mute + block *together*.) **Already present (REUSE, no changes):** -- `PeopleListEvent` (quartz `nip51Lists/peopleList/PeopleListEvent.kt`): `KIND=30000`, +- `FollowSetEvent` (quartz `nip51Lists/followSet/FollowSetEvent.kt`): `KIND=30000`, `BLOCK_LIST_D_TAG="mute"`, `createBlockAddress(pubKey)`; `addUser(earlierVersion, pubKeyHex, relayHint, isPrivate, signer)`, `removeUser(earlierVersion, pubKeyHex, isUserPrivate, signer)`, `remove/removeAll`. -- `PeopleListDecryptionCache(signer)` (commons) — `userIdSet(event)` merges public+private, +- `FollowSetDecryptionCache(signer)` (commons) — `userIdSet(event)` merges public+private, **fail-closed** (`privateTags` → null / `UnauthorizedDecryptionException` on read-only). - `DesktopHiddenUsersState` **already loads the own kind-30000 block list** - (`blockListNote = cache.getOrCreateAddressableNote(PeopleListEvent.createBlockAddress(signer.pubKey))`). + (`blockListNote = cache.getOrCreateAddressableNote(FollowSetEvent.createBlockAddress(signer.pubKey))`). - `publishAndConfirmDetailed(event, relays, timeout): Map` (quartz `NostrClientPublishExt.kt`); `DmSendTracker.sendBatch` shows the confirmed-publish + status pattern. **WIRE (new, small):** -- `DesktopHiddenUsersState.currentBlockList(): PeopleListEvent?` — mirror the existing - `currentMuteList()` (`blockListNote.event as? PeopleListEvent`). +- `DesktopHiddenUsersState.currentBlockList(): FollowSetEvent?` — mirror the existing + `currentMuteList()` (`blockListNote.event as? FollowSetEvent`). - `DesktopIAccount.blockUser(pubkeyHex)` / `unblockUser(pubkeyHex)` + private `updateBlockList(tag, isPrivate, add)` — **mirror `updateMuteList` exactly**: load - `currentBlockList()`, `PeopleListEvent.addUser/removeUser` (or `create` **only if null**), + `currentBlockList()`, `FollowSetEvent.addUser/removeUser` (or `create` **only if null**), `localCache.justConsumeMyOwnEvent(event)` (optimistic), then publish. - Publish Block via **`publishAndConfirmDetailed`** (Block is security-relevant; Desktop NIP-42 AUTH is partial so fire-and-forget `broadcastToAll` — what `publishModeration` uses for mute — risks @@ -106,7 +106,7 @@ moderation-safety already merges mute ∪ block: `DesktopHiddenUsersState.assemb ## Acceptance criteria - [ ] DM opens the correct 1:1 room (`ChatroomKey(setOf(pubkey))` + `selectRoom`); graceful when target has no inbox relays / read-only. - [ ] Share yields a valid `npub` / `nostr:` link via `toNpub()`/`toNProfile()` + `copyToClipboard`. -- [ ] Add-to-list adds to a chosen pack (39089, `FollowListEvent.add`) and persists; zero-packs offers create-new; disabled until pack loaded. +- [ ] Add-to-list adds to a chosen pack (39089, `StarterPackEvent.add`) and persists; zero-packs offers create-new; disabled until pack loaded. - [ ] Block (30000, d=`mute`) hides user across feeds/threads/replies/profile (via existing `LiveHiddenUsers`); Unblock reverses. - [ ] Block round-trip preserves prior private entries (test mirrors `MuteListEventTest.add_eventTagPreservesPriorUserAndWordTags`); disabled until `currentBlockList()` loaded; decrypt-fail is fail-closed. - [ ] Block publish confirmed via `publishAndConfirmDetailed` (not fire-and-forget); optimistic UI rolls back on total failure. @@ -117,7 +117,7 @@ moderation-safety already merges mute ∪ block: `DesktopHiddenUsersState.assemb ## Open questions 1. **Block write location:** inline on `DesktopIAccount` symmetric with mute (recommended, overrides parent's "extract to commons") — confirm, or still want a `commons/actions` home (then extract mute+block together)? 2. Fold Block into a combined "block & mute" (Android-style dialog) or keep standalone? (parent Open Q #1) — note enforcement effect is identical since `LiveHiddenUsers` already unions both. -3. Add-member to a 39089 pack: `FollowListEvent.add` inline in `FollowPackActions` builder — any existing desktop add-member path to prefer? (agent found none.) +3. Add-member to a 39089 pack: `StarterPackEvent.add` inline in `FollowPackActions` builder — any existing desktop add-member path to prefer? (agent found none.) ### Resolved by research - ✅ moderation-safety already loads the own kind-30000 list and unions mute ∪ block in `LiveHiddenUsers` — **enforcement needs no new code** (retires parent Open Q #3 / #6). @@ -127,9 +127,9 @@ moderation-safety already merges mute ∪ block: `DesktopHiddenUsersState.assemb - Parent plan. Branch `origin/feat/desktop-moderation-safety`: `DesktopIAccount.kt` (`updateMuteList`, `hideUser/showUser`, `publishModeration`, header menu ~L350-390), `DesktopHiddenUsersState.kt` (`currentMuteList`, loads block via `createBlockAddress`, `assemble()`). -- quartz `nip51Lists/peopleList/PeopleListEvent.kt` (KIND 30000, d=`mute`, `addUser/removeUser`), - commons `PeopleListDecryptionCache.kt`, commons `IAccount.kt` (`LiveHiddenUsers.isUserHidden`), +- quartz `nip51Lists/followSet/FollowSetEvent.kt` (KIND 30000, d=`mute`, `addUser/removeUser`), + commons `FollowSetDecryptionCache.kt`, commons `IAccount.kt` (`LiveHiddenUsers.isUserHidden`), quartz `NostrClientPublishExt.kt` (`publishAndConfirm`/`Detailed`), `DmSendTracker.kt`. -- Packs/DM/share: `followpacks/FollowPacksState.kt` (`allPacks`), quartz `FollowListEvent.kt` +- Packs/DM/share: `followpacks/FollowPacksState.kt` (`allPacks`), quartz `StarterPackEvent.kt` (`add`), `ChatroomKey.kt`, `ui/chats/ChatroomListState.kt` (`selectRoom`, `fetchMetadataIfNeeded`), `ShareMenu.kt` (`copyToClipboard`), `nip19Bech32/ByteArrayExt.kt` (`toNpub`/`toNProfile`). diff --git a/desktopApp/plans/2026-07-27-feat-desktop-profile-parity-plan.md b/desktopApp/plans/2026-07-27-feat-desktop-profile-parity-plan.md index cecd64ede7..f6b948a473 100644 --- a/desktopApp/plans/2026-07-27-feat-desktop-profile-parity-plan.md +++ b/desktopApp/plans/2026-07-27-feat-desktop-profile-parity-plan.md @@ -20,11 +20,11 @@ Android (`amethyst/`), for both viewing and editing. Six parallel research/review agents (codebase-grounding, architecture, security, simplicity, flow-gaps, past-learnings) verified this plan against source. Key changes folded in: -1. **CORRECTION — Block list kind was wrong.** Block is **`PeopleListEvent` kind 30000, - d-tag `"mute"`** — *not* 30382. Kind 30382 is already **NIP-85 `ContactCardEvent` +1. **CORRECTION — Block list kind was wrong.** Block is **`FollowSetEvent` kind 30000, + d-tag `"mute"`** — *not* 30382. Kind 30382 is already **NIP-85 `UserAssertionEvent` (WoT/GrapeRank)** in this codebase; using it would collide with the WoT feature. -2. **Don't hand-roll Block.** `PeopleListEvent.addUser(earlierVersion, isPrivate=true)` + - `BlockPeopleListState` + `PeopleListDecryptionCache` already exist and do a correct, +2. **Don't hand-roll Block.** `FollowSetEvent.addUser(earlierVersion, isPrivate=true)` + + `BlockPeopleListState` + `FollowSetDecryptionCache` already exist and do a correct, fail-closed decrypt→merge→encrypt round-trip. The new commons builder is a **thin adapter**; **never call `create()` on an already-existing (replaceable) list** — that silently un-blocks everyone. @@ -54,7 +54,7 @@ Phases 2 and 3 are unpacked in dedicated grounded sub-plans (Phase 1/4 need no f enforcement code, no commons extraction** (retires Open Q #3 & #6, and the "unify enforcement now" task). 2. **Block is inline, not a commons builder.** moderation-safety keeps *mute* inline on `DesktopIAccount.updateMuteList`; Block mirrors it (`blockUser/unblockUser/updateBlockList`) using - quartz `PeopleListEvent.addUser/removeUser`. This overrides the "extract Block to `commons/actions/`" + quartz `FollowSetEvent.addUser/removeUser`. This overrides the "extract Block to `commons/actions/`" line below and resolves the architecture reviewer's two-parallel-patterns smell. (`FollowPackActions` may still be a thin commons builder.) 3. **`observeEvents` is a fallback, not a requirement.** Existing Desktop tabs use a cache-scan @@ -69,7 +69,7 @@ Phases 2 and 3 are unpacked in dedicated grounded sub-plans (Phase 1/4 need no f | mute/block/report missing on Desktop | mute + report built on **unmerged** `feat/desktop-moderation-safety` (`DesktopIAccount.hideUser/showUser/report`, desktop `ReportNoteDialog`, header overflow menu, `DesktopHiddenUsersState`, feed enforcement, NIP-36 blur). **Not in this worktree** (currently on `main`). | Build on top; do NOT re-implement mute/report. **Resolve the base first** (see Base Strategy). | | Extract mute/block/report to `commons` | moderation-safety put mute/report **desktop-local** on `DesktopIAccount` | New builders (Block, Add-to-pack) → `commons/actions/` (matches `FollowActions`/`ReportAction`). Route Block's **write** through `DesktopIAccount` symmetric with `updateMuteList`. Unify **enforcement** now; extract shared `HiddenUsersState` as follow-up. | | Rich-text bio needs extraction (open Q) | `DesktopRichTextViewer` + commons `RichTextParser` already render feed cards | **Reuse as-is** (~5-line wrap). Resolved. | -| Block = NIP-51 kind **30382** | 30382 = NIP-85 `ContactCardEvent` (WoT). Block = `PeopleListEvent` **30000**, d=`mute` | **Corrected throughout.** | +| Block = NIP-51 kind **30382** | 30382 = NIP-85 `UserAssertionEvent` (WoT). Block = `FollowSetEvent` **30000**, d=`mute` | **Corrected throughout.** | ## Base & Branch Strategy @@ -107,7 +107,7 @@ Phases 1–2 (banner, bio, CLINK, tabs) **don't depend on moderation-safety** an - **DM** → open Desktop Messages for this pubkey. - **Share** (copy `npub` / `nostr:` link). - **Add to list / follow-pack** (reuse Desktop Follow Packs). -- **Block** — `PeopleListEvent` **kind 30000, d=`mute`** (encrypted), distinct from the mute list; +- **Block** — `FollowSetEvent` **kind 30000, d=`mute`** (encrypted), distinct from the mute list; + Unblock. (Reuses existing quartz builder — see Technical Approach.) - **Edit Profile:** add the missing **CLINK offer** field (`noffer1…`). @@ -159,7 +159,7 @@ Write-path template must guard `pubKeyHex == account.signer.pubKey` (self) and ` | Edit dialog (add CLINK) | `desktopApp/.../desktop/ui/profile/EditProfileScreen.kt` + commons `profile/EditProfileFields.kt` | | Follow Packs (add-to-list target) | `desktopApp/.../desktop/followpacks/` | | Desktop Messages (DM target) | `desktopApp/.../desktop/ui/chats/` (`ChatroomListState.selectRoom(build1on1(pubkey))`) | -| **Block — reuse, don't rebuild** | quartz `nip51Lists/peopleList/PeopleListEvent.kt` (`KIND=30000`, `BLOCK_LIST_D_TAG="mute"`, `addUser(earlierVersion, isPrivate=true)`); Android `blockPeopleList/BlockPeopleListState.kt`; commons `PeopleListDecryptionCache.kt` | +| **Block — reuse, don't rebuild** | quartz `nip51Lists/followSet/FollowSetEvent.kt` (`KIND=30000`, `BLOCK_LIST_D_TAG="mute"`, `addUser(earlierVersion, isPrivate=true)`); Android `blockPeopleList/BlockPeopleListState.kt`; commons `FollowSetDecryptionCache.kt` | | Zap decryption | quartz `PrivateZapCache(signer)` / `PrivateZapRequestBuilder.decryptZapEvent`; Android `UserProfileZapsViewModel` | | Commons actions (pattern) | `commons/.../actions/FollowActions.kt`, `commons/.../model/nip56Reports/ReportAction.kt` | @@ -193,7 +193,7 @@ if (!account.isWriteable()) return // read-only/watch-only: no-op, U // 2. NIP-51 lists (Block): reuse existing decrypt→merge→encrypt; NEVER create() an existing list val current = blockListState.getBlockList() // must be LOADED from relays first (see guard) -val event = PeopleListEvent.addUser(earlierVersion = current, pubKeyHex, relayHint, isPrivate = true, signer) +val event = FollowSetEvent.addUser(earlierVersion = current, pubKeyHex, relayHint, isPrivate = true, signer) // fail-closed: addUser throws UnauthorizedDecryptionException rather than dropping private entries // 3. publish. Block = security-relevant → confirm, don't fire-and-forget @@ -247,7 +247,7 @@ account.justConsumeMyOwnEvent(event) // optimistic local apply, symmetric wit - **Add to list:** `commons/actions/FollowPackActions.kt` (build/publish kind 39089) + pack-picker modal; **zero-packs → offer "Create new pack"** (no dead-end). - **Block:** thin `commons/actions/BlockActions.kt` adapter over - `PeopleListEvent.addUser(earlierVersion, isPrivate=true)`; route write through `DesktopIAccount` + `FollowSetEvent.addUser(earlierVersion, isPrivate=true)`; route write through `DesktopIAccount` symmetric with `updateMuteList`; add to header overflow menu; enforce via unified hidden-set. ### Phase 4 — Tests & manual sheet @@ -334,9 +334,9 @@ Every tab defines **loading / empty / populated**; Followers + Zaps add a fourth - `amethyst/.../profile/mutual/dal/UserProfileMutualFeedFilter.kt` ### Verified shared / quartz -- `quartz/.../nip51Lists/peopleList/PeopleListEvent.kt` (KIND 30000, d=`mute`, `addUser`) +- `quartz/.../nip51Lists/followSet/FollowSetEvent.kt` (KIND 30000, d=`mute`, `addUser`) - `amethyst/.../model/nip51Lists/blockPeopleList/BlockPeopleListState.kt` -- `commons/.../model/nip51Lists/peopleList/PeopleListDecryptionCache.kt` +- `commons/.../model/nip51Lists/followSets/FollowSetDecryptionCache.kt` - quartz `PrivateZapCache` / `PrivateZapRequestBuilder.decryptZapEvent` - `commons/.../actions/FollowActions.kt`, `commons/.../model/nip56Reports/ReportAction.kt` - `commons/.../richtext/RichTextParser.kt`, `desktopApp/.../ui/note/DesktopRichTextViewer.kt` diff --git a/desktopApp/plans/2026-07-27-feat-desktop-profile-tabs-plan.md b/desktopApp/plans/2026-07-27-feat-desktop-profile-tabs-plan.md index 583efaa3fa..c7dfd9a6bc 100644 --- a/desktopApp/plans/2026-07-27-feat-desktop-profile-tabs-plan.md +++ b/desktopApp/plans/2026-07-27-feat-desktop-profile-tabs-plan.md @@ -56,7 +56,7 @@ override val privateZapsDecryptionCache: IPrivateZapsDecryptionCache = PrivateZa lazily decrypts per event via `signer.decryptZapEvent`. Gate on `isWriteable()`; decrypt **own-profile only** (Android `UserProfileZapsViewModel:71-85`: `if (user.pubkeyHex == account.pubKey)` decrypt else fall back to `zapRequest.pubKey`). Lazy per row — a NIP-46 bunker does one round-trip -per zap. Kinds: `LnZapEvent.KIND=9735`, `OnchainZapEvent.KIND=8333`. +per zap. Kinds: `ZapReceiptEvent.KIND=9735`, `OnchainZapEvent.KIND=8333`. ## Per-tab implementation diff --git a/desktopApp/plans/2026-08-06-shared-metadata-loading-upstream-reconciliation-plan.md b/desktopApp/plans/2026-08-06-shared-metadata-loading-upstream-reconciliation-plan.md index e30caa24c7..562dca4e0b 100644 --- a/desktopApp/plans/2026-08-06-shared-metadata-loading-upstream-reconciliation-plan.md +++ b/desktopApp/plans/2026-08-06-shared-metadata-loading-upstream-reconciliation-plan.md @@ -23,7 +23,7 @@ Upstream re-architected the same subsystem but **also completed the model-sharin - `watchers/UserWatcherSubAssembler.kt` — commons `BaseEoseManager`; reads `account.indexerRelayList.flow.value`; calls `filterUserMetadataForKey(...)`. - `watchers/FilterUserMetadataForKey.kt` — emits `ExplainedFilter(PROFILE_METADATA, …)`; reads `LocalCache.relayHints.hintsForKey(...)` **statically** (the one non-injected cache ref). - `watchers/UserReportsSubAssembler.kt` — commons `SingleSubEoseManager`; reads `account.declaredFollowsPerOutboxRelay.value`, `account.userProfile().pubkeyHex`. -- `watchers/UserCardsSubAssembler.kt` — commons `SingleSubEoseManager`; reads `account.homeRelays.flow.value`, `account.trustProviderList.liveUserRankProvider`, `…liveUserFollowerCount`, `account.userProfile().pubkeyHex`; emits `filterContactCardsToTargetKeysFromTrustedAccountsInTheRelay(...)` (already in commons `assemblers/ContactCardFilters.kt`). +- `watchers/UserCardsSubAssembler.kt` — commons `SingleSubEoseManager`; reads `account.homeRelays.flow.value`, `account.trustProviderList.liveUserRankProvider`, `…liveUserFollowerCount`, `account.userProfile().pubkeyHex`; emits `filterUserAssertionsToTargetKeysFromTrustedAccountsInTheRelay(...)` (already in commons `assemblers/ContactCardFilters.kt`). ### Event side — `amethyst/.../reqCommand/event/` - `EventFinderFilterAssembler.kt` — `EventFinderQueryState(note, override val account: Account) : AccountScopedQuery`; groups `NoteEventLoaderSubAssembler`, `EventWatcherSubAssembler`, `AddressableAuthorRelayLoaderSubAssembler(cache, ::allKeys, userFinder)`. diff --git a/desktopApp/plans/archive/2026-05-09-embedded-local-relay-plan.md b/desktopApp/plans/archive/2026-05-09-embedded-local-relay-plan.md index 72cfbb3080..6d675db17a 100644 --- a/desktopApp/plans/archive/2026-05-09-embedded-local-relay-plan.md +++ b/desktopApp/plans/archive/2026-05-09-embedded-local-relay-plan.md @@ -290,7 +290,7 @@ SQLiteEventStore's `DeletionRequestModule` already handles this via BEFORE INSER - When kind 5 event inserted → trigger deletes referenced events - Trigger also prevents re-insertion of deleted events -**Desktop hook**: In `DesktopLocalCache.consume()`, when a `DeletionEvent` is consumed, the write-through to `LocalRelayStore.enqueue()` handles it — the store's trigger does the rest. +**Desktop hook**: In `DesktopLocalCache.consume()`, when a `DeletionRequestEvent` is consumed, the write-through to `LocalRelayStore.enqueue()` handles it — the store's trigger does the rest. #### Pruning Strategy Add to `LocalRelayStore`: diff --git a/desktopApp/plans/archive/2026-05-11-wallet-zapping-brainstorm.md b/desktopApp/plans/archive/2026-05-11-wallet-zapping-brainstorm.md index 102d27904e..a9435ffd03 100644 --- a/desktopApp/plans/archive/2026-05-11-wallet-zapping-brainstorm.md +++ b/desktopApp/plans/archive/2026-05-11-wallet-zapping-brainstorm.md @@ -44,7 +44,7 @@ - Biometric auth for sensitive operations ### Shared Infrastructure (Already in quartz/commons) -- **NIP-57:** `LnZapEvent`, `LnZapRequestEvent`, `LnZapPrivateEvent`, private zap encryption +- **NIP-57:** `ZapReceiptEvent`, `ZapRequestEvent`, `PrivateZapEvent`, private zap encryption - **NIP-47:** `Nip47Client`, request/response events, full RPC method set - **NIP-60:** `CashuWalletEvent`, `CashuTokenEvent`, `CashuSpendingHistoryEvent` (skeleton) - **NIP-61:** `NutzapEvent`, `NutzapInfoEvent` (skeleton) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index c3aa87d9c9..ce53aa2bde 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -166,8 +166,9 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket +import com.vitorpamplona.quartz.nip04Dm.messages.EncryptedDmEvent import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable -import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.nip17Dm.settings.DmRelayListEvent import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent @@ -1817,7 +1818,7 @@ fun MainContent( kinds = listOf( AdvertisedRelayListEvent.KIND, - ChatMessageRelayListEvent.KIND, + DmRelayListEvent.KIND, SearchRelayListEvent.KIND, BlockedRelayListEvent.KIND, BlossomServersEvent.KIND, @@ -1845,7 +1846,7 @@ fun MainContent( // dmInboxRelaysStrict, self-copy resolution) alongside // accountRelays' persisted copy. if (event is AdvertisedRelayListEvent || - event is ChatMessageRelayListEvent || + event is DmRelayListEvent || event is BlossomServersEvent || event is MuteListEvent ) { @@ -1877,7 +1878,7 @@ fun MainContent( kinds = listOf( AdvertisedRelayListEvent.KIND, - ChatMessageRelayListEvent.KIND, + DmRelayListEvent.KIND, SearchRelayListEvent.KIND, BlockedRelayListEvent.KIND, BlossomServersEvent.KIND, @@ -1926,7 +1927,7 @@ fun MainContent( // Process into chatroomList based on event type when (event) { - is com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent -> { + is EncryptedDmEvent -> { iAccount.chatroomList.addMessage( event.chatroomKey(iAccount.pubKey), note, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt index 1d201d6c6c..40736a1fa6 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt @@ -44,7 +44,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.aTag.taggedAddresses import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent -import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.nip17Dm.settings.DmRelayListEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip18Reposts.quotes.QAddressableTag @@ -52,17 +52,17 @@ import com.vitorpamplona.quartz.nip18Reposts.quotes.QEventTag import com.vitorpamplona.quartz.nip18Reposts.quotes.taggedQuotes import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull import com.vitorpamplona.quartz.nip22Comments.CommentEvent -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent -import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent -import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcRequestEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcResponseEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent -import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import com.vitorpamplona.quartz.nip51Lists.starterPack.StarterPackEvent import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapRequestEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent @@ -310,11 +310,11 @@ class DesktopLocalCache : ICacheProvider { consumeReaction(event, relay) } - is LnZapRequestEvent -> { + is ZapRequestEvent -> { consumeZapRequest(event, relay) } - is LnZapEvent -> { + is ZapReceiptEvent -> { consumeZap(event, relay) } @@ -330,8 +330,8 @@ class DesktopLocalCache : ICacheProvider { consumeContactList(event) } - is LongTextNoteEvent -> { - consumeLongTextNote(event, relay) + is LongFormContentEvent -> { + consumeLongFormContent(event, relay) } is BookmarkListEvent -> { @@ -342,8 +342,8 @@ class DesktopLocalCache : ICacheProvider { consumeOldBookmarkList(event) } - is FollowListEvent -> { - consumeFollowList(event) + is StarterPackEvent -> { + consumeStarterPack(event) } is CommentEvent -> { @@ -354,8 +354,8 @@ class DesktopLocalCache : ICacheProvider { consumeAdvertisedRelayList(event, relay) } - is ChatMessageRelayListEvent -> { - consumeChatMessageRelayList(event, relay) + is DmRelayListEvent -> { + consumeDmRelayList(event, relay) } is BlossomServersEvent -> { @@ -435,8 +435,8 @@ class DesktopLocalCache : ICacheProvider { * indexer fan-out. Mirrors [consumeAdvertisedRelayList]; emits nothing to * the event stream because the UI doesn't render kind 10050s directly. */ - private fun consumeChatMessageRelayList( - event: ChatMessageRelayListEvent, + private fun consumeDmRelayList( + event: DmRelayListEvent, relay: NormalizedRelayUrl?, ): Boolean { val addressableNote = getOrCreateAddressableNote(event.address()) @@ -586,10 +586,10 @@ class DesktopLocalCache : ICacheProvider { /** * Consumes a kind 9734 zap request event. - * Must be consumed before the corresponding LnZapEvent (kind 9735). + * Must be consumed before the corresponding ZapReceiptEvent (kind 9735). */ private fun consumeZapRequest( - event: LnZapRequestEvent, + event: ZapRequestEvent, relay: NormalizedRelayUrl?, ): Boolean { val note = getOrCreateNote(event.id) @@ -605,7 +605,7 @@ class DesktopLocalCache : ICacheProvider { * Links zap to target notes via the embedded zap request. */ private fun consumeZap( - event: LnZapEvent, + event: ZapReceiptEvent, relay: NormalizedRelayUrl?, ): Boolean { val note = getOrCreateNote(event.id) @@ -738,8 +738,8 @@ class DesktopLocalCache : ICacheProvider { * Consumes a kind 30023 long-form text note event. * Creates Note in cache like TextNoteEvent. */ - private fun consumeLongTextNote( - event: LongTextNoteEvent, + private fun consumeLongFormContent( + event: LongFormContentEvent, relay: NormalizedRelayUrl?, ): Boolean { val note = getOrCreateNote(event.id) @@ -788,7 +788,7 @@ class DesktopLocalCache : ICacheProvider { private val _followPackVersion = MutableStateFlow(0L) val followPackVersion: StateFlow = _followPackVersion.asStateFlow() - private fun consumeFollowList(event: FollowListEvent): Boolean { + private fun consumeStarterPack(event: StarterPackEvent): Boolean { val address = event.address() val addressableNote = getOrCreateAddressableNote(address) val author = getOrCreateUser(event.pubKey) @@ -883,14 +883,14 @@ class DesktopLocalCache : ICacheProvider { } /** - * Snapshot all 39089 (FollowListEvent) addressable notes currently in cache. + * Snapshot all 39089 (StarterPackEvent) addressable notes currently in cache. * Use [followPackVersion] to drive reactive recomputation. */ - fun snapshotFollowPacks(): List { - val out = mutableListOf() + fun snapshotFollowPacks(): List { + val out = mutableListOf() addressableNotes.forEach { _, note -> val ev = note.event - if (ev is FollowListEvent) out.add(ev) + if (ev is StarterPackEvent) out.add(ev) } return out } @@ -908,10 +908,10 @@ class DesktopLocalCache : ICacheProvider { * @return true if event was processed, false if already seen */ fun consume( - event: LnZapPaymentRequestEvent, + event: NwcRequestEvent, zappedNote: Note?, relay: NormalizedRelayUrl?, - onResponse: suspend (LnZapPaymentResponseEvent) -> Unit, + onResponse: suspend (NwcResponseEvent) -> Unit, wasVerified: Boolean = false, ): Boolean { if (!wasVerified && !justVerify(event)) return false @@ -945,7 +945,7 @@ class DesktopLocalCache : ICacheProvider { */ @OptIn(DelicateCoroutinesApi::class) fun consume( - event: LnZapPaymentResponseEvent, + event: NwcResponseEvent, relay: NormalizedRelayUrl?, wasVerified: Boolean = false, ): Boolean { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessViewModelNew.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessViewModelNew.kt index d120a24714..3a5123f8b7 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessViewModelNew.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessViewModelNew.kt @@ -23,12 +23,12 @@ package com.vitorpamplona.amethyst.desktop.chess import com.vitorpamplona.amethyst.commons.model.cache.UserMetadataCache import com.vitorpamplona.amethyst.commons.nip64Chess.ChessBroadcastStatus import com.vitorpamplona.amethyst.commons.nip64Chess.ChessChallenge -import com.vitorpamplona.amethyst.commons.nip64Chess.ChessDismissedGamesStorage import com.vitorpamplona.amethyst.commons.nip64Chess.ChessLobbyLogic import com.vitorpamplona.amethyst.commons.nip64Chess.ChessPollingDefaults import com.vitorpamplona.amethyst.commons.nip64Chess.ChessSyncStatus import com.vitorpamplona.amethyst.commons.nip64Chess.CompletedGame import com.vitorpamplona.amethyst.commons.nip64Chess.PublicGame +import com.vitorpamplona.amethyst.commons.nip64Chess.desktopChessDismissedGamesStore import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.quartz.nip01Core.core.Event @@ -60,7 +60,7 @@ class DesktopChessViewModelNew( private val publisher = DesktopChessPublisher(account, relayManager) private val fetcher = DesktopRelayFetcher(relayManager, account.pubKeyHex) private val metadataProvider = DesktopMetadataProvider(userMetadataCache) - private val dismissedStorage = ChessDismissedGamesStorage.create() + private val dismissedStorage = desktopChessDismissedGamesStore() // Shared business logic (creates its own ChessLobbyState internally) private val logic = diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/feeds/DesktopFeedFilters.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/feeds/DesktopFeedFilters.kt index 2dba33235d..9e0830cebe 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/feeds/DesktopFeedFilters.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/feeds/DesktopFeedFilters.kt @@ -35,9 +35,9 @@ import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent private fun isFeedNote(event: Event?): Boolean = @@ -317,11 +317,11 @@ class DesktopReadsFeedFilter( override fun feed(): List = cache.notes - .filterIntoSet { _, note -> note.event is LongTextNoteEvent && !note.isHiddenFor(hidden()) } + .filterIntoSet { _, note -> note.event is LongFormContentEvent && !note.isHiddenFor(hidden()) } .sortedWith(DefaultFeedOrder) .take(limit()) - override fun applyFilter(newItems: Set): Set = newItems.filterTo(HashSet()) { it.event is LongTextNoteEvent && !it.isHiddenFor(hidden()) } + override fun applyFilter(newItems: Set): Set = newItems.filterTo(HashSet()) { it.event is LongFormContentEvent && !it.isHiddenFor(hidden()) } override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder) @@ -342,7 +342,7 @@ class DesktopNotificationFeedFilter( setOf( TextNoteEvent.KIND, ReactionEvent.KIND, - LnZapEvent.KIND, + ZapReceiptEvent.KIND, ) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/BulkFollowAction.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/BulkFollowAction.kt index bcae97ce02..662984deaf 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/BulkFollowAction.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/BulkFollowAction.kt @@ -25,7 +25,7 @@ import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount import com.vitorpamplona.amethyst.desktop.network.RelayConnectionManager import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import com.vitorpamplona.quartz.nip51Lists.starterPack.StarterPackEvent data class BulkFollowPreview( val totalMembers: Int, @@ -52,7 +52,7 @@ object BulkFollowAction { * cached metadata (so the bulk-follow always carries relay hints). */ fun computePreview( - pack: FollowListEvent, + pack: StarterPackEvent, cache: DesktopLocalCache, currentFollows: Set, myPubkey: HexKey, @@ -101,7 +101,7 @@ object BulkFollowAction { /** Unfollows every pack member that the user currently follows. */ suspend fun commitUnfollowAll( - pack: FollowListEvent, + pack: StarterPackEvent, iAccount: DesktopIAccount, relayManager: RelayConnectionManager, cache: DesktopLocalCache, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/FollowPackEditor.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/FollowPackEditor.kt index 0e11b07b30..50ab804017 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/FollowPackEditor.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/FollowPackEditor.kt @@ -21,7 +21,7 @@ package com.vitorpamplona.amethyst.desktop.followpacks import com.vitorpamplona.quartz.nip01Core.core.Address -import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import com.vitorpamplona.quartz.nip51Lists.starterPack.StarterPackEvent /** * Lightweight helpers for follow-pack viewing actions. @@ -30,12 +30,12 @@ import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent */ object FollowPackEditor { /** Encodes the pack as a `nostr:naddr1…` URI for sharing via clipboard. */ - fun toShareUri(pack: FollowListEvent): String { + fun toShareUri(pack: StarterPackEvent): String { val naddr = com.vitorpamplona.quartz.nip19Bech32.entities .NAddress .create( - kind = FollowListEvent.KIND, + kind = StarterPackEvent.KIND, pubKeyHex = pack.pubKey, dTag = pack.dTag(), relays = emptyList(), @@ -44,5 +44,5 @@ object FollowPackEditor { } /** Address coordinate (e.g. "39089::") — stable identity. */ - fun aTag(pack: FollowListEvent): String = Address.assemble(FollowListEvent.KIND, pack.pubKey, pack.dTag()) + fun aTag(pack: StarterPackEvent): String = Address.assemble(StarterPackEvent.KIND, pack.pubKey, pack.dTag()) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/FollowPacksState.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/FollowPacksState.kt index cb506f3282..cbb0465f48 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/FollowPacksState.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/FollowPacksState.kt @@ -28,7 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer -import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import com.vitorpamplona.quartz.nip51Lists.starterPack.StarterPackEvent import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay @@ -81,7 +81,7 @@ class FollowPacksState( } /** All 39089 events currently in the cache. Recomputes on cache change. */ - val allPacks: StateFlow> = + val allPacks: StateFlow> = cache.followPackVersion .map { cache.snapshotFollowPacks().sortedByDescending { it.createdAt } } .stateIn( @@ -91,7 +91,7 @@ class FollowPacksState( ) /** Packs authored by accounts the user follows. Top-3 for sidebar. */ - val followedAuthorsPacks: StateFlow> = + val followedAuthorsPacks: StateFlow> = combine(allPacks, followedAuthors) { packs, followed -> packs.filter { it.pubKey in followed } }.stateIn( @@ -104,7 +104,7 @@ class FollowPacksState( * Sidebar top-3: own packs + packs by people user follows. * Falls back to most-recent-from-relays when user has no follow-derived packs. */ - val sidebarTopPacks: StateFlow> = + val sidebarTopPacks: StateFlow> = followedAuthorsPacks .map { followedPacks -> if (followedPacks.isNotEmpty()) { @@ -128,8 +128,8 @@ class FollowPacksState( private val recentlyShown = ArrayDeque(5) - private val featuredPackInternal = MutableStateFlow(null) - val featuredPack: StateFlow = featuredPackInternal.asStateFlow() + private val featuredPackInternal = MutableStateFlow(null) + val featuredPack: StateFlow = featuredPackInternal.asStateFlow() init { // Recompute featured on cache or shuffle change. @@ -147,7 +147,7 @@ class FollowPacksState( shuffleTickInternal.value++ } - private fun pickRandom(packs: List): FollowListEvent? { + private fun pickRandom(packs: List): StarterPackEvent? { if (packs.isEmpty()) return null val candidates = packs.filter { it.address().toValue() !in recentlyShown } val pool = if (candidates.isEmpty()) packs else candidates @@ -182,7 +182,7 @@ class FollowPacksState( relayManager.subscribeOnRelay( relay = relay, subId = "$SUB_ID-${relay.url.hashCode()}", - filters = listOf(Filter(kinds = listOf(FollowListEvent.KIND), limit = 200)), + filters = listOf(Filter(kinds = listOf(StarterPackEvent.KIND), limit = 200)), onEvent = { event, r -> cache.consume(event, r) }, ) // Suppress unused variable warning @@ -193,5 +193,5 @@ class FollowPacksState( } /** Returns hashtags present on this pack, lowercase + deduped. */ - fun hashtagsFor(pack: FollowListEvent): List = pack.hashtags().map { it.lowercase() }.distinct() + fun hashtagsFor(pack: StarterPackEvent): List = pack.hashtags().map { it.lowercase() }.distinct() } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/ui/DiscoverScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/ui/DiscoverScreen.kt index 13a34faf40..fb3f820b52 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/ui/DiscoverScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/ui/DiscoverScreen.kt @@ -71,7 +71,7 @@ import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount import com.vitorpamplona.amethyst.desktop.network.RelayConnectionManager import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback import com.vitorpamplona.amethyst.desktop.ui.live.LivesSection -import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import com.vitorpamplona.quartz.nip51Lists.starterPack.StarterPackEvent import kotlinx.coroutines.launch import java.awt.Toolkit import java.awt.datatransfer.StringSelection @@ -280,7 +280,7 @@ fun DiscoverScreen( @Composable private fun MiniPackThumb( - pack: FollowListEvent, + pack: StarterPackEvent, onClick: () -> Unit, ) { val title = pack.title()?.ifBlank { null } ?: "Untitled pack" diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/ui/FollowAllConfirmDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/ui/FollowAllConfirmDialog.kt index d5ffe5d4e5..be3285ac81 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/ui/FollowAllConfirmDialog.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/ui/FollowAllConfirmDialog.kt @@ -42,7 +42,7 @@ import com.vitorpamplona.amethyst.desktop.followpacks.BulkFollowAction import com.vitorpamplona.amethyst.desktop.followpacks.BulkFollowPreview import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount import com.vitorpamplona.amethyst.desktop.network.RelayConnectionManager -import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import com.vitorpamplona.quartz.nip51Lists.starterPack.StarterPackEvent import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -54,7 +54,7 @@ import kotlinx.coroutines.launch */ @Composable fun FollowAllConfirmDialog( - pack: FollowListEvent, + pack: StarterPackEvent, iAccount: DesktopIAccount, cache: DesktopLocalCache, relayManager: RelayConnectionManager, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/ui/FollowPackBrowseAllScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/ui/FollowPackBrowseAllScreen.kt index 60ed6c4164..33a0c974bc 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/ui/FollowPackBrowseAllScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/ui/FollowPackBrowseAllScreen.kt @@ -53,7 +53,7 @@ import com.vitorpamplona.amethyst.desktop.followpacks.FollowPackEditor import com.vitorpamplona.amethyst.desktop.followpacks.FollowPacksState import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull -import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import com.vitorpamplona.quartz.nip51Lists.starterPack.StarterPackEvent /** * Search-all view for follow packs. @@ -138,10 +138,10 @@ fun FollowPackBrowseAllScreen( } private fun filter( - packs: List, + packs: List, raw: String, cache: DesktopLocalCache, -): List { +): List { val q = raw.lowercase() val asHex: HexKey? = decodePublicKeyAsHexOrNull(raw) return packs.filter { pack -> diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/ui/FollowPackComponents.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/ui/FollowPackComponents.kt index a3c78d9a1e..f684915b26 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/ui/FollowPackComponents.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/ui/FollowPackComponents.kt @@ -62,7 +62,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import com.vitorpamplona.quartz.nip51Lists.starterPack.StarterPackEvent /** * Stack of overlapping circular avatars for pack members. @@ -143,7 +143,7 @@ fun FollowPackAvatarStack( */ @Composable fun FollowPackHeroCard( - pack: FollowListEvent, + pack: StarterPackEvent, cache: DesktopLocalCache, onFollowAll: () -> Unit, onShare: () -> Unit, @@ -284,7 +284,7 @@ fun FollowPackChipRail( */ @Composable fun FollowPackRow( - pack: FollowListEvent, + pack: StarterPackEvent, cache: DesktopLocalCache, onClick: () -> Unit, modifier: Modifier = Modifier, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/ui/FollowPackDetailScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/ui/FollowPackDetailScreen.kt index ae07b1d85d..f925e3ab3e 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/ui/FollowPackDetailScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/ui/FollowPackDetailScreen.kt @@ -68,7 +68,7 @@ import com.vitorpamplona.amethyst.desktop.followpacks.subscribeMetadataFor import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount import com.vitorpamplona.amethyst.desktop.network.RelayConnectionManager import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import com.vitorpamplona.quartz.nip51Lists.starterPack.StarterPackEvent import kotlinx.coroutines.launch /** @@ -94,7 +94,7 @@ fun FollowPackDetailScreen( @Suppress("UNUSED_VARIABLE") val metadataVersion by cache.metadataVersion.collectAsState() - val pack: FollowListEvent? = + val pack: StarterPackEvent? = remember(addressTag, allPacks) { allPacks.firstOrNull { FollowPackEditor.aTag(it) == addressTag } } @@ -208,7 +208,7 @@ fun FollowPackDetailScreen( @Composable private fun PackHeroDetail( - pack: FollowListEvent, + pack: StarterPackEvent, cache: DesktopLocalCache, onFollowAll: () -> Unit, onShare: () -> Unit, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/ui/RenderFollowPackCard.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/ui/RenderFollowPackCard.kt index 3a1bc3f9b6..1821eaf2d7 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/ui/RenderFollowPackCard.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/ui/RenderFollowPackCard.kt @@ -58,7 +58,7 @@ import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount import com.vitorpamplona.amethyst.desktop.network.RelayConnectionManager import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter -import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import com.vitorpamplona.quartz.nip51Lists.starterPack.StarterPackEvent import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress as NAddressEntity /** @@ -81,7 +81,7 @@ fun RenderFollowPackCard( val packVersion by cache.followPackVersion.collectAsState() val aTag = remember(address) { Address.assemble(address.kind, address.author, address.dTag) } - val pack: FollowListEvent? = + val pack: StarterPackEvent? = remember(packVersion, aTag) { cache.snapshotFollowPacks().firstOrNull { FollowPackEditor.aTag(it) == aTag } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopAccountRelays.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopAccountRelays.kt index 6a911cb6af..ef8963f466 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopAccountRelays.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopAccountRelays.kt @@ -25,7 +25,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer -import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.nip17Dm.settings.DmRelayListEvent import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent @@ -45,7 +45,7 @@ import java.util.prefs.Preferences * simpler relay management. * * On Android, Account.dmRelays aggregates: - * - DmRelayListState (ChatMessageRelayListEvent, kind 10050) + * - DmRelayListState (DmRelayListEvent, kind 10050) * - Nip65RelayListState (NIP-65 advertised relays) * - PrivateStorageRelayListState * - LocalRelayListState @@ -119,8 +119,8 @@ class DesktopAccountRelays( private fun loadFromPersistence() { // Load DM relays from event or URL cache - val dmEvent = loadEvent(ChatMessageRelayListEvent.KIND) - if (dmEvent is ChatMessageRelayListEvent) { + val dmEvent = loadEvent(DmRelayListEvent.KIND) + if (dmEvent is DmRelayListEvent) { _dmRelayList.value = dmEvent.relays().toSet() lastDmCreatedAt.set(dmEvent.createdAt) } else { @@ -160,8 +160,8 @@ class DesktopAccountRelays( true } - ChatMessageRelayListEvent.KIND -> { - if (event is ChatMessageRelayListEvent && event.createdAt >= lastDmCreatedAt.get()) { + DmRelayListEvent.KIND -> { + if (event is DmRelayListEvent && event.createdAt >= lastDmCreatedAt.get()) { lastDmCreatedAt.set(event.createdAt) val relays = event.relays().toSet() _dmRelayList.value = relays diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopDmRelayState.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopDmRelayState.kt index 3a193f95ec..9bd76f9ef6 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopDmRelayState.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopDmRelayState.kt @@ -33,7 +33,7 @@ import kotlinx.coroutines.flow.stateIn * Desktop equivalent of DmInboxRelayState. * * Aggregates DM inbox relays from multiple sources: - * - DM relay list (NIP-17 ChatMessageRelayListEvent, kind 10050) + * - DM relay list (NIP-17 DmRelayListEvent, kind 10050) * - Connected relays (fallback when no DM-specific relays are configured) * * On Android, the full Account class manages four relay sources: diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopHiddenUsersState.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopHiddenUsersState.kt index 81356eadc7..ed87d85e64 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopHiddenUsersState.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopHiddenUsersState.kt @@ -23,11 +23,11 @@ package com.vitorpamplona.amethyst.desktop.model import com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers import com.vitorpamplona.amethyst.commons.model.NoteState import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider +import com.vitorpamplona.amethyst.commons.model.nip51Lists.followSets.FollowSetDecryptionCache import com.vitorpamplona.amethyst.commons.model.nip51Lists.muteList.MuteListDecryptionCache -import com.vitorpamplona.amethyst.commons.model.nip51Lists.peopleList.PeopleListDecryptionCache import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip51Lists.followSet.FollowSetEvent import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent -import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import com.vitorpamplona.quartz.utils.DualCase import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -43,12 +43,12 @@ import kotlinx.coroutines.flow.stateIn * Desktop mute/block state holder. * * Assembles the user's NIP-51 mute list (kind 10000, [MuteListEvent]) and the - * legacy block people list (kind 30000 `d=mute`, [PeopleListEvent]) into a single + * legacy block people list (kind 30000 `d=mute`, [FollowSetEvent]) into a single * [LiveHiddenUsers] value that [com.vitorpamplona.amethyst.desktop.model.DesktopIAccount] * feeds to [com.vitorpamplona.amethyst.commons.model.Note.isHiddenFor]. * * Both lists carry a mix of public tags and an NIP-44-encrypted private section; - * the shared [MuteListDecryptionCache]/[PeopleListDecryptionCache] handle the + * the shared [MuteListDecryptionCache]/[FollowSetDecryptionCache] handle the * async decrypt (and are no-ops for read-only accounts, which simply see the * public portion). The result is exposed as a hot [StateFlow] so feeds can * re-filter live: when the list events change (or decryption resolves), a new @@ -65,11 +65,11 @@ class DesktopHiddenUsersState( private val showSensitiveContent: StateFlow = MutableStateFlow(null), ) { private val muteCache = MuteListDecryptionCache(signer) - private val blockCache = PeopleListDecryptionCache(signer) + private val blockCache = FollowSetDecryptionCache(signer) // Strong refs so the GC keeps these addressable notes (and their decrypt caches) alive. private val muteListNote = cache.getOrCreateAddressableNote(MuteListEvent.createAddress(signer.pubKey)) - private val blockListNote = cache.getOrCreateAddressableNote(PeopleListEvent.createBlockAddress(signer.pubKey)) + private val blockListNote = cache.getOrCreateAddressableNote(FollowSetEvent.createBlockAddress(signer.pubKey)) /** Session-only user hides (e.g. "hide this spammer" without persisting a mute). */ val transientHiddenUsers = MutableStateFlow>(emptySet()) @@ -79,7 +79,7 @@ class DesktopHiddenUsersState( private suspend fun assemble( muteEvent: MuteListEvent?, - blockEvent: PeopleListEvent?, + blockEvent: FollowSetEvent?, transient: Set, showSensitive: Boolean?, ): LiveHiddenUsers { @@ -118,7 +118,7 @@ class DesktopHiddenUsersState( ) { muteState, blockState, transient, showSensitive -> assemble( muteState.note.event as? MuteListEvent, - blockState.note.event as? PeopleListEvent, + blockState.note.event as? FollowSetEvent, transient, showSensitive, ) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt index e68f95c233..b1c29e32ee 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt @@ -46,12 +46,12 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent -import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip04Dm.messages.EncryptedDmEvent import com.vitorpamplona.quartz.nip17Dm.NIP17Factory import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent -import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent -import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcRequestEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcResponseEvent import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent @@ -222,9 +222,9 @@ class DesktopIAccount( override val nip47SignerState: INwcSignerState = object : INwcSignerState { - override suspend fun decryptResponse(event: LnZapPaymentResponseEvent): Response? = null + override suspend fun decryptResponse(event: NwcResponseEvent): Response? = null - override suspend fun decryptRequest(event: LnZapPaymentRequestEvent): Request? = null + override suspend fun decryptRequest(event: NwcRequestEvent): Request? = null override fun isNIP47Author(pubKey: String?): Boolean = false } @@ -249,7 +249,7 @@ class DesktopIAccount( return !note.isHiddenFor(hiddenUsersState.flow.value) } - override suspend fun sendNip04PrivateMessage(eventTemplate: EventTemplate) { + override suspend fun sendNip04PrivateMessage(eventTemplate: EventTemplate) { if (!isWriteable()) return val signedEvent = signer.sign(eventTemplate) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopRelayListMutator.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopRelayListMutator.kt index d704b2b7d6..07dfb77539 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopRelayListMutator.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopRelayListMutator.kt @@ -27,7 +27,7 @@ import com.vitorpamplona.amethyst.commons.relays.health.RelayRemovalResult import com.vitorpamplona.amethyst.desktop.network.RelayConnectionManager import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.nip17Dm.settings.DmRelayListEvent import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent import kotlinx.coroutines.async @@ -80,7 +80,7 @@ class DesktopRelayListMutator( async { runCatching { val newRelays = (dmRelays - url).toList() - val event = ChatMessageRelayListEvent.create(newRelays, signer) + val event = DmRelayListEvent.create(newRelays, signer) accountRelays.consumePublishedEvent(event) relayManager.broadcastToAll(event) }.fold(onSuccess = { null }, onFailure = { RelayListKind.DmInbox }) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nwc/NwcPaymentHandler.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nwc/NwcPaymentHandler.kt index 4fcd463061..514d85b0b2 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nwc/NwcPaymentHandler.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/nwc/NwcPaymentHandler.kt @@ -29,8 +29,8 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip47WalletConnect.Nip47Client import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect -import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent -import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcRequestEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcResponseEvent import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBalanceSuccessResponse import com.vitorpamplona.quartz.nip47WalletConnect.rpc.IErrorResponseLike import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeInvoiceSuccessResponse @@ -93,7 +93,7 @@ class NwcPaymentHandler( // Create payment request event val requestEvent = - LnZapPaymentRequestEvent.create( + NwcRequestEvent.create( lnInvoice = bolt11, walletServicePubkey = nwcConnection.pubKeyHex, signer = nwcSigner, @@ -126,7 +126,7 @@ class NwcPaymentHandler( suspendCancellableCoroutine { continuation -> val filter = Filter( - kinds = listOf(LnZapPaymentResponseEvent.KIND), + kinds = listOf(NwcResponseEvent.KIND), authors = listOf(nwcConnection.pubKeyHex), tags = mapOf("e" to listOf(requestId)), ) @@ -138,7 +138,7 @@ class NwcPaymentHandler( subId = subId, filters = listOf(filter), onEvent = { event, relay -> - if (event is LnZapPaymentResponseEvent && event.requestId() == requestId) { + if (event is NwcResponseEvent && event.requestId() == requestId) { // Move verify + cache mutation + decrypt off the relay's // WebSocket reader thread; Schnorr verify is non-trivial // CPU work and shouldn't block frame parsing. @@ -313,7 +313,7 @@ class NwcPaymentHandler( suspendCancellableCoroutine { continuation -> val filter = Filter( - kinds = listOf(LnZapPaymentResponseEvent.KIND), + kinds = listOf(NwcResponseEvent.KIND), authors = listOf(nwcConnection.pubKeyHex), tags = mapOf("e" to listOf(requestId)), ) @@ -325,7 +325,7 @@ class NwcPaymentHandler( subId = subId, filters = listOf(filter), onEvent = { event, _ -> - if (event is LnZapPaymentResponseEvent && event.requestId() == requestId) { + if (event is NwcResponseEvent && event.requestId() == requestId) { @OptIn(kotlinx.coroutines.DelicateCoroutinesApi::class) kotlinx.coroutines.GlobalScope.launch(kotlinx.coroutines.Dispatchers.IO) { if (!localCache.justVerify(event)) return@launch diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/scheduledposts/DesktopScheduledPostStore.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/scheduledposts/DesktopScheduledPostStore.kt index ebd05606b5..96ccc66328 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/scheduledposts/DesktopScheduledPostStore.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/scheduledposts/DesktopScheduledPostStore.kt @@ -52,7 +52,7 @@ object DesktopScheduledPostStore { if (file.exists()) { setOwnerOnly(file, PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE) } - return ScheduledPostStore(file) + return ScheduledPostStore(file.path) } private fun setDirPermissions(dir: File) = diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/scheduledposts/HeadlessScheduledPublish.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/scheduledposts/HeadlessScheduledPublish.kt index 9c5fb8ee77..49b554ba66 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/scheduledposts/HeadlessScheduledPublish.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/scheduledposts/HeadlessScheduledPublish.kt @@ -82,7 +82,7 @@ fun runHeadlessPublish(): Int { } private fun headlessDrain(log: (String) -> Unit): Int { - val store = ScheduledPostStore(File(scheduledDir(), ScheduledPostStore.FILE_NAME)) + val store = ScheduledPostStore(File(scheduledDir(), ScheduledPostStore.FILE_NAME).path) // Build a plain OkHttp client for websockets — no Tor, no proxy. Headless mode // reads no settings, so we cannot know Tor preferences; scheduled posts that diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt index 78c1cffa71..140f96d844 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt @@ -39,6 +39,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAll import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CancellationException @@ -310,7 +311,7 @@ class DesktopRelaySubscriptionsCoordinator( ), // Zap receipts (kind 9735) targeting these notes Filter( - kinds = listOf(com.vitorpamplona.quartz.nip57Zaps.LnZapEvent.KIND), + kinds = listOf(ZapReceiptEvent.KIND), tags = mapOf("e" to noteIds), ), // Reposts (kind 6) targeting these notes diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FilterBuilders.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FilterBuilders.kt index 525013809f..0b2d354bdd 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FilterBuilders.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FilterBuilders.kt @@ -447,7 +447,7 @@ object FilterBuilders { limit: Int? = null, ): Filter = Filter( - kinds = listOf(9735), // LnZapEvent.KIND + kinds = listOf(9735), // ZapReceiptEvent.KIND tags = mapOf("e" to eventIds), limit = limit, ) @@ -517,7 +517,7 @@ object FilterBuilders { since: Long? = null, ): Filter = Filter( - kinds = listOf(4), // PrivateDmEvent.KIND + kinds = listOf(4), // EncryptedDmEvent.KIND tags = mapOf("p" to listOf(pubKeyHex)), limit = limit, since = since, @@ -537,7 +537,7 @@ object FilterBuilders { since: Long? = null, ): Filter = Filter( - kinds = listOf(4), // PrivateDmEvent.KIND + kinds = listOf(4), // EncryptedDmEvent.KIND authors = listOf(pubKeyHex), limit = limit, since = since, @@ -569,7 +569,7 @@ object FilterBuilders { */ fun dmRelayList(pubKeyHex: String): Filter = Filter( - kinds = listOf(10050), // ChatMessageRelayListEvent.KIND + kinds = listOf(10050), // DmRelayListEvent.KIND authors = listOf(pubKeyHex), limit = 1, ) @@ -588,7 +588,7 @@ object FilterBuilders { until: Long? = null, ): Filter = Filter( - kinds = listOf(30023), // LongTextNoteEvent.KIND + kinds = listOf(30023), // LongFormContentEvent.KIND limit = limit, since = since, until = until, @@ -606,7 +606,7 @@ object FilterBuilders { dTag: String, ): Filter = Filter( - kinds = listOf(30023), // LongTextNoteEvent.KIND + kinds = listOf(30023), // LongFormContentEvent.KIND authors = listOf(pubkey), tags = mapOf("d" to listOf(dTag)), limit = 1, @@ -628,7 +628,7 @@ object FilterBuilders { until: Long? = null, ): Filter = Filter( - kinds = listOf(30023), // LongTextNoteEvent.KIND + kinds = listOf(30023), // LongFormContentEvent.KIND authors = authors, limit = limit, since = since, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FilterDMs.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FilterDMs.kt index 9ab82738b8..e9f52e6115 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FilterDMs.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FilterDMs.kt @@ -24,7 +24,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip04Dm.messages.EncryptedDmEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.EphemeralGiftWrapEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent @@ -50,7 +50,7 @@ object FilterDMs { limit: Int? = null, ): Filter = Filter( - kinds = listOf(PrivateDmEvent.KIND), + kinds = listOf(EncryptedDmEvent.KIND), tags = mapOf("p" to listOf(userPubKeyHex)), since = since, limit = limit, @@ -70,7 +70,7 @@ object FilterDMs { limit: Int? = null, ): Filter = Filter( - kinds = listOf(PrivateDmEvent.KIND), + kinds = listOf(EncryptedDmEvent.KIND), authors = listOf(userPubKeyHex), since = since, limit = limit, @@ -96,14 +96,14 @@ object FilterDMs { return listOf( // Messages TO me from conversation participants Filter( - kinds = listOf(PrivateDmEvent.KIND), + kinds = listOf(EncryptedDmEvent.KIND), authors = conversationPubKeys.toList(), tags = mapOf("p" to listOf(userPubKeyHex)), since = since, ), // Messages FROM me to conversation participants Filter( - kinds = listOf(PrivateDmEvent.KIND), + kinds = listOf(EncryptedDmEvent.KIND), authors = listOf(userPubKeyHex), tags = mapOf("p" to conversationPubKeys.toList()), since = since, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ArticleReaderScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ArticleReaderScreen.kt index 8092de6e4e..4a97e66179 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ArticleReaderScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ArticleReaderScreen.kt @@ -91,11 +91,11 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription import com.vitorpamplona.amethyst.desktop.ui.highlights.ArticleHighlightsPanel import com.vitorpamplona.amethyst.desktop.ui.highlights.HighlightAnnotationDialog import com.vitorpamplona.quartz.nip18Reposts.RepostEvent -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent import kotlinx.coroutines.launch import java.text.DateFormat import java.util.Date @@ -144,7 +144,7 @@ fun ArticleReaderScreen( val dTag = parsed?.third // Article state - var article by remember(addressTag) { mutableStateOf(null) } + var article by remember(addressTag) { mutableStateOf(null) } var eoseReceived by remember(addressTag) { mutableStateOf(false) } // Zoom level for article text @@ -212,7 +212,7 @@ fun ArticleReaderScreen( filters = listOf(FilterBuilders.longFormByAddress(pubkey, dTag)), relays = configuredRelays, onEvent = { event, _, _, _ -> - if (event is LongTextNoteEvent) { + if (event is LongFormContentEvent) { // Keep the most recent version val current = article if (current == null || event.createdAt > current.createdAt) { @@ -246,7 +246,7 @@ fun ArticleReaderScreen( relays = configuredRelays, eventIds = eventIds, onEvent = { event, _, _, _ -> - if (event is LnZapEvent) { + if (event is ZapReceiptEvent) { val receipt = event.toZapReceipt(localCache) ?: return@createZapsSubscription if (zapReceipts.none { it.createdAt == receipt.createdAt && it.senderPubKey == receipt.senderPubKey }) { zapReceipts = zapReceipts + receipt diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt index 34cdbcfae5..9d4c62415b 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt @@ -102,7 +102,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip88Polls.poll.tags.PollType import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent @@ -168,9 +168,9 @@ data class ZapReceipt( ) /** - * Converts an LnZapEvent to a ZapReceipt for display. + * Converts an ZapReceiptEvent to a ZapReceipt for display. */ -fun LnZapEvent.toZapReceipt(localCache: DesktopLocalCache): ZapReceipt? { +fun ZapReceiptEvent.toZapReceipt(localCache: DesktopLocalCache): ZapReceipt? { val senderPubKey = zappedRequestAuthor() ?: return null val amountSats = amount?.toLong() ?: return null @@ -484,7 +484,7 @@ fun ZapReceiptsPopup( val name = user?.toBestDisplayName() ?: pubKey.take(12) val pictureUrl = user?.profilePicture() val amount = - (receipt?.event as? LnZapEvent)?.amount?.toLong() + (receipt?.event as? ZapReceiptEvent)?.amount?.toLong() ?: return@mapNotNull null val message = request.event?.content?.ifBlank { null } ZapEntry(pubKey, pictureUrl, name, amount, message) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt index 4bdb8f1083..3791bfcfbf 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt @@ -87,7 +87,7 @@ import com.vitorpamplona.amethyst.desktop.ui.notifications.NotificationGroup import com.vitorpamplona.amethyst.desktop.ui.notifications.groupNotifications import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull -import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip04Dm.messages.EncryptedDmEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent @@ -97,7 +97,7 @@ import com.vitorpamplona.quartz.nip19Bech32.toNpub import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent @@ -157,7 +157,7 @@ sealed class NotificationItem( val NotificationItem.effectiveAuthorPubKey: String get() = when (val e = event) { - is LnZapEvent -> e.zapRequest?.pubKey ?: e.pubKey + is ZapReceiptEvent -> e.zapRequest?.pubKey ?: e.pubKey else -> event.pubKey } @@ -169,7 +169,7 @@ private fun classifyNotification(event: Event): NotificationItem? = when (event) { is ReactionEvent -> NotificationItem.Reaction(event, event.createdAt, event.content) is RepostEvent, is GenericRepostEvent -> NotificationItem.Repost(event, event.createdAt) - is LnZapEvent -> NotificationItem.Zap(event, event.createdAt, event.amount?.toLong()) + is ZapReceiptEvent -> NotificationItem.Zap(event, event.createdAt, event.amount?.toLong()) // Nutzaps: treat like a zap; sats amount extraction requires the Cashu // token proof and is deferred to when Desktop renders zap detail. is NutzapEvent -> NotificationItem.Zap(event, event.createdAt, null) @@ -186,7 +186,7 @@ private fun classifyNotification(event: Event): NotificationItem? = // NIP-28 channel messages read like public mentions. is ChannelMessageEvent -> NotificationItem.Mention(event, event.createdAt) // DMs (NIP-04 legacy + NIP-17 gift-wrap + rumor + file-header). - is PrivateDmEvent, + is EncryptedDmEvent, is ChatMessageEvent, is GiftWrapEvent, is ChatMessageEncryptedFileHeaderEvent, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt index 96a44b0cee..42fd5cfb90 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt @@ -67,7 +67,7 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.createFollowingLongFormF import com.vitorpamplona.amethyst.desktop.subscriptions.createLongFormFeedSubscription import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import java.text.DateFormat import java.util.Date @@ -83,7 +83,7 @@ private fun formatDate(timestamp: Long): String = dateFormat.format(Date(timesta */ @Composable fun LongFormCard( - event: LongTextNoteEvent, + event: LongFormContentEvent, localCache: DesktopLocalCache, onAuthorClick: (String) -> Unit = {}, onClick: () -> Unit = {}, @@ -185,9 +185,9 @@ fun ReadsScreen( val eventState = remember { - EventCollectionState( + EventCollectionState( getId = { it.id }, - sortComparator = compareByDescending { it.publishedAt() ?: it.createdAt }.thenBy { it.id }, + sortComparator = compareByDescending { it.publishedAt() ?: it.createdAt }.thenBy { it.id }, maxSize = 100, scope = scope, ) @@ -204,10 +204,10 @@ fun ReadsScreen( LaunchedEffect(Unit) { val cached = localCache.notes.filterIntoSet { _, note -> - note.event is LongTextNoteEvent + note.event is LongFormContentEvent } cached.forEach { note -> - (note.event as? LongTextNoteEvent)?.let { eventState.addItem(it) } + (note.event as? LongFormContentEvent)?.let { eventState.addItem(it) } } if (cached.isNotEmpty()) eoseReceivedCount++ } @@ -249,7 +249,7 @@ fun ReadsScreen( relays = connectedRelays, onEvent = { event, _, relay, _ -> subscriptionsCoordinator?.consumeEvent(event, relay) - if (event is LongTextNoteEvent) { + if (event is LongFormContentEvent) { eventState.addItem(event) } }, @@ -266,7 +266,7 @@ fun ReadsScreen( followedUsers = followedUsers.toList(), onEvent = { event, _, relay, _ -> subscriptionsCoordinator?.consumeEvent(event, relay) - if (event is LongTextNoteEvent) { + if (event is LongFormContentEvent) { eventState.addItem(event) } }, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt index bf08ddbbc2..a690bd1032 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt @@ -117,7 +117,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip19Bech32.toNpub -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent import com.vitorpamplona.quartz.nip39ExtIdentities.ExternalIdentitiesEvent import com.vitorpamplona.quartz.nip39ExtIdentities.GitHubIdentity import com.vitorpamplona.quartz.nip39ExtIdentities.MastodonIdentity @@ -125,9 +125,9 @@ import com.vitorpamplona.quartz.nip39ExtIdentities.TwitterIdentity import com.vitorpamplona.quartz.nip39ExtIdentities.identityClaims import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark -import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag -import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip51Lists.starterPack.StarterPackEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent @@ -231,7 +231,7 @@ fun UserProfileScreen( var showProfileReportDialog by remember(pubKeyHex) { mutableStateOf(false) } var showAddToListMenu by remember(pubKeyHex) { mutableStateOf(false) } val followPacksState = LocalFollowPacksState.current - val followPacks by (followPacksState?.allPacks ?: MutableStateFlow(emptyList())).collectAsState() + val followPacks by (followPacksState?.allPacks ?: MutableStateFlow(emptyList())).collectAsState() val profileHidden by (iAccount?.hiddenUsers ?: kotlinx.coroutines.flow.MutableStateFlow(com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers.EMPTY)).collectAsState() val isUserMuted = profileHidden.isUserHidden(pubKeyHex) @@ -351,7 +351,7 @@ fun UserProfileScreen( var selectedTab by remember { mutableStateOf(0) } var lightboxState by remember { mutableStateOf(null) } val pictureEvents = remember { mutableStateListOf() } - val articleEvents = remember { mutableStateListOf() } + val articleEvents = remember { mutableStateListOf() } val highlightEvents = remember { mutableStateListOf() } // Followers / Following user lists (pubkeys) and the profile's relay list. @@ -361,7 +361,7 @@ fun UserProfileScreen( var relayList by remember(pubKeyHex) { mutableStateOf>>(emptyList()) } // Zaps received: raw zap receipts, aggregated per zapper (pubkey → total sats). - val zapEvents = remember(pubKeyHex) { mutableStateListOf() } + val zapEvents = remember(pubKeyHex) { mutableStateListOf() } var zapAmounts by remember(pubKeyHex) { mutableStateOf>>(emptyList()) } var zapTotalSats by remember(pubKeyHex) { mutableStateOf(BigDecimal.ZERO) } @@ -559,13 +559,13 @@ fun UserProfileScreen( listOf( FilterBuilders.byAuthors( authors = listOf(pubKeyHex), - kinds = listOf(LongTextNoteEvent.KIND), + kinds = listOf(LongFormContentEvent.KIND), limit = 50, ), ), relays = connectedRelays, onEvent = { event, _, _, _ -> - if (event is LongTextNoteEvent && articleEvents.none { it.id == event.id }) { + if (event is LongFormContentEvent && articleEvents.none { it.id == event.id }) { articleEvents.add(event) } }, @@ -710,13 +710,13 @@ fun UserProfileScreen( listOf( FilterBuilders.byPTags( pubKeys = listOf(pubKeyHex), - kinds = listOf(LnZapEvent.KIND), + kinds = listOf(ZapReceiptEvent.KIND), limit = 500, ), ), relays = connectedRelays, onEvent = { event, _, _, _ -> - if (event is LnZapEvent && zapEvents.none { it.id == event.id }) { + if (event is ZapReceiptEvent && zapEvents.none { it.id == event.id }) { zapEvents.add(event) } }, @@ -971,7 +971,7 @@ fun UserProfileScreen( scope.launch { try { val updated = - FollowListEvent.add( + StarterPackEvent.add( pack, UserTag(pubKeyHex, null), acct.signer, @@ -1447,7 +1447,7 @@ fun UserProfileScreen( } } else { items( - articleEvents.sortedWith(compareByDescending { it.publishedAt() ?: it.createdAt }.thenBy { it.id }), + articleEvents.sortedWith(compareByDescending { it.publishedAt() ?: it.createdAt }.thenBy { it.id }), key = { "art-${it.id}" }, ) { article -> LongFormCard( @@ -1455,7 +1455,7 @@ fun UserProfileScreen( localCache = localCache, onAuthorClick = { onNavigateToProfile(article.pubKey) }, onClick = { - val addressTag = "${LongTextNoteEvent.KIND}:${article.pubKey}:${article.dTag()}" + val addressTag = "${LongFormContentEvent.KIND}:${article.pubKey}:${article.dTag()}" onNavigateToArticle(addressTag) }, ) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt index 1c3fc89cf5..c34aea979b 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt @@ -125,7 +125,7 @@ import com.vitorpamplona.amethyst.desktop.ui.media.QualitySelectorChip import com.vitorpamplona.amethyst.desktop.ui.note.DesktopRichText import com.vitorpamplona.amethyst.desktop.ui.note.RichTextCallbacks import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle -import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip04Dm.messages.EncryptedDmEvent import com.vitorpamplona.quartz.nip17Dm.NIP17Factory import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent @@ -714,7 +714,7 @@ private fun MessageWithReactions( LaunchedEffect(note.idHex, event) { decryptedContent = when (event) { - is PrivateDmEvent -> { + is EncryptedDmEvent -> { try { event.decryptContent(account.signer) } catch (_: Exception) { @@ -776,7 +776,7 @@ private fun MessageWithReactions( ) { // Encryption badge when (event) { - is PrivateDmEvent -> { + is EncryptedDmEvent -> { Icon( MaterialSymbols.LockOpen, contentDescription = "NIP-04 (legacy)", @@ -947,7 +947,7 @@ private fun MessageReplyContext( preview = when (val e = parent.event) { is ChatMessageEncryptedFileHeaderEvent -> "📎 Attachment" - is PrivateDmEvent -> + is EncryptedDmEvent -> try { e.decryptContent(account.signer) } catch (_: Exception) { @@ -1076,7 +1076,7 @@ private fun ReplyPreviewBar( preview = when (event) { is ChatMessageEncryptedFileHeaderEvent -> "📎 Attachment" - is PrivateDmEvent -> + is EncryptedDmEvent -> try { event.decryptContent(account.signer) } catch (_: Exception) { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatroomListState.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatroomListState.kt index 2b18254d14..34873597ac 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatroomListState.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatroomListState.kt @@ -33,7 +33,7 @@ import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip04Dm.messages.EncryptedDmEvent import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -138,7 +138,7 @@ class ChatroomListState( private suspend fun decryptPreview(event: com.vitorpamplona.quartz.nip01Core.core.Event?): String { if (event == null) return "" return when (event) { - is PrivateDmEvent -> { + is EncryptedDmEvent -> { decryptedContentCache.getOrPut(event.id) { try { event.decryptContent(account.signer) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/composer/GifResult.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/composer/GifResult.kt index f8eaa49707..302b3651dd 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/composer/GifResult.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/composer/GifResult.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.desktop.ui.chats.composer import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAll import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter -import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent +import com.vitorpamplona.quartz.nip94FileMetadata.FileMetadataEvent /** A single GIF result surfaced from a NIP-94 (kind 1063) metadata event. */ data class GifResult( @@ -47,7 +47,7 @@ suspend fun searchGifs( val trimmed = query.trim() val filter = Filter( - kinds = listOf(FileHeaderEvent.KIND), + kinds = listOf(FileMetadataEvent.KIND), tags = mapOf("m" to listOf("image/gif")), limit = limitPerRelay, search = trimmed.ifBlank { null }, @@ -59,7 +59,7 @@ suspend fun searchGifs( runCatching { client.fetchAll(relay, filter, idleTimeoutMs = 8_000L) } .getOrDefault(emptyList()) for (event in events) { - val fileHeader = event as? FileHeaderEvent ?: continue + val fileHeader = event as? FileMetadataEvent ?: continue val url = fileHeader.url() ?: continue raw.add(GifResult(url = url, description = fileHeader.content)) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/DesktopRichText.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/DesktopRichText.kt index 62f67372bb..eb45da9db6 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/DesktopRichText.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/DesktopRichText.kt @@ -84,6 +84,7 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent import com.vitorpamplona.quartz.nip19Bech32.entities.NNote import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile import com.vitorpamplona.quartz.nip19Bech32.entities.NPub +import com.vitorpamplona.quartz.nip51Lists.starterPack.StarterPackEvent import java.awt.Toolkit import java.awt.datatransfer.StringSelection import java.net.URI @@ -376,7 +377,7 @@ private fun RenderBechSegment( .NAddress .parse(segment.segmentText) } - if (naddr != null && naddr.kind == com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent.KIND) { + if (naddr != null && naddr.kind == StarterPackEvent.KIND) { val followPacks = com.vitorpamplona.amethyst.desktop.ui.deck.LocalFollowPacksState.current val relayManager = com.vitorpamplona.amethyst.desktop.ui.deck.LocalRelayManager.current if (localCache != null && relayManager != null && followPacks != null) { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/notifications/DesktopNotificationAutoDispatcher.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/notifications/DesktopNotificationAutoDispatcher.kt index 23981aab22..6ec6fd2e7c 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/notifications/DesktopNotificationAutoDispatcher.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/notifications/DesktopNotificationAutoDispatcher.kt @@ -30,7 +30,7 @@ import com.vitorpamplona.amethyst.commons.moderation.notifications.nowEpochSecon import com.vitorpamplona.amethyst.commons.moderation.notifications.sanitizeForToast import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip04Dm.messages.EncryptedDmEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent @@ -39,7 +39,7 @@ import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent @@ -178,14 +178,14 @@ class DesktopNotificationAutoDispatcher( when (event) { is ReactionEvent -> NotifKind.REACTION is RepostEvent, is GenericRepostEvent -> NotifKind.REPOST - is LnZapEvent, is NutzapEvent, is OnchainZapEvent -> NotifKind.ZAP + is ZapReceiptEvent, is NutzapEvent, is OnchainZapEvent -> NotifKind.ZAP is TextNoteEvent -> { val isReply = event.tags.any { it.size > 1 && it[0] == "e" } if (isReply) NotifKind.REPLY else NotifKind.MENTION } is CommentEvent -> NotifKind.REPLY is ChannelMessageEvent -> NotifKind.MENTION - is PrivateDmEvent, + is EncryptedDmEvent, is ChatMessageEvent, is GiftWrapEvent, is ChatMessageEncryptedFileHeaderEvent, @@ -199,7 +199,7 @@ class DesktopNotificationAutoDispatcher( ): NotificationSpec { val effectivePubKey = when (event) { - is LnZapEvent -> event.zapRequest?.pubKey ?: event.pubKey + is ZapReceiptEvent -> event.zapRequest?.pubKey ?: event.pubKey else -> event.pubKey } val displayName = @@ -207,7 +207,7 @@ class DesktopNotificationAutoDispatcher( ?: "Someone" val amountText = - (event as? LnZapEvent)?.amount?.let { " ${it.toLong() / 1000} sats" } ?: "" + (event as? ZapReceiptEvent)?.amount?.let { " ${it.toLong() / 1000} sats" } ?: "" val title = when (kind) { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/DmRelayEditor.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/DmRelayEditor.kt index e4063ffaf2..86673cc80b 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/DmRelayEditor.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/DmRelayEditor.kt @@ -57,7 +57,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.nip17Dm.settings.DmRelayListEvent import kotlinx.coroutines.delay import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch @@ -181,7 +181,7 @@ fun DmRelayEditor( } scope.launch { try { - val event = ChatMessageRelayListEvent.create(localRelays.toList(), signer) + val event = DmRelayListEvent.create(localRelays.toList(), signer) onPublish(event) onDmRelaysUpdated(localRelays.toSet()) savedMessage = "Published ${localRelays.size} relay(s)" diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchResultsList.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchResultsList.kt index c225256ee4..3b0fab764f 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchResultsList.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchResultsList.kt @@ -69,7 +69,7 @@ import com.vitorpamplona.amethyst.desktop.ui.note.NoteCard import com.vitorpamplona.amethyst.desktop.ui.note.SpamCheckedNoteRender import com.vitorpamplona.amethyst.desktop.ui.note.WoTBadge import com.vitorpamplona.amethyst.desktop.ui.rememberDisplayData -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent @Composable @@ -96,9 +96,9 @@ fun SearchResultsList( // Group notes by kind val textNotes = notes.filter { it.kind == 1 } - val articles = notes.filter { it.kind == LongTextNoteEvent.KIND } + val articles = notes.filter { it.kind == LongFormContentEvent.KIND } val polls = notes.filter { it.kind == PollEvent.KIND } - val otherNotes = notes.filter { it.kind != 1 && it.kind != LongTextNoteEvent.KIND && it.kind != PollEvent.KIND } + val otherNotes = notes.filter { it.kind != 1 && it.kind != LongFormContentEvent.KIND && it.kind != PollEvent.KIND } // Per-section collapsed state (absent = expanded) val collapsedSections = remember { mutableStateMapOf() } diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopDmRelayListConsumeTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopDmRelayListConsumeTest.kt index 8d0cbd22ce..ee625359b3 100644 --- a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopDmRelayListConsumeTest.kt +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopDmRelayListConsumeTest.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.desktop.cache import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync -import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.nip17Dm.settings.DmRelayListEvent import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull @@ -50,7 +50,7 @@ class DesktopDmRelayListConsumeTest { signer: NostrSignerSync, relays: List = dmInbox, createdAt: Long = 1_700_000_000, - ): ChatMessageRelayListEvent = ChatMessageRelayListEvent.create(relays, signer, createdAt) + ): DmRelayListEvent = DmRelayListEvent.create(relays, signer, createdAt) @Test fun `consume routes kind 10050 into the User model`() { diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/nwc/NwcPaymentHandlerTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/nwc/NwcPaymentHandlerTest.kt index 752deaf576..28ef0d7dc8 100644 --- a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/nwc/NwcPaymentHandlerTest.kt +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/nwc/NwcPaymentHandlerTest.kt @@ -24,7 +24,7 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect -import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcResponseEvent import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBalanceSuccessResponse import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeInvoiceSuccessResponse import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcTransaction @@ -157,8 +157,8 @@ class NwcPaymentHandlerTest { // -- Response event structure -- @Test - fun `LnZapPaymentResponseEvent has correct KIND`() { - assertEquals(23195, LnZapPaymentResponseEvent.KIND) + fun `NwcResponseEvent has correct KIND`() { + assertEquals(23195, NwcResponseEvent.KIND) } // -- PayInvoiceSuccessResponse structure -- diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/nwc/NwcRpcIntegrationTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/nwc/NwcRpcIntegrationTest.kt index ce6bf5f8a5..04d1f2e080 100644 --- a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/nwc/NwcRpcIntegrationTest.kt +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/nwc/NwcRpcIntegrationTest.kt @@ -25,8 +25,8 @@ import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip47WalletConnect.Nip47Client -import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent -import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcRequestEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcResponseEvent import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBalanceMethod import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBalanceSuccessResponse import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeInvoiceMethod @@ -66,7 +66,7 @@ class NwcRpcIntegrationTest { val requestEvent = client.payInvoice("lnbc50n1pjtest...") // Verify request event structure - assertEquals(LnZapPaymentRequestEvent.KIND, requestEvent.kind) + assertEquals(NwcRequestEvent.KIND, requestEvent.kind) assertTrue(requestEvent.content.isNotBlank()) // Wallet decrypts the request @@ -82,7 +82,7 @@ class NwcRpcIntegrationTest { val requestEvent = client.getBalance() // Verify it's a valid NWC request - assertEquals(LnZapPaymentRequestEvent.KIND, requestEvent.kind) + assertEquals(NwcRequestEvent.KIND, requestEvent.kind) // Wallet decrypts and verifies method type val decrypted = requestEvent.decryptRequest(walletSigner) @@ -90,7 +90,7 @@ class NwcRpcIntegrationTest { // Wallet builds encrypted response val responseEvent = - LnZapPaymentResponseEvent.createResponse( + NwcResponseEvent.createResponse( response = GetBalanceSuccessResponse( result = GetBalanceSuccessResponse.GetBalanceResult(balance = 125_000), @@ -100,7 +100,7 @@ class NwcRpcIntegrationTest { ) // Verify response event structure - assertEquals(LnZapPaymentResponseEvent.KIND, responseEvent.kind) + assertEquals(NwcResponseEvent.KIND, responseEvent.kind) assertEquals(requestEvent.id, responseEvent.requestId()) // Client decrypts response @@ -124,7 +124,7 @@ class NwcRpcIntegrationTest { // Wallet responds with invoice val fakeInvoice = "lnbc500n1pjgenerated..." val responseEvent = - LnZapPaymentResponseEvent.createResponse( + NwcResponseEvent.createResponse( response = MakeInvoiceSuccessResponse( result = @@ -153,7 +153,7 @@ class NwcRpcIntegrationTest { val requestEvent = client.payInvoice("lnbc100n1...") val responseEvent = - LnZapPaymentResponseEvent.createResponse( + NwcResponseEvent.createResponse( response = PayInvoiceSuccessResponse( result = @@ -183,7 +183,7 @@ class NwcRpcIntegrationTest { // Build a request and verify it works val requestEvent = client.getBalance() - assertEquals(LnZapPaymentRequestEvent.KIND, requestEvent.kind) + assertEquals(NwcRequestEvent.KIND, requestEvent.kind) // Wallet can decrypt it val decrypted = requestEvent.decryptRequest(walletSigner) diff --git a/docs/TODO.md b/docs/TODO.md index 979b4c9996..a9711dbd38 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -5,7 +5,7 @@ **Question:** What happens when a conversation with an npub has both NIP-04 (kind 4) and NIP-17 (kind 14/15 in GiftWrap) messages? **Current behavior to investigate:** -- NIP-04 messages use `PrivateDmEvent.chatroomKey(pubKey)` → creates a `ChatroomKey` based on recipient +- NIP-04 messages use `EncryptedDmEvent.chatroomKey(pubKey)` → creates a `ChatroomKey` based on recipient - NIP-17 messages use `ChatMessageEvent.chatroomKey(pubKey)` → also creates a `ChatroomKey` based on group members - Do these produce the **same** `ChatroomKey` for 1-on-1 chats? If yes, both message types merge into one conversation. If no, they appear as separate conversations. - Android Amethyst handles this — check how `Account.kt` merges them diff --git a/docs/brainstorms/2026-03-16-desktop-media-brainstorm.md b/docs/brainstorms/2026-03-16-desktop-media-brainstorm.md index 7793b10901..e3d503140a 100644 --- a/docs/brainstorms/2026-03-16-desktop-media-brainstorm.md +++ b/docs/brainstorms/2026-03-16-desktop-media-brainstorm.md @@ -136,7 +136,7 @@ ExoPlayer is Android-only (Media3). VLCJ wraps VLC's libvlc via JNA — supports | BlossomServersEvent | `quartz/nipB7Blossom/` | Ready (kind 10063) | | BlossomUri | `quartz/nipB7Blossom/` | Ready (blossom: URI parsing) | | BlossomUploadResult | `quartz/nipB7Blossom/` | Ready | -| FileHeaderEvent (NIP-94) | `quartz/nip94FileMetadata/` | Ready (kind 1063) | +| FileMetadataEvent (NIP-94) | `quartz/nip94FileMetadata/` | Ready (kind 1063) | | BlurhashTag | `quartz/nip94FileMetadata/tags/` | Ready | | DimensionTag | `quartz/nip94FileMetadata/tags/` | Ready | | IMetaTag/Builder (NIP-92) | `quartz/nip92IMeta/` | Ready | diff --git a/docs/brainstorms/2026-03-19-dm-encryption-badge-brainstorm.md b/docs/brainstorms/2026-03-19-dm-encryption-badge-brainstorm.md index e106e509db..221f8a1500 100644 --- a/docs/brainstorms/2026-03-19-dm-encryption-badge-brainstorm.md +++ b/docs/brainstorms/2026-03-19-dm-encryption-badge-brainstorm.md @@ -24,7 +24,7 @@ Users need to know which messages are truly private (NIP-17: relay can't see sen ## Implementation The badge goes in `MessageWithReactions` in ChatPane.kt, in the `detailRow` slot of `ChatMessageCompose`. Check `note.event` type: -- `is PrivateDmEvent` → NIP-04 → lock-open gray +- `is EncryptedDmEvent` → NIP-04 → lock-open gray - `is ChatMessageEvent` or `is ChatMessageEncryptedFileHeaderEvent` → NIP-17 → lock primary - else → no badge diff --git a/docs/changelog/translators.json b/docs/changelog/translators.json index dc561939dc..018e4cd32a 100644 --- a/docs/changelog/translators.json +++ b/docs/changelog/translators.json @@ -129,18 +129,18 @@ "Hindi" ] }, - { - "user": "summoner001", - "languages": [ - "Hungarian" - ] - }, { "user": "maxblake2015", "languages": [ "Polish" ] }, + { + "user": "summoner001", + "languages": [ + "Hungarian" + ] + }, { "user": "markkks", "languages": [ diff --git a/docs/changelog/v0.31.04.md b/docs/changelog/v0.31.04.md index ef95bfdc15..4f28425146 100644 --- a/docs/changelog/v0.31.04.md +++ b/docs/changelog/v0.31.04.md @@ -3,7 +3,7 @@ - Login with QR by [@greenart7c3](https://github.com/greenart7c3) - Adds full nip19 parsing on key decoding capabilities by [@vitorpamplona](https://github.com/vitorpamplona) - Speeds up RichText rendering parsers by [@vitorpamplona](https://github.com/vitorpamplona) -- added message to LnZapEvent and interface by [@believethehype](https://github.com/believethehype) +- added message to ZapReceiptEvent and interface by [@believethehype](https://github.com/believethehype) - Faster processing of Contact List events by [@vitorpamplona](https://github.com/vitorpamplona) - Faster user in follow list algo by [@vitorpamplona](https://github.com/vitorpamplona) diff --git a/docs/changelog/v0.76.01.md b/docs/changelog/v0.76.01.md index 592371ba62..8e6a2a72e8 100644 --- a/docs/changelog/v0.76.01.md +++ b/docs/changelog/v0.76.01.md @@ -14,7 +14,7 @@ Adds support for Zap Split setup and payment via NWC and manually via wallet cal - Updated translations for cs/de/sv - Forces valid hexes in p- and e- tags when receiving the event - Stops accepting space as a Valid hex char and requires an even number of chars (padding) -- Forces the ZapRequest to exist when processing the LnZapEvent +- Forces the ZapRequest to exist when processing the ZapReceiptEvent - Fixes some long NIP-19 cutting off in notes - Updates to Finnish, Swedish, Czech and German translations - Adds Bengali, Bangladesh translations diff --git a/docs/changelog/v1.03.00.md b/docs/changelog/v1.03.00.md index 5b4cfbe6f7..2a2942030b 100644 --- a/docs/changelog/v1.03.00.md +++ b/docs/changelog/v1.03.00.md @@ -12,7 +12,7 @@ Improvements: - Improves the list of visible authors on live stream bubbles - Adds the error status to the URL normalizer to avoid checking it again - Adds a follow list state from kind 3 to keep following users in memory. -- Moves from RelationshipStatus to ContactCard as per NIP update +- Moves from RelationshipStatus to UserAssertion as per NIP update Fixes: - Removes crashing relay URIs with null-encoded bytes (%00) diff --git a/docs/changelog/v1.12.00.md b/docs/changelog/v1.12.00.md index efc05f16c2..f350e4e6ed 100644 --- a/docs/changelog/v1.12.00.md +++ b/docs/changelog/v1.12.00.md @@ -267,7 +267,7 @@ Highlights: - **Amethyst → commons migration (KMP/iOS).** - Moves shared, CLI-safe code out of the Android app into `commons`. - Decryption caches (Mute/People/Community, Hashtag, Favorite-algo, Trust-provider). - - Models: InterestSet, LabeledBookmarkList, NwcWalletEntry, CashuToken, OwnedEmojiPack. + - Models: InterestSet, BookmarkSet, NwcWalletEntry, CashuToken, OwnedEmojiPack. - The link-preview fetcher, relay broadcast tracker, and CLI-safe util extensions. - `HtmlParser` made KMP (dropped its Java `Charset` dependency). - A consolidated package taxonomy with a new architecture doc. diff --git a/docs/changelog/v1.12.02.md b/docs/changelog/v1.12.02.md index 3a8cb91df0..0c6e2f4b50 100644 --- a/docs/changelog/v1.12.02.md +++ b/docs/changelog/v1.12.02.md @@ -20,7 +20,7 @@ Highlights: - Adds a pausable, resumable full-text-search reindex to the SQLite and filesystem event stores, and clamps non-positive reindex batch sizes. - Indexes direct message content (NIP-17 ChatMessageEvent). -- Indexes zap comments (LnZap request/receipt, on-chain zap, and nutzap). +- Indexes zap comments (ZapReceipt request/receipt, on-chain zap, and nutzap). - Makes polls, code snippets, audio, and git/torrent replies and interest sets searchable. - Indexes parsed JSON fields of profile, channel, and app-handler events. - Indexes natural-language fields of more event kinds, and makes the remaining text-bearing events searchable. diff --git a/docs/plans/2026-06-10-feat-desktop-dm-reliability-plan.md b/docs/plans/2026-06-10-feat-desktop-dm-reliability-plan.md index 3046cbb196..9bb432f044 100644 --- a/docs/plans/2026-06-10-feat-desktop-dm-reliability-plan.md +++ b/docs/plans/2026-06-10-feat-desktop-dm-reliability-plan.md @@ -381,7 +381,7 @@ The bulk of the change lives in `quartz/.../nip01Core/relay/client/` (publish pa **Scope:** 1. **Shared `rumorCreatedAt` across recipient wraps** (R9): - - In `NIP17Factory.createWraps` (currently `quartz/.../nip17Dm/NIP17Factory.kt:43-72`), compute `rumorCreatedAt = TimeUtils.now()` once before the per-recipient `mapNotNullAsync` loop. Pass into every `SealedRumorEvent.create(...)` so all seals encode the same rumor (same `rumor.id`). + - In `NIP17Factory.createWraps` (currently `quartz/.../nip17Dm/NIP17Factory.kt:43-72`), compute `rumorCreatedAt = TimeUtils.now()` once before the per-recipient `mapNotNullAsync` loop. Pass into every `SealEvent.create(...)` so all seals encode the same rumor (same `rumor.id`). - Same `rumorId` becomes the dedupe anchor + receipt target across all recipients of a group message. 2. **Self-copy gift wrap to own DM relays** (R10): @@ -476,7 +476,7 @@ ComposeUI(send button click) → RecipientRelayFetcher.fetch([indexer relays]) → NIP17Factory.createWraps(text, recipients, signer) ── shared rumor_created_at (Phase 5) → for each recipient (parallel): - → SealedRumorEvent.create(rumor, recipient, signer) + → SealEvent.create(rumor, recipient, signer) → signer.sign(seal) (bunker round-trip if Remote) ── batch via Phase 6 capability → signer.nip44Encrypt(rumor, recipient) (bunker round-trip)── batch via Phase 6 capability → GiftWrapEvent.create(seal, recipient, ephemeralKey) diff --git a/docs/plans/archive/2026-03-18-feat-desktop-cache-navigation-persistence-plan.md b/docs/plans/archive/2026-03-18-feat-desktop-cache-navigation-persistence-plan.md index 40c4f29743..abc58a5be8 100644 --- a/docs/plans/archive/2026-03-18-feat-desktop-cache-navigation-persistence-plan.md +++ b/docs/plans/archive/2026-03-18-feat-desktop-cache-navigation-persistence-plan.md @@ -124,8 +124,8 @@ Start with 4 new event kinds (kind 9734 required for zap processing). Add remain | 0 | `MetadataEvent` | Done | Already exists (`consumeMetadata`) | | 1 | `TextNoteEvent` | 1 | Core feed content | | 7 | `ReactionEvent` | 1 | Reaction counts on Note | -| 9734 | `LnZapRequestEvent` | 1 | Required before kind 9735 can process | -| 9735 | `LnZapEvent` | 1 | Zap counts on Note | +| 9734 | `ZapRequestEvent` | 1 | Required before kind 9735 can process | +| 9735 | `ZapReceiptEvent` | 1 | Zap counts on Note | Each consume method follows Android's pattern. Use `event.tagsWithoutCitations()` for reply parsing (handles both NIP-10 marked and legacy positional tags, excluding inline nostr: citations): @@ -410,7 +410,7 @@ rememberSubscription(configuredRelays, feedMode, followedUsers, relayManager = r | UserProfileScreen | Parameterized (pubkey) | — | Uses existing kinds | | SearchResultsList | Parameterized (query) | — | Uses DesktopSearchFeedFilter | | BookmarksScreen | Singleton | 30078 (BookmarkList) | Bookmark state from events | -| ReadsScreen | Singleton | 30023 (LongTextNote) | Articles feed | +| ReadsScreen | Singleton | 30023 (LongFormContent) | Articles feed | | NotificationsScreen | Singleton | — | Uses DesktopNotificationFeedFilter | **FeedNoteCard rewrite** — done alongside FeedScreen migration (first screen). All subsequent screen migrations benefit. diff --git a/docs/plans/archive/2026-03-22-feat-clean-cache-single-source-of-truth-plan.md b/docs/plans/archive/2026-03-22-feat-clean-cache-single-source-of-truth-plan.md index efd1a1f98a..a72899608b 100644 --- a/docs/plans/archive/2026-03-22-feat-clean-cache-single-source-of-truth-plan.md +++ b/docs/plans/archive/2026-03-22-feat-clean-cache-single-source-of-truth-plan.md @@ -166,7 +166,7 @@ Four-phase migration: FeedScreen first (proves pattern), then expand cache cover ##### 1a. Expand `DesktopLocalCache.consume()` coverage -Currently handles: MetadataEvent, TextNoteEvent, ReactionEvent, LnZapRequestEvent, LnZapEvent. +Currently handles: MetadataEvent, TextNoteEvent, ReactionEvent, ZapRequestEvent, ZapReceiptEvent. **Add support for:** @@ -174,7 +174,7 @@ Currently handles: MetadataEvent, TextNoteEvent, ReactionEvent, LnZapRequestEven |------------|------|--------|-------| | `RepostEvent` | 6 | `consumeRepost()` | `Note.addBoost()` on target | | `ContactListEvent` | 3 | `consumeContactList()` | Updates `_followedUsers` StateFlow | -| `LongTextNoteEvent` | 30023 | `consumeLongTextNote()` | Creates Note like TextNote | +| `LongFormContentEvent` | 30023 | `consumeLongFormContent()` | Creates Note like TextNote | | `BookmarkListEvent` | 30001 | `consumeBookmarkList()` | Stores on `addressableNotes` or dedicated field | **File:** `desktopApp/.../cache/DesktopLocalCache.kt` @@ -461,7 +461,7 @@ when (val state = feedState) { ##### Phase 1 Acceptance Criteria -- [ ] `DesktopLocalCache.consume()` handles RepostEvent, ContactListEvent, LongTextNoteEvent, BookmarkListEvent +- [ ] `DesktopLocalCache.consume()` handles RepostEvent, ContactListEvent, LongFormContentEvent, BookmarkListEvent - [ ] consume() uses kind-based registry dispatch (not growing `when` block) - [ ] `followedUsers` is `MutableStateFlow` (not `@Volatile`) - [ ] Coordinator scope uses `SupervisorJob` + `CoroutineExceptionHandler` @@ -513,7 +513,7 @@ Apply the proven pattern from Phase 1 to all other screens. Each screen gets at ##### 2d. ReadsScreen - `DesktopFeedViewModel(DesktopReadsFeedFilter(cache), cache)` -- **Card rendering:** ReadsScreen needs `LongFormNoteCard` that extracts title/summary/topics from `Note.event as? LongTextNoteEvent`. Separate composable, not FeedNoteCard. +- **Card rendering:** ReadsScreen needs `LongFormNoteCard` that extracts title/summary/topics from `Note.event as? LongFormContentEvent`. Separate composable, not FeedNoteCard. **File:** `desktopApp/.../ui/ReadsScreen.kt` @@ -523,7 +523,7 @@ Apply the proven pattern from Phase 1 to all other screens. Each screen gets at ```kotlin fun Note.toNotificationItem(): NotificationItem? = when (event) { is ReactionEvent -> NotificationItem.Reaction(this) - is LnZapEvent -> NotificationItem.Zap(this) + is ZapReceiptEvent -> NotificationItem.Zap(this) is TextNoteEvent -> if (isReply) NotificationItem.Reply(this) else NotificationItem.Mention(this) else -> null } diff --git a/docs/plans/archive/2026-03-24-feat-article-highlights-notes-plan.md b/docs/plans/archive/2026-03-24-feat-article-highlights-notes-plan.md index 6c663793cc..64c71fbebc 100644 --- a/docs/plans/archive/2026-03-24-feat-article-highlights-notes-plan.md +++ b/docs/plans/archive/2026-03-24-feat-article-highlights-notes-plan.md @@ -286,7 +286,7 @@ Track mouse via `Modifier.pointerInput` + `awaitPointerEventScope` (pattern from object HighlightPublishAction { suspend fun publish( highlightText: String, - articleEvent: LongTextNoteEvent, + articleEvent: LongFormContentEvent, note: String?, signer: NostrSigner, ): HighlightEvent { @@ -323,8 +323,8 @@ object HighlightPublishAction { ##### NIP-09 Deletion for Published Highlights ```kotlin -suspend fun deleteHighlight(eventId: String, signer: NostrSigner): DeletionEvent { - return DeletionEvent.create( +suspend fun deleteHighlight(eventId: String, signer: NostrSigner): DeletionRequestEvent { + return DeletionRequestEvent.create( deleteEvents = listOf(eventId), deleteKinds = listOf(9802), reason = "User deleted highlight", diff --git a/docs/plans/archive/2026-04-21-feat-relay-config-parity-plan.md b/docs/plans/archive/2026-04-21-feat-relay-config-parity-plan.md index 51320c1701..4e5093464d 100644 --- a/docs/plans/archive/2026-04-21-feat-relay-config-parity-plan.md +++ b/docs/plans/archive/2026-04-21-feat-relay-config-parity-plan.md @@ -115,12 +115,12 @@ fun DmRelayEditor( - Simple relay list (no read/write split — DM relays are all-or-nothing) - Warning banner if empty: "No DM relays configured — DMs will use connected relays as fallback" -- "Save" builds `ChatMessageRelayListEvent` → sign → publish +- "Save" builds `DmRelayListEvent` → sign → publish - Security: highlight that these relays see your DM metadata **Data flow:** 1. Read current: `accountRelays.dmRelayList` StateFlow -2. Save: build `ChatMessageRelayListEvent.create(relays, signer)` → publish +2. Save: build `DmRelayListEvent.create(relays, signer)` → publish **Integration:** - Replace "DM Relays — coming soon" placeholder in `RelayConfigTab` @@ -197,7 +197,7 @@ All editors follow the same pattern — reuse `RelayListEditor` for the add/remo | Kind | Event Class | Tag Format | |------|------------|------------| | 10002 | `AdvertisedRelayListEvent` | `["r", "wss://...", "read"\|"write"]` | -| 10050 | `ChatMessageRelayListEvent` | `["relay", "wss://..."]` | +| 10050 | `DmRelayListEvent` | `["relay", "wss://..."]` | | 10007 | `SearchRelayListEvent` | `["relay", "wss://..."]` | | 10006 | `BlockedRelayListEvent` | `["relay", "wss://..."]` | @@ -214,7 +214,7 @@ All editors follow the same pattern — reuse `RelayListEditor` for the add/remo ### Phase 3b: DM Relays - [ ] DM section shows current kind 10050 relays - [ ] Warning if empty -- [ ] Save signs + publishes ChatMessageRelayListEvent +- [ ] Save signs + publishes DmRelayListEvent - [ ] Shows publish confirmation ### Phase 3c: Search Relays diff --git a/docs/plans/archive/2026-04-21-feat-relay-subscription-wiring-plan.md b/docs/plans/archive/2026-04-21-feat-relay-subscription-wiring-plan.md index 3226771755..95697cce76 100644 --- a/docs/plans/archive/2026-04-21-feat-relay-subscription-wiring-plan.md +++ b/docs/plans/archive/2026-04-21-feat-relay-subscription-wiring-plan.md @@ -144,7 +144,7 @@ val blockedRelayList: StateFlow> = _blockedRelayList.asS fun consumeIfRelevant(event: Event): Boolean { return when (event.kind) { - ChatMessageRelayListEvent.KIND -> { consumeDmRelayList(event as ChatMessageRelayListEvent); true } + DmRelayListEvent.KIND -> { consumeDmRelayList(event as DmRelayListEvent); true } SearchRelayListEvent.KIND -> { consumeSearchRelayList(event); true } BlockedRelayListEvent.KIND -> { consumeBlockedRelayList(event); true } else -> false diff --git a/docs/plans/archive/2026-04-22-feat-relay-subscription-wiring-plan.md b/docs/plans/archive/2026-04-22-feat-relay-subscription-wiring-plan.md index 497ceecb91..b69d557e79 100644 --- a/docs/plans/archive/2026-04-22-feat-relay-subscription-wiring-plan.md +++ b/docs/plans/archive/2026-04-22-feat-relay-subscription-wiring-plan.md @@ -61,10 +61,10 @@ private var lastDmCreatedAt = 0L fun consumeIfRelevant(event: Event): Boolean { return when (event.kind) { - ChatMessageRelayListEvent.KIND -> { + DmRelayListEvent.KIND -> { if (event.createdAt > lastDmCreatedAt) { lastDmCreatedAt = event.createdAt - consumeDmRelayList(event as ChatMessageRelayListEvent) + consumeDmRelayList(event as DmRelayListEvent) } true } diff --git a/docs/plans/archive/2026-04-23-feat-desktop-multi-account-support-plan.md b/docs/plans/archive/2026-04-23-feat-desktop-multi-account-support-plan.md index 43ecaa7cbd..68844ec0ae 100644 --- a/docs/plans/archive/2026-04-23-feat-desktop-multi-account-support-plan.md +++ b/docs/plans/archive/2026-04-23-feat-desktop-multi-account-support-plan.md @@ -399,7 +399,7 @@ The KMP expert found that `AccountSessionManager` and `AccountCacheState` **cann - Use `#p` tag filters (not `authors`) — listening *for* the account, not *from* it - **Exclude kind 7 reactions** — too noisy for background counts. Use NIP-45 count query at foreground activation if needed - **Include kind 6 reposts** — same filter as kind 1 mentions, relatively rare, worth tracking -- NIP-65 `readRelaysNorm()` for general notifications; kind 10050 `ChatMessageRelayListEvent` for DM-specific inbox relays +- NIP-65 `readRelaysNorm()` for general notifications; kind 10050 `DmRelayListEvent` for DM-specific inbox relays - Max 5 relay connections per inactive account (3 inbox + 2 DM) - Always set `since` field — prevents relay flooding on reconnect - Separate `RelayAuthenticator` for background connections (don't reuse active account's) diff --git a/docs/plans/archive/2026-05-04-fix-bunker-timeouts-and-decryption-plan.md b/docs/plans/archive/2026-05-04-fix-bunker-timeouts-and-decryption-plan.md index 1c6acd5ace..fbc4bc0c94 100644 --- a/docs/plans/archive/2026-05-04-fix-bunker-timeouts-and-decryption-plan.md +++ b/docs/plans/archive/2026-05-04-fix-bunker-timeouts-and-decryption-plan.md @@ -221,7 +221,7 @@ No `since` → relay reconnection replays old events. This is GOOD for our retry Current code: ```kotlin decryptedContent = when (event) { - is PrivateDmEvent -> { + is EncryptedDmEvent -> { try { event.decryptContent(account.signer) } catch (_: Exception) { @@ -245,7 +245,7 @@ if (eventContent != null) { ```kotlin decryptedContent = when (event) { - is PrivateDmEvent -> { + is EncryptedDmEvent -> { try { event.decryptContent(account.signer) } catch (_: Exception) { diff --git a/docs/plans/archive/2026-05-22-feat-desktop-note-action-bar-ux-plan.md b/docs/plans/archive/2026-05-22-feat-desktop-note-action-bar-ux-plan.md index 1ccfdf5fac..a333621aa3 100644 --- a/docs/plans/archive/2026-05-22-feat-desktop-note-action-bar-ux-plan.md +++ b/docs/plans/archive/2026-05-22-feat-desktop-note-action-bar-ux-plan.md @@ -146,7 +146,7 @@ val zapEntries = note.zaps.mapNotNull { (request, receipt) -> val sender = request.author?.toBestDisplayName() ?: request.event?.pubKey?.take(8) ?: return@mapNotNull null - val amount = (receipt?.event as? LnZapEvent)?.amount?.toLong() + val amount = (receipt?.event as? ZapReceiptEvent)?.amount?.toLong() ?: return@mapNotNull null Triple(sender, amount, request.event?.content?.ifBlank { null }) }.sortedByDescending { it.second } diff --git a/docs/plans/archive/2026-06-02-fix-desktop-feed-review-findings-plan.md b/docs/plans/archive/2026-06-02-fix-desktop-feed-review-findings-plan.md index d61e49c14a..17697b2eaa 100644 --- a/docs/plans/archive/2026-06-02-fix-desktop-feed-review-findings-plan.md +++ b/docs/plans/archive/2026-06-02-fix-desktop-feed-review-findings-plan.md @@ -39,8 +39,8 @@ Eight parallel agents resolved all 5 open questions and surfaced 3 plan revision debounce. - **Q4 — NoteActions.kt:264 formatSats:** sats, safe to swap to `amount.toZapAmount()`. Inputs are hardcoded preset amounts (line 111 - `ZAP_AMOUNTS = listOf(21L, 100L, ...)`) and `LnZapEvent.amount` which is - already sats (`LnZapEvent.kt:69`). + `ZAP_AMOUNTS = listOf(21L, 100L, ...)`) and `ZapReceiptEvent.amount` which is + already sats (`ZapReceiptEvent.kt:69`). - **Q5 — WalletColumnScreen.kt:979 formatSats:** intentional. Wallet shows precise balance with locale-aware grouping (`1,000,000`). Leave + add `// intentional` comment to prevent future drift. @@ -378,7 +378,7 @@ zapCount = if (note.zapsAmount > BigDecimal.ZERO) showAmount(note.zapsAmount) el **Edit:** `NoteActions.kt:264` — swap to `amount.toZapAmount()`. Verified sats (not msats): inputs are `ZAP_AMOUNTS = listOf(21L, 100L, 500L, 1000L, 5000L, 10000L)` -at line 111 + `LnZapEvent.amount` which is sats per `LnZapEvent.kt:69`. +at line 111 + `ZapReceiptEvent.amount` which is sats per `ZapReceiptEvent.kt:69`. **Delete the private `formatSats` fun if no remaining references** (run `grep -n formatSats desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt` after swap). diff --git a/geode/src/test/kotlin/com/vitorpamplona/geode/ConcordPlaneKeyDeletionTest.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/ConcordPlaneKeyDeletionTest.kt index 15c5fcd7a1..051c1736f8 100644 --- a/geode/src/test/kotlin/com/vitorpamplona/geode/ConcordPlaneKeyDeletionTest.kt +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/ConcordPlaneKeyDeletionTest.kt @@ -34,7 +34,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync -import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent import com.vitorpamplona.quartz.utils.RandomInstance import com.vitorpamplona.quartz.utils.TimeUtils @@ -177,7 +177,7 @@ class ConcordPlaneKeyDeletionTest { // The banned member still derives `plane`, and every wrap above IS authored by it — so // this kind-5 satisfies a same-author check. It must still be refused. - val deletion = planeSigner().sign(DeletionEvent.build(history, createdAt = now + 10)) + val deletion = planeSigner().sign(DeletionRequestEvent.build(history, createdAt = now + 10)) assertEquals(true, client.publishAndConfirm(deletion, setOf(relayUrl)), "the relay accepts the event itself") assertEquals( @@ -230,7 +230,7 @@ class ConcordPlaneKeyDeletionTest { assertEquals(true, client.publishAndConfirm(addressedWrap, setOf(relayUrl))) assertEquals(1, query(Filter(ids = listOf(addressedWrap.id))).size) - val deletion = mallory.sign(DeletionEvent.build(listOf(addressedWrap), createdAt = now + 1)) + val deletion = mallory.sign(DeletionRequestEvent.build(listOf(addressedWrap), createdAt = now + 1)) assertEquals(true, client.publishAndConfirm(deletion, setOf(relayUrl))) assertEquals( diff --git a/geode/src/test/kotlin/com/vitorpamplona/geode/DeletionSettleBenchmark.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/DeletionSettleBenchmark.kt index 46e4b7a1ca..5f229740d9 100644 --- a/geode/src/test/kotlin/com/vitorpamplona/geode/DeletionSettleBenchmark.kt +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/DeletionSettleBenchmark.kt @@ -29,7 +29,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.negentropySet import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore -import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.runBlocking @@ -81,7 +81,7 @@ class DeletionSettleBenchmark : RelayClientTest() { // The last K are the ones we deleted locally. val deleted = notes.takeLast(k) val kept = notes.dropLast(k) - val deletions = deleted.map { signer.sign(DeletionEvent.build(listOf(it), createdAt = it.createdAt + 1)) } + val deletions = deleted.map { signer.sign(DeletionRequestEvent.build(listOf(it), createdAt = it.createdAt + 1)) } // Relay holds all N notes; we hold the N-K we didn't delete, plus the K kind-5s. defaultRelay.preload(notes) diff --git a/geode/src/test/kotlin/com/vitorpamplona/geode/DeletionSyncTest.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/DeletionSyncTest.kt index f3a7ef4306..22b07e8be7 100644 --- a/geode/src/test/kotlin/com/vitorpamplona/geode/DeletionSyncTest.kt +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/DeletionSyncTest.kt @@ -35,7 +35,7 @@ import com.vitorpamplona.quartz.nip01Core.store.IdAndTime import com.vitorpamplona.quartz.nip01Core.store.deletionsCovering import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent -import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent import kotlinx.coroutines.runBlocking @@ -69,7 +69,7 @@ class DeletionSyncTest : RelayClientTest() { fun idBasedDeletionCoversByETag() = runBlocking { val target = note("delete me") - val deletion = signer.sign(DeletionEvent.build(listOf(target), createdAt = target.createdAt + 1)) + val deletion = signer.sign(DeletionRequestEvent.build(listOf(target), createdAt = target.createdAt + 1)) store.insert(deletion) assertEquals(listOf(deletion.id), store.deletionsCovering(listOf(target), here).map { it.id }) @@ -82,7 +82,7 @@ class DeletionSyncTest : RelayClientTest() { runBlocking { val contacts = ContactListEvent.createFromScratch(emptyList(), null, signer) // Address-only deletion (no `e` tag) → only the `a`-tag path can match it. - val delAddr = signer.sign(DeletionEvent.buildAddressOnly(listOf(contacts), createdAt = contacts.createdAt + 1)) + val delAddr = signer.sign(DeletionRequestEvent.buildAddressOnly(listOf(contacts), createdAt = contacts.createdAt + 1)) store.insert(delAddr) assertEquals( @@ -93,7 +93,7 @@ class DeletionSyncTest : RelayClientTest() { // NIP-09 cutoff: a deletion OLDER than the event does not delete it. val stale = EventStore(null) - stale.insert(signer.sign(DeletionEvent.buildAddressOnly(listOf(contacts), createdAt = contacts.createdAt - 1))) + stale.insert(signer.sign(DeletionRequestEvent.buildAddressOnly(listOf(contacts), createdAt = contacts.createdAt - 1))) assertTrue(stale.deletionsCovering(listOf(contacts), here).isEmpty(), "an older address deletion does not cover") stale.close() } @@ -129,7 +129,7 @@ class DeletionSyncTest : RelayClientTest() { fun sendsCoveringDeletionSoRelayRemovesTheNote() = runBlocking { val target = note("delete me e2e") - val deletion = signer.sign(DeletionEvent.build(listOf(target), createdAt = target.createdAt + 1)) + val deletion = signer.sign(DeletionRequestEvent.build(listOf(target), createdAt = target.createdAt + 1)) // Relay holds the note; we already deleted it locally (hold only the kind-5). defaultRelay.preload(listOf(target)) @@ -163,7 +163,7 @@ class DeletionSyncTest : RelayClientTest() { fun appliesRelaysDeletionSoLocalRemovesTheNote() = runBlocking { val target = note("delete me down") - val deletion = signer.sign(DeletionEvent.build(listOf(target), createdAt = target.createdAt + 1)) + val deletion = signer.sign(DeletionRequestEvent.build(listOf(target), createdAt = target.createdAt + 1)) // Relay already applied the deletion → holds only the kind-5. defaultRelay.preload(listOf(target, deletion)) @@ -190,7 +190,7 @@ class DeletionSyncTest : RelayClientTest() { val ourEvents = local.store.query(Filter(ids = diff.haveIds)) val relayDeletions = deletionsCovering(ourEvents, defaultRelayUrl) { f -> defaultRelay.store.query(f) } assertEquals(listOf(deletion.id), relayDeletions.map { it.id }) - relayDeletions.filterIsInstance().forEach { local.store.insert(it) } + relayDeletions.filterIsInstance().forEach { local.store.insert(it) } assertTrue( local.store.query(Filter(ids = listOf(target.id))).isEmpty(), @@ -206,7 +206,7 @@ class DeletionSyncTest : RelayClientTest() { fun settleSendsOurDeletionUp() = runBlocking { val target = note("settle up") - val deletion = signer.sign(DeletionEvent.build(listOf(target), createdAt = target.createdAt + 1)) + val deletion = signer.sign(DeletionRequestEvent.build(listOf(target), createdAt = target.createdAt + 1)) val localStore = EventStore(null) localStore.insert(target) localStore.insert(deletion) // deletes target locally, keeps the kind-5 @@ -239,7 +239,7 @@ class DeletionSyncTest : RelayClientTest() { fun settleAppliesRelayDeletionDown() = runBlocking { val target = note("settle down") - val deletion = signer.sign(DeletionEvent.build(listOf(target), createdAt = target.createdAt + 1)) + val deletion = signer.sign(DeletionRequestEvent.build(listOf(target), createdAt = target.createdAt + 1)) defaultRelay.preload(listOf(target, deletion)) // relay deletes target, keeps the kind-5 val localStore = EventStore(null) localStore.insert(target) diff --git a/geode/src/test/kotlin/com/vitorpamplona/geode/Nip01ComplianceTest.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/Nip01ComplianceTest.kt index 7f69de4c89..9326acbb7f 100644 --- a/geode/src/test/kotlin/com/vitorpamplona/geode/Nip01ComplianceTest.kt +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/Nip01ComplianceTest.kt @@ -33,7 +33,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED import kotlinx.coroutines.runBlocking @@ -350,7 +350,7 @@ class Nip01ComplianceTest : RelayClientTest() { /** * Addressable events (kind 30000-39999, NIP-01 §"Kinds") are replaced - * by `(pubkey, kind, d)`. Uses a real signed [LongTextNoteEvent] because + * by `(pubkey, kind, d)`. Uses a real signed [LongFormContentEvent] because * the SQLite store dispatches on the typed `AddressableEvent` subclass * to extract the d-tag — synthetic plain `Event`s aren't recognised. */ @@ -358,15 +358,15 @@ class Nip01ComplianceTest : RelayClientTest() { fun parameterizedReplaceableEventsKeepNewestPerDTag() = runBlocking { val signer = NostrSignerSync(KeyPair()) - val v1 = signer.sign(LongTextNoteEvent.build("old", "title", dTag = "list-a", createdAt = 100)) - val v2 = signer.sign(LongTextNoteEvent.build("new", "title", dTag = "list-a", createdAt = 200)) - val v3 = signer.sign(LongTextNoteEvent.build("list-b", "title", dTag = "list-b", createdAt = 100)) + val v1 = signer.sign(LongFormContentEvent.build("old", "title", dTag = "list-a", createdAt = 100)) + val v2 = signer.sign(LongFormContentEvent.build("new", "title", dTag = "list-a", createdAt = 200)) + val v3 = signer.sign(LongFormContentEvent.build("list-b", "title", dTag = "list-b", createdAt = 100)) preload(v1, v2, v3) val (events, _) = client.collectUntilEose( defaultRelayUrl, - Filter(kinds = listOf(LongTextNoteEvent.KIND), authors = listOf(signer.pubKey)), + Filter(kinds = listOf(LongFormContentEvent.KIND), authors = listOf(signer.pubKey)), ) assertEquals(2, events.size) diff --git a/geode/src/test/kotlin/com/vitorpamplona/geode/Nip09DeletionTest.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/Nip09DeletionTest.kt index 914da8e385..03336f4779 100644 --- a/geode/src/test/kotlin/com/vitorpamplona/geode/Nip09DeletionTest.kt +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/Nip09DeletionTest.kt @@ -28,7 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync -import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -123,7 +123,7 @@ class Nip09DeletionTest { runBlocking { val signer = NostrSignerSync(KeyPair()) val note = signer.sign(TextNoteEvent.build("delete me")) - val deletion = signer.sign(DeletionEvent.build(listOf(note), createdAt = note.createdAt + 1)) + val deletion = signer.sign(DeletionRequestEvent.build(listOf(note), createdAt = note.createdAt + 1)) // Publish original, confirm it's stored. assertEquals(true, client.publishAndConfirm(note, setOf(relayUrl))) @@ -139,12 +139,12 @@ class Nip09DeletionTest { runBlocking { val signer = NostrSignerSync(KeyPair()) val note = signer.sign(TextNoteEvent.build("a")) - val deletion = signer.sign(DeletionEvent.build(listOf(note), createdAt = note.createdAt + 1)) + val deletion = signer.sign(DeletionRequestEvent.build(listOf(note), createdAt = note.createdAt + 1)) client.publishAndConfirm(note, setOf(relayUrl)) client.publishAndConfirm(deletion, setOf(relayUrl)) - val results = query(Filter(kinds = listOf(DeletionEvent.KIND), authors = listOf(signer.pubKey))) + val results = query(Filter(kinds = listOf(DeletionRequestEvent.KIND), authors = listOf(signer.pubKey))) assertEquals(1, results.size) assertEquals(deletion.id, results[0].id) } @@ -154,7 +154,7 @@ class Nip09DeletionTest { runBlocking { val signer = NostrSignerSync(KeyPair()) val note = signer.sign(TextNoteEvent.build("once-upon-a-time")) - val deletion = signer.sign(DeletionEvent.build(listOf(note), createdAt = note.createdAt + 1)) + val deletion = signer.sign(DeletionRequestEvent.build(listOf(note), createdAt = note.createdAt + 1)) client.publishAndConfirm(note, setOf(relayUrl)) client.publishAndConfirm(deletion, setOf(relayUrl)) @@ -178,7 +178,7 @@ class Nip09DeletionTest { // kind-5 event itself (it's just an event), but the SQL DELETE // is owner-scoped, so Alice's event survives. val malloryDelete = - mallory.sign(DeletionEvent.build(listOf(aliceNote), createdAt = aliceNote.createdAt + 1)) + mallory.sign(DeletionRequestEvent.build(listOf(aliceNote), createdAt = aliceNote.createdAt + 1)) client.publishAndConfirm(malloryDelete, setOf(relayUrl)) assertEquals( diff --git a/marmotBench/src/main/kotlin/com/vitorpamplona/marmotbench/Fixtures.kt b/marmotBench/src/main/kotlin/com/vitorpamplona/marmotbench/Fixtures.kt index 47a25cc4c1..50b256ffaa 100644 --- a/marmotBench/src/main/kotlin/com/vitorpamplona/marmotbench/Fixtures.kt +++ b/marmotBench/src/main/kotlin/com/vitorpamplona/marmotbench/Fixtures.kt @@ -21,9 +21,9 @@ package com.vitorpamplona.marmotbench import com.vitorpamplona.amethyst.commons.marmot.MarmotPublisher +import com.vitorpamplona.quartz.marmot.groups.MarmotMessageStore +import com.vitorpamplona.quartz.marmot.groups.MlsGroupStateStore import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore -import com.vitorpamplona.quartz.marmot.mls.group.MarmotMessageStore -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore import com.vitorpamplona.quartz.nip01Core.core.Event // In-memory stores, matching the commons test doubles byte for byte. diff --git a/marmotBench/src/main/kotlin/com/vitorpamplona/marmotbench/PrimitiveBenchmarks.kt b/marmotBench/src/main/kotlin/com/vitorpamplona/marmotbench/PrimitiveBenchmarks.kt index 4754138eaf..8e997fe0eb 100644 --- a/marmotBench/src/main/kotlin/com/vitorpamplona/marmotbench/PrimitiveBenchmarks.kt +++ b/marmotBench/src/main/kotlin/com/vitorpamplona/marmotbench/PrimitiveBenchmarks.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.marmotbench -import com.vitorpamplona.quartz.marmot.mls.crypto.Ed25519 -import com.vitorpamplona.quartz.marmot.mls.crypto.X25519 +import com.vitorpamplona.quartz.mls.crypto.Ed25519 +import com.vitorpamplona.quartz.mls.crypto.X25519 // The elliptic-curve primitives on their own. // diff --git a/quartz/CLIENT.md b/quartz/CLIENT.md index 99338a8821..0b03c120ab 100644 --- a/quartz/CLIENT.md +++ b/quartz/CLIENT.md @@ -460,9 +460,9 @@ Common event kinds you'll work with: | 0 | NIP-01 | `MetadataEvent` | User metadata (name, picture, about) | | 1 | NIP-01 | `TextNoteEvent` | Text note (short post) | | 3 | NIP-02 | `ContactListEvent` | Contact list / follow list | -| 4 | NIP-04 | `PrivateDmEvent` | Encrypted direct message | +| 4 | NIP-04 | `EncryptedDmEvent` | Encrypted direct message | | 7 | NIP-25 | `ReactionEvent` | Reaction (like, emoji) | -| 30023 | NIP-23 | `LongTextNoteEvent` | Long-form content (articles) | +| 30023 | NIP-23 | `LongFormContentEvent` | Long-form content (articles) | ## Multi-Relay Subscriptions diff --git a/quartz/interop/verify-with-ts-mls.sh b/quartz/interop/verify-with-ts-mls.sh new file mode 100755 index 0000000000..a3fde7a9c9 --- /dev/null +++ b/quartz/interop/verify-with-ts-mls.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# +# The reverse interop direction: ts-mls checks OUR output. +# +# `quartz/cordn`'s own tests prove we can read what ts-mls writes. That is half of +# interoperating -- a client can parse everything correctly and still emit +# something nobody accepts, and that failure keeps our tests green while every +# peer silently drops us. This script closes the loop by handing our artifacts +# to the implementation cordn's client actually runs. +# +# It is opt-in because it needs two checkouts and a Node toolchain, which CI +# for this repo does not carry. `KotlinArtifactProducerTest` writes the +# artifacts unconditionally; only the verification below needs the extras. +# +# Usage: +# quartz/interop/verify-with-ts-mls.sh +# +# Environment: +# CORDN_DIR cordn checkout with `pnpm install` run (default ../cordn) +# STAIRCASE_DIR staircase checkout, for its verify.ts (default ../staircase) +# +# git clone https://github.com/Cordn-msg/cordn ../cordn && (cd ../cordn && pnpm install) +# git clone https://code.relay.tools/opensauce/staircase ../staircase +# +set -euo pipefail + +REPO="$(cd "$(dirname "$0")/../.." && pwd)" +CORDN="${CORDN_DIR:-$REPO/../cordn}" +STAIRCASE="${STAIRCASE_DIR:-$REPO/../staircase}" +OUT="$REPO/quartz/build/interop" +FIXTURES="$REPO/quartz/src/commonTest/resources/tsmls" +VERIFY="$STAIRCASE/conformance/fixtures-gen/verify.ts" + +die() { echo "error: $*" >&2; exit 2; } + +[ -d "$CORDN/packages/cli" ] || die "no cordn checkout at $CORDN (set CORDN_DIR)" +[ -d "$CORDN/node_modules" ] || die "run 'pnpm install' in $CORDN first" +[ -f "$VERIFY" ] || die "no staircase checkout at $STAIRCASE (set STAIRCASE_DIR)" + +# Node, not bun. staircase's own run.sh uses bun, and bun's WebCrypto has no +# X25519 DHKEM, so ts-mls there cannot open a Welcome at all -- not even one it +# produced itself. The failure surfaces as `DecapError: The algorithm is not +# supported` deep inside HPKE and looks exactly like a wire-format mismatch, +# which is worth knowing before spending an afternoon on it. +command -v node >/dev/null || die "node is required" + +echo "==> producing artifacts from quartz" +"$REPO/gradlew" -p "$REPO" :quartz:jvmTest --tests '*KotlinArtifactProducerTest*' --rerun-tasks -q + +# verify.ts reads /gen for the ts-mls KeyPackage it joins with, and +# /kotlin for everything we produced. +echo "==> assembling fixture root at $OUT" +mkdir -p "$OUT/gen" +cp "$FIXTURES/bob2-kp.bin" "$FIXTURES/bob2-privkp.bin" "$OUT/gen/" + +STAGE="$CORDN/packages/cli/.staircase" +mkdir -p "$STAGE" +cp "$VERIFY" "$STAGE/" + +echo "==> running ts-mls against them" +cd "$CORDN/packages/cli" +node --experimental-strip-types "$STAGE/verify.ts" "$OUT" diff --git a/quartz/plans/2026-06-12-giftwrap-deletion-requests.md b/quartz/plans/2026-06-12-giftwrap-deletion-requests.md index d2791d65db..f392c4d991 100644 --- a/quartz/plans/2026-06-12-giftwrap-deletion-requests.md +++ b/quartz/plans/2026-06-12-giftwrap-deletion-requests.md @@ -46,7 +46,7 @@ special case describes — does nothing. `GiftWrapEvent.recipientPubKey()` at unseal time, so the validation below works after the wrap note is GC'd. -3. **Live cascade (amethyst `LocalCache.consume(DeletionEvent)`):** the +3. **Live cascade (amethyst `LocalCache.consume(DeletionRequestEvent)`):** the wrap note that knew its `innerEventId` is GC'd by the time a deletion arrives, so find the rumor by reverse lookup: scan notes for `note.rumorHost?.id == deletedId` (precedent: the addressable pass in diff --git a/quartz/plans/2026-07-11-concord-event-classes.md b/quartz/plans/2026-07-11-concord-event-classes.md index 82e57ac9a4..76b1a1ce32 100644 --- a/quartz/plans/2026-07-11-concord-event-classes.md +++ b/quartz/plans/2026-07-11-concord-event-classes.md @@ -10,7 +10,7 @@ Two problems: 1. **Duplicates standard Nostr kinds.** `ConcordKinds.MESSAGE=9`, `REACTION=7`, `DELETE=5`, `COMMENT=1111`, `EDIT=3302` shadow `ChatEvent.KIND`, - `ReactionEvent.KIND`, `DeletionEvent.KIND`, `CommentEvent.KIND`. Concord chat + `ReactionEvent.KIND`, `DeletionRequestEvent.KIND`, `CommentEvent.KIND`. Concord chat rumors *are* those standard events (they already parse back as `ChatEvent` etc. on read) — the build side should reuse the classes, not re-derive by number. @@ -37,7 +37,7 @@ binding. Introduce a binding-tag package and reuse the standard builders: | message (9) | `ChatEvent.build` | `channel` + `epoch` | | reply (9 + q) | `ChatEvent.build` + `q`/`p` | `channel` + `epoch` | | reaction (7) | `ReactionEvent.build` | `channel` + `epoch` | -| delete (5) | `DeletionEvent.build` | `channel` + `epoch` | +| delete (5) | `DeletionRequestEvent.build` | `channel` + `epoch` | `ChannelChat` keeps its public API (returns unsigned rumors via `RumorAssembler`) but builds tags from the standard event's DSL + the binding ext. Delete diff --git a/quartz/plans/2026-09-17-cordn-interop.md b/quartz/plans/2026-09-17-cordn-interop.md new file mode 100644 index 0000000000..b410415140 --- /dev/null +++ b/quartz/plans/2026-09-17-cordn-interop.md @@ -0,0 +1,1351 @@ +# Cordn interop: extract the MLS core, then add a second binding + +Status: Stages 1, 2, the core of 3 and the wire half of Stage 0 landed. The RFC 9420 engine is `quartz/…/mls/` and imports +nothing from `marmot/` — a binding supplies its rules through `MlsGroupPolicy`. `quartz/…/contextvm/` +implements the core spec plus all 12 CEPs on the client side, with the Tier C fixture server; +`quartz/…/cordn/` implements the MLS profile, the eleven coordinator tools, the seal, envelopes, +group refs and the sync rules, verified against ts-mls in both directions **and against cordn's +own wire contracts** (`quartz/tools/cordn-vector-gen` → `resources/cordn/`). Stage 4 (app +integration) is open. **Tier B has now been run** — on an explicit decision to proceed despite +the licensing problem in §7 — and found two bugs no fixture could (§7.1). §4.1 turned out not to gate the binding — see Stage 3 — and is now settled on our +side by implementing both encodings rather than waiting for an agreement (§4.1). + +Correction to an earlier gate in this plan: §4.1 does **not** block Stage 2. ContextVM is +credential-agnostic and has no MLS dependency at all, so the transport was safe to build first; +only Stage 3's KeyPackage work depends on that decision. + +Sources checked on 2026-09-17: + +- `Cordn-msg/cordn` @ `b465df0` (2026-08-27), package version `0.5.1` — specs + reference + coordinator + `@cordn/cli` +- `Cordn-msg/cordn-web` @ `c38e307` (2026-09-16), version `0.4.0` — the client at + +- `Cordn-msg/cordn-rs` @ `9aff928` (2026-08-29) — Rust coordinator, wire- and SQLite-compatible + with the TS one; **not** an MLS implementation +- `ContextVM/contextvm-docs` @ `e63bce6` — the ContextVM specification and all 12 CEPs; the + clean-room source for §6. **No LICENSE file in the repo** +- `ContextVM/sdk` @ `b5d1e4e` (2026-09-17), version `0.13.17` — **LGPL-3.0**, see §7. Read only to + confirm deployed defaults, never as an implementation source + +Verification status: `:quartz:jvmTest` passes in a container, covering the MLS engine, ContextVM +and cordn, so the interop claims in §3 are execution-verified rather than read-verified. +`:quartz:testAndroidHostTest` passes as well — the four failures this plan previously recorded as +pre-existing (`NostrServerTest`, `LiveNegentropyIndexStoreTest`) were fixed on 2026-09-18: the +event store classified insert failures by parsing the SQLite driver's exception message, and +Android's carries none. Note the source-set shape when reading test counts: `jvmTest` dependsOn +`jvmAndroidTest`, but `androidHostTest` does **not**, so the cordn and ContextVM suites under +`jvmAndroidTest/` run on the JVM target only. + +## 1. Executive summary + +Cordn is **an alternative to Marmot, not an alternative to MLS**. Both are bindings of RFC 9420 +onto Nostr; they agree almost exactly on the crypto layer and disagree on everything above it. + +- **Same:** ciphersuite `0x0001`, the ChaCha20-Poly1305 outer seal (byte-identical framing), the + MLS-exporter-derived seal key, the pre-commit epoch rule for Commits, and the unsigned + NIP-01-shaped application envelope with `kind: 9` chat. +- **Different:** the delivery service. Marmot publishes kinds 443/444/445 to relays. Cordn calls + an **MCP server over ContextVM** — 11 tools, per-group monotonic cursors, history in the + coordinator's SQLite, and *no key-package event kind at all*. + +So the only code both bindings can share is the RFC 9420 engine. Everything named `Marmot*`, +`Mip*` or `mip0*` is the wrong layer and must not be reused. + +The engine is reusable but **not yet binding-agnostic**: it lives inside +`quartz/…/marmot/mls/`, and three files plus three hardcoded policies leak Marmot into it (§5.1). +Extracting it is the prerequisite for this work and is worth doing on its own merits. + +The transport has to be written from scratch: **ContextVM is MCP-over-Nostr and nothing in Quartz +speaks it.** Scope is **the core spec plus all 12 CEPs** (§6.2), so `:contextvm` is a complete +MCP-over-Nostr implementation rather than a cordn adapter — cordn only exercises 7 of the 13 +documents, but the rest are cheap next to the two large transfer profiles and several ride on NIPs +we already have. Only **CEP-4, CEP-6 and CEP-16** are Final; the core spec and the other nine CEPs +are Draft, including CEP-22 and CEP-41 (§6.7). + +Because the CEPs are symmetric, compliance is not demonstrable against cordn alone. §6.4 defines +five test tiers. **Tier C — a Kotlin fixture server that misbehaves on demand — is built** +(`contextvm/…/fixture/`) and is what makes the negative half testable: no real server sends a +non-monotonic `progress`, a stale `pong` nonce or a mismatched digest, yet those are MUST-fail +requirements. **RFC 8785 JCS** is built too, in `quartz/…/utils/jcs/`, shared by CEP-8 and CEP-15. +**Tier B (live coordinator) has been run** — see §7.1 for the two bugs it found and §7 for the +terms it was run on. Tiers D (cross-implementation vectors) and E (a real wallet) remain open. + +Sequencing, as revised in practice: **Stage 2 (the transport) was built first**, because +ContextVM has no MLS dependency and so no dependency on the §4.1 decision. What that decision +gates is **Stage 3**, the cordn binding: if it goes the wrong way every KeyPackage is permanently +ecosystem-bound and "interop" degrades to Amethyst speaking two unrelated protocols. Remaining +order: Stage 0 (cordn-side vectors) → Stage 1 (extract the MLS engine) → decide §4.1 → Stage 3-4. + +## 2. The coordinator protocol surface + +Eleven MCP tools (`cordn/packages/core/src/contracts.ts`): + +``` +kp_publish kp_list kp_take kp_remove +welcome_store welcome_take +join_request_store join_request_take_many +msg_post msg_fetch_many msg_sub_many +``` + +Transport is ContextVM: kind **25910** ephemeral events whose `content` is a stringified MCP +JSON-RPC message, `p` = peer, `e` = request correlation, optionally gift-wrapped in 1059/21059 +(`contextvm-sdk/src/core/constants.ts`). `msg_sub_many` needs **CEP-41** open streams and the +coordinator also enables **CEP-22** oversized transfer, both framed over +`notifications/progress`. + +Delivery model (`cordn/spec/00.md`, `spec/03.md`): the coordinator stores opaque bytes keyed by an +outer delivery id `gid`, assigns a monotonic per-group cursor, and must not parse payloads. It +cannot distinguish a Commit from a chat message — epoch, wireformat and content-type were +deliberately removed from its view (`cordn/design/private-coordinator-refactor.md`, which is an +explicit study of Marmot's kind-445 and the reason the two seals match). + +`gid` is spec'd as decoupled from the MLS `group_id`, but the reference client sets both to +`utf8(crypto.randomUUID())` (`cordn-web/src/lib/services/chatGroupLifecycle.svelte.ts:76`, +and `getProtocolGroupId` at `:83` reads the `gid` straight back out of the group context). + +Group sharing is a bech32 `cordn1…` string with NIP-19-shaped TLV (type 0 = `gid` as UTF-8, +1 = coordinator pubkey as raw 32 bytes, 2 = relay URL), bech32 not bech32m +(`cordn/spec/applications/group-ref.md`). Our NIP-19 codec covers it with a prefix change. + +## 3. Verified compatible (the crypto layer) + +| Layer | Marmot (ours) | cordn | Verdict | +| ----- | ------------- | ----- | ------- | +| Ciphersuite | `MlsCiphersuite.DEFAULT` = `0x0001` (`mip01Groups/MlsCiphersuite.kt`) | `MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519` (`chatMlsUtils.ts:38`) | identical | +| Outer seal | ChaCha20-Poly1305, 12-byte random nonce, **empty AAD**, `base64(nonce‖ct‖tag)` (`mip03GroupMessages/GroupEventEncryption.kt`) | same, same, same, same (`spec/03.md` §4) | byte-identical framing | +| Seal key | `MLS-Exporter("marmot","group-event",32)` | `MLS-Exporter("cordn","group-payload",32)` | label/context only | +| Commit epoch | pre-commit epoch | pre-commit epoch (`spec/03.md` §5) | identical | +| App payload | unsigned NIP-01 event, no `sig`, `id` = NIP-01 hash, `kind: 9` default (`foundation/appEvents/MarmotAppEvent.kt:78`) | same, and rejects any payload carrying `sig` (`spec/02.md`) | same wire shape | +| Threading / reactions | NIP-22 `1111`, NIP-25 `7` | same (`spec/02.md` §6) | identical | +| Sender privacy intent | fresh ephemeral key per kind-445 | ephemeral ContextVM identity on the message path | same idea, different grain (§8.2) | + +cordn-web's MLS engine is **ts-mls 2.0.0-rc.13**, the same library our existing +`TsMlsWelcomeInteropTest` has vectors for +(`quartz/src/commonTest/resources/mls/tsmls-welcome.json` — key packages, Welcome, exporter and +app messages at ciphersuite 1). The RFC 9420 core is therefore a known quantity, subject to the +caveat in the header that this container could not re-run it. + +Note what this table is *not*: none of these are reuse candidates. `GroupEventEncryption`, +`MarmotAppEvent` and MIP-04 media are Marmot-layer code. The table says a cordn-side +implementation will look structurally familiar and can be written with confidence — it will be +parallel code under `quartz/…/cordn/`, not a shared path. + +## 4. Verified divergences + +### 4.1 Credential identity encoding — hard incompatibility, decide first + +- Ours: raw **32 bytes**, x-only pubkey. `KeyPackageUtils.kt:253` — + `if (credential.identity.size != 32) return false`. +- Theirs: **64 ASCII bytes** of lowercase hex. `chatMlsUtils.ts:251` — + `identity: new TextEncoder().encode(stablePubkey)`; the coordinator reads it back with + `TextDecoder` and string-compares to `event.pubkey` + (`coordinatorMethods.ts:118-151`). + +One KeyPackage cannot satisfy both. This is exactly what `spec/00.md` §13 requires +implementations to agree on, and the two ecosystems picked differently. It is a one-line change +on either side today and unfixable once either has deployed users at scale. + +**Decision (2026-09-18): support both encodings; do not wait for an agreement.** Neither +ecosystem is expected to move, and neither needs to. The credential encoding is a *binding* +choice, and since Stage 1 the engine no longer has an opinion about it — `MlsGroupPolicy` picks +the profile, `MarmotCapabilities`/`CordnCredential` supply the encoding. Marmot KeyPackages carry +the raw 32 bytes; cordn KeyPackages carry the 64 ASCII hex bytes; both are produced and read by +the same engine, and neither can be mistaken for the other (32 raw bytes is not valid 64-char +ASCII hex, so `CordnCredential.identityOrNull` and `KeyPackageUtils`'s 32-byte check are +mutually exclusive by construction). + +What this costs is §4.4: a member advertising only one profile's capabilities cannot be added to +the other's groups, so "one MLS group, both clients" stays out of reach. That was already a +deliberate profile decision rather than a consequence of this one. What it buys is that Amethyst +speaks both today rather than blocking on a conversation neither side has an incentive to finish. + +Still worth raising with gzuuus as a fact rather than a request — an implementation that reads +both is useful evidence for whichever encoding a future joint profile picks. + +### 4.2 No key-package event kind + +There is no cordn equivalent of Marmot's kind 443. `kp_publish` takes `{ kp_ref, kp_64 }` — plain +base64 in the JSON-RPC arguments. The "signed publication payload" of `spec/00.md` §7 is the +**ContextVM request event itself**: the coordinator reaches back into the transport for it +(`coordinatorServer.ts:92` → `transport.getNostrRequestEvent(requestEventId)`), stores it +verbatim, and returns it on `kp_take`. A consumer then does +(`chatMlsUtils.ts:579-613`): + +```ts +if (!verifyEvent(publicationEvent)) throw … +const kp64 = JSON.parse(publicationEvent.content).params?.arguments?.kp_64 + ?? JSON.parse(publicationEvent.content).params?.arguments?.keyPackageBase64; +``` + +i.e. the KeyPackage is recovered by parsing JSON-RPC out of a kind-25910 event's `content`, and +identity binding is `decodeKeyPackageIdentity(kp) === publicationEvent.pubkey`. + +Consequences: + +- A Marmot kind-443 event cannot serve as a cordn publication payload or vice versa. There is no + "publish to relays, interop on KeyPackages only" shortcut — KeyPackage publication is + inseparable from having a working ContextVM client. +- The invariant rides JSON-RPC envelope shape rather than a stable event schema, and the client + already carries a fallback from an earlier field rename (the `?? keyPackageBase64` above). + +**Worth proposing upstream** alongside §4.1: give KeyPackage publication its own signed payload +(or its own kind). That is mutual-benefit — it would let a cordn KeyPackage be published to relays +and consumed without a coordinator at all. + +### 4.3 Last-resort marker + +Ours: MLS extension `0x000A`. Theirs: mls-extensions `app_data_dictionary` (`0x0006`) with +component id `0x0004` and empty component data +(`cordn/packages/core/src/lastResortKeyPackage.ts`). We already have `AppDataDictionary.kt`, +`ComponentsList.kt` and `AppDataDictionaryInteropTest` — this is a codec selection, not new work. + +### 4.4 Capability gates keep the two group types disjoint + +Marmot groups carry `required_capabilities = { extensions: [0xF2EE], proposals: [0x000A +self_remove] }` (`MlsGroup.kt:3255`, built by `buildMarmotRequiredCapabilitiesExtension()` at +`:3259`). Cordn groups use GroupContext extension `0xC04D cordn_group_metadata` and require +members to advertise it (`spec/01.md` §7). Neither client can be *added* to the other's groups. +Cheap to fix by advertising both in `Capabilities`, but "one group, both clients" is a deliberate +profile decision, not a free consequence of sharing an engine. + +### 4.5 Encrypted media diverges + +Exporter *context* matches (`"encrypted-media"`), but cordn uses the exporter output directly as +the file key with `aad = mime‖0x00‖filename‖0x00‖sha256(plaintext)`, where our MIP-04 v2 does +`HKDF-Expand(exporter, context)` with `aad = "mip04-v2"‖0x00‖hash‖0x00‖mime‖0x00‖filename`. +Separate codec; shared primitives (ChaCha20-Poly1305, NIP-92 `imeta`, Blossom) all already exist. + +### 4.6 Multi-device — cross-client interop is impossible; the feature is not + +`spec/applications/multi-device.md` gives a user's devices **one shared MLS leaf** per group and +carries that group's `ClientState` inside sealed Blossom documents advertised by an opaque tip. + +**What is not implementable: interop with their devices.** The document's `clientState` is +`base64(serialized MLS ClientState)`, and §4.2 says outright that it is *"library-serialized and +intentionally not pinned to a wire format"* — the only pinned MLS serialization in the document is +TLS, used for `lastResortKeyPackage`. So the field is ts-mls's private encoding by design, not by +omission. An Amethyst device and a cordn-web device can never share a leaf, and no work on our side +changes that. + +**What IS implementable: our own fleet.** Everything else §14 lists as a MUST is pinned and +vendor-neutral — the two document JSON shapes, the NIP-44 v2 seal to a per-identity DEK, `sha256` +content addressing, the highest-epoch-wins reconciliation rule, the `{gid, epoch}` tombstone shape, +the sibling-skip rule, and the tip format. Amethyst-device ↔ Amethyst-device sync would work with +`MlsGroupState` in that one field. + +**Status: the fleet stays a non-goal; migration LANDED.** A handoff — one phone +publishing a snapshot and standing down, another seeding from it — has one writer, so +§10's race is out of reach by construction rather than by mitigation. That shipped; see +`amethyst/plans/2026-09-19-cordn-ui.md` §5.3 for what was built and what was deliberately +left out. The reasoning below is why a *live fleet* is still not worth it, and it is +unchanged: + +- It buys Amethyst-only device sync. A user who mixes clients gets nothing. +- §10's **symmetric commit race is unresolved in the spec**. Two devices committing inside one + delivery round-trip both reach epoch N+1 with different states; the forward-only epoch check + cannot break the tie, and §15 concedes that *"equal-epoch MLS states have no merge function."* + The spec offers only refuse-to-commit-while-behind and a manual re-sync prompt; automatic + resolution is *"possible but unspecified."* We would be shipping that race. +- It is a heavy, always-on discipline for a Draft spec: a document republish plus a full tip + rewrite after **every** epoch-advancing Commit (§10.5), and a full reconcile before opening any + delivery stream on startup (§10.6), because a backlog fetched while behind arrives sealed under + epochs the device has not adopted. +- It needs an `MlsGroupState` ⇄ document codec and a `prev`-chain walk (§8.5) that nothing else + in the codebase wants. + +So: do not attempt it — but record it as a cost/benefit call on a Draft spec with a known race, +not as "there is nothing to implement." + +## 5. What we already have + +### 5.1 The engine, and how Marmot-clean it is + +**Superseded by Stage 1, which landed.** The measurements below are what the engine looked like +before the extraction; they are kept because they are what the stage was scoped against. The +engine is now `quartz/…/mls/` with zero `marmot/` imports. + +`quartz/…/marmot/mls/` was **11,964 LOC**. Measured coupling to the Marmot layer: + +- **3 files**, **10 imports** total: + - `group/MlsGroup.kt` (7): `MarmotGroupData`, `MarmotGroupState`, `AdminPolicyV1`, + `AppComponentIds`, `AgentTextStreamCrypto`, `AgentTextStreamQuicPolicyV1`, + `AgentTextStreamRoles` + - `group/MlsGroupManager.kt` (2): `AdminPolicyV1`, `GroupLifecycleV1` + - `messages/MlsKeyPackage.kt` (1): `AppComponentIds` +- **Zero** Marmot imports in `codec/`, `crypto/`, `tree/`, `schedule/`, `framing/`, + `components/`, `messages/{Commit,Proposal,Welcome}`. + +Plus three Marmot policies hardcoded *inside* the engine that a cordn group must not inherit: + +| Location | Hardcoded | +| -------- | --------- | +| `MlsGroup.kt:3259` | `required_capabilities = {ext:[0xF2EE], props:[0x000A]}` | +| `MlsGroup.kt:650`, `:4276` | `exporterSecret("marmot", "group-event", 32)` — label baked in | +| `KeyPackageRotationManager.kt:645-670` | leaf `capabilities.extensions = [0x000A, 0xF2EE]` | + +`MlsGroup.kt:2072` already exposes `exporterSecret(label, context, length)`, so the last of those +is a call-site fix, not a redesign. + +### 5.2 Reusable as-is + +NIP-44 (and NIP-59) for the ContextVM gift wrap, NIP-19 bech32/TLV for `cordn1…`, +`ChaCha20Poly1305`, the TLS presentation-language codec (`mls/codec/`) for the `0xC04D` extension, +Blossom (`nipB7Blossom`), `INostrClient` and the `accessories/` one-shot helpers for relay I/O, +and `NostrSigner` for both identities. + +## 6. ContextVM: full surface review and compliance matrix + +We have to write this from scratch — nothing in Quartz speaks it, and the only SDKs are TypeScript +and Rust. **Scope decision: implement the whole CEP list**, not just the subset cordn exercises. +That makes `:contextvm` a complete MCP-over-Nostr implementation rather than a cordn adapter, and +it means compliance has to be demonstrable per CEP rather than "cordn works". + +**Clean-room sourcing.** Everything below is derived from the specification documents in +`ContextVM/contextvm-docs` (the `docs/contextvm-docs` submodule of the SDK, at +`src/content/docs/reference/`), **not** from the LGPL SDK source (§7). Implementers should work +from those documents. The docs repo carries **no LICENSE file** — the protocol is free to +implement, but do not paste spec prose into our repo; paraphrase. + +### 6.1 The core spec is small + +`spec/ctxvm-draft-spec.md` (352 lines, Draft) is nearly all of the base protocol: + +- **One event kind, 25910**, ephemeral (NIP-01 range 20000–30000). `content` is the stringified + MCP JSON-RPC message, preserved exactly. Nostr metadata lives only in tags: `p` addresses the + peer, `e` references the request event for correlation. +- **Standard MCP lifecycle** — `initialize` → result → `notifications/initialized` — and the spec + explicitly says it is **not required**, because servers may operate statelessly. +- Everything else (`tools/list`, `tools/call`, notifications) is unmodified MCP in `content`. + +The consequence worth flagging up front: **25910 is ephemeral, so relays do not store it.** A +client must already be subscribed when the response is published or the response is simply gone — +there is no REQ-after-the-fact recovery. That shapes our subscription lifecycle more than anything +else in the spec. + +### 6.2 Compliance matrix + +Thirteen documents: the core spec plus 12 CEPs. `Rules` is the rule-id prefix this plan assigns +for citing individual requirements, following the `STORE-Fxx` convention the `event-store-semantics` +skill established — so a future divergence can be named precisely instead of described. `Gate` is +what has to pass before we claim compliance; the test catalog is §6.5. + +| Spec | Status | Surface | Rules | Gate | +| ---- | ------ | ------- | ----- | ---- | +| **Core** draft spec | Draft | Kind 25910, `content` = stringified JSON-RPC, `p`/`e` tags, optional MCP lifecycle | `CVM-CORE-*` | A + B | +| **CEP-4** Encryption | **Final** | NIP-44 encrypt the *signed* inner 25910 event into a NIP-59 wrap (kind 1059), **no rumor layer**; `support_encryption` | `CVM-4-*` | A + B + **D** | +| **CEP-19** Ephemeral Gift Wraps | Draft | Kind **21059**, identical semantics to 1059 but ephemeral; `support_encryption_ephemeral`; MUST fall back to 1059 | `CVM-19-*` | A + B | +| **CEP-6** Public Announcements | **Final** | Addressable **11316** server, **11317** tools, **11318** resources, **11319** resource templates, **11320** prompts; discovery tags `name`/`about`/`picture`/`website`/`support_*` | `CVM-6-*` | A + B | +| **CEP-17** Relay List Metadata | Draft | NIP-65 **kind 10002**, unmarked `r` tags in the ContextVM profile; bootstrap vs advertised relays are distinct | `CVM-17-*` | A + B | +| **CEP-35** Stateless Discovery | Draft, Info | Discovery tags on the **first direct message each side sends**; unknown tags MUST be preserved; `p`/`e` excluded from the learned surface | `CVM-35-*` | A + C | +| **CEP-22** Oversized Transfer | Draft | Bounded reassembly over `notifications/progress`; `progressToken` = transfer id; `start`/`accept`/`chunk`/`end`/`abort`; `completionMode: "render"`; SHA-256 digest + `totalBytes` + `totalChunks` | `CVM-22-*` | A + B + C | +| **CEP-41** Open Streams | Draft | Long-lived streams, same envelope; `start`/`accept`/`chunk`/`ping`/`pong`/`close`/`abort`; per-sender `progress`; contiguous `chunkIndex` | `CVM-41-*` | A + B + C | +| **CEP-16** Client Pubkey Injection | **Final**, Info | Server injects `_meta.clientPubkey` into inbound requests; opt-in, default off | `CVM-16-*` | A + C (server role) | +| **CEP-8** Pricing and Payment | Draft | `cap`/`pmi`/`payment_interaction`/`direct_payment`/`change` tags; transparent notification lifecycle vs `explicit_gating` JSON-RPC errors (`-32042`, `-32043`, `-32602`); canonical invocation identity | `CVM-8-*` | A + C + **E** | +| **CEP-15** Common Tool Schemas | Draft | RFC 8785 JCS hash of `{name, normalized inputSchema, normalized outputSchema?}`; `io.contextvm/common-schema` `_meta`; NIP-73 `i`/`k` tags | `CVM-15-*` | A + B | +| **CEP-21** PMI Recommendations | Draft, Info | PMI naming conventions, `-direct` suffix for bearer settlement | `CVM-21-*` | A | +| **CEP-23** Server Profile Metadata | Draft | Server-published **kind 0** and optional **kind 1** | `CVM-23-*` | A + B | +| **CEP-24** Server Reviews | Draft | NIP-22 **kind 1111** anchored to the `11316::` `a` coordinate | `CVM-24-*` | A + B | + +Gate legend (method in §6.4): **A** rule-derived unit tests · **B** live integration against a +real counterparty · **C** adversarial tests against our own fixture server · **D** cross- +implementation vector exchange · **E** wallet integration. + +Status reality check: only **CEP-4, CEP-6 and CEP-16** are Final. The core spec and the nine other +CEPs are Draft, including both large transfer profiles. Per the CEP guidelines a CEP reaches Final +only once its reference implementation lands, so "Draft" here means the spec text may still move, +not that it is unimplemented. Budget for churn (§6.7). + +### 6.3 The high-risk rules + +These are where an implementation passes its own unit tests and then diverges against a real peer. +Each is a spec MUST and each maps to a named test in §6.5. + +1. **CEP-41 has two ordering fields, and they are not interchangeable.** `progress` orders *all* + frames (control frames included) and is explicitly **not** a chunk counter. `chunkIndex` starts + at `0`, increases contiguously by `1`, and is what receivers MUST use to validate contiguity + and completeness. Conflating them is the obvious first bug. +2. **Progress sequences are per-sender**, each starting at `1`. A frame's `progress` may only be + compared against frames from the same peer. A `pong`'s `progress` comes from the responder's own + sequence and has **no** required ordering relationship to the triggering `ping`; pongs match by + `nonce` only. Do not build one shared counter per stream. +3. **`close` does not complete the JSON-RPC request.** After `close` the sender MUST still send the + final JSON-RPC success response, and a client MUST NOT synthesize success from `close` alone. + So `msg_sub_many` has two independent completion signals — cordn-web's API shape + (`{ stream, result, abort }`) is a direct consequence, not a style choice. +4. **The CEP-22 digest is over the exact serialized string**, UTF-8-encoded — not over a + re-serialized JSON object. Any reparse-and-reserialize (key reordering, whitespace) breaks it. + The reassembly path must carry bytes end to end and only parse after the digest verifies. +5. **`accept` is conditional bootstrap, not a handshake.** Skip it when peer support is already + known; it is **mandatory before the first `chunk` in stateless flows**. cordn is stateless, so + our client→server oversized path must wait for `accept`. +6. **CEP-41 keepalive is mandatory.** Any valid frame resets the idle timer; on expiry the peer + MUST send `ping`; no matching `pong` before the probe timeout MUST fail the stream. cordn-web + runs 30s/30s explicitly because the SDK's 20s default turned a single lost relay round-trip into + a stream abort. Do not ship a 20s window. +7. **Relay size limits apply to the whole serialized event**, ~64 KiB in practice, not just + `content`. Chunk sizing must budget for base64 expansion plus JSON plus event overhead, with + conservative margin, and MUST NOT assume a uniform threshold across relays. +8. **Encryption leaks the recipient.** CEP-4 says so outright: the gift wrap carries a `p` tag for + the recipient. Sender, inner kind and real timestamp are hidden; the recipient pubkey is not. +9. **Gift wrap timestamps are randomized** per NIP-59 — never use them for ordering. +10. **The inner event is fully signed, not a rumor.** CEP-4's flow signs the 25910 event *first*, + then NIP-44-encrypts the whole thing into the wrap. Receivers verify the inner signature, and + response correlation uses the **inner** `id`, not the gift wrap's. +11. **Four separate correlation identifiers coexist**: the nostr `e` tag (event level), the + JSON-RPC `id` (MCP level), `progressToken` (CEP-22/41 transfer level), and CEP-8's canonical + invocation identity (payment level). Plus `_meta.clientPubkey` (CEP-16) as the authenticated + caller identity — the mechanism §4.2's KeyPackage binding ultimately rests on. +12. **Ephemeral delivery has no replay.** Per §6.1, subscribe before you publish. Combined with + per-identity subscriptions (§8.2), each identity needs its own live `#p` subscription on 25910 + plus both gift wrap kinds. +13. **A zero-chunk stream is valid** — `close` immediately after `start`, with `lastChunkIndex` + omitted. An empty `msg_sub_many` backlog will exercise this on day one. +14. **`close.lastChunkIndex` is optional and meaningful.** Present, it is a completeness bound and + every index `0..lastChunkIndex` must have arrived; omitted, the stream was open-ended and no + bound is asserted. Senders omit it for live feeds. +15. **CEP-8 excludes `params._meta` from the canonical invocation identity, but forwards it at + execution.** The exclusion exists because MCP clients regenerate `progressToken` per call, so + without it two semantically identical invocations never match one paid authorization. Getting + this backwards either breaks retry matching or strips transport metadata from the handler. +16. **CEP-8 forbids silent fallback.** A server that will not accept `explicit_gating` MUST NOT + quietly use the transparent lifecycle; and a client that required `explicit_gating` SHOULD NOT + auto-satisfy transparent `payment_required` notifications. A naive payment handler that pays + whatever it is asked to pay violates the client half of this. +17. **CEP-15's hash is a verification target, not a label.** The whole point is that two servers + documenting a tool differently produce the same hash. A client that trusts the advertised + `schemaHash` without recomputing it from the tool definition gains nothing from the CEP. + +### 6.4 Conformance method + +Five tiers, because the CEPs are symmetric and no single counterparty exercises all of them. + +**Tier A — rule-derived unit tests.** Every MUST/MUST NOT in a CEP becomes a named test carrying +its rule id, asserted against pure codecs and frame state machines with no network. This is where +the negative cases live and it is the bulk of the value: the CEP-22/41 validation sections are +written almost entirely as failure conditions. Offline, fast, runs in `commonTest`. + +**Tier B — live integration against a real counterparty.** `ghcr.io/cordn-msg/cordn:latest` with +`CORDN_STORAGE_BACKEND=memory` and `CORDN_ANNOUNCED=false` boots in one command and enables +CEP-22 and CEP-41. It covers the core spec, CEP-4/19, CEP-6/17 and the happy paths of 22/41. +`cordn/packages/test-utils/src/mockRelay.ts` exists if we want a relay stub instead of a public +relay. Tagged as an integration suite, not run on every build. + +**Tier C — adversarial tests against our own fixture server.** This is the tier that does not +exist yet and has to be built: **a Kotlin `:contextvm` test fixture that can play the server role +and misbehave on demand.** No real server will send a non-monotonic `progress`, a second `start` +on a live token, a `pong` with a stale nonce, a digest that does not match, or a +`payment_required` in a session where `explicit_gating` was accepted — but our client must handle +all of them correctly, and several are outright MUST-fail requirements. The fixture is also the +only practical way to test CEP-16 (a server-side obligation) and the server half of CEP-8. +Building it is a first-class Stage 2 deliverable, not test scaffolding. + +**Tier D — cross-implementation vector exchange.** For the crypto surface, agreeing with ourselves +is not evidence. Generate CEP-4 wrap/unwrap vectors and CEP-22/41 frame sequences, check them in +under `quartz/src/commonTest/resources/contextvm/`, and verify both directions against the +reference implementation the way `TsMlsWelcomeInteropTest` does for MLS. Ask upstream to adopt them +(§10) — a shared vector set helps every non-TypeScript implementation and is a cheap contribution. + +**Tier E — wallet integration.** CEP-8's client role is a payment *handler*, so compliance is only +demonstrable end to end against a real rail. `bitcoin-lightning-bolt11` is the one recommended PMI +(CEP-21) and we already have NIP-47 NWC and NIP-57 zaps in Quartz, so this is integration, not new +payment code. Regtest or a small-amount live wallet; gated behind a manual test tag. + +**Shared prerequisite: RFC 8785 (JCS).** Both CEP-8 (canonical invocation identity) and CEP-15 +(schema hash) require it, and **Quartz has no JCS implementation** — I checked; the +`canonicalize` hits in the tree are IPv6, media types and relay URLs, all unrelated. So JCS is its +own build item with its own vector suite (the RFC's test vectors, plus the number-formatting edge +cases that make JCS genuinely tricky: `1E30`, `-0`, very small and very large doubles). Put it in +`quartz/…/utils/` rather than in `:contextvm` — it is a generic primitive and NIP work may want it. + +### 6.5 Per-CEP test catalog + +Tier A cases, grouped by rule prefix. Negative cases are marked ✗ — they are the majority by +design, and a suite without them proves nothing. + +**`CVM-CORE`** — round-trip every message class (request, response, error, notification); +`content` is a *string*, not an embedded object (a plausible early bug); reject a non-25910 kind; +`e`-tag correlation maps a response to its request; ✗ a response published before we subscribed is +unrecoverable (asserts the §6.1 lifecycle rule rather than pretending it works); the +`initialize` → `notifications/initialized` sequence; and the stateless path succeeding with no +initialize at all. + +**`CVM-4`** — inner event is signed and verifies; wrap `p` tag names the recipient; two wraps of +the same payload have **different** outer pubkeys (fresh key per wrap); decrypt recovers the inner +event byte-for-byte; correlation uses the inner `id`; ✗ inner signature invalid → reject; ✗ +unsupported wrap kind → reject; conversation-key symmetry both directions. Tier D vectors here. + +**`CVM-19`** — prefer 21059 when both peers advertise `support_encryption_ephemeral`; MUST fall +back to 1059 when the peer does not; 21059 and 1059 decode identically; subscription filters +include both kinds. + +**`CVM-6`** — parse each of 11316–11320 (`content` is a stringified initialize/list result); +replaceable semantics keep the newest `created_at` per `(kind, pubkey)`; all discovery tags parsed; +optional tags absent → no failure; discovery tags seen on a first direct message are treated as +equivalent to announcement tags (the CEP-6/CEP-35 overlap). + +**`CVM-17`** — unmarked `r` tag means read **and** write; `read`/`write` markers honored when +present; latest-wins replacement; bootstrap relays are publication targets and MUST NOT be assumed +operational; absent 10002 → fall back to configured relays. + +**`CVM-35`** — client sends capability/negotiation tags on its first direct message and omits them +after; server tags are learned from the first direct server→client message even when it is not an +initialize result; **unknown tags preserved** and reachable via a raw accessor; `p` and `e` excluded +from the learned surface; a feature tag on a later message is message-local and does not mutate the +session baseline. + +**`CVM-22`** — happy path reassembles and validates; out-of-order chunks inside the buffer window +reassemble correctly by `progress`; nothing is surfaced upward before validation succeeds; ✗ digest +mismatch; ✗ `totalBytes` mismatch; ✗ `totalChunks` mismatch; ✗ `chunk` before `accept` in a +stateless flow; ✗ non-monotonic `progress`; ✗ `end` with unresolved gaps; ✗ unknown +`completionMode`; ✗ declared totals over local policy rejected at `start`; ✗ transfer started for a +request with no `progressToken`; `abort` is terminal. + +**`CVM-41`** — happy path streams incrementally; zero-chunk stream (`close` straight after +`start`) succeeds; `close` with `lastChunkIndex` and every index present succeeds; `close` without +`lastChunkIndex` on an open-ended feed succeeds; the final JSON-RPC response is still required and +delivered after `close`; idle → `ping` → `pong` keeps the stream alive; ✗ no `pong` before probe +timeout fails the stream; ✗ `pong` with unknown, duplicate or expired nonce is not liveness +evidence; ✗ nonce over 64 bytes rejected; ✗ second `start` on a live `progressToken`; ✗ +non-contiguous `chunkIndex` at `close`; ✗ `close` with `lastChunkIndex` and a missing index; ✗ +frames after `close` or `abort` ignored; `pong.progress` unrelated to `ping.progress` (asserts +§6.3.2 explicitly). + +**`CVM-16`** — the fixture server injects `_meta.clientPubkey` derived from the event pubkey; +injection is off by default; our client never sends `clientPubkey` itself (a client-supplied value +would be a spoof, and the coordinator's §4.2 binding depends on it being server-derived). + +**`CVM-8`** — `cap` tag parses fixed (`"100"`) and range (`"100-1000"`) prices with the +`tool:`/`prompt:`/`resource:` prefixes; PMI intersection selection picks a mutually supported +method; absent `payment_interaction` means `transparent`; a requested `explicit_gating` accepted by +the server is disclosed on the first direct response; ✗ requested `explicit_gating` not accepted +MUST NOT silently become transparent, and our handler MUST NOT auto-pay transparent +`payment_required` in that session (§6.3.16); `-32602` shape on an unsupported mode; `-32042` +`Payment Required` carries one or more `payment_options`; `-32043` `Payment Pending` with +`retry_after`; mid-session mode upsert re-discloses on transition to `explicit_gating`; canonical +identity is stable across a changed JSON-RPC `id`, a changed outer event id, and a regenerated +`progressToken` (the `_meta` exclusion); transparent idempotency — the same outer event id is not +charged twice; `ttl` expiry; at most one `direct_payment` tag, first supported PMI wins; `change` +tag parsed on `payment_accepted`. + +**`CVM-15`** — normalization strips `title`/`description`/`examples`/`default`/`deprecated`/ +`readOnly`/`writeOnly` and `x-*` keys **at every nesting level**; the same tool documented +differently yields the same hash (the CEP's whole purpose); adding an `outputSchema` changes the +hash; `$ref` bundled into a self-contained representation, with ✗ no network resolution attempted; +`i`/`k` NIP-73 tags emitted and parsed; and the key client-side rule — **recompute the hash from +the tool definition and reject a mismatched advertised `schemaHash`** rather than trusting it. + +**`CVM-21`** — PMI format matches `[a-z0-9-]+`; `-direct` suffix detected as bearer-settlement +capable; unknown PMI degrades gracefully rather than failing the session. + +**`CVM-23`** — parse a server `kind 0` as NIP-01 metadata (reuses existing Quartz code); `kind 1` +notes from a server pubkey carry no special semantics. + +**`CVM-24`** — top-level review builds both uppercase `A`/`K`/`P` and lowercase `a`/`k`/`p` with +`k = 11316`; a reply keeps uppercase `A`/`K`/`P` at the root announcement while using lowercase +`e` for the parent comment and `k = 1111`; the discovery filter returns reviews for a given server. + +### 6.6 Build list for `:contextvm` + +Ordered so each item is testable before the next depends on it: + +| # | Component | Gate | Notes | +| - | --------- | ---- | ----- | +| 1 | Kinds, tags, frame types, JSON-RPC 2.0 codec | A | Pure data | +| 2 | Minimal MCP client | A + B | `initialize`, `notifications/initialized`, `tools/call`, `tools/list`, typed errors, `_meta`/`progressToken` plumbing | +| 3 | CEP-4/19 gift wrap | A + B + D | On existing `nip44Encryption` + `nip59Giftwrap`. Pin `REQUIRED` (§8.6) | +| 4 | Correlation + subscription lifecycle | A + B | `#p` subscriptions on 25910 + both wrap kinds, `e`-tag routing, subscribe-before-publish, per-identity scoping | +| 5 | CEP-35 discovery-tag learning | A | First-message exchange, unknown-tag preservation, raw accessor | +| 6 | CEP-6/17/23 server discovery | A + B | 11316–11320 + 10002 + kind 0. Reuses `INostrClient` `accessories/` one-shots | +| 7 | **Fixture server (Tier C)** | — | Plays the server role, misbehaves on demand. Unblocks every adversarial test below | +| 8 | CEP-22 receiver | A + B + C | Frame machine, bounded reassembly, admission control, digest verify | +| 9 | CEP-41 receiver + writer | A + B + C | Two-counter machine, keepalive, `Flow` delivery, dual completion | +| 10 | CEP-22 sender | A + C | Proactive fragmentation with relay-size margin | +| 11 | RFC 8785 JCS | A | In `quartz/…/utils/`, not here — shared by 8 and 15 (§6.4) | +| 12 | CEP-15 common tool schemas | A + B | Normalization, hash, `i`/`k` tags, recompute-and-verify | +| 13 | CEP-8 + CEP-21 payments | A + C + E | Both lifecycles, canonical identity, PMI registry; handler on NIP-47 NWC | +| 14 | CEP-16 injection (server role) | A + C | Only meaningful in the fixture server and any server we later expose | +| 15 | CEP-24 reviews | A + B | Thin layer on existing NIP-22 | +| 16 | Dual-signer plumbing | A + B | Account `NostrSigner` for stable, local keypair for ephemeral | + +Sizing reference: the SDK's client-side surface (`src/core` + `src/transport/nostr-client` + +oversized-transfer + open-stream, tests excluded) is ~5.4k lines of TypeScript, and that excludes +payments, the server role and the announcement manager. Items 8, 9 and 13 are the bulk of the work +and the bulk of the risk. + +Reusable beyond cordn: this is a general MCP-over-Nostr client *and* the beginnings of a server. +Any future Amethyst work that wants to call a remote MCP server — or expose one — lands here +rather than in a feature module. + +### 6.7 Spec stability risk + +Only CEP-4, CEP-6 and CEP-16 are **Final**. Everything else, including the core spec and both +transfer profiles, is **Draft** — and CEP-8 (722 lines), CEP-41 (534) and CEP-15 (502) are the +three largest documents. Per the CEP guidelines, Final requires a completed reference +implementation, so Draft here means the text can still move. + +Mitigations: keep each CEP's rules behind a narrow interface so a revision is a localized change; +record the implemented revision (commit hash of `contextvm-docs`) in the module README and in each +rule-id group; and make the Tier A suite the tripwire — when a CEP revises, the diff against our +named rules says exactly what to change. + +## 7. Licensing + +🔴 **`ContextVM/sdk` is LGPL-3.0** (`COPYING.LESSER` over the GPL-3 base text; `package.json` +declares `LGPL-3.0-1`). We would not link a TypeScript library into Quartz regardless, but per the +dependency rule in `.claude/CLAUDE.md` this means: + +- A Kotlin ContextVM implementation **must be clean-room from the spec and CEP documents**, not a + translation of that source. A derivative translation would carry LGPL terms into MIT Quartz. +- `cordn-rs` links the Rust `contextvm-sdk` as a dependency; that is their artifact, not ours. +- If a Kotlin/JVM ContextVM library ever appears under LGPL, linking it is a WARN-and-call-out + (call it out in the PR description), not an automatic stop. + +🔴 **Correction (2026-09-18, verified): "everything under `Cordn-msg` is MIT" was wrong.** Only +two of the five packages are licensed at all. Checked against the actual files at `b465df0` and +against the published npm tarballs: + +| Package | `LICENSE` file | `license` field | Published to npm | +| ------- | -------------- | --------------- | ---------------- | +| `packages/core` | yes | MIT | `@cordn/core` | +| `packages/cli` | yes | MIT | `@cordn/cli` | +| `packages/coordinator` | **no** | **none** | no | +| `packages/server` | **no** | **none** | no | +| `packages/test-utils` | **no** | **none** | no | + +The repository root has no LICENSE either. So the **reference coordinator is unlicensed** — +default copyright, all rights reserved — and so is the `ghcr.io/cordn-msg/cordn` image built from +it. Almost certainly an oversight rather than intent, given the two licensed siblings, but the +rule in `.claude/CLAUDE.md` does not have an "obviously meant to be MIT" branch. + +Consequences, and they are the reason Stage 0 landed the way it did: + +- **Tier B rests on unlicensed code, and was run anyway on an explicit decision.** The plan's + Tier B (run their coordinator locally with `CORDN_STORAGE_BACKEND=memory`) rests entirely on + unlicensed code. Nothing about that has changed and nothing here is a shipping dependency: the + image is pulled by hand, on a developer machine, and no build file, test task or CI job + references it. What changed is that a maintainer decided the verification was worth doing on + those terms — see §7.1 for what it found, which is the argument for the decision. The line the + dependency rule draws still holds: **do not commit it as a dependency, and do not wire it into + a build or CI.** If Tier B becomes a routine part of the test process, it needs the LICENSE + below first. +- **`@cordn/core` is fine and is enough for the valuable half.** It is MIT, it ships a LICENSE, + and it holds the authoritative zod contracts for all eleven tools plus the group-ref bech32 + codec — i.e. the wire surface. That is what `quartz/tools/cordn-vector-gen` uses. +- **Ask upstream for a LICENSE on the remaining packages.** Cheap for them, and it is what + unblocks Tier B. Worth raising alongside §4.2. + +The specs themselves (`spec/`, `design/`) are also unlicensed, so the existing rule stands: we +implement from them, we do not paste their prose into KDoc. + +## 7.1 Tier B, run: what a live counterparty found that five tiers of our own tests could not + +Run on 2026-09-22 against `ghcr.io/cordn-msg/cordn:latest` (`v0.5.7`, digest +`sha256:3e20391…`, `CORDN_STORAGE_BACKEND=memory`, `CORDN_ANNOUNCED=false`) over **geode** as the +relay, driven by the new `amy cordn …` verbs. Two accounts, one process per command. + +**It works, end to end.** `kp_publish` → `group create` → `invite` (take KeyPackage, verify the +publication payload, commit, store Welcome) → `welcome_take` → accept → messages in both +directions → `group info` agreeing on both sides at epoch 1 with the same two members. The +coordinator reports `name: cordn-server`, `version: 0.1.0`, `protocolVersion: 2025-11-25`, +`capabilities: [tools]`. + +But it did not work at first, and neither reason was findable without a real peer. + +### Finding 1 — our CEP-4 gift wraps were invisible to any real ContextVM server + +`CvmGiftWrap.wrap` set `created_at` to NIP-59's randomized timestamp: the real time minus a +random offset of up to two days. The reference server subscribes with **`since = now`** when it +connects, which is the obvious filter for a live request stream, and a relay honours `since`. So +every request we have ever sent to a real coordinator was dropped **by the relay**, before the +coordinator saw it. The symptom is a 20-second timeout with nothing in any log, because from the +server's side nothing happened. + +Why no test caught it: our fixture server (Tier C) reads whatever is addressed to it with no +`since` filter, and so does every test double. A backdated wrap is indistinguishable from a +fresh one unless something on the other end filters on time — and only a real server does. +There was even a test, `CVM-4-14`, asserting the *broken* behaviour ("the wrap timestamp is +shifted and must not be used for ordering"); it passed for the whole life of the transport. A +test that pins what the code does is not evidence about what the protocol needs. + +The fix is to send the real time, and the reasoning is worth keeping because the NIP-59 rule is +right in its own context: NIP-59 shifts the timestamp because a gift-wrapped DM is **stored** and +fetched later, so its timestamp would otherwise reveal when a conversation happened. A ContextVM +wrap carries an RPC request to a peer listening right now. The shift also protects nothing here — +CEP-4 puts the recipient in a visible `p` tag, delivery is real time, and kind 21059 is ephemeral +so no relay retains it — so it hid from an observer a fact that same observer reads off the +socket, at the cost of the request never arriving. `CVM-4-14` now asserts the send time. + +### Finding 2 — self-echo bookkeeping does not survive a process boundary + +With delivery working, a client's own traffic came back to it as `undecryptable`: alice's +`fetch` after her own invite and message reported `cursor 1: Authentication failed` (her Commit, +sealed under the pre-commit epoch key she has since left) and `cursor 2: Generation 0 already +consumed` (her own message, whose sender ratchet has moved on). + +The cause is that `GroupInbox` holds `pendingOperations` and `ownMessageCursors` in memory while +the cursor beside them is persisted. Post advances neither cursor nor disk — correctly, since a +lower cursor may still hold somebody else's unprocessed message — so a client that exits between +posting and ingesting starts the next run with a cursor that will re-read its own traffic and no +record that it is its own. + +`amy` exposes this on every run because each verb is a process. It is **not** an `amy` bug: the +Android app hits it whenever the OS kills it between sending and syncing, which is ordinary. The +visible damage is a gap in the sender's own conversation. The latent damage is worse and narrower: +`Ingestion.SelfEchoUnapplied` is how a client that died mid-post applies its own Commit, and that +recovery path depends on exactly the record that dying destroys. + +Fixed by persisting the bookkeeping next to the cursor: `EchoState` beside `GroupCursor`, saved on +the same beat, deleted when there is nothing pending. Own-message cursors at or below the fetch +cursor are pruned — the stream has delivered them and they can never come round again — while +pending Commits are kept until their echo matches, because that echo is the only copy that will +ever be offered. + +There was a test for this too, and it also passed: `a group survives a restart` asserted +`delivered.none { it is Delivery.Message }`. An `Undecryptable` is not a `Message`, so a gap in +the sender's own conversation satisfied it. Both restart tests now assert what the delivery **is** +— `Echo` — and both fail if either half of the persistence is removed. + +With both fixed, `cli/tests/cordn/tier-b.sh` passes end to end: handshake, `kp_publish`, group +create, invite, Welcome opened without joining, join, messages both ways with the sender's own +traffic reported as echoes rather than gaps, and both sides agreeing on epoch 1 and the same two +members. + +### 7.2 The other half: our MLS against theirs + +Tier B above runs amy against amy through their coordinator. Both MLS +endpoints are ours, so the ratchet tree, the Welcome and the Commit only ever +agree with themselves — it proves the transport and the coordinator client, +and nothing about RFC 9420 interop. + +`cli/tests/cordn/interop-client.sh` closes that. It puts **`@cordn/cli` +(ts-mls)** on one end and **amy (quartz)** on the other, in one group, over the +live wire. That half carries no licensing problem — `@cordn/cli` is MIT and +comes from npm — though the coordinator underneath it still does. + +Three directions, and they are not redundant: + +1. **Their group, our joiner.** Our engine opens a ts-mls Welcome and reads + their GroupContext extensions, their metadata and their credentials out of + it, then decrypts their application messages. +2. **Our group, their joiner.** Their engine opens **our** Welcome. This is + the direction no fixture can test: a fixture we wrote accepts what we emit + by construction, so only a foreign implementation can say our Welcome is + well formed. +3. **Our later Commit.** The sharpest, and the one worth having built the + harness for. Until direction 3 their epoch came from a Welcome, which + carries the group state ready-made; this is the first time they must apply + one of our handshake messages. Ours are **public-framed** + (`MlsMessage(PublicMessage)`, wireformat 2) where theirs are private-framed, + and `CordnGroupManager.invite` has always asserted in its KDoc that their + `processMessageBase64` admits both — a claim read off their source and never + executed. It holds: they apply our Commit, advance to epoch 2, and seal a + message we then open. + +All of it passes. Mutation-checked rather than trusted: sealing +`result.commitBytes` (the bare RFC 9420 struct) instead of +`result.framedCommitBytes` fails directions 3 and the third-member join and +**leaves direction 2 green**, because a peer that joined by Welcome never +parses that Commit and only stalls once it has to. That is the whole reason +direction 3 exists as its own case, and it is now demonstrated rather than +argued. + +One asymmetry this surfaced and did not resolve: **the reference client sends +kind 25910 in the clear**, unwrapped, where we pin `EncryptionMode.REQUIRED` +and always gift-wrap (§8.6). Both work against the coordinator, so nothing is +broken — but the two clients exercise different halves of CEP-4 against the +same server, and our encrypted path is the one with no second implementation +behind it. Worth a Tier D vector exchange. + +### What Tier B is, and is not + +These are both **transport and bookkeeping** bugs. Not one byte of the crypto surface moved: the +MLS engine, the seal, the envelopes and the group refs were already verified against ts-mls and +against cordn's own wire contracts, and Tier B found nothing wrong with any of them. That is the +shape to expect from a live tier — it tests the things a fixture cannot model, which are the +things a fixture was written by the same person who wrote the client. §7.2 then covers the +crypto surface against a foreign implementation, and finds it sound. + +## 8. What the coordinator can see + +Content: **nothing.** Double-sealed (MLS ciphertext, then ChaCha20-Poly1305 under the epoch +exporter), and the coordinator is forbidden from parsing. It cannot even tell handshake from +application traffic. This is genuinely stronger than a conventional MLS delivery service. + +Metadata is where the cost sits. Every call carries an authenticated caller pubkey +(`extra._meta.clientPubkey`, derived from the signed inner 25910 event), and cordn-web splits +identities deliberately: + +| identity | calls | +| -------- | ----- | +| **stable** (real npub) | `kp_publish`, `kp_remove`, `welcome_take`, `join_request_store` | +| **ephemeral** | `kp_take`, `kp_list`, `welcome_store`, `join_request_take_many`, `msg_post`, `msg_fetch_many`, `msg_sub_many` | + +### 8.1 Admission is in the clear on both ends + +`join_request_store` carries the **stable** identity and the `gid` → "npub X wants into group G". +`welcome_store` names the **target's stable pubkey**; `welcome_take` is called by that target's +**stable** identity. For any group joined via a share link the coordinator observes real-identity +membership directly. Structural, not a bug. + +### 8.2 The ephemeral identity is per-session, not per-message + +`coordinatorClient.ts:169` → `new PrivateKeySigner()` with no argument, constructed once per +`cordnClient`, and `chatRuntime.ts:62` caches one client per coordinator pubkey. So **one +pseudonym posts, fetches and subscribes across every group you have on that coordinator, for the +whole session.** That `gid` set is a stable fingerprint: it links all your groups together and +leaks how many you are in. Marmot rotates per kind-445 event and has no equivalent linkage +(relays still see `h` tags and fetch patterns — different exposure, not obviously better). + +### 8.3 Complete ordered history in one place + +Per-group cursors, `at` timestamps, sealed payload sizes (no padding specified anywhere I could +find), per-`gid` rates, live subscription membership. Cross-`gid` timing correlation inside one +session is trivial. Unlike relays there is no redundancy or partitioning: one operator sees the +whole graph of every group it serves, retained in SQLite. + +### 8.4 A signed, verifiable record that you use cordn + +`kp_publish` rides the stable identity and the coordinator retains the signed event — it is +*designed* to be re-servable as proof, rotation cadence included. `kp_take`/`kp_list` also reveal +who is being looked up, i.e. "someone is about to add X to a group". + +### 8.5 IPs go to relays, not the coordinator + +A real structural win over an HTTP delivery service. The relay then sees the 21059 traffic pattern +and the `p` tag naming the coordinator: who talks to which coordinator, how often, how big. + +### 8.6 Encryption can silently downgrade — pin it + +cordn-web pins `giftWrapMode: GiftWrapMode.EPHEMERAL` (`coordinatorClient.ts:186`) but leaves +`encryptionMode` at the SDK default **`OPTIONAL`** +(`contextvm-sdk/src/transport/base-nostr-transport.ts:102`), which resolves as +`isEncrypted ?? true` from negotiated session state (`:544`). The reference coordinator does +announce `support_encryption`, so live deployments are encrypted — but the policy permits a +coordinator that does not announce it to receive **plaintext JSON-RPC on public relays**, +exposing `gid`, `target_pk`, `kp_64` and cursors to any relay. + +**Our client must pin `REQUIRED` and fail closed.** Non-negotiable. + +### 8.7 Net + +Content privacy equals Marmot's. Metadata privacy is **weaker than Marmot's against the delivery +operator** and **stronger against the network**. Whether that trade is acceptable depends on +whether coordinators are self-hosted per community or a handful of public ones; the spec permits +either, and the shipped default is one coordinator pubkey over three public relays +(`cordn/README.md`). + +This section should be surfaced in the UI if we ship this, not buried. A Marmot group and a cordn +group have materially different metadata exposure and users cannot infer that from either one +looking like a group chat. + +## 9. Plan + +### Stage 0 — Ground truth and the upstream conversation — PARTLY LANDED + +The wire half landed on 2026-09-18; the live-coordinator half ran on 2026-09-22 (§7.1). + +**Landed: contract vectors from cordn's own code.** `quartz/tools/cordn-vector-gen` generates +`resources/cordn/coordinator-contracts.json` from **`@cordn/core`** (MIT) — the package their +reference coordinator and client both import. It hands each payload *we* send to *their* zod +schema, so a field we named wrong fails at generation time, and carries a `rejects` set per +method so a passing positive means something. `CoordinatorContractVectorTest` (15 tests) drives +`CoordinatorClient` through the real ContextVM transport and asserts the arguments the +coordinator sees equal those vectors, then replays their result shapes back through our parser; +`CordnGroupRefVectorTest` (5 tests) checks `cordn1…` refs in both directions against their bech32 +codec. + +Two findings from doing it: + +1. **Mutation-checked, because 20 tests passing on the first run means nothing on its own.** + Making a first-ever fetch send `after = 0` instead of omitting it, and flipping the group-ref + TLV emission to ascending order, each killed exactly one test. The TLV mutation was caught + *only* by the encode-direction test — decoders accept any order by spec, so a decode-only + suite would have shipped that divergence. +2. **The `kp_publish` legacy field is read-only.** Their current schema rejects + `{kp_ref, keyPackageBase64}`, so our `?? keyPackageBase64` fallback is correct for parsing old + publication events and must never be used when sending. + +**Landed: Tier B (live coordinator).** Run on 2026-09-22 against the reference coordinator in +Docker, over geode as the relay, driven by `amy cordn …`; the harness is +`cli/tests/cordn/tier-b.sh`. It found two bugs — backdated CEP-4 gift wraps that no real +ContextVM server could see, and self-echo bookkeeping that did not survive a process boundary — +both fixed. §7.1 has the detail; §7 has the licensing terms it was run on, which have not +changed: not a dependency, not in CI. + +**Not done: the upstream conversation**, which is the maintainer's to have. §4.1 no longer waits +on it (we implement both encodings); §4.2 and the missing LICENSE files are the asks worth making. + +The original plan for this stage, for reference: + +1. **Restore executable verification.** Get `:quartz:jvmTest` green in a clean container + (the 429 in the header) and confirm `TsMlsWelcomeInteropTest`, `MdkWelcomeInteropTest` and + `AppDataDictionaryInteropTest` still pass. Everything downstream assumes the engine is sound. +2. **cordn-side vectors.** `@cordn/cli` is published and has a filesystem-queue mode built for + scripting; the coordinator ships as `ghcr.io/cordn-msg/cordn:latest` with + `CORDN_STORAGE_BACKEND=memory`. Drive both to emit a vector file (KeyPackage with hex-ASCII + credential, Welcome, a sealed application payload, a sealed Commit, a `cordn1…` group ref, a + `0xC04D` extension blob) into `quartz/src/commonTest/resources/cordn/`, with a generator under + `quartz/tools/cordn-vector-gen/` mirroring `tools/tsmls-vector-gen/`. Assert them in + `quartz/…/cordn/interop/`. + + These tests will fail on the credential encoding, by design — that is the point. They convert + §4.1 from an argument into a reproducible artifact we can hand upstream. +3. **Raise §4.1 and §4.2 with gzuuus.** Credential encoding is the blocking one. The + key-package-payload shape is the higher-value ask. + +**Gate:** do not start Stage 2 until §4.1 has an answer. Stage 1 is safe to do regardless. + +### Stage 1 — Extract a binding-agnostic RFC 9420 engine — LANDED + +Done in four commits. The engine is `quartz/…/mls/` and imports nothing from `quartz/…/marmot/`. + +| What | Where it went | +| ---- | ------------- | +| The engine (28 files, 11,964 LOC) | `quartz/…/marmot/mls/` → `quartz/…/mls/` | +| `MlsGroupManager`, `MlsGroupStateStore`, `MarmotMessageStore` | → `marmot/groups/` (all keyed on `nostrGroupId`) | +| MIP-03 authorization, depletion guard, join role check, self-remove gate | → `marmot/groups/MarmotGroupPolicy` | +| Leaf + `required_capabilities` profiles | → `marmot/groups/MarmotCapabilities` | +| `currentMarmotData/GroupState/NostrGroupId`, `agentTextStreamSecret` | → `marmot/groups/MarmotGroupViews` (extension functions) | +| `last_resort_key_package` (0x0004) | → `mls/components/ComponentsList` — it is the extensions draft's, not Marmot's | + +**The seam is `MlsGroupPolicy`**: three hooks the engine calls where RFC 9420 defers to the +application (`authorizeCommit`, `authorizeSelfRemove`, `validateJoin`) and four values it reads +(leaf capabilities, `required_capabilities`, extra known extension types, the commit exporter +label). One argument selects a whole profile — `MlsGroup.create(id, policy = MarmotGroupPolicy)` +brings the rules, the capabilities and `MLS-Exporter("marmot", "group-event", 32)` together — so +adopting it cost one added argument per call site rather than five. + +Policies receive a read-only `GroupView`, not the `MlsGroup`: a policy holding the group could +commit or rotate keys from inside the check meant to gate those things. + +**The default is permissive**, which is a trade worth naming. Closed would make the engine +unusable without a policy and would push callers into writing an allow-everything one anyway. +The cost is that a group restored without its policy silently drops the binding's rules — a +policy is behaviour, not state, so it is deliberately not in `MlsGroupState`. All ten production +construction sites are in `marmot/` and all ten name it. + +Two findings from doing it: + +1. **`:quic` was already a second consumer, reaching through the wrong package.** + `quic/tls/TlsClient.kt` imported `quartz.marmot.mls.crypto.X25519` for its TLS 1.3 handshake — + neither Marmot nor MLS. That is the argument for this stage independent of cordn. +2. **The test suite found every site that had been relying on Marmot defaults.** The first run + after `MlsGroup.create` stopped defaulting to Marmot's profile failed 13 tests, each one a + Marmot test that had been getting a Marmot-shaped group for free. The other ~180 construction + sites kept passing on the permissive default — they are engine tests, and they now prove the + engine runs without Marmot at all. + +8 tests were added for the seam itself (`MlsGroupPolicySeamTest`, `MarmotPolicySeamTest`), +including the half no existing test covered: the same group at the same state accepts the same +commit once the policy is gone. Verified by mutation — ignoring the policy in `commit()` and +re-hardcoding the exporter label each kill exactly their guarding tests. + +Suite: `:quartz:jvmTest` 5074 → 5082, `:commons:jvmTest` 2202, both green. + +What Stage 3 still owes: `MlsGroupManager` is keyed on `nostrGroupId` 147 times, so cordn cannot +reuse it and needs its own manager over the same `MlsGroup`. Class names were left alone in the +move — `MlsGroupManager` under `marmot/groups/` reads correctly and renaming four classes would +have churned 22 files across five modules for clarity the package path already gives. + +### Stage 2 — ContextVM (clean-room) — LANDED + +Shipped as `quartz/…/contextvm/`, implemented from the specification documents rather than the +LGPL SDK. 172 tests, +green on jvm. What is in: + +| Build item | Where | +| ---------- | ----- | +| 1 constants, tags, JSON-RPC codec | `core/CvmKinds`, `core/CvmTags`, `jsonrpc/` | +| 2 minimal MCP client | `mcp/CvmMcpClient`, `mcp/McpMethods` | +| 3 CEP-4/19 gift wrap | `cep04Encryption/CvmGiftWrap` (pins `REQUIRED`) | +| 4 correlation + subscription lifecycle | `transport/CvmTransport` | +| 5 CEP-35 discovery learning | `cep35Discovery/SessionDiscovery` | +| 6 CEP-6/17/23 discovery | `cep06Announcements/`, `cep17RelayList/ServerRelay` | +| 7 **fixture server (Tier C)** | `fixture/CvmFixtureServer`, `fixture/InMemoryRelayPool` | +| 8 CEP-22 receiver | `cep22OversizedTransfer/OversizedTransferReceiver` | +| 9 CEP-41 receiver | `cep41OpenStreams/OpenStreamReceiver` | +| 10 CEP-22 sender | `cep22OversizedTransfer/OversizedTransferSender` | +| 11 RFC 8785 JCS | `quartz/…/utils/jcs/JsonCanonicalization` | +| 12 CEP-15 schemas | `cep15CommonSchemas/CommonToolSchema` | +| 13 CEP-8 + CEP-21 | `cep08Payments/` | +| 14 CEP-16 injection | in the fixture's server role | +| 15 CEP-24 reviews | `cep24Reviews/ServerReview` | +| 16 dual-signer | `transport/DualSigner` | + +Source is organized one package per CEP (`cep04Encryption/`, `cep08Payments/`, …), matching the +`nipXX` convention in `quartz` and `mipXX` in `marmot`; only what the core draft spec defines +(`core/`, `jsonrpc/`, `transport/`, `mcp/`) and the CEP-22/41 shared framing (`transfer/`) keep +names, having no CEP number to carry. `contextvm/README.md` records the four placements that are +judgment calls. + +Four findings worth carrying forward, all caught by tests rather than review: + +1. **CEP-22/41 ordering.** The first receiver rejected frames whose `progress` did not increase on + arrival, conflating "the sender emits monotonic progress" with "frames arrive in order". Both + CEPs say the opposite: `progress` is the assembly index and explicitly not an arrival-order + guarantee, and receivers may buffer out-of-order chunks. Validation is positional now. +2. **JCS and `Double.MIN_VALUE`.** JVM prints `4.9E-324` where ECMAScript requires `5e-324`, so + "trust the platform to already be shortest" would have hashed differently from every other + implementation. Digits are shortened explicitly until the shortest round-tripping form is + found, which removes the platform assumption entirely. +3. **Event subclassing.** `CvmMessageEvent` began as an `Event` subclass whose `create()` claimed + to return that subclass, but quartz mints subclasses through its own kind-to-class factory, + which knows nothing about 25910. It is a wrapper over `Event` now. +4. **Subscribe-before-publish is an API-shape problem, not a discipline problem.** `CvmTransport` + exposes `request()` with no public publish/subscribe pair, and `InMemoryRelayPool` drops an + event nobody is subscribed to so the property is actually tested rather than assumed. + +Remaining gaps in this stage: Tier B (live integration against +`ghcr.io/cordn-msg/cordn:latest`), Tier D (cross-implementation vectors) and Tier E (a real +wallet for CEP-8) are all unstarted — see §6.4. `testAndroidHostTest` has not been run in a +container yet; Maven Central rate-limits the Android secp256k1 artifact. + +Original scope notes follow. + + +New Gradle module, peer of `:quic`. Depends on `:quartz` only; no Android framework deps. **Scope, +CEP inventory, the fourteen subtle rules and the ordered build list are §6** — this stage is that +section turned into code, so read it before starting rather than working from this summary. + +Sourcing discipline, restated because it constrains the whole stage: implement from the +specification documents in `ContextVM/contextvm-docs`, **not** from the LGPL SDK (§7). Whoever +takes this should avoid reading the SDK source at all; §6 was written so they do not have to. + +Substages, matching §6.6's build list. Each ships when its rule-id group in §6.5 is green at the +tier §6.2 assigns it: + +- **2a — wire layer.** Items 1–4: constants and codecs, the minimal MCP client, CEP-4/19 gift wrap + pinned to `REQUIRED` (§8.6), and the correlation + subscription lifecycle. Ships when a + `tools/list` round-trips against the reference coordinator in Docker and `CVM-CORE`/`CVM-4`/ + `CVM-19` pass, with CEP-4 vectors exchanged both ways (Tier D). +- **2b — discovery.** Items 5–6: CEP-35 first-message tag learning, CEP-6/17/23 announcement and + relay resolution. Ships when a coordinator pubkey alone is enough to connect. +- **2c — fixture server.** Item 7, and the gate for everything after it. A Kotlin `:contextvm` + test double that plays the server role and can be told to violate any rule in §6.5. Without it + the adversarial half of CEP-22/41 and all of CEP-16 are untestable. +- **2d — transfer profiles.** Items 8–10: CEP-22 receiver, CEP-41 receiver/writer, CEP-22 sender. + The bulk of the work and the risk; §6.3 items 1–7, 13 and 14 all live here. Ships when + `msg_sub_many` streams a live backlog, a >64 KiB `msg_fetch_many` reassembles with a verified + digest, and every ✗ case in `CVM-22`/`CVM-41` fails the way the spec requires. +- **2e — JCS and schemas.** Items 11–12: RFC 8785 in `quartz/…/utils/` against the RFC's own + vectors plus number-formatting edge cases, then CEP-15 on top of it. +- **2f — payments and the rest.** Items 13–16: CEP-8 both lifecycles with the NIP-47 NWC handler + (Tier E), CEP-16 injection in the fixture, CEP-24 reviews on existing NIP-22, dual-signer + plumbing. Lowest priority — cordn needs none of it — but it is what makes the module complete. + +Two constraints that shape the API and are easy to discover too late: + +- **Ephemeral events have no replay** (§6.1). The subscription must be live before the request is + published. This is a lifecycle requirement on the public API, not an implementation detail — + a `suspend fun call()` that subscribes after publishing will lose responses nondeterministically + and look like flaky relays. +- **The stable transport needs `sign()` *and* NIP-44 encrypt/decrypt per MCP message.** On a + NIP-55/NIP-46 external signer that is a signer round-trip per request. cordn-web's + stable/ephemeral split (§8) already keeps stable traffic rare; preserve that property rather + than fighting it, and make the identity split explicit in the API so it cannot be bypassed. + +Conformance approach — rule-derived unit tests plus live integration against +`ghcr.io/cordn-msg/cordn:latest` — is §6.5. Write the negative tests; the MUST-fail cases in +CEP-22/41 are where an implementation that "works" quietly diverges. + +### Stage 3 — the cordn binding — LANDED (core), app work outstanding + +Shipped as `quartz/…/cordn/`, as this plan originally said. Packages follow the spec documents +(`spec00Coordinator`, `spec01GroupMetadata`, `spec02Envelopes`, `spec03Payloads`, `appGroupRef`) +plus `groups/` and `sync/`. + +Both ContextVM and cordn spent a while as separate Gradle modules and were folded back in. The +stated reason for `:cordn` — "the coordinator client needs `:contextvm`, and quartz cannot depend +on it without a cycle" — was circular: it only held because `:contextvm` had been put outside +quartz first, on the grounds that it was "a peer of `:quic`". That analogy does not survive +contact: `:quic` is a transport library with no Nostr in it at all, while ContextVM is nothing +but Nostr — kind 25910 events, NIP-59 gift wraps, relay subscriptions. Meanwhile `marmot/` +(18,876 LOC) is a complete non-NIP protocol family living inside quartz, `concord/` is another, +and neither new module had a single platform-specific file. Both now compile for every quartz +target, including iOS and linuxX64, which they never did as jvm+android modules. + +| Item | Where | +| ---- | ----- | +| 11-tool coordinator client, identity split in the API | `spec00Coordinator/CoordinatorClient`, `CoordinatorMethod` | +| `cordn1…` group ref | `appGroupRef/CordnGroupRef` | +| `CordnGroupMetadata` (`0xC04D`) | `spec01GroupMetadata/` | +| last-resort `app_data_dictionary` variant | `groups/CordnGroupPolicy.lastResortExtension` | +| cordn-profile KeyPackage | `groups/CordnCredential` + `CordnGroupPolicy` | +| the seal | `spec03Payloads/SealedPayload` | +| the application envelope | `spec02Envelopes/CordnEnvelope` | +| cursors, self-echo, fetch-then-subscribe | `sync/GroupCursor`, `sync/CordnGroupSync` | +| KeyPackage publication + §9 verification | `spec00Coordinator/KeyPackagePublication` | + +`CordnGroupPolicy` is what Stage 1 was for: one argument gives an `MlsGroup` cordn's +capabilities, extension registry and payload exporter. It deliberately leaves `authorizeCommit` +at the default — **cordn has no MIP-03**. `spec/01.md` §5.3 makes `admin_pubkeys` presentation +metadata and nothing restricts who may commit, so any member can commit anything MLS permits. +Empty means egalitarian *permanently*, where Marmot reads the same empty set as a bootstrap +window. Same bytes, opposite meaning; no authorization code is shared. + +Five findings, all caught by tests or by reading the reference rather than the prose: + +1. **§4.1 does not block the binding.** The 32-byte credential check is in Marmot's own + `KeyPackageUtils`, not the engine, so our binding just implements cordn's encoding + (`groups/CordnCredential`). The incompatibility between the two ecosystems is unchanged and + still worth raising. +2. **`spec/01.md` §3 contradicts itself** — "MLS variable-length vector encoding conventions" + and then `opaque Name<0..2^16-1>`. The reference emits a plain uint16, so that is what + interoperates. Our test derives the bytes by hand from the spec, because a round trip agrees + with itself whichever encoding we had picked. +3. **Group refs match the reference byte for byte** on the first run. The three golden strings + in `packages/core/src/groupRef.test.ts` are cross-checked there against an independent + TLV+bech32 assembly, so pinning them is a real Tier D vector. +4. **quartz's NIP-19 `Tlv.parse` is too lenient for a group ref.** It stops silently at a + malformed tuple — right for an `nprofile`, wrong here, where dropping the tail turns a ref + naming a coordinator into one that reaches for a default. `appGroupRef` parses strictly and + still ignores unknown types. +5. **ContextVM's fixture could not answer a second call.** It echoed a constant JSON-RPC id, + which passed every contextvm test because each made exactly one call, and hung the first + cordn test that made two. The handler now takes the request id, and two contextvm tests pin + the behaviour: a second call correlates, and a stale id is ignored rather than resolving the + wrong call. + +Verified by mutation: ignoring a pending self-echo, and advancing the cursor only for processed +messages, each kill their guarding tests at both the unit and end-to-end level. The second of +those needed a new end-to-end test — a single catch-up pass looks correct either way, and only a +second pass reveals the stall. + +**Cross-implementation verification — added after reading +[Staircase](https://code.relay.tools/opensauce/staircase) (`d9dd1a0`, MIT), an +independent Kotlin cordn client that vendors this project's own MLS engine.** + +`quartz/…/cordn/interop/CordnLifecycleInteropTest` walks the whole lifecycle against +fixtures **ts-mls** generated (Staircase's `conformance/fixtures/gen`, vendored +under `quartz/src/commonTest/resources/tsmls/`): read their KeyPackage and agree on +its `kp_ref`, unseal their commit under the published epoch exporter, join from +their Welcome, derive the same epoch exporter byte for byte, apply their +`0xC04D` metadata commit, and read their application message end to end. 15 +tests, all green. + +Reading Staircase found three things no amount of self-consistent testing would +have: + +1. **`authenticated_data` is a wire requirement the cordn spec never mentions.** + The reference client puts the sender's account pubkey in MLS + `authenticated_data` and **rejects** any application message that arrives + with it empty (`packages/cli/src/groupSync.ts:247`). Our engine AEAD-bound + the field correctly on receive but hardcoded `ByteArray(0)` on send and never + exposed it — so we would have shipped a client every cordn peer silently + discarded. `MlsGroup.encrypt` now takes it and `DecryptedMessage` carries it; + `spec02Envelopes/CordnApplicationMessage` owns the binding. Worth raising + upstream: it belongs in `spec/02.md` §5. +2. **Our GroupContextExtensions check implemented a rule RFC 9420 does not + have, and omitted the one it does.** §12.1.7 says nothing about recognising + extension types; its only validity rule is that the resulting group must not + require capabilities some member lacks. We rejected any type outside a + hardcoded list — which would have refused cordn's `0xC04D` metadata commit + outright — while never checking the real rule, so a commit could install a + `required_capabilities` a sitting member could not meet and split the group. + Both fixed; `MlsGroupPolicy.knownExtensionTypes` is gone, because it encoded + the invented rule. +3. **`Ed25519` could not rebuild a key pair from a known seed.** `keyPairFromSeed` + is now in the expect/actual set — any interop fixture needs it, and ts-mls + stores only the seed (inside a PKCS#8 blob). + +Staircase also independently confirms Stage 1's design. Their `VENDORED.md` +lists the same decoupling we did — remove `currentMarmotData`/`currentGroupState`/ +`currentNostrGroupId`/`agentTextStreamSecret`, drop the `AdminPolicyV1` branch, +inject the admin resolver, do not vendor `MlsGroupManager` or +`MarmotMessageStore` — arrived at independently, as patches against a fork. +**Now that the seam is upstream they could stop forking**, and their remaining +patches are a ready-made list of what a cordn binding still wants from the +engine: caller-chosen `group_id`, explicit leaf lifetimes (cordn uses ~100 +years), retained per-epoch receiver data, and skipped-generation keys. + +One trap worth recording: `MlsGroup.memberIdentityHex` hex-encodes the +credential bytes, which is right only for a binding that stores a raw key. +cordn's identity is already hex, so it returns 128 characters of hex-of-hex; +`CordnCredential.memberIdentities` is the cordn-side accessor. + +**Both directions now verified.** Reading their output was half of it; a client +can parse everything correctly and still emit something nobody accepts, and that +failure keeps our own tests green while every peer silently drops us. So +`KotlinArtifactProducerTest` builds a group under `CordnGroupPolicy`, adds a real +ts-mls KeyPackage, sends an application message and commits a metadata change, +and `quartz/interop/verify-with-ts-mls.sh` hands the result to Staircase's +`verify.ts`. ts-mls joins from our Welcome, reads our metadata, derives the same +epoch-1 and epoch-2 exporters, decrypts our message, checks the AAD sender and +envelope id, and applies our commit — **ten checks, all passing on the first +run**. Confirmed non-vacuous: flipping one nibble of `k-exporter-e1.hex` fails +exactly that check and exits 1. + +That gate lives in a script rather than the test suite because it needs a cordn +checkout with `pnpm install` and a staircase checkout. The producer half runs +unconditionally in `:quartz:jvmTest`. + +**Run it under Node, not bun.** Staircase's own `run.sh` uses bun, and bun's +WebCrypto has no X25519 DHKEM, so ts-mls there cannot open a Welcome at all — +not even one it generated itself. It surfaces as `DecapError: The algorithm is +not supported` inside HPKE and reads exactly like a wire-format mismatch. Worth +telling them; a one-line change to their runner. + +Still open in Stage 3: + +- **A ts-mls `ClientState` export.** `verify.ts` carries an optional gate that + decodes a Kotlin-exported ts-mls state and sends from it. We write no such + file, so it is skipped. Producing one means re-encoding `MlsGroupState` into + ts-mls's layout — real work, and the only thing it would buy is cross-client + multi-device, which §4.6 shows the spec rules out by design (`clientState` is + deliberately library-private). So this gate has no reachable purpose, not + merely a deferred one. +- **Tier B**, live against `ghcr.io/cordn-msg/cordn:latest`. + +### Stage 4 — App integration — LANDED (disclosure UI + headless layer); group UI open + +Landed in `commons/…/cordn/` (2026-09-19): + +| What | Where | +| ---- | ----- | +| Coordinator identity, relays, provenance | `CoordinatorConfig` (+ `from(CordnGroupRef)`) | +| Per-coordinator health, recorded not polled | `CoordinatorHealth` | +| §8 as a model a UI renders | `CordnExposure` (`GroupExposure`, `ExposureNote`) | +| Group lifecycle keyed on `gid` | `CordnGroupManager` | +| Encrypted-at-rest state + cursors | `CordnGroupStore` (+ an in-memory one for tests) | +| The 11 tools as a contract | `ICoordinator` in `quartz`, implemented by `CoordinatorClient` | +| `amy cordn ref encode/decode`, `amy cordn exposure` | `cli/…/CordnCommands` | +| §8 rendered: the disclosure card, health row, group badge | `commonsUI/…/cordn/ui/` | +| Parse a pasted `cordn1…` and compute its disclosure | `commons/…/cordn/CordnLinkInspection` | +| The screen that shows it, Settings → Cordn group link | `amethyst/…/settings/cordn/CordnLinkScreen` | + +`CordnGroupManager` is the cordn counterpart of `MarmotManager` and shares no code with it, as +Stage 1 predicted: Marmot's is keyed on the Nostr group id 147 times and its delivery model — +kinds 443/444/445, `h`-tag subscriptions, publish obligations — has no cordn analogue. What the +two share is the engine underneath. Its store is separate from `MlsGroupStateStore` for the same +reason: the keys look alike (`nostrGroupId` vs `gid`) and mean different things, so one interface +serving both would invite handing the wrong id to the wrong store. + +Four findings, each of which was a bug until the test that found it: + +1. **`CommitResult.commitBytes` is the bare RFC 9420 `Commit` struct**, not an `MLSMessage`. + Posting it produces a payload no receiver can parse. `framedCommitBytes` is the wire form. +2. **The pre-commit epoch rule (`spec/03.md` §5) is invisible to a two-party test.** Sealing a + Commit under the epoch it *creates* still passes create→invite→join→read, because the + joiner's Welcome cursor skips the only Commit in the stream. It takes a third member — one + already in the group when a later Commit lands — to catch it. `CommitResult` already hands + back `preCommitExporterSecret` precisely because the key is unobtainable afterwards. +3. **A Welcome does not carry the `gid`.** `spec/03.md` §2 decouples the delivery id from the MLS + `group_id`, so in principle a joiner cannot know where to fetch from. The reference client + closes the gap by convention — `group_id = utf8(gid)` — which staircase's fixtures confirm + (their `group_id` decodes to exactly their published `gid`). We follow the convention and + treat it as an observation, not a guarantee: a non-UTF-8 group id yields a named skip rather + than a mojibake key that fetches nothing forever. `MlsGroup.create` gained an optional + `groupId` so our groups carry it too. +4. **Public-framed handshake messages interoperate.** Our engine frames Commits as + `MLSMessage(PublicMessage)`; cordn's client emits private framing. Checked rather than + assumed: `packages/cli/src/utils/mlsMessages.ts:68` accepts wireformat 1 *and* 2 and hands + both to ts-mls's `processMessage`. Nothing is weakened — the coordinator sees only the outer + seal either way — so the manager emits public framing and reads both. + +**The §8 disclosure now has a screen** (2026-09-19). The requirement was that exposure be +"surfaced in the UI if we ship this, not buried" — so it sits where it can still change a +decision: on a pasted `cordn1…` link, before joining. The screen reads the link, names the +coordinator and its relays, and renders `GroupExposure`; it deliberately does not join, because +joining needs a live coordinator and Tier B is blocked (§7). + +The card states facts and does not rank the two bindings — cordn is weaker against the operator +and stronger against the network (§8.5), and which trade is right depends on who runs the +coordinator. Its notes come from `GroupExposure.notes()` rather than from prose, so a group +linked to four others says so and a lone group does not. + +Rendering it found two things compiling could not: + +1. **The severity colours were not monotonic.** `tertiary` for the middle level rendered pink + against a dark `onSurface` for the worst one, so "under a throwaway key" looked more alarming + than "tied to your real account". Emphasis for the worst case is now weight, not another hue; + nothing uses `error`, because everything on the card is how cordn works, not a fault. +2. **Note order is part of the disclosure.** Cross-group linkage (§8.2) — the item nobody + predicts — was below message padding, the least consequential one. `notes()` now returns + most-surprising-first. + +`CordnExposureRenderTest` keeps both honest by rasterising the surface headlessly +(`ImageComposeScene`, software Skia, no display) and looking at the pixels. It catches the two +things a compile cannot: a missing string resource, which throws at render because the generated +`Res.string.*` accessors compile regardless, and a composable that draws nothing. Mutation-checked +— hardcoding the card's background and disabling the §8.2 conditional each kill exactly one test. +The first attempt at the theme test sampled only the page background and let the hardcoded card +through, which is why it now samples inside the card too. + +Still open: + +- **Group UI.** Chatroom/feed models beside `model/marmotGroups/`, a ViewModel, and the badge + wired into a real group list — all of which need groups to exist on a device first. +- **Coordinator-driving `amy` verbs** (`publish`, `invite`, `send`, `sync`). Deliberately not + shipped: with Tier B blocked (§7) there is nothing to exercise them against, and unexercised + coordinator verbs are a guess with a command-line interface. The logic they would call is in + `CordnGroupManager` and is covered against an in-memory coordinator. +- Key-package rotation and a published-KeyPackage lifecycle, which cordn has no event kind for + (§4.2) and which therefore lives entirely in coordinator calls. + +The original scope, for reference: + +- `commons/` state holders and ViewModels for cordn groups, alongside the Marmot ones. +- Coordinator configuration and health surface (their client tracks per-coordinator health). +- **Surface the metadata model (§8) in the UI.** A cordn group and a Marmot group are not + equivalent privacy-wise and must not look identical. +- `amy` verbs for scripted interop testing, per the thin-assembly-layer rule. + +### Non-goals + +- A live multi-device **fleet** (§4.6) — it would ship §10's unresolved equal-epoch commit + race. Device **migration** is a different shape and landed: a handoff has one writer, so + that race is out of reach by construction. See `amethyst/plans/2026-09-19-cordn-ui.md` §5.3. +- Cross-client migration, which stays impossible by design: `clientState` is deliberately + library-private (§4.2), so an Amethyst device and a cordn-web device can never share a leaf. +- Reusing any `Marmot*`/`Mip*` type for cordn. +- A production Kotlin ContextVM *server*. The Tier C fixture (§6.4) plays the server role for + tests only. For a real coordinator, `cordn-rs` exists, is faster, and shares the SQLite schema — + run theirs. The fixture is deliberately not hardened for deployment. +- A full MCP SDK. `:contextvm` implements the client surface the CEPs define plus the fixture's + server role, not MCP's sampling/roots/elicitation breadth. +- Bridging a Marmot group and a cordn group into one MLS group. Possible in principle once §4.1 + and §4.4 are resolved, but it is a separate design with its own trust questions. + +## 10. Open questions + +1. ~~**§4.1 credential encoding** — who moves?~~ **Answered 2026-09-18: nobody has to.** We + implement both. The engine has had no opinion on credential encoding since Stage 1, and the + two encodings are 32 raw bytes vs 64 ASCII bytes — mutually exclusive by length, so no leaf + can be misread as the other profile's. `BothCredentialProfilesTest` pins that, including the + `memberIdentityHex` hex-of-hex trap. The cost is §4.4 (no single group holding both clients), + which was already a deliberate profile decision. +2. **§4.2 publication payload** — will upstream give KeyPackage publication its own signed payload + or kind? Changes whether cordn KeyPackages can exist outside a coordinator. Still open, and now + the highest-value ask, since §4.1 no longer needs one. +2b. **Will upstream license the coordinator?** `packages/coordinator`, `packages/server` and + `packages/test-utils` carry no LICENSE (§7). Until they do, Tier B cannot be built on them. + Cheap for upstream to fix and it unblocks the only remaining verification tier that needs + their code. +3. **Is the goal interop or a second transport?** "Amethyst can talk to cordn users" and "Amethyst + supports coordinator-backed groups" are different products. The second is strictly less work + (no §4.1 dependency for group creation among Amethyst users) and strictly less valuable. +4. **Padding.** Nothing in `spec/03.md` pads the sealed payload, so message sizes leak. Worth + proposing; cheap to add on both sides while the deployed base is small. +5. **Whose coordinator?** The privacy analysis reads very differently for a self-hosted + per-community coordinator versus the shipped public default. +6. **Are there ContextVM conformance vectors?** None found in `contextvm-docs` or the SDK. Ask + upstream, and offer ours — a shared CEP-4 wrap and CEP-22/41 framing vector set helps every + non-TypeScript implementation and is a cheap contribution (Tier D, §6.4). +7. **How stable are CEP-22 and CEP-41?** Both are Draft, both are the largest CEPs, and both are + mandatory for cordn. A breaking revision mid-implementation is the main schedule risk in + Stage 2c. Worth asking whether either is close to Final. +8. **Should ContextVM be published separately?** It is a general MCP-over-Nostr client with no + Amethyst or cordn dependency. If the Nostr ecosystem wants a JVM/KMP ContextVM implementation, + this is it — but that is a maintenance commitment, and the answer changes how carefully the + public API needs designing in Stage 2a. +9. **The ContextVM spec repo has no LICENSE.** Implementing a protocol from a published spec is + normal and fine, but if we want to quote rule text into KDoc or the module README, ask upstream + to add one (CC-BY or similar) rather than assuming. +10. **Should the Tier C fixture server become a shared conformance harness?** It is the piece the + ecosystem is missing — a counterparty that can violate any rule on demand. Offering it + upstream would make it the de facto ContextVM test suite, which is influence worth having but + also a maintenance commitment beyond our own needs. +11. **Is CEP-8 worth implementing at all, or just not precluding?** It is 722 lines, needs Tier E + wallet integration, and no coordinator we know of prices anything. The full-list decision says + build it; if that is really "build it when someone charges", say so now and 2f drops to a + stub that surfaces `-32042` to the user instead of paying. diff --git a/quartz/plans/README.md b/quartz/plans/README.md index e56dc4d372..f1c06931f1 100644 --- a/quartz/plans/README.md +++ b/quartz/plans/README.md @@ -1,6 +1,6 @@ # quartz plans -_Audited 2026-09-08. 12 plans: 7 shipped (archived), 0 in-progress, 4 queued, 1 closed (negative result)._ +_Audited 2026-09-17. 13 plans: 7 shipped (archived), 0 in-progress, 5 queued, 1 closed (negative result)._ ## Queued | Plan | Summary | @@ -10,6 +10,7 @@ _Audited 2026-09-08. 12 plans: 7 shipped (archived), 0 in-progress, 4 queued, 1 | [2026-07-03-incremental-negentropy-storage.md](2026-07-03-incremental-negentropy-storage.md) | Always-current (created_at, id) index so cold NEG-OPENs stop paying a full scan + seal (~340 ms at 50k vs strfry's ~21 ms). | | [2026-07-04-small-req-floor.md](2026-07-04-small-req-floor.md) | Small-REQ dispatch floor: decomposed, inline fast path tried and reverted (no wire-level win); floor is transport-side. | | [2026-08-13-gpu-pow-mining.md](2026-08-13-gpu-pow-mining.md) | GPU NIP-13 mining declined (ARMv8 has SHA-256 in silicon, mobile GPUs do not). Midstate is ~3x on JVM targets; Android hinges on Conscrypt per-digest JNI cost, still unmeasured. created_at refresh while mining shipped. | +| [2026-09-17-cordn-interop.md](2026-09-17-cordn-interop.md) | Cordn (cordn.net) is an alternative binding of MLS onto Nostr, not an alternative to MLS: same ciphersuite `0x0001`, byte-identical ChaCha20-Poly1305 seal and NIP-01 envelope, but the delivery service is an MCP server over ContextVM with no key-package event kind. Only the RFC 9420 engine is shareable, and it is not yet Marmot-clean (3 files, 10 imports, 3 hardcoded policies). ContextVM must be written from scratch: full review of the spec + all 12 CEPs, with a per-CEP compliance matrix, `CVM-*` rule ids and a 5-tier test method (including a fixture server that misbehaves on demand, and RFC 8785 JCS which Quartz lacks). Blocked on the credential-identity encoding (raw 32 bytes vs 64-byte hex ASCII). Includes a coordinator metadata-exposure analysis. | | [2026-09-08-marmot-spec-resync.md](2026-09-08-marmot-spec-resync.md) | Marmot moved off the MIP-era spec (2026-07-02): group state split into `app_data_dictionary` components, account identity proof v2, and a convergence engine. Current MDK rejects our groups outright. Gap analysis + 8-stage plan; Stages 0-4 done (mdk interop reference, app_data_dictionary, identity proof v2, the six group components, transport corrections); lifecycle + branch selection landed. | | [2026-09-22-cyberspace-region-bags.md](2026-09-22-cyberspace-region-bags.md) | Open `kind 33330` region bags: §2 coordinates, §4 Cantor roots, §7.2 region keys, §7.7 hint sweeps, §7.6 item verification. Reverses SNO's D2 — §7.7 says a seeker's position never enters the cost, and a key is 1.2 ms at h8 against the spec's own 1.3 ms. Three layers: quartz, `amy`, and a tap-to-search card. | diff --git a/quartz/plans/archive/2026-06-09-clink.md b/quartz/plans/archive/2026-06-09-clink.md index 3529780fe0..fff712d54a 100644 --- a/quartz/plans/archive/2026-06-09-clink.md +++ b/quartz/plans/archive/2026-06-09-clink.md @@ -59,7 +59,7 @@ tags, `["e", reqId]` on responses, NIP-44 JSON content, GFY error codes | NIP-44 encrypt/decrypt | ✅ Reuse | `quartz/.../nip44Encryption/` + `signer.nip44Encrypt/Decrypt` | Use as-is | | Bech32 + TLV codec | ✅ Reuse | `nip19Bech32/bech32/`, `tlv/TlvBuilder.kt`, `tlv/Tlv.kt` | Use the codec; do NOT reuse `Nip19Parser` | | Entity TLV pattern (reference) | 📦 Mirror | `nip19Bech32/entities/NProfile.kt` | Copy shape into new Clink entities | -| Encrypted req/resp event pattern | 📦 Mirror | `nip47WalletConnect/events/LnZapPaymentRequestEvent.kt` (kind 23194) | Clone for kinds 21001/2/3, client + server | +| Encrypted req/resp event pattern | 📦 Mirror | `nip47WalletConnect/events/NwcRequestEvent.kt` (kind 23194) | Clone for kinds 21001/2/3, client + server | | Request/response over relay | ✅ Reuse | `Nip47Client.responseFilter()`, `relayClient/reqCommand/nwc/` | Same filter-by-`e`-tag pattern | | BOLT-11 parsing | ✅ Reuse | `quartz/.../lightning/LnInvoiceUtil.kt` | Use as-is | | LN payment execution | ✅ Reuse | `amethyst/.../service/ZapPaymentHandler.kt`, `NwcSignerState` | Add CLINK routes | @@ -85,7 +85,7 @@ with `events/`, `pointers/`, `rpc/` subfolders. 3. **Events** — `OfferEvent(21001)`, `DebitEvent(21002)`, `ManageEvent(21003)`, each `isContentEncoded() = true`, with `createRequest()` / `createResponse()` companions that NIP-44-encrypt JSON and set `p` / `clink_version` / `e` tags - (direct analogue of `LnZapPaymentRequestEvent`). Register in `EventFactory`. + (direct analogue of `NwcRequestEvent`). Register in `EventFactory`. 4. **DTOs + errors** — Jackson request/response classes per spec, shared `GfyError(code, message, range?, retryAfter?, delta?)`, Offers error model (`code` 1..5, `range`, `latest`). diff --git a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip01Core/EventSigCheck.kt b/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip01Core/EventSigCheck.kt index 7b53776a8f..3bac4762e7 100644 --- a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip01Core/EventSigCheck.kt +++ b/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip01Core/EventSigCheck.kt @@ -28,7 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.crypto.verifyId import com.vitorpamplona.quartz.nip01Core.crypto.verifySignature import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.utils.EventFactory import org.junit.Assert.assertEquals @@ -139,7 +139,7 @@ class EventSigCheck { @Test fun checkSerializationLargeZap() { val event = - EventFactory.create( + EventFactory.create( id = "e7f09fddf39fc6cb604708b6af7b4d4adbb07b412847ebb004064040fe8c4b1e", pubKey = "79f00d3f5a19ec806189fcab03c1be4ff81d18ee4f653c88fac41fe03570f432", createdAt = 1728921077, diff --git a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestNip44EventTest.kt b/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcRequestNip44EventTest.kt similarity index 93% rename from quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestNip44EventTest.kt rename to quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcRequestNip44EventTest.kt index d588554f74..8758bed7ad 100644 --- a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestNip44EventTest.kt +++ b/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcRequestNip44EventTest.kt @@ -25,7 +25,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal -import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcRequestEvent import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBalanceMethod import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetInfoMethod import kotlinx.coroutines.test.runTest @@ -35,7 +35,7 @@ import kotlin.test.assertEquals import kotlin.test.assertIs @RunWith(AndroidJUnit4::class) -class LnZapPaymentRequestNip44EventTest { +class NwcRequestNip44EventTest { @Test fun testCreateRequestWithNip44() = runTest { @@ -47,7 +47,7 @@ class LnZapPaymentRequestNip44EventTest { val request = GetBalanceMethod.create() val event = - LnZapPaymentRequestEvent.createRequest( + NwcRequestEvent.createRequest( request = request, walletServicePubkey = walletServicePubkey, signer = clientSigner, @@ -71,7 +71,7 @@ class LnZapPaymentRequestNip44EventTest { val request = GetInfoMethod.create() val event = - LnZapPaymentRequestEvent.createRequest( + NwcRequestEvent.createRequest( request = request, walletServicePubkey = walletServicePubkey, signer = clientSigner, diff --git a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip57Zaps/PrivateZapTests.kt b/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip57Zaps/PrivateZapTests.kt index 367c45a736..232e0f9708 100644 --- a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip57Zaps/PrivateZapTests.kt +++ b/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip57Zaps/PrivateZapTests.kt @@ -92,13 +92,13 @@ class PrivateZapTests { ) val privateZapRequest = - LnZapRequestEvent.create( + ZapRequestEvent.create( zappedEvent = poll, relays = setOf(RelayUrlNormalizer.normalize("wss://relay.damus.io/")), signer = loggedIn, pollOption = 0, message = "", - zapType = LnZapEvent.ZapType.PRIVATE, + zapType = ZapReceiptEvent.ZapType.PRIVATE, toUserPubHex = null, ) @@ -151,7 +151,7 @@ class PrivateZapTests { ) val privateZapRequest = - LnZapRequestEvent.create( + ZapRequestEvent.create( zappedEvent = textNote, relays = setOf( @@ -162,7 +162,7 @@ class PrivateZapTests { signer = loggedIn, pollOption = null, message = "test", - zapType = LnZapEvent.ZapType.PRIVATE, + zapType = ZapReceiptEvent.ZapType.PRIVATE, toUserPubHex = null, ) diff --git a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip59GiftWraps/GiftWrapEventTest.kt b/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip59GiftWraps/GiftWrapEventTest.kt index 47455faeb5..ebdd83efbc 100644 --- a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip59GiftWraps/GiftWrapEventTest.kt +++ b/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip59GiftWraps/GiftWrapEventTest.kt @@ -31,7 +31,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser import com.vitorpamplona.quartz.nip17Dm.NIP17Factory import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent -import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent +import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.utils.Hex import kotlinx.coroutines.test.runTest @@ -65,7 +65,7 @@ class GiftWrapEventTest { val eventsReceiverGets = events.wraps.filter { it.isTaggedUser(receiver.pubKey) } eventsReceiverGets.forEach { val event = it.unwrapThrowing(receiver) - if (event is SealedRumorEvent) { + if (event is SealEvent) { val innerData = event.unsealThrowing(receiver) assertEquals(message, innerData.content) } else { @@ -77,7 +77,7 @@ class GiftWrapEventTest { val eventsSenderGets = events.wraps.filter { it.isTaggedUser(sender.pubKey) } eventsSenderGets.forEach { val event = it.unwrapThrowing(sender) - if (event is SealedRumorEvent) { + if (event is SealEvent) { val innerData = event.unsealThrowing(sender) assertEquals(message, innerData.content) } else { @@ -118,7 +118,7 @@ class GiftWrapEventTest { val eventsReceiverGets = events.wraps.filter { it.isTaggedUser(receiver.pubKey) } eventsReceiverGets.forEach { val event = it.unwrapThrowing(receiver) - if (event is SealedRumorEvent) { + if (event is SealEvent) { val innerData = event.unsealThrowing(receiver) assertEquals(message, innerData.content) } else { @@ -131,7 +131,7 @@ class GiftWrapEventTest { val eventsSenderGets = events.wraps.filter { it.isTaggedUser(sender.pubKey) } eventsSenderGets.forEach { val event = it.unwrapThrowing(sender) - if (event is SealedRumorEvent) { + if (event is SealEvent) { val innerData = event.unsealThrowing(sender) assertEquals(message, innerData.content) } else { @@ -155,7 +155,7 @@ class GiftWrapEventTest { ) // MsgFor the Receiver val encMsgFromSenderToReceiver = - SealedRumorEvent.create( + SealEvent.create( event = senderMessage, encryptTo = receiver.pubKey, signer = sender, @@ -184,7 +184,7 @@ class GiftWrapEventTest { // MsgFor the Sender val encMsgFromSenderToSender = - SealedRumorEvent.create( + SealEvent.create( event = senderMessage, encryptTo = sender.pubKey, signer = sender, @@ -221,10 +221,10 @@ class GiftWrapEventTest { assertNotNull(giftWrapToReceiver) val unwrappedMsgForSenderBySender = giftWrapToSender.unwrapThrowing(sender) - assertEquals(SealedRumorEvent.KIND, unwrappedMsgForSenderBySender.kind) - assertTrue(unwrappedMsgForSenderBySender is SealedRumorEvent) + assertEquals(SealEvent.KIND, unwrappedMsgForSenderBySender.kind) + assertTrue(unwrappedMsgForSenderBySender is SealEvent) - if (unwrappedMsgForSenderBySender is SealedRumorEvent) { + if (unwrappedMsgForSenderBySender is SealEvent) { val unwrappedRumorToSenderBySender = unwrappedMsgForSenderBySender.unsealThrowing(sender) assertEquals("Hi There!", unwrappedRumorToSenderBySender.content) @@ -242,10 +242,10 @@ class GiftWrapEventTest { } val unwrappedMsgForReceiverByReceiver = giftWrapToReceiver.unwrapThrowing(receiver) - assertEquals(SealedRumorEvent.KIND, unwrappedMsgForReceiverByReceiver.kind) - assertTrue(unwrappedMsgForReceiverByReceiver is SealedRumorEvent) + assertEquals(SealEvent.KIND, unwrappedMsgForReceiverByReceiver.kind) + assertTrue(unwrappedMsgForReceiverByReceiver is SealEvent) - if (unwrappedMsgForReceiverByReceiver is SealedRumorEvent) { + if (unwrappedMsgForReceiverByReceiver is SealEvent) { val unwrappedRumorToReceiverByReceiver = unwrappedMsgForReceiverByReceiver.unsealThrowing(receiver) assertEquals("Hi There!", unwrappedRumorToReceiverByReceiver.content) @@ -273,7 +273,7 @@ class GiftWrapEventTest { ) val msgFromSenderToReceiverA = - SealedRumorEvent.create( + SealEvent.create( event = senderMessage, encryptTo = receiverA.pubKey, signer = sender, @@ -302,7 +302,7 @@ class GiftWrapEventTest { assertEquals(giftWrapForReceiverA.recipientPubKey(), receiverA.pubKey) val msgFromSenderToReceiverB = - SealedRumorEvent.create( + SealEvent.create( event = senderMessage, encryptTo = receiverB.pubKey, signer = sender, @@ -331,7 +331,7 @@ class GiftWrapEventTest { assertEquals(giftWrapForReceiverB.recipientPubKey(), receiverB.pubKey) val msgFromSenderToSender = - SealedRumorEvent.create( + SealEvent.create( event = senderMessage, encryptTo = sender.pubKey, signer = sender, @@ -371,9 +371,9 @@ class GiftWrapEventTest { assertNotNull(giftWrapForReceiverB) val unwrappedMsgForSenderBySender = giftWrapToSender.unwrapThrowing(sender) - assertEquals(SealedRumorEvent.KIND, unwrappedMsgForSenderBySender.kind) + assertEquals(SealEvent.KIND, unwrappedMsgForSenderBySender.kind) - if (unwrappedMsgForSenderBySender is SealedRumorEvent) { + if (unwrappedMsgForSenderBySender is SealEvent) { unwrappedMsgForSenderBySender.unsealOrNull(receiverA)?.let { _ -> fail() } @@ -399,9 +399,9 @@ class GiftWrapEventTest { } val unwrappedMsgForReceiverAByReceiverA = giftWrapForReceiverA.unwrapThrowing(receiverA) - assertEquals(SealedRumorEvent.KIND, unwrappedMsgForReceiverAByReceiverA.kind) + assertEquals(SealEvent.KIND, unwrappedMsgForReceiverAByReceiverA.kind) - if (unwrappedMsgForReceiverAByReceiverA is SealedRumorEvent) { + if (unwrappedMsgForReceiverAByReceiverA is SealEvent) { val unwrappedRumorToReceiverAByReceiverA = unwrappedMsgForReceiverAByReceiverA.unsealThrowing(receiverA) assertEquals("Who is going to the party tonight?", unwrappedRumorToReceiverAByReceiverA.content) @@ -426,9 +426,9 @@ class GiftWrapEventTest { } val unwrappedMsgForReceiverBByReceiverB = giftWrapForReceiverB.unwrapThrowing(receiverB) - assertEquals(SealedRumorEvent.KIND, unwrappedMsgForReceiverBByReceiverB.kind) + assertEquals(SealEvent.KIND, unwrappedMsgForReceiverBByReceiverB.kind) - if (unwrappedMsgForReceiverBByReceiverB is SealedRumorEvent) { + if (unwrappedMsgForReceiverBByReceiverB is SealEvent) { unwrappedMsgForReceiverBByReceiverB.unsealOrNull(receiverA)?.let { unwrappedRumorToReceiverBByReceiverA -> fail() } @@ -617,7 +617,7 @@ class GiftWrapEventTest { assertEquals(signer.pubKey, wrap.recipientPubKey()) val event = wrap.unwrapThrowing(signer) - return if (event is SealedRumorEvent) { + return if (event is SealEvent) { event.unsealThrowing(signer) } else { println(event.toJson()) diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/SignerResult.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/SignerResult.kt index 2191d6a903..63dae210a5 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/SignerResult.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/SignerResult.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.quartz.nip55AndroidSigner.api import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent +import com.vitorpamplona.quartz.nip57Zaps.PrivateZapEvent sealed interface SignerResult { sealed interface RequestAddressed : SignerResult { @@ -83,7 +83,7 @@ data class DecryptionResult( ) : IResult data class ZapEventDecryptionResult( - val privateEvent: LnZapPrivateEvent, + val privateEvent: PrivateZapEvent, ) : IResult data class DerivationResult( diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/background/queries/DecryptZapQuery.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/background/queries/DecryptZapQuery.kt index b41c591e9c..bb9cf0c4f7 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/background/queries/DecryptZapQuery.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/background/queries/DecryptZapQuery.kt @@ -30,7 +30,7 @@ import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult import com.vitorpamplona.quartz.nip55AndroidSigner.api.ZapEventDecryptionResult import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.utils.getStringByName import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.utils.query -import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent +import com.vitorpamplona.quartz.nip57Zaps.PrivateZapEvent class DecryptZapQuery( val loggedInUser: HexKey, @@ -45,7 +45,7 @@ class DecryptZapQuery( val decryptedEventAsJson = cursor.getStringByName("result") if (!decryptedEventAsJson.isNullOrBlank()) { if (decryptedEventAsJson.startsWith("{")) { - val event = Event.fromJsonOrNull(decryptedEventAsJson) as? LnZapPrivateEvent + val event = Event.fromJsonOrNull(decryptedEventAsJson) as? PrivateZapEvent if (event != null) { if (event.verify()) { SignerResult.RequestAddressed.Successful(ZapEventDecryptionResult(event)) diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/requests/DecryptZapRequest.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/requests/DecryptZapRequest.kt index cdd96dc214..1e74fa6b8f 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/requests/DecryptZapRequest.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/requests/DecryptZapRequest.kt @@ -24,12 +24,12 @@ import android.content.Intent import androidx.core.net.toUri import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip55AndroidSigner.api.CommandType -import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapRequestEvent class DecryptZapRequest { companion object { fun assemble( - event: LnZapRequestEvent, + event: ZapRequestEvent, loggedInUser: HexKey, packageName: String, ): Intent { diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/responses/DecryptZapResponse.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/responses/DecryptZapResponse.kt index 38b7253126..f39e730a00 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/responses/DecryptZapResponse.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/responses/DecryptZapResponse.kt @@ -24,11 +24,11 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult import com.vitorpamplona.quartz.nip55AndroidSigner.api.ZapEventDecryptionResult import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.results.IntentResult -import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent +import com.vitorpamplona.quartz.nip57Zaps.PrivateZapEvent class DecryptZapResponse { companion object { - fun assemble(event: LnZapPrivateEvent): IntentResult = + fun assemble(event: PrivateZapEvent): IntentResult = IntentResult( result = event.toJson(), ) @@ -40,7 +40,7 @@ class DecryptZapResponse { val eventJson = intent.result return if (!eventJson.isNullOrBlank()) { if (eventJson.startsWith("{")) { - val event = Event.fromJsonOrNull(eventJson) as? LnZapPrivateEvent + val event = Event.fromJsonOrNull(eventJson) as? PrivateZapEvent if (event != null) { SignerResult.RequestAddressed.Successful(ZapEventDecryptionResult(event)) } else { diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/client/NostrSignerExternal.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/client/NostrSignerExternal.kt index 2da61eba2a..218ca03dbc 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/client/NostrSignerExternal.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/client/NostrSignerExternal.kt @@ -36,8 +36,8 @@ import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult import com.vitorpamplona.quartz.nip55AndroidSigner.api.ZapEventDecryptionResult import com.vitorpamplona.quartz.nip55AndroidSigner.client.handlers.BackgroundRequestHandler import com.vitorpamplona.quartz.nip55AndroidSigner.client.handlers.ForegroundRequestHandler -import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import com.vitorpamplona.quartz.nip57Zaps.PrivateZapEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapRequestEvent class NostrSignerExternal( pubKey: HexKey, @@ -163,7 +163,7 @@ class NostrSignerExternal( throw convertExceptions("Could not derive key", result) } - override suspend fun decryptZapEvent(event: LnZapRequestEvent): LnZapPrivateEvent { + override suspend fun decryptZapEvent(event: ZapRequestEvent): PrivateZapEvent { if (!event.isPrivateZap()) throw SignerExceptions.NothingToDecrypt() val result = backgroundQuery.decryptZapEvent(event) ?: foregroundQuery.decryptZapEvent(event) diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/client/handlers/BackgroundRequestHandler.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/client/handlers/BackgroundRequestHandler.kt index d6d7cec689..941d9a8c5e 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/client/handlers/BackgroundRequestHandler.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/client/handlers/BackgroundRequestHandler.kt @@ -40,7 +40,7 @@ import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.queries.Nip44D import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.queries.Nip44EncryptQuery import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.queries.SignPsbtQuery import com.vitorpamplona.quartz.nip55AndroidSigner.api.background.queries.SignQuery -import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapRequestEvent class BackgroundRequestHandler( loggedInUser: HexKey, @@ -81,7 +81,7 @@ class BackgroundRequestHandler( fromPubKey: HexKey, ) = nip44Decrypt.query(ciphertext, fromPubKey) as? SignerResult.RequestAddressed - fun decryptZapEvent(event: LnZapRequestEvent) = decryptZap.query(event) as? SignerResult.RequestAddressed + fun decryptZapEvent(event: ZapRequestEvent) = decryptZap.query(event) as? SignerResult.RequestAddressed fun deriveKey(nonce: HexKey) = deriveKey.query(nonce) as? SignerResult.RequestAddressed diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/client/handlers/ForegroundRequestHandler.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/client/handlers/ForegroundRequestHandler.kt index 78841f3172..0d695c61c0 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/client/handlers/ForegroundRequestHandler.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/client/handlers/ForegroundRequestHandler.kt @@ -39,7 +39,7 @@ import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.respon import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.responses.Nip44EncryptResponse import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.responses.SignPsbtResponse import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.responses.SignResponse -import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapRequestEvent class ForegroundRequestHandler( val loggedInUser: HexKey, @@ -86,7 +86,7 @@ class ForegroundRequestHandler( parser = Nip44DecryptResponse::parse, ) - suspend fun decryptZapEvent(event: LnZapRequestEvent) = + suspend fun decryptZapEvent(event: ZapRequestEvent) = launcher.launchWaitAndParse( requestIntentBuilder = { DecryptZapRequest.assemble(event, loggedInUser, packageName) }, parser = DecryptZapResponse::parse, diff --git a/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/Ed25519.apple.kt b/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/mls/crypto/Ed25519.apple.kt similarity index 99% rename from quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/Ed25519.apple.kt rename to quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/mls/crypto/Ed25519.apple.kt index e311cd8aa1..5f6f1c6e31 100644 --- a/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/Ed25519.apple.kt +++ b/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/mls/crypto/Ed25519.apple.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.crypto +package com.vitorpamplona.quartz.mls.crypto import com.vitorpamplona.quartz.utils.RandomInstance import io.github.andreypfau.kotlinx.crypto.Sha512 @@ -129,7 +129,7 @@ actual object Ed25519 { } actual fun keyPairFromSeed(seed: ByteArray): Ed25519KeyPair { - require(seed.size == SEED_LENGTH) { "Seed must be 32 bytes" } + require(seed.size == SEED_LENGTH) { "Ed25519 seed must be $SEED_LENGTH bytes, was ${seed.size}" } val publicKey = derivePublicKey(seed) return Ed25519KeyPair(seed + publicKey, publicKey) } diff --git a/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/X25519.apple.kt b/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/mls/crypto/X25519.apple.kt similarity index 99% rename from quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/X25519.apple.kt rename to quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/mls/crypto/X25519.apple.kt index 6577f257ed..e7b3959ec3 100644 --- a/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/X25519.apple.kt +++ b/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/mls/crypto/X25519.apple.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.crypto +package com.vitorpamplona.quartz.mls.crypto import com.vitorpamplona.quartz.utils.RandomInstance diff --git a/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256.apple.kt b/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256.apple.kt index e21f31f86d..6d9058379a 100644 --- a/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256.apple.kt +++ b/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256.apple.kt @@ -34,7 +34,8 @@ actual fun sha256(data: ByteArray): ByteArray { data.usePinned { inputPinned -> digest.usePinned { digestPinned -> CC_SHA256( - inputPinned.addressOf(0), + // addressOf(0) throws on an empty array; CommonCrypto accepts null for a 0-byte input. + if (data.isEmpty()) null else inputPinned.addressOf(0), data.size.convert(), digestPinned.addressOf(0), ) @@ -53,7 +54,8 @@ actual fun sha256Into( data.usePinned { inputPinned -> out.asUByteArray().usePinned { digestPinned -> CC_SHA256( - inputPinned.addressOf(0), + // addressOf(0) throws on an empty array; CommonCrypto accepts null for a 0-byte input. + if (data.isEmpty()) null else inputPinned.addressOf(0), len.convert(), digestPinned.addressOf(0), ) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/README.md b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/README.md index cd8bc2c7ac..e1d2491c59 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/README.md +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/README.md @@ -121,7 +121,7 @@ root as a pinned message. Covered by `nip29RelayGroups/PinEventsTest`. | Kind | Buzz class | Incumbent (registered) | |---|---|---| -| 9041 | `moderation.ModerationUnbanEvent` | `nip75ZapGoals.GoalEvent` | +| 9041 | `moderation.ModerationUnbanEvent` | `nip75ZapGoals.ZapGoalEvent` | | 49001 | `media.MediaUploadEvent` | — (Buzz's own `kind.rs` marks 49001 "Not a relay event kind") | | 30078 | `rsReadState` (helpers) | `nip78AppData.AppSpecificDataEvent` (NIP-RS reuses 30078) | diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/moderation/ModerationUnbanEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/moderation/ModerationUnbanEvent.kt index 7823ddea73..ee3dac4e73 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/moderation/ModerationUnbanEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/moderation/ModerationUnbanEvent.kt @@ -35,8 +35,8 @@ import com.vitorpamplona.quartz.utils.TimeUtils * `buzz-sdk/src/builders.rs::build_moderation_unban`. * * WARNING — KIND COLLISION: `9041` is also - * [com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent]'s kind in Quartz. This class MUST NOT - * be registered in `utils/EventFactory.kt` (the NIP-75 GoalEvent owns 9041 there). Buzz + * [com.vitorpamplona.quartz.nip75ZapGoals.ZapGoalEvent]'s kind in Quartz. This class MUST NOT + * be registered in `utils/EventFactory.kt` (the NIP-75 ZapGoalEvent owns 9041 there). Buzz * relays dispatch moderation commands by connection context, not by the shared `EventFactory` * kind switch, so leaving 9041 unregistered is correct — construct/parse this type explicitly. */ @@ -53,7 +53,7 @@ class ModerationUnbanEvent( fun target() = tags.moderationTarget() companion object { - /** Kind 9041 — COLLIDES with NIP-75 GoalEvent. Do not register in EventFactory. */ + /** Kind 9041 — COLLIDES with NIP-75 ZapGoalEvent. Do not register in EventFactory. */ const val KIND = 9041 fun build( diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordDirectInvite.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordDirectInvite.kt index 910eaedb68..f4b1277a9e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordDirectInvite.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordDirectInvite.kt @@ -27,7 +27,7 @@ import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler -import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent +import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent /** @@ -57,7 +57,7 @@ object ConcordDirectInvite { createdAt: Long, ): GiftWrapEvent { val rumor = RumorAssembler.assembleRumor(senderSigner.pubKey, createdAt, KIND, emptyArray(), json(invite)) - val seal = SealedRumorEvent.create(rumor, recipientPubKey, senderSigner, createdAt = createdAt) + val seal = SealEvent.create(rumor, recipientPubKey, senderSigner, createdAt = createdAt) // Wrap with a random ephemeral key, adding the ["k","3313"] index tag. val wrapSigner = NostrSignerInternal(KeyPair()) @@ -80,7 +80,7 @@ object ConcordDirectInvite { recipientSigner: NostrSigner, ): CommunityInvite? { val seal = wrap.unwrapOrNull(recipientSigner) ?: return null - if (seal !is SealedRumorEvent) return null + if (seal !is SealEvent) return null val rumor = seal.unsealOrNull(recipientSigner) ?: return null if (rumor.kind != KIND) return null return ConcordJson.decodeOrNull(rumor.content) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/README.md b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/README.md new file mode 100644 index 0000000000..59a82c90b3 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/README.md @@ -0,0 +1,127 @@ +# `contextvm/` + +**ContextVM** — the Model Context Protocol (MCP) carried over Nostr. Kind 25910 +ephemeral events whose `content` is a JSON-RPC message, gift-wrapped per CEP-4. + +A general MCP-over-Nostr implementation, not a client for any one server. It +exists because Amethyst needs to talk to [cordn](https://cordn.net) +coordinators (see `quartz/plans/2026-09-17-cordn-interop.md`), but nothing in it +is cordn-specific. + +It lives in quartz for the same reason `marmot/` and `concord/` do: it is a +Nostr specification family, and quartz is where those go. The CEPs are to +ContextVM what NIPs are to Nostr core — the package layout mirrors them the way +`nipXX` packages mirror NIPs. + +## Implemented specification revision + +Built from the specification documents in +[`ContextVM/contextvm-docs`](https://github.com/ContextVM/contextvm-docs) at +commit **`e63bce6`**, read on 2026-09-17. + +Most of these are **Draft**, including both transfer profiles, so the text can +still move. When bumping the revision, diff against the `CVM-*` rule ids in the +plan's §6.5 catalog — the test names carry them, so a spec change shows up as +named failures rather than as silent divergence. + +| Spec | Status | Implemented in | +| ---- | ------ | -------------- | +| Core draft spec | Draft | `core/`, `jsonrpc/`, `transport/`, `mcp/` | +| CEP-4 Encryption | Final | `cep04Encryption/CvmGiftWrap` | +| CEP-6 Public Announcements | Final | `cep06Announcements/` | +| CEP-8 Pricing and Payment | Draft | `cep08Payments/` | +| CEP-15 Common Tool Schemas | Draft | `cep15CommonSchemas/CommonToolSchema` | +| CEP-16 Client Pubkey Injection | Final | `mcp/McpMethods` (the `_meta` key), `fixture/` (server role) | +| CEP-17 Relay List Metadata | Draft | `cep17RelayList/ServerRelay` | +| CEP-19 Ephemeral Gift Wraps | Draft | `cep04Encryption/CvmGiftWrap` | +| CEP-21 PMI Recommendations | Draft | `cep08Payments/PaymentSession` | +| CEP-22 Oversized Transfer | Draft | `cep22OversizedTransfer/` | +| CEP-23 Server Profile Metadata | Draft | `cep06Announcements/DiscoverySurface` (kind 0 via quartz) | +| CEP-24 Server Reviews | Draft | `cep24Reviews/ServerReview` | +| CEP-35 Stateless Discovery | Draft | `cep35Discovery/SessionDiscovery` | +| CEP-41 Open Streams | Draft | `cep41OpenStreams/` | + +### Why the packages are named this way + +Each CEP gets its own `cepNNName/` package, the way `quartz` uses `nipXX` and +`marmot` uses `mipXX`: a spec number in the path is what makes "is this rule +implemented, and where" answerable without grep. Four placements are judgment +calls rather than mechanics: + +- **CEP-19 lives in `cep04Encryption/`.** Choosing the wrap kind (21059 with the + 1059 fallback) and building the wrap are one negotiation inside + `CvmGiftWrap`; a separate package would split a single method from its caller. +- **CEP-16 has no package.** It is a `_meta` key name plus the server-side + obligation to inject it — a key constant and fixture behaviour, not a + subsystem. +- **`transfer/ProgressEnvelope`** stays cross-cutting because CEP-22 and CEP-41 + share that framing; it is the `marmot/foundation/` analogue. +- **`core/`, `jsonrpc/`, `transport/`, `mcp/`** keep names because the core draft + spec is not a CEP and has no number to carry. + +RFC 8785 (JCS), required by CEP-8 and CEP-15, lives in +`quartz/…/utils/jcs/JsonCanonicalization.kt` — it is a generic primitive, not a +ContextVM concern. + +## Licensing + +Implemented **clean-room from the specification**. The reference +[`ContextVM/sdk`](https://github.com/ContextVM/sdk) is **LGPL-3.0**; Amethyst +ships under MIT, so translating that source would carry copyleft terms into +Quartz. Do not read it while working here — the plan's §6 was written so you do +not have to. + +The spec repository carries no LICENSE file. Implementing a published protocol +is fine; do not paste spec prose into this repo. + +## Three things that are easy to get wrong + +1. **Kind 25910 is ephemeral, so relays do not store it.** A subscription + opened after the peer published has missed the response permanently. This is + why `CvmTransport` exposes `request()` and no public publish/subscribe pair: + the ordering is the transport's job, not the caller's. `InMemoryRelayPool` + drops an event nobody is listening for, so the property is tested rather + than assumed. + +2. **CEP-41 has two independent ordering fields.** `progress` orders every + frame, control frames included, and is explicitly *not* a chunk counter; + `chunkIndex` (contiguous from 0) is what validates completeness. Progress + sequences are also per-sender, so a `pong`'s progress bears no relation to + the `ping` it answers — they match by nonce alone. + +3. **`close` does not complete the request.** After a CEP-41 stream closes, the + originating JSON-RPC request still needs its own response, and a client must + never synthesize success from `close`. `ToolCallResult` carries `streamed` + fragments and the `result` separately for exactly this reason. + +## Testing + +```bash +./gradlew :quartz:jvmTest --tests '*.contextvm.*' +./gradlew :quartz:testAndroidHostTest --tests '*.contextvm.*' +``` + +Protocol-level tests live in `commonTest` and run on every target; the ones +needing real secp256k1 and NIP-44 — the gift wrap round trip, the transport and +the MCP client — live in `jvmAndroidTest`, which is where quartz keeps +crypto-dependent tests and gives them the JVM *and* Android host runs. + +The suite is **Tier A and Tier C** from the plan's §6.4: rule-derived unit +tests, plus adversarial tests against `fixture/CvmFixtureServer`, which plays +the server role and can be told to violate any rule on demand (`FixtureFaults`). +Most tests are negative, because the CEPs are written as failure conditions. + +Still open: + +- **Tier B** — live integration against `ghcr.io/cordn-msg/cordn:latest`. +- **Tier D** — cross-implementation vector exchange for CEP-4 wraps and + CEP-22/41 framing. Worth offering upstream; no official vectors exist. +- **Tier E** — a real Lightning wallet behind CEP-8 (NIP-47 NWC is already in + Quartz). + +## Not in scope + +- A production server. `fixture/` plays the server role for tests only and is + deliberately not hardened; for a real coordinator, `cordn-rs` exists. +- Full MCP. Lifecycle, tool listing and tool calling are implemented because + that is what the CEPs define; sampling, roots and elicitation are not. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep04Encryption/CvmGiftWrap.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep04Encryption/CvmGiftWrap.kt new file mode 100644 index 0000000000..a758ad2a8c --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep04Encryption/CvmGiftWrap.kt @@ -0,0 +1,243 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.contextvm.cep04Encryption + +import com.vitorpamplona.quartz.contextvm.core.CvmKinds +import com.vitorpamplona.quartz.contextvm.core.CvmTags +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Kind +import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.crypto.verify +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * Whether ContextVM messages must be encrypted. + * + * Defaults to [REQUIRED] rather than mirroring the reference SDK's permissive + * default. Under `OPTIONAL` a coordinator that simply does not advertise + * encryption receives plaintext JSON-RPC on public relays, exposing every tool + * argument to any relay operator. Failing closed is the only safe default for a + * messaging client. + */ +enum class EncryptionMode { + /** Encrypt always; refuse to talk to a peer that cannot. */ + REQUIRED, + + /** Encrypt when the peer advertises support. Opt in deliberately. */ + OPTIONAL, + + /** Never encrypt. For test fixtures and diagnostics only. */ + DISABLED, +} + +/** Which gift wrap kind to use (CEP-19). */ +enum class GiftWrapMode { + /** Kind 21059 — ephemeral, so relays do not retain the envelope. */ + EPHEMERAL, + + /** Kind 1059 — persistent, for deliberately retained delivery. */ + PERSISTENT, +} + +/** Thrown when a gift wrap cannot be produced or trusted. */ +class CvmEncryptionException( + message: String, +) : IllegalStateException(message) + +/** + * CEP-4 and CEP-19 message encryption. + * + * The scheme is a simplified NIP-59: the inner kind-25910 event is **signed + * first**, then the whole signed event JSON is NIP-44 encrypted to the + * recipient and placed in a gift wrap. There is no separate seal and no unsigned + * rumor, so a receiver verifies the inner signature and correlates on the + * **inner** event id. + * + * What this hides and what it does not: the sender, the inner kind and the real + * timestamp are concealed, but the recipient's pubkey is a `p` tag on the wrap + * and is therefore visible to relays. CEP-4 states that limitation outright; it + * is inherent to the addressing model, not a defect here. + */ +class CvmGiftWrap( + private val encryptionMode: EncryptionMode = EncryptionMode.REQUIRED, + private val giftWrapMode: GiftWrapMode = GiftWrapMode.EPHEMERAL, +) { + /** + * What this side can *receive*, as CEP-35 tag names. + * + * Receive, not prefer: the transport subscribes to both wrap kinds, so a + * client that emits 1059 still accepts 21059 and says so. Declaring only + * what we emit would tell a peer to withhold something we can read. + * + * Empty under [EncryptionMode.DISABLED] - there, a wrap is not something + * this client will open at all. + */ + fun receivableWrapTags(): List = + if (encryptionMode == EncryptionMode.DISABLED) { + emptyList() + } else { + listOf(CvmTags.SUPPORT_ENCRYPTION, CvmTags.SUPPORT_ENCRYPTION_EPHEMERAL) + } + + /** The wrap kind this client emits. */ + fun wrapKind(): Kind = + when (giftWrapMode) { + GiftWrapMode.EPHEMERAL -> CvmKinds.EPHEMERAL_GIFT_WRAP + GiftWrapMode.PERSISTENT -> CvmKinds.GIFT_WRAP + } + + /** + * The kind to actually use against a peer. + * + * CEP-19 requires falling back to the persistent wrap for a peer that does + * not advertise ephemeral support, so preferring 21059 must never mean + * refusing to talk to a 1059-only server. + */ + fun negotiatedWrapKind(peerSupportsEphemeral: Boolean): Kind = + if (giftWrapMode == GiftWrapMode.EPHEMERAL && peerSupportsEphemeral) { + CvmKinds.EPHEMERAL_GIFT_WRAP + } else { + CvmKinds.GIFT_WRAP + } + + /** + * Whether a message to this peer must be encrypted. + * + * @throws CvmEncryptionException under [EncryptionMode.REQUIRED] when the + * peer cannot encrypt — failing loudly rather than downgrading. + */ + fun shouldEncrypt(peerSupportsEncryption: Boolean): Boolean = + when (encryptionMode) { + EncryptionMode.DISABLED -> false + EncryptionMode.OPTIONAL -> peerSupportsEncryption + EncryptionMode.REQUIRED -> + if (peerSupportsEncryption) { + true + } else { + throw CvmEncryptionException( + "peer does not advertise encryption and this client requires it", + ) + } + } + + /** + * Wraps an already-signed inner event for [recipient]. + * + * The wrap is signed by a fresh throwaway key, so nothing links two wraps + * from the same sender. + * + * ## `created_at` is the real send time, NOT NIP-59's shifted timestamp + * + * This is the one place a ContextVM wrap deliberately parts from NIP-59, + * and it is not a shortcut. NIP-59 shifts a wrap's timestamp into the past + * because a gift-wrapped DM is **stored** and fetched later, so its + * timestamp would otherwise reveal when a conversation happened. A + * ContextVM wrap carries an RPC request that has to reach a peer + * **listening right now**: the reference server subscribes with + * `since = now` when it connects, which is the obvious filter for a live + * request stream, and a relay honours `since`. A wrap dated an hour ago is + * therefore dropped by the relay and the request is never delivered — it + * does not fail, it simply times out. + * + * The shift also protects nothing here. CEP-4 puts the recipient in a + * visible `p` tag, the request is delivered in real time, and kind 21059 + * is ephemeral so no relay retains it to be read later. A shifted + * timestamp would hide from an observer a fact that same observer reads + * off the socket. + */ + suspend fun wrap( + inner: Event, + recipient: HexKey, + kind: Kind = wrapKind(), + ): Event { + if (!CvmKinds.isGiftWrap(kind)) { + throw CvmEncryptionException("$kind is not a gift wrap kind") + } + + val wrapSigner = NostrSignerInternal(KeyPair()) + val ciphertext = wrapSigner.nip44Encrypt(OptimizedJsonMapper.toJson(inner), recipient) + + return wrapSigner.sign( + createdAt = TimeUtils.now(), + kind = kind, + tags = arrayOf(PTag.assemble(recipient, relayHint = null)), + content = ciphertext, + ) + } + + /** + * Unwraps [giftWrap] and returns the signed inner event. + * + * The inner signature is verified here: without that check anyone able to + * encrypt to us could impersonate any pubkey, since the wrap's own signature + * only proves the throwaway key signed it. + */ + suspend fun unwrap( + giftWrap: Event, + signer: NostrSigner, + ): Event { + if (!CvmKinds.isGiftWrap(giftWrap.kind)) { + throw CvmEncryptionException("${giftWrap.kind} is not a gift wrap kind") + } + + val json = + try { + signer.nip44Decrypt(giftWrap.content, giftWrap.pubKey) + } catch (e: Exception) { + throw CvmEncryptionException("could not decrypt gift wrap: ${e.message}") + } + + val inner = + try { + OptimizedJsonMapper.fromJson(json) + } catch (e: Exception) { + throw CvmEncryptionException("gift wrap did not contain an event: ${e.message}") + } + + if (!inner.verify()) { + throw CvmEncryptionException("inner event signature is invalid") + } + + return inner + } + + /** Reads whether a peer advertised encryption support (CEP-4). */ + fun peerSupportsEncryption(tags: Array>) = CvmTags.hasFlag(tags, CvmTags.SUPPORT_ENCRYPTION) + + /** Reads whether a peer advertised ephemeral wrap support (CEP-19). */ + fun peerSupportsEphemeral(tags: Array>) = CvmTags.hasFlag(tags, CvmTags.SUPPORT_ENCRYPTION_EPHEMERAL) + + /** The capability flags this client advertises to peers. */ + fun capabilityTags(): List> = + buildList { + if (encryptionMode != EncryptionMode.DISABLED) { + add(CvmTags.flag(CvmTags.SUPPORT_ENCRYPTION)) + if (giftWrapMode == GiftWrapMode.EPHEMERAL) { + add(CvmTags.flag(CvmTags.SUPPORT_ENCRYPTION_EPHEMERAL)) + } + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep06Announcements/AnnouncedTools.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep06Announcements/AnnouncedTools.kt new file mode 100644 index 0000000000..22ff81a402 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep06Announcements/AnnouncedTools.kt @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.contextvm.cep06Announcements + +import com.vitorpamplona.quartz.contextvm.core.CvmKinds +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +/** + * The tool list a CEP-6 kind-11317 announcement carries. + * + * [ServerAnnouncement] deliberately leaves `content` as text, because the + * announcement kinds each carry a different MCP result. This decodes the one + * shape that matters for finding a server worth talking to: the `tools/list` + * result, `{"tools":[{"name":…,"inputSchema":…}, …]}`. + * + * ## Why the names are the useful part + * + * An announcement says nothing about *what protocol* a server speaks — there + * is no marker tag and no registry. What a server is, from the outside, is the + * set of tools it serves. So a client looking for one specific kind of server + * matches on the names, and [names] is what it matches against. + */ +class AnnouncedTools( + /** Each tool definition verbatim, for a caller that wants the schemas. */ + val tools: List, +) { + /** Every advertised tool name, in announcement order, deduplicated. */ + val names: Set = + tools.mapNotNullTo(LinkedHashSet()) { + (it[NAME] as? JsonPrimitive)?.takeIf { p -> p.isString }?.content + } + + /** Whether every one of [required] is advertised. Extra tools are fine. */ + fun serves(required: Collection): Boolean = names.containsAll(required) + + companion object { + private const val NAME = "name" + private const val TOOLS = "tools" + + private val json = Json { ignoreUnknownKeys = true } + + /** + * Reads the content of a kind-11317 announcement. + * + * Null when the text is not JSON or carries no `tools` array — an + * announcement we cannot read is not an error to propagate, it is one + * server in a relay's worth of them that this client skips. + */ + fun parseOrNull(content: String): AnnouncedTools? { + val root = + try { + json.parseToJsonElement(content) as? JsonObject + } catch (e: IllegalArgumentException) { + null + } ?: return null + + val tools = root[TOOLS] as? JsonArray ?: return null + return AnnouncedTools(tools.filterIsInstance()) + } + + /** Reads [announcement] when it is a tool list; null for any other kind. */ + fun from(announcement: ServerAnnouncement): AnnouncedTools? = + if (announcement.kind == CvmKinds.TOOLS_LIST) { + parseOrNull(announcement.content) + } else { + null + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep06Announcements/CvmAnnouncementEvents.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep06Announcements/CvmAnnouncementEvents.kt new file mode 100644 index 0000000000..b3fa0a6b74 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep06Announcements/CvmAnnouncementEvents.kt @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.contextvm.cep06Announcements + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.contextvm.core.CvmKinds +import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** + * A server's CEP-6 announcement of itself: kind [CvmKinds.SERVER_ANNOUNCEMENT]. + * + * A typed class so the announcement can live in the event cache like anything + * else. Without one the kind is unlisted, and an unlisted kind is rejected as + * unsupported on the way in -- which is why a coordinator's announced name had + * to be fetched and parsed by hand everywhere it was wanted, with the + * newest-wins rule, the author check and the signature check all re-derived per + * caller. Being replaceable (10000..19999), the cache now keeps the newest per + * (kind, pubkey) and verifies before trusting it. + * + * [discovery] is the surface the server advertises -- its name, its blurb, and + * what it says it supports. Every field of it is the server's own claim; + * [pubKey] is the only thing here that is not. + */ +@Immutable +class CvmServerAnnouncementEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + /** + * What the server says about itself, parsed from the tags. + * + * Not cached on the instance: the cache keeps one event per coordinator and + * the screens read a name off it, so the parse is rare and cheap next to + * holding a second copy of every surface in memory. + */ + fun discovery(): DiscoverySurface = DiscoverySurface.parse(tags) + + /** The server's own name for itself, or null when it publishes none. */ + fun serverName(): String? = discovery().name?.takeIf { it.isNotBlank() } + + companion object { + const val KIND = CvmKinds.SERVER_ANNOUNCEMENT + } +} + +/** + * A server's CEP-6 `tools/list` announcement: kind [CvmKinds.TOOLS_LIST]. + * + * Stored for the same reason as [CvmServerAnnouncementEvent], and needed + * alongside it because what makes a ContextVM server a *cordn coordinator* is + * the eleven tools it advertises here, not anything it says about itself. + */ +@Immutable +class CvmToolsListEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + /** The advertised tools, or null when the content does not parse as a list. */ + fun tools(): AnnouncedTools? = AnnouncedTools.parseOrNull(content) + + companion object { + const val KIND = CvmKinds.TOOLS_LIST + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep06Announcements/DiscoverySurface.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep06Announcements/DiscoverySurface.kt new file mode 100644 index 0000000000..0d158f6a2a --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep06Announcements/DiscoverySurface.kt @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.contextvm.cep06Announcements + +import com.vitorpamplona.quartz.contextvm.core.CvmTags +import com.vitorpamplona.quartz.nip01Core.core.Tag + +/** + * What a peer told us about itself. + * + * CEP-35 makes this a *session* concept rather than an announcement one: the + * same tag vocabulary arrives either on a public announcement (CEP-6) or on the + * first direct message of a session, and both are treated as equivalent. + */ +data class DiscoverySurface( + val name: String? = null, + val about: String? = null, + val picture: String? = null, + val website: String? = null, + val supportsEncryption: Boolean = false, + val supportsEphemeralEncryption: Boolean = false, + val supportsOversizedTransfer: Boolean = false, + val supportsOpenStream: Boolean = false, + /** + * Everything else, routing excluded. + * + * CEP-35 requires unknown discovery tags to be preserved rather than + * discarded, so custom protocols can build on the same exchange. Keeping + * them reachable is what makes that work. + */ + val unknownTags: List = emptyList(), +) { + /** + * Whether the peer said anything about itself at all. + * + * The distinction this exists for: CEP-35 reads an absent flag as "not + * supported", which is right for a peer that sent a discovery surface and + * left a flag out of it. It is wrong for a peer that sent no discovery tags + * whatsoever — that peer has made no claim, and treating its silence as a + * denial would have us conclude it cannot do anything. + * + * This is not hypothetical. A live cordn coordinator's kind-25910 responses + * carry only the routing tags `p` and `e` (observed on the public relays, + * 2026-09-23), so reading them as a full surface would say "supports + * nothing" about a server that in fact accepts every wrap we send. + */ + val declaresNothing: Boolean + get() = + name == null && + about == null && + picture == null && + website == null && + !supportsEncryption && + !supportsEphemeralEncryption && + !supportsOversizedTransfer && + !supportsOpenStream && + unknownTags.isEmpty() + + /** Raw access for a caller that understands a tag this version does not. */ + fun rawTag(name: String): Tag? = unknownTags.firstOrNull { it.isNotEmpty() && it[0] == name } + + companion object { + private val KNOWN = + setOf( + CvmTags.NAME, + CvmTags.ABOUT, + CvmTags.PICTURE, + CvmTags.WEBSITE, + CvmTags.SUPPORT_ENCRYPTION, + CvmTags.SUPPORT_ENCRYPTION_EPHEMERAL, + CvmTags.SUPPORT_OVERSIZED_TRANSFER, + CvmTags.SUPPORT_OPEN_STREAM, + ) + + fun parse(tags: Array): DiscoverySurface { + fun value(name: String) = tags.firstOrNull { it.size >= 2 && it[0] == name }?.get(1) + + return DiscoverySurface( + name = value(CvmTags.NAME), + about = value(CvmTags.ABOUT), + picture = value(CvmTags.PICTURE), + website = value(CvmTags.WEBSITE), + supportsEncryption = CvmTags.hasFlag(tags, CvmTags.SUPPORT_ENCRYPTION), + supportsEphemeralEncryption = CvmTags.hasFlag(tags, CvmTags.SUPPORT_ENCRYPTION_EPHEMERAL), + supportsOversizedTransfer = CvmTags.hasFlag(tags, CvmTags.SUPPORT_OVERSIZED_TRANSFER), + supportsOpenStream = CvmTags.hasFlag(tags, CvmTags.SUPPORT_OPEN_STREAM), + unknownTags = + tags.filter { + it.isNotEmpty() && it[0] !in KNOWN && !CvmTags.isRouting(it[0]) + }, + ) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep06Announcements/ServerAnnouncement.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep06Announcements/ServerAnnouncement.kt new file mode 100644 index 0000000000..3dc665ddb8 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep06Announcements/ServerAnnouncement.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.contextvm.cep06Announcements + +import com.vitorpamplona.quartz.contextvm.core.CvmKinds +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Kind + +/** + * A CEP-6 announcement. + * + * `content` is the stringified result of the matching MCP call — the initialize + * result for a server announcement, a list result for the others — so it is left + * as text for the caller to decode with the same codec it uses on the wire. + */ +data class ServerAnnouncement( + val kind: Kind, + val pubKey: HexKey, + val createdAt: Long, + val content: String, + val discovery: DiscoverySurface, +) { + companion object { + fun parseOrNull(event: Event): ServerAnnouncement? { + if (event.kind !in CvmKinds.ANNOUNCEMENTS) return null + return ServerAnnouncement( + kind = event.kind, + pubKey = event.pubKey, + createdAt = event.createdAt, + content = event.content, + discovery = DiscoverySurface.parse(event.tags), + ) + } + + /** + * Keeps the newest announcement per `(kind, pubkey)`. + * + * These kinds are replaceable, so an older event arriving late from a + * lagging relay must not overwrite a newer one already held. + */ + fun latestPerKind(events: List): Map = latestOf(events.mapNotNull { parseOrNull(it) }) + + /** + * As [latestPerKind], for announcements already parsed. + * + * A caller that grouped announcements itself — by pubkey, say — would + * otherwise have to re-derive the newest-per-kind rule, which is the + * part that is easy to get wrong. + */ + fun latestOf(announcements: List): Map = + announcements + .groupBy { it.kind } + .mapValues { (_, list) -> list.maxBy { it.createdAt } } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep08Payments/CanonicalInvocation.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep08Payments/CanonicalInvocation.kt new file mode 100644 index 0000000000..161a89764d --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep08Payments/CanonicalInvocation.kt @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.contextvm.cep08Payments + +import com.vitorpamplona.quartz.contextvm.json.toPlainJson +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcRequest +import com.vitorpamplona.quartz.contextvm.mcp.McpParams +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.utils.jcs.JsonCanonicalization +import com.vitorpamplona.quartz.utils.sha256.sha256 +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject + +/** + * CEP-8's canonical invocation identity for the `explicit_gating` lifecycle. + * + * A successful payment authorizes a *future* execution, and the client is told + * to retry "the same request". That has to be defined on something stable, so + * the identity is the client pubkey plus + * `sha256(JCS({method, params}))` — with `params._meta` removed. + * + * The `_meta` exclusion is the load-bearing part. MCP regenerates + * `progressToken` on every `callTool`, so without it two semantically identical + * invocations hash differently and a paid authorization could never be matched. + * The JSON-RPC id, the outer event id, timestamps, signatures and tags are all + * excluded for the same reason: a retry may legitimately change any of them. + * + * The exclusion applies **only** to identity derivation. When an authorization + * is consumed, the full original `params` — `_meta` included — must still reach + * the handler, so progress and streaming keep working at execution time. + */ +object CanonicalInvocation { + /** Derives the invocation identity hash from an MCP request. */ + fun identityOf(request: JsonRpcRequest): HexKey = identityOf(request.method, request.params) + + fun identityOf( + method: String, + params: JsonObject?, + ): HexKey { + val payload = + buildMap { + put(METHOD, method) + params?.let { put(PARAMS, semanticParams(it).toPlainJson()) } + } + + return sha256(JsonCanonicalization.canonicalize(payload).encodeToByteArray()).toHexKey() + } + + /** + * The full authorization key: the requesting client plus the invocation. + * + * A payment authorizes one client's future execution, not anyone's, so the + * pubkey is part of the identity rather than context around it. + */ + fun authorizationKey( + clientPubKey: HexKey, + request: JsonRpcRequest, + ) = clientPubKey.lowercase() + ":" + identityOf(request) + + /** [params] with `_meta` removed; everything else untouched. */ + fun semanticParams(params: JsonObject): JsonObject = + if (!params.containsKey(McpParams.META)) { + params + } else { + buildJsonObject { + params.forEach { (key, value) -> if (key != McpParams.META) put(key, value) } + } + } + + private const val METHOD = "method" + private const val PARAMS = "params" +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep08Payments/PaymentMessages.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep08Payments/PaymentMessages.kt new file mode 100644 index 0000000000..11e5880c06 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep08Payments/PaymentMessages.kt @@ -0,0 +1,160 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.contextvm.cep08Payments + +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcError +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcNotification +import com.vitorpamplona.quartz.contextvm.mcp.McpMethods +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.longOrNull + +/** + * A payment the server is asking for. + * + * Carried by `notifications/payment_required` in the transparent lifecycle and + * as one entry of `error.data.payment_options` under explicit gating — the + * fields are identical, which is why one type covers both. + */ +data class PaymentRequest( + val amount: Double, + val pmi: Pmi, + /** + * The settlement payload, opaque here. + * + * Its format is PMI-defined, so a handler must match [pmi] before trying to + * interpret it. Treating it as a BOLT11 invoice because it starts with "ln" + * is exactly the guess the PMI exists to prevent. + */ + val payRequest: String, + val description: String? = null, + /** Seconds. Absent means the PMI or the implementation defines expiry. */ + val ttl: Long? = null, + val meta: JsonObject? = null, +) { + companion object { + const val AMOUNT = "amount" + const val PAY_REQ = "pay_req" + const val PMI = "pmi" + const val DESCRIPTION = "description" + const val TTL = "ttl" + const val META = "_meta" + + fun parseOrNull(params: JsonObject): PaymentRequest? { + val amount = (params[AMOUNT] as? JsonPrimitive)?.doubleOrNull ?: return null + val pmi = (params[PMI] as? JsonPrimitive)?.contentOrNullIfNotString()?.let { Pmi.parseOrNull(it) } ?: return null + val payRequest = (params[PAY_REQ] as? JsonPrimitive)?.contentOrNullIfNotString() ?: return null + + return PaymentRequest( + amount = amount, + pmi = pmi, + payRequest = payRequest, + description = (params[DESCRIPTION] as? JsonPrimitive)?.contentOrNullIfNotString(), + ttl = (params[TTL] as? JsonPrimitive)?.longOrNull, + meta = params[META] as? JsonObject, + ) + } + } +} + +/** Server confirmation that payment was accepted (transparent lifecycle). */ +data class PaymentAccepted( + val amount: Double, + val pmi: Pmi, + val meta: JsonObject? = null, +) + +/** Server rejection of an attempted payment (transparent lifecycle). */ +data class PaymentRejected( + val pmi: Pmi, + /** For a bearer asset, the amount actually required. */ + val amount: Double? = null, + val message: String? = null, +) + +/** Parsers for the CEP-8 notifications and errors. */ +object PaymentMessages { + /** Reads a `notifications/payment_required`, or null if this is another notification. */ + fun paymentRequired(notification: JsonRpcNotification): PaymentRequest? { + if (notification.method != McpMethods.PAYMENT_REQUIRED) return null + return notification.params?.let { PaymentRequest.parseOrNull(it) } + } + + fun paymentAccepted(notification: JsonRpcNotification): PaymentAccepted? { + if (notification.method != McpMethods.PAYMENT_ACCEPTED) return null + val params = notification.params ?: return null + val amount = (params[PaymentRequest.AMOUNT] as? JsonPrimitive)?.doubleOrNull ?: return null + val pmi = + (params[PaymentRequest.PMI] as? JsonPrimitive) + ?.contentOrNullIfNotString() + ?.let { Pmi.parseOrNull(it) } ?: return null + return PaymentAccepted(amount, pmi, params[PaymentRequest.META] as? JsonObject) + } + + fun paymentRejected(notification: JsonRpcNotification): PaymentRejected? { + if (notification.method != McpMethods.PAYMENT_REJECTED) return null + val params = notification.params ?: return null + val pmi = + (params[PaymentRequest.PMI] as? JsonPrimitive) + ?.contentOrNullIfNotString() + ?.let { Pmi.parseOrNull(it) } ?: return null + return PaymentRejected( + pmi = pmi, + amount = (params[PaymentRequest.AMOUNT] as? JsonPrimitive)?.doubleOrNull, + message = (params["message"] as? JsonPrimitive)?.contentOrNullIfNotString(), + ) + } + + /** + * The payment options carried by a `-32042 Payment Required` error. + * + * Returns null when [error] is a different failure, and an empty list when + * the error claims payment is required but offers no way to make it — which + * the caller should treat as malformed rather than as "nothing to pay". + */ + fun paymentOptions(error: JsonRpcError): List? { + if (error.code != JsonRpcError.PAYMENT_REQUIRED) return null + val data = error.data as? JsonObject ?: return emptyList() + val options = data[PAYMENT_OPTIONS] as? JsonArray ?: return emptyList() + return options.mapNotNull { (it as? JsonObject)?.let(PaymentRequest::parseOrNull) } + } + + /** Seconds the server suggests waiting before retrying a `-32043 Payment Pending`. */ + fun retryAfter(error: JsonRpcError): Long? { + if (error.code != JsonRpcError.PAYMENT_PENDING) return null + val data = error.data as? JsonObject ?: return null + return (data[RETRY_AFTER] as? JsonPrimitive)?.longOrNull + } + + /** Human-readable guidance a payment error carries. */ + fun instructions(error: JsonRpcError): String? = + (error.data as? JsonObject) + ?.get(INSTRUCTIONS) + ?.let { (it as? JsonPrimitive)?.contentOrNullIfNotString() } + + const val PAYMENT_OPTIONS = "payment_options" + const val RETRY_AFTER = "retry_after" + const val INSTRUCTIONS = "instructions" +} + +private fun JsonPrimitive.contentOrNullIfNotString() = if (isString) content else null diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep08Payments/PaymentSession.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep08Payments/PaymentSession.kt new file mode 100644 index 0000000000..9c68e2cdad --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep08Payments/PaymentSession.kt @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.contextvm.cep08Payments + +import com.vitorpamplona.quartz.nip01Core.core.Tag + +/** + * Client-side CEP-8 negotiation state for one session. + * + * The rule this exists to enforce: a server that will not honour + * `explicit_gating` MUST NOT silently fall back to the transparent lifecycle, + * and a client that *required* explicit gating MUST NOT auto-satisfy transparent + * `payment_required` notifications it receives anyway. A payment handler that + * simply pays whatever it is asked to pay violates the client half of that, so + * [mayAutoPay] is the gate every handler has to pass through. + */ +class PaymentSession( + /** The lifecycle this client wants. */ + private val requested: PaymentInteraction = PaymentInteraction.DEFAULT, + /** + * True when the application needs payment decisions to be visible — an agent + * or a user must approve them. + * + * With this set, failing to negotiate [PaymentInteraction.EXPLICIT_GATING] + * disables automatic payment entirely rather than degrading to silent + * spending. + */ + private val requiresVisiblePayments: Boolean = false, + /** PMIs this client can actually settle. */ + private val supportedPmis: List = emptyList(), +) { + private var negotiated = false + private var effective = PaymentInteraction.DEFAULT + + /** The lifecycle in force. Before the first response this is the default. */ + val effectiveMode get() = effective + + /** True once the server has disclosed (or implied) the effective mode. */ + val isNegotiated get() = negotiated + + /** + * True when negotiation did not deliver the mode this client asked for. + * + * Only meaningful after [observeServerTags]. + */ + val negotiationFailed get() = negotiated && effective != requested + + /** Tags to attach to the first direct message of the session. */ + fun negotiationTags(): List = + buildList { + // transparent is the default, so advertising it is noise; only a + // non-default request needs to be stated. + if (requested != PaymentInteraction.DEFAULT) add(PaymentTags.paymentInteraction(requested)) + supportedPmis.forEach { add(PaymentTags.pmi(it)) } + } + + /** + * Applies the server's first direct response. + * + * Absence of the tag means transparent, per CEP-8's first-message semantics. + */ + fun observeServerTags(tags: Array) { + effective = PaymentTags.parsePaymentInteraction(tags) ?: PaymentInteraction.DEFAULT + negotiated = true + } + + /** + * Applies a mid-session upsert. + * + * CEP-8 treats a repeated `payment_interaction` as an upsert rather than a + * one-shot handshake, because ContextVM messaging is connectionless: a + * server cannot observe a client reconnecting, so first-message-only + * negotiation could never be renegotiated after a transport reset. An absent + * tag on a later message inherits the current mode. + */ + fun observeLaterServerTags(tags: Array) { + PaymentTags.parsePaymentInteraction(tags)?.let { + effective = it + negotiated = true + } + } + + /** + * Whether a handler may settle [request] without asking anyone. + * + * False when the client required visible payments but did not get explicit + * gating, and false for a PMI this client cannot settle anyway. + */ + fun mayAutoPay(request: PaymentRequest): Boolean { + if (requiresVisiblePayments && effective != PaymentInteraction.EXPLICIT_GATING) return false + return request.pmi in supportedPmis + } + + /** + * The first offered option this client can settle, or null. + * + * Order is the server's preference; a client picks the first it supports + * rather than the cheapest, since price comparison across payment rails is + * not something this layer can do. + */ + fun selectPayable(options: List): PaymentRequest? = options.firstOrNull { it.pmi in supportedPmis } + + /** PMIs both sides support, preserving the server's ordering. */ + fun intersectPmis(serverPmis: List): List = serverPmis.filter { it in supportedPmis } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep08Payments/PaymentTags.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep08Payments/PaymentTags.kt new file mode 100644 index 0000000000..959c1bc1c2 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep08Payments/PaymentTags.kt @@ -0,0 +1,204 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.contextvm.cep08Payments + +import com.vitorpamplona.quartz.contextvm.core.CvmTags +import com.vitorpamplona.quartz.nip01Core.core.Tag + +/** + * How payment is surfaced for a session (CEP-8). + * + * The distinction is not cosmetic: under [TRANSPARENT] payment is handled by + * transport middleware and the application may never see it, while under + * [EXPLICIT_GATING] payment becomes the invocation's own error result so an + * agent or user can decide. A client that needs the decision visible must not + * silently accept the other mode — see [PaymentSession]. + */ +enum class PaymentInteraction( + val wire: String, +) { + TRANSPARENT("transparent"), + EXPLICIT_GATING("explicit_gating"), + ; + + companion object { + /** The compatibility baseline when no tag is present. */ + val DEFAULT = TRANSPARENT + + fun fromWire(value: String?) = entries.firstOrNull { it.wire == value } + } +} + +/** + * A Payment Method Identifier. + * + * Follows the W3C format (`[a-z0-9-]+`). A PMI is not just a discovery label: it + * is the type tag for the opaque `pay_req` string, so a handler that does not + * recognise the PMI cannot interpret the payment request at all. + */ +data class Pmi( + val value: String, +) { + init { + require(PATTERN.matches(value)) { "PMI must match [a-z0-9-]+ but was '$value'" } + } + + /** + * True for bearer-asset methods that allow settlement directly on the + * request via a `direct_payment` tag (CEP-21's `-direct` suffix convention). + */ + val supportsDirectPayment get() = value.endsWith(DIRECT_SUFFIX) + + override fun toString() = value + + companion object { + private val PATTERN = Regex("[a-z0-9-]+") + + const val DIRECT_SUFFIX = "-direct" + + /** The one PMI CEP-21 currently recommends: `pay_req` is a BOLT11 invoice. */ + val LIGHTNING_BOLT11 = Pmi("bitcoin-lightning-bolt11") + + fun parseOrNull(value: String) = if (PATTERN.matches(value)) Pmi(value) else null + } +} + +/** A capability's advertised reference price. */ +sealed interface Price { + data class Fixed( + val amount: Long, + ) : Price + + /** + * An inclusive range. The server may request any amount within it, so this + * is a discovery hint rather than a commitment. + */ + data class Range( + val min: Long, + val max: Long, + ) : Price + + fun includes(amount: Long) = + when (this) { + is Fixed -> amount == this.amount + is Range -> amount in min..max + } +} + +/** What a [CapTag] prices. */ +enum class CapabilityKind( + val prefix: String, +) { + TOOL("tool:"), + PROMPT("prompt:"), + RESOURCE("resource:"), + ; + + companion object { + fun of(identifier: String) = entries.firstOrNull { identifier.startsWith(it.prefix) } + } +} + +/** `["cap", "", "", ""]` — a reference price for discovery and UX. */ +data class CapTag( + val kind: CapabilityKind, + val name: String, + val price: Price, + val unit: String, +) { + fun toTag(): Tag = arrayOf(CvmTags.CAPABILITY_PRICE, kind.prefix + name, priceWire(), unit) + + private fun priceWire() = + when (price) { + is Price.Fixed -> price.amount.toString() + is Price.Range -> "${price.min}-${price.max}" + } + + companion object { + fun parse(tag: Tag): CapTag? { + if (tag.size < 4 || tag[0] != CvmTags.CAPABILITY_PRICE) return null + val kind = CapabilityKind.of(tag[1]) ?: return null + val price = parsePrice(tag[2]) ?: return null + return CapTag(kind, tag[1].removePrefix(kind.prefix), price, tag[3]) + } + + private fun parsePrice(raw: String): Price? { + val separator = raw.indexOf('-') + if (separator <= 0) return raw.toLongOrNull()?.let { Price.Fixed(it) } + + val min = raw.substring(0, separator).toLongOrNull() ?: return null + val max = raw.substring(separator + 1).toLongOrNull() ?: return null + return if (min <= max) Price.Range(min, max) else null + } + } +} + +/** Assembling and reading the CEP-8 tag family. */ +object PaymentTags { + fun pmi(pmi: Pmi): Tag = arrayOf(CvmTags.PAYMENT_METHOD, pmi.value) + + fun paymentInteraction(mode: PaymentInteraction): Tag = arrayOf(CvmTags.PAYMENT_INTERACTION, mode.wire) + + fun directPayment( + pmi: Pmi, + payload: String, + ): Tag = arrayOf(CvmTags.DIRECT_PAYMENT, pmi.value, payload) + + fun change( + pmi: Pmi, + payload: String, + ): Tag = arrayOf(CvmTags.CHANGE, pmi.value, payload) + + fun parsePmis(tags: Array): List = + tags + .filter { it.size >= 2 && it[0] == CvmTags.PAYMENT_METHOD } + .mapNotNull { Pmi.parseOrNull(it[1]) } + + fun parseCaps(tags: Array): List = tags.mapNotNull(CapTag::parse) + + /** + * The interaction mode a peer is asking for, or null when the tag is absent. + * + * Absence is meaningful: on a session's first direct message it means + * [PaymentInteraction.TRANSPARENT], while on a later message it means the + * current effective mode is inherited unchanged. + */ + fun parsePaymentInteraction(tags: Array): PaymentInteraction? = + tags + .firstOrNull { it.size >= 2 && it[0] == CvmTags.PAYMENT_INTERACTION } + ?.let { PaymentInteraction.fromWire(it[1]) } + + /** + * Direct-payment offers in request order. + * + * A client SHOULD send at most one, but a server evaluating several takes + * the first whose PMI it supports, so order is preserved here. + */ + fun parseDirectPayments(tags: Array): List> = + tags + .filter { it.size >= 3 && it[0] == CvmTags.DIRECT_PAYMENT } + .mapNotNull { tag -> Pmi.parseOrNull(tag[1])?.let { it to tag[2] } } + + fun parseChange(tags: Array): Pair? = + tags + .firstOrNull { it.size >= 3 && it[0] == CvmTags.CHANGE } + ?.let { tag -> Pmi.parseOrNull(tag[1])?.let { it to tag[2] } } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep15CommonSchemas/CommonToolSchema.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep15CommonSchemas/CommonToolSchema.kt new file mode 100644 index 0000000000..640293fcd0 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep15CommonSchemas/CommonToolSchema.kt @@ -0,0 +1,175 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.contextvm.cep15CommonSchemas + +import com.vitorpamplona.quartz.contextvm.core.CvmTags +import com.vitorpamplona.quartz.contextvm.json.toPlainJson +import com.vitorpamplona.quartz.nip01Core.core.Tag +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.utils.jcs.JsonCanonicalization +import com.vitorpamplona.quartz.utils.sha256.sha256 +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject + +/** + * CEP-15 common tool schemas. + * + * Two servers implementing the same tool interface produce the same + * `schemaHash`, so a client can recognise an equivalent service across + * providers and switch between them without code changes. That only works if + * documentation differences are stripped before hashing, which is what + * [normalize] does. + * + * The rule that matters most on the client side: the advertised hash is a + * **verification target, not a label**. [verify] recomputes it from the tool + * definition; trusting the advertised value would give up everything the CEP + * provides. + */ +object CommonToolSchema { + const val META_NAMESPACE = CvmTags.COMMON_SCHEMA_NAMESPACE + const val SCHEMA_HASH = "schemaHash" + + const val NAME = "name" + const val INPUT_SCHEMA = "inputSchema" + const val OUTPUT_SCHEMA = "outputSchema" + const val META = "_meta" + + /** + * Annotation and documentation keywords removed at every nesting level. + * + * These carry no structural meaning, so two providers describing the same + * interface differently must still agree on the hash. + */ + val ANNOTATION_KEYWORDS = + setOf( + "title", + "description", + "examples", + "default", + "deprecated", + "readOnly", + "writeOnly", + ) + + /** Vendor extensions are stripped too, by prefix. */ + const val VENDOR_PREFIX = "x-" + + /** + * Strips annotation and vendor keywords recursively. + * + * This applies only to the hashed representation — the tool definition a + * server actually returns from `tools/list` is untouched. + */ + fun normalize(schema: JsonElement): JsonElement = + when (schema) { + is JsonObject -> + buildJsonObject { + schema.forEach { (key, value) -> + if (key !in ANNOTATION_KEYWORDS && !key.startsWith(VENDOR_PREFIX)) { + put(key, normalize(value)) + } + } + } + + is JsonArray -> buildJsonArray { schema.forEach { add(normalize(it)) } } + + else -> schema + } + + /** + * The schema hash: `sha256(JCS({name, inputSchema, outputSchema?}))`, hex. + * + * The tool name is part of the payload on purpose — MCP invokes tools by + * name, so a shared hash is only useful if the name is shared too. + */ + fun hash( + name: String, + inputSchema: JsonElement, + outputSchema: JsonElement? = null, + ): String { + val payload = + buildMap { + put(NAME, name) + put(INPUT_SCHEMA, normalize(inputSchema).toPlainJson()) + // Presence changes the hash, so an omitted output schema is not + // the same as an empty one. + if (outputSchema != null) put(OUTPUT_SCHEMA, normalize(outputSchema).toPlainJson()) + } + + return sha256(JsonCanonicalization.canonicalize(payload).encodeToByteArray()).toHexKey() + } + + /** Computes the hash from a `tools/list` tool definition. */ + fun hashOf(tool: JsonObject): String { + val name = + (tool[NAME] as? JsonPrimitive)?.takeIf { it.isString }?.content + ?: throw IllegalArgumentException("tool definition requires a name") + val input = tool[INPUT_SCHEMA] ?: throw IllegalArgumentException("tool definition requires an inputSchema") + return hash(name, input, tool[OUTPUT_SCHEMA]) + } + + /** The hash a server claims in `_meta`, or null when the tool is bespoke. */ + fun advertisedHash(tool: JsonObject): String? { + val meta = tool[META] as? JsonObject ?: return null + val namespace = meta[META_NAMESPACE] as? JsonObject ?: return null + return (namespace[SCHEMA_HASH] as? JsonPrimitive)?.takeIf { it.isString }?.content + } + + /** + * True when the tool advertises a common schema whose hash matches what its + * own definition produces. + * + * Returns false for a mismatch rather than throwing: a server advertising a + * wrong hash is a tool to ignore, not a session to fail. + */ + fun verify(tool: JsonObject): Boolean { + val advertised = advertisedHash(tool) ?: return false + return advertised.equals(hashOf(tool), ignoreCase = true) + } + + /** Builds the `_meta` block a server attaches to a common-schema tool. */ + fun metaFor(schemaHash: String): JsonObject = + buildJsonObject { + put( + META_NAMESPACE, + buildJsonObject { put(SCHEMA_HASH, JsonPrimitive(schemaHash)) }, + ) + } + + /** NIP-73 `["i", "", ""]` marker for an implemented schema. */ + fun externalIdTag( + schemaHash: String, + toolName: String, + ): Tag = arrayOf(CvmTags.EXTERNAL_ID, schemaHash, toolName) + + /** NIP-73 `["k", "io.contextvm/common-schema"]`; one per announcement event. */ + fun externalKindTag(): Tag = arrayOf(CvmTags.EXTERNAL_KIND, META_NAMESPACE) + + /** Reads `(schemaHash, toolName)` pairs off an announcement's `i` tags. */ + fun parseExternalIds(tags: Array): List> = + tags + .filter { it.size >= 2 && it[0] == CvmTags.EXTERNAL_ID } + .map { it[1] to it.getOrNull(2) } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep17RelayList/ServerRelay.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep17RelayList/ServerRelay.kt new file mode 100644 index 0000000000..2eb5131b57 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep17RelayList/ServerRelay.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.contextvm.cep17RelayList + +import com.vitorpamplona.quartz.contextvm.core.CvmKinds +import com.vitorpamplona.quartz.contextvm.core.CvmTags +import com.vitorpamplona.quartz.nip01Core.core.Event + +/** + * A relay a server is reachable on (CEP-17, NIP-65 kind 10002). + * + * The ContextVM profile publishes unmarked tags, meaning the relay serves both + * directions. Markers are honoured when present but are not the norm here. + */ +data class ServerRelay( + val url: String, + val read: Boolean = true, + val write: Boolean = true, +) { + companion object { + const val READ = "read" + const val WRITE = "write" + + fun parseAll(event: Event): List { + if (event.kind != CvmKinds.RELAY_LIST) return emptyList() + return event.tags + .filter { it.size >= 2 && it[0] == CvmTags.RELAY && it[1].isNotBlank() } + .map { tag -> + when (tag.getOrNull(2)) { + READ -> ServerRelay(tag[1], read = true, write = false) + WRITE -> ServerRelay(tag[1], read = false, write = true) + // Unmarked is the recommended ContextVM profile: the + // relay is usable for both publishing and subscribing. + else -> ServerRelay(tag[1]) + } + } + } + + /** + * Relays usable for a full request/response exchange. + * + * A read-only or write-only relay cannot carry both halves, and since + * kind 25910 is ephemeral there is no fetching a response later from + * somewhere else. + */ + fun operational(relays: List) = relays.filter { it.read && it.write } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep22OversizedTransfer/OversizedFrame.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep22OversizedTransfer/OversizedFrame.kt new file mode 100644 index 0000000000..a40cbe679b --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep22OversizedTransfer/OversizedFrame.kt @@ -0,0 +1,215 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.contextvm.cep22OversizedTransfer + +import com.vitorpamplona.quartz.contextvm.transfer.ProgressEnvelope +import com.vitorpamplona.quartz.contextvm.transfer.ProgressEnvelope.Companion.asLongOrNull +import com.vitorpamplona.quartz.contextvm.transfer.ProgressEnvelope.Companion.asStringOrNull +import com.vitorpamplona.quartz.contextvm.transfer.ProgressToken +import com.vitorpamplona.quartz.contextvm.transfer.TransferFrameException +import kotlinx.serialization.json.JsonObjectBuilder +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject + +/** + * A CEP-22 bounded oversized-transfer frame. + * + * The payload is reassembled from the `data` fragments of [Chunk] frames, which + * are **raw substrings of the serialized JSON-RPC message**, not base64. That is + * what makes the digest rule work: concatenate the fragments, encode the result + * as UTF-8, and hash those bytes. + */ +sealed interface OversizedFrame { + val envelope: ProgressEnvelope + val token: ProgressToken get() = envelope.token + val progress: Double get() = envelope.progress + + data class Start( + override val envelope: ProgressEnvelope, + val completionMode: String, + val digest: String, + val totalBytes: Long, + val totalChunks: Long, + ) : OversizedFrame + + data class Accept( + override val envelope: ProgressEnvelope, + ) : OversizedFrame + + data class Chunk( + override val envelope: ProgressEnvelope, + val data: String, + ) : OversizedFrame + + data class End( + override val envelope: ProgressEnvelope, + ) : OversizedFrame + + data class Abort( + override val envelope: ProgressEnvelope, + val reason: String? = null, + ) : OversizedFrame + + companion object { + const val START = "start" + const val ACCEPT = "accept" + const val CHUNK = "chunk" + const val END = "end" + const val ABORT = "abort" + + /** The only completion mode this CEP version defines. */ + const val COMPLETION_MODE_RENDER = "render" + + /** Digest values are algorithm-prefixed on the wire, e.g. `sha256:ab12…`. */ + const val DIGEST_PREFIX_SHA256 = "sha256:" + + const val COMPLETION_MODE = "completionMode" + const val DIGEST = "digest" + const val TOTAL_BYTES = "totalBytes" + const val TOTAL_CHUNKS = "totalChunks" + const val DATA = "data" + const val REASON = "reason" + + /** + * Parses a CEP-22 frame, or returns null when the envelope belongs to a + * different transfer profile (CEP-41 shares this envelope). + */ + fun parseOrNull(envelope: ProgressEnvelope): OversizedFrame? { + if (envelope.type != ProgressEnvelope.TYPE_OVERSIZED) return null + val cvm = envelope.cvm + + return when (val frameType = envelope.frameType) { + START -> { + val completionMode = + cvm[COMPLETION_MODE]?.asStringOrNull() + ?: throw TransferFrameException("start requires completionMode") + // Receivers MUST reject unknown or unsupported completion + // modes; the field exists as an extension point for future + // CEPs, so silently treating anything else as render would + // be the wrong kind of tolerance. + if (completionMode != COMPLETION_MODE_RENDER) { + throw TransferFrameException("unsupported completionMode: $completionMode") + } + OversizedFrame.Start( + envelope = envelope, + completionMode = completionMode, + digest = + cvm[DIGEST]?.asStringOrNull() + ?: throw TransferFrameException("start requires digest"), + totalBytes = + cvm[TOTAL_BYTES]?.asLongOrNull() + ?: throw TransferFrameException("start requires totalBytes"), + totalChunks = + cvm[TOTAL_CHUNKS]?.asLongOrNull() + ?: throw TransferFrameException("start requires totalChunks"), + ) + } + + ACCEPT -> OversizedFrame.Accept(envelope) + + CHUNK -> + OversizedFrame.Chunk( + envelope = envelope, + data = + cvm[DATA]?.asStringOrNull() + ?: throw TransferFrameException("chunk requires string data"), + ) + + END -> OversizedFrame.End(envelope) + + ABORT -> OversizedFrame.Abort(envelope, cvm[REASON]?.asStringOrNull()) + + else -> throw TransferFrameException("unknown oversized frameType: $frameType") + } + } + + fun start( + token: ProgressToken, + progress: Double, + digest: String, + totalBytes: Long, + totalChunks: Long, + message: String? = null, + ) = OversizedFrame.Start( + envelope = + envelopeOf(START, token, progress, message) { + put(COMPLETION_MODE, JsonPrimitive(COMPLETION_MODE_RENDER)) + put(DIGEST, JsonPrimitive(digest)) + put(TOTAL_BYTES, JsonPrimitive(totalBytes)) + put(TOTAL_CHUNKS, JsonPrimitive(totalChunks)) + }, + completionMode = COMPLETION_MODE_RENDER, + digest = digest, + totalBytes = totalBytes, + totalChunks = totalChunks, + ) + + fun accept( + token: ProgressToken, + progress: Double, + ) = OversizedFrame.Accept(envelopeOf(ACCEPT, token, progress)) + + fun chunk( + token: ProgressToken, + progress: Double, + data: String, + ) = OversizedFrame.Chunk( + envelope = envelopeOf(CHUNK, token, progress) { put(DATA, JsonPrimitive(data)) }, + data = data, + ) + + fun end( + token: ProgressToken, + progress: Double, + message: String? = null, + ) = OversizedFrame.End(envelopeOf(END, token, progress, message)) + + fun abort( + token: ProgressToken, + progress: Double, + reason: String? = null, + ) = OversizedFrame.Abort( + envelope = + envelopeOf(ABORT, token, progress) { + reason?.let { put(REASON, JsonPrimitive(it)) } + }, + reason = reason, + ) + + private fun envelopeOf( + frameType: String, + token: ProgressToken, + progress: Double, + message: String? = null, + body: JsonObjectBuilder.() -> Unit = {}, + ) = ProgressEnvelope( + token = token, + progress = progress, + message = message, + cvm = + buildJsonObject { + put(ProgressEnvelope.TYPE, JsonPrimitive(ProgressEnvelope.TYPE_OVERSIZED)) + put(ProgressEnvelope.FRAME_TYPE, JsonPrimitive(frameType)) + body() + }, + ) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep22OversizedTransfer/OversizedTransferReceiver.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep22OversizedTransfer/OversizedTransferReceiver.kt new file mode 100644 index 0000000000..bd7c4b5598 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep22OversizedTransfer/OversizedTransferReceiver.kt @@ -0,0 +1,232 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.contextvm.cep22OversizedTransfer + +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcCodec +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcMessage +import com.vitorpamplona.quartz.contextvm.transfer.ProgressToken +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.utils.sha256.sha256 + +/** + * Admission-control limits for one receiver. + * + * CEP-22 requires evaluating the declared `totalBytes` and `totalChunks` against + * local policy **before** committing reassembly state, so a peer cannot make us + * allocate by announcing a huge transfer. + */ +data class OversizedLimits( + val maxTotalBytes: Long = 8L * 1024 * 1024, + val maxTotalChunks: Long = 4_096, + /** + * The most chunks one transfer may consist of. + * + * It bounds memory, not a reordering window. Relay delivery can reorder + * and assembly is by `progress`, so every chunk has to be held until + * `end` — which means nothing is ever released early and this is the + * effective ceiling on a whole transfer. Bounded because otherwise a peer + * parks unbounded memory by withholding one low-`progress` chunk. + * + * Enforced at `start` as well as per chunk, so an over-large transfer is + * refused before any bytes move. Keep it at or below [maxTotalChunks], + * which bounds what a peer may *declare*; the larger of the two is + * otherwise decoration. + */ + val maxPendingChunks: Int = 256, +) + +/** Outcome of feeding one frame to [OversizedTransferReceiver]. */ +sealed interface OversizedProgressResult { + /** Frame accepted; the transfer continues. Nothing is surfaced upward yet. */ + data object Continue : OversizedProgressResult + + /** The receiver should send an `accept` frame before chunks may flow. */ + data object SendAccept : OversizedProgressResult + + /** The transfer completed and validated. [message] is the reassembled payload. */ + data class Completed( + val message: JsonRpcMessage, + val raw: String, + ) : OversizedProgressResult + + /** The transfer ended without producing a payload. */ + data class Aborted( + val reason: String?, + ) : OversizedProgressResult +} + +/** Thrown when a transfer violates CEP-22. The transfer is terminal once this is raised. */ +class OversizedTransferException( + message: String, +) : IllegalStateException(message) + +/** + * Receives one CEP-22 bounded transfer, keyed by its `progressToken`. + * + * The rules this enforces are §6.5 `CVM-22-*` in + * `quartz/plans/2026-09-17-cordn-interop.md`. Two are worth stating here because + * they shape the design: + * + * - **Nothing is surfaced upward before validation succeeds.** The reassembled + * string only becomes a message after the chunk count, byte length and digest + * all agree, so a partially delivered payload can never be mistaken for a + * complete one. + * - **The digest covers the exact serialized string.** Fragments are + * concatenated and encoded to UTF-8 once; the payload is never parsed and + * re-serialized on the way, because that would reorder keys or change + * whitespace and break the hash. + * + * @param requireAccept true in stateless bootstrap, where the sender must wait + * for `accept` before the first chunk. When peer support is already known the + * sender may go straight from `start` to `chunk`, and this is false. + */ +class OversizedTransferReceiver( + val token: ProgressToken, + private val limits: OversizedLimits = OversizedLimits(), + private val requireAccept: Boolean = true, +) { + private var started: OversizedFrame.Start? = null + private var accepted = false + private var terminal = false + + /** Fragments keyed by `progress`, so out-of-order arrivals assemble correctly. */ + private val chunks = mutableMapOf() + + val isTerminal get() = terminal + + fun accept(frame: OversizedFrame): OversizedProgressResult { + if (terminal) throw OversizedTransferException("frame received after the transfer ended") + if (frame.token != token) throw OversizedTransferException("frame belongs to another transfer") + + if (frame is OversizedFrame.Abort) { + terminal = true + return OversizedProgressResult.Aborted(frame.reason) + } + + return when (frame) { + is OversizedFrame.Start -> onStart(frame) + is OversizedFrame.Accept -> OversizedProgressResult.Continue + is OversizedFrame.Chunk -> onChunk(frame) + is OversizedFrame.End -> onEnd(frame) + is OversizedFrame.Abort -> error("handled above") + } + } + + private fun onStart(frame: OversizedFrame.Start): OversizedProgressResult { + if (started != null) fail("a second start frame arrived for this transfer") + + // Admission control runs before any state is committed. + if (frame.totalBytes < 0) fail("totalBytes must not be negative") + if (frame.totalChunks < 0) fail("totalChunks must not be negative") + if (frame.totalBytes > limits.maxTotalBytes) { + fail("declared totalBytes ${frame.totalBytes} exceeds the limit ${limits.maxTotalBytes}") + } + if (frame.totalChunks > limits.maxTotalChunks) { + fail("declared totalChunks ${frame.totalChunks} exceeds the limit ${limits.maxTotalChunks}") + } + // Checked here, not only per chunk. Every chunk stays buffered until + // `end` — assembly is by `progress`, and relays reorder, so nothing can + // be released early — which makes [maxPendingChunks] the real ceiling + // on a transfer rather than a window within one. Leaving it to the + // per-chunk check would admit a transfer at `start`, let the sender + // push a few megabytes, and then fail it at chunk 257 every single + // time: the same refusal, paid for. + if (frame.totalChunks > limits.maxPendingChunks) { + fail("declared totalChunks ${frame.totalChunks} exceeds the buffer limit ${limits.maxPendingChunks}") + } + if (!frame.digest.startsWith(OversizedFrame.DIGEST_PREFIX_SHA256)) { + fail("unsupported digest algorithm: ${frame.digest.substringBefore(':')}") + } + + started = frame + return if (requireAccept) OversizedProgressResult.SendAccept else OversizedProgressResult.Continue + } + + private fun onChunk(frame: OversizedFrame.Chunk): OversizedProgressResult { + val start = started ?: fail("chunk arrived before start") + + if (requireAccept && !accepted) { + // In stateless bootstrap the sender must not transmit before the + // receiver confirms. Seeing a chunk first means the peer skipped a + // step the profile requires, so the transfer cannot be trusted. + fail("chunk arrived before accept in a bootstrap transfer") + } + if (chunks.size >= limits.maxPendingChunks) { + fail("buffered chunk count exceeds the limit ${limits.maxPendingChunks}") + } + if (chunks.size.toLong() >= start.totalChunks) { + fail("more chunks arrived than the declared totalChunks ${start.totalChunks}") + } + // `progress` is the assembly index, so a chunk must sort after `start`. + // Arrival order is NOT checked: relays reorder, and the CEP explicitly + // says progress is not a guarantee of arrival order. Out-of-order + // frames are buffered and assembled by progress at `end`. + if (frame.progress <= start.progress) { + fail("chunk progress ${frame.progress} does not follow start at ${start.progress}") + } + if (chunks.put(frame.progress, frame.data) != null) { + fail("duplicate chunk at progress ${frame.progress}") + } + return OversizedProgressResult.Continue + } + + private fun onEnd(frame: OversizedFrame.End): OversizedProgressResult { + val start = started ?: fail("end arrived before start") + + val highestChunk = chunks.keys.maxOrNull() + if (highestChunk != null && frame.progress <= highestChunk) { + fail("end progress ${frame.progress} does not follow the last chunk at $highestChunk") + } + if (frame.progress <= start.progress) { + fail("end progress ${frame.progress} does not follow start at ${start.progress}") + } + + if (chunks.size.toLong() != start.totalChunks) { + fail("expected ${start.totalChunks} chunks but assembled ${chunks.size}") + } + + // `progress` is the canonical assembly index, not arrival order. + val raw = chunks.entries.sortedBy { it.key }.joinToString("") { it.value } + val bytes = raw.encodeToByteArray() + + if (bytes.size.toLong() != start.totalBytes) { + fail("reassembled ${bytes.size} bytes but start declared ${start.totalBytes}") + } + + val actual = OversizedFrame.DIGEST_PREFIX_SHA256 + sha256(bytes).toHexKey() + if (!actual.equals(start.digest, ignoreCase = true)) { + fail("digest mismatch: expected ${start.digest} but reassembled $actual") + } + + terminal = true + return OversizedProgressResult.Completed(JsonRpcCodec.decode(raw), raw) + } + + /** Records that we sent `accept`, unblocking chunk frames. */ + fun markAccepted() { + accepted = true + } + + private fun fail(reason: String): Nothing { + terminal = true + throw OversizedTransferException(reason) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep22OversizedTransfer/OversizedTransferSender.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep22OversizedTransfer/OversizedTransferSender.kt new file mode 100644 index 0000000000..46dd94c964 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep22OversizedTransfer/OversizedTransferSender.kt @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.contextvm.cep22OversizedTransfer + +import com.vitorpamplona.quartz.contextvm.transfer.ProgressToken +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.utils.sha256.sha256 + +/** + * Splits an oversized serialized JSON-RPC message into CEP-22 frames. + * + * Two subtleties the chunking has to respect: + * + * - **Relay limits apply to the whole serialized event**, not just `content`, + * and roughly 64 KiB is the practical ceiling. [DEFAULT_CHUNK_CHARS] leaves + * generous room for the JSON-RPC envelope, the event's tags and signature, + * and any gift wrap around it. + * - **A chunk boundary must not split a surrogate pair.** Fragments are + * concatenated as text and only then encoded to UTF-8 for the digest, so + * cutting a pair in half would corrupt the reassembled bytes even though each + * fragment still looks like a valid JSON string. + */ +class OversizedTransferSender( + private val chunkChars: Int = DEFAULT_CHUNK_CHARS, +) { + init { + require(chunkChars > 1) { "chunkChars must leave room for a surrogate pair" } + } + + /** + * Frames [serialized] for [token], starting at `progress` 1. + * + * The returned list is `start`, then the chunks, then `end`. When the peer's + * support is not yet known the caller must withhold everything after `start` + * until the peer's `accept` arrives; the frames themselves are unchanged. + */ + fun frame( + token: ProgressToken, + serialized: String, + ): List { + val pieces = split(serialized) + val bytes = serialized.encodeToByteArray() + val digest = OversizedFrame.DIGEST_PREFIX_SHA256 + sha256(bytes).toHexKey() + + var progress = 1.0 + val frames = mutableListOf() + frames += + OversizedFrame.start( + token = token, + progress = progress, + digest = digest, + totalBytes = bytes.size.toLong(), + totalChunks = pieces.size.toLong(), + ) + + pieces.forEach { piece -> + progress += 1.0 + frames += OversizedFrame.chunk(token, progress, piece) + } + + frames += OversizedFrame.end(token, progress + 1.0) + return frames + } + + /** True when [serialized] is large enough to be worth fragmenting. */ + fun shouldFragment(serialized: String) = serialized.length > chunkChars + + private fun split(text: String): List { + if (text.isEmpty()) return emptyList() + + val pieces = mutableListOf() + var index = 0 + while (index < text.length) { + var end = minOf(index + chunkChars, text.length) + // Never cut between a high and low surrogate: the two halves would + // each survive JSON encoding but rejoin into a different codepoint. + if (end < text.length && text[end - 1].isHighSurrogate()) end -= 1 + pieces += text.substring(index, end) + index = end + } + return pieces + } + + companion object { + /** + * Conservative fragment size in UTF-16 characters. + * + * Well below the ~64 KiB practical relay ceiling because the fragment is + * only part of what ships: it is wrapped in a progress notification, then + * an event with tags and a signature, then possibly a gift wrap. + */ + const val DEFAULT_CHUNK_CHARS = 16 * 1024 + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep24Reviews/ServerReview.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep24Reviews/ServerReview.kt new file mode 100644 index 0000000000..239b4ce5e7 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep24Reviews/ServerReview.kt @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.contextvm.cep24Reviews + +import com.vitorpamplona.quartz.contextvm.core.CvmKinds +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Tag + +/** CEP-24 server reviews: NIP-22 comments anchored to a kind-11316 announcement. */ +object ServerReview { + /** The addressable coordinate a review targets. */ + fun coordinate(serverPubKey: HexKey) = "${CvmKinds.SERVER_ANNOUNCEMENT}:$serverPubKey:" + + /** + * Tags for a top-level review. + * + * NIP-22 uses uppercase tags for the root and lowercase for the immediate + * parent; for a top-level comment both are the announcement, hence the + * apparent duplication. + */ + fun topLevelTags( + serverPubKey: HexKey, + relayHint: String? = null, + announcementEventId: HexKey? = null, + ): List { + val coordinate = coordinate(serverPubKey) + val kind = CvmKinds.SERVER_ANNOUNCEMENT.toString() + return buildList { + add(tagOf("A", coordinate, relayHint)) + add(arrayOf("K", kind)) + add(tagOf("P", serverPubKey, relayHint)) + add(tagOf("a", coordinate, relayHint)) + announcementEventId?.let { add(arrayOf("e", it, relayHint ?: "", serverPubKey)) } + add(arrayOf("k", kind)) + add(tagOf("p", serverPubKey, relayHint)) + } + } + + /** + * Tags for a reply to an existing review. + * + * The uppercase root stays on the announcement while the lowercase parent + * moves to the comment being answered — that asymmetry is the whole point of + * NIP-22's dual tagging and is easy to get wrong. + */ + fun replyTags( + serverPubKey: HexKey, + parentCommentId: HexKey, + parentAuthor: HexKey, + relayHint: String? = null, + ): List = + buildList { + add(tagOf("A", coordinate(serverPubKey), relayHint)) + add(arrayOf("K", CvmKinds.SERVER_ANNOUNCEMENT.toString())) + add(tagOf("P", serverPubKey, relayHint)) + add(arrayOf("e", parentCommentId, relayHint ?: "", parentAuthor)) + add(arrayOf("k", CvmKinds.REVIEW.toString())) + add(tagOf("p", parentAuthor, relayHint)) + } + + private fun tagOf( + name: String, + value: String, + relayHint: String?, + ): Tag = if (relayHint != null) arrayOf(name, value, relayHint) else arrayOf(name, value) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep35Discovery/SessionDiscovery.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep35Discovery/SessionDiscovery.kt new file mode 100644 index 0000000000..65157e496c --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep35Discovery/SessionDiscovery.kt @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.contextvm.cep35Discovery + +import com.vitorpamplona.quartz.contextvm.cep06Announcements.DiscoverySurface +import com.vitorpamplona.quartz.nip01Core.core.Tag + +/** + * Per-session capability learning (CEP-35). + * + * Discovery is a first-message exchange in each direction, not an + * initialize-only step: a server-to-client message that is not an initialize + * response may still carry the server's baseline. After that exchange, later + * feature tags are message-local and do not mutate the baseline unless a + * feature-specific CEP says they do — CEP-8's `payment_interaction` upsert being + * the one that does. + */ +class SessionDiscovery { + private var baseline: DiscoverySurface? = null + + /** The peer's learned baseline, or null before their first message. */ + val peer get() = baseline + + val hasLearned get() = baseline != null + + /** + * The baseline, but only when the peer actually declared something. + * + * Null both before the peer's first message and when that message carried + * no discovery tags — two different facts a caller cannot act on + * differently, because both mean "this peer has told us nothing". Negotiate + * off this, not off [peer]: see [DiscoverySurface.declaresNothing] for the + * live case that makes the difference. + */ + val declaredPeer get() = baseline?.takeIf { !it.declaresNothing } + + /** + * Applies a peer message's tags. + * + * The first one establishes the baseline; later ones are returned for + * message-local interpretation but leave the baseline alone. + */ + fun observe(tags: Array): DiscoverySurface { + val surface = DiscoverySurface.parse(tags) + if (baseline == null) baseline = surface + return surface + } + + /** Replaces the baseline outright. For a feature CEP that defines an update. */ + fun replaceBaseline(tags: Array) { + baseline = DiscoverySurface.parse(tags) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep41OpenStreams/OpenStreamFrame.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep41OpenStreams/OpenStreamFrame.kt new file mode 100644 index 0000000000..ebfcdf552b --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep41OpenStreams/OpenStreamFrame.kt @@ -0,0 +1,234 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.contextvm.cep41OpenStreams + +import com.vitorpamplona.quartz.contextvm.transfer.ProgressEnvelope +import com.vitorpamplona.quartz.contextvm.transfer.ProgressEnvelope.Companion.asLongOrNull +import com.vitorpamplona.quartz.contextvm.transfer.ProgressEnvelope.Companion.asStringOrNull +import com.vitorpamplona.quartz.contextvm.transfer.ProgressToken +import com.vitorpamplona.quartz.contextvm.transfer.TransferFrameException +import kotlinx.serialization.json.JsonObjectBuilder +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject + +/** + * A CEP-41 open-ended stream frame. + * + * The profile carries **two** independent ordering fields and conflating them is + * the classic implementation bug: + * + * - `progress` (on the envelope) orders *all* frames, control frames included, + * and is explicitly not a chunk counter. Each peer numbers its own outbound + * frames from 1, so a value may only be compared against frames from the same + * sender. + * - [Chunk.chunkIndex] starts at 0, increases contiguously, and is what + * validates payload completeness. + */ +sealed interface OpenStreamFrame { + val envelope: ProgressEnvelope + val token: ProgressToken get() = envelope.token + val progress: Double get() = envelope.progress + + data class Start( + override val envelope: ProgressEnvelope, + ) : OpenStreamFrame + + data class Accept( + override val envelope: ProgressEnvelope, + ) : OpenStreamFrame + + data class Chunk( + override val envelope: ProgressEnvelope, + val data: String, + val chunkIndex: Long, + ) : OpenStreamFrame + + data class Ping( + override val envelope: ProgressEnvelope, + val nonce: String, + ) : OpenStreamFrame + + data class Pong( + override val envelope: ProgressEnvelope, + val nonce: String, + ) : OpenStreamFrame + + /** + * Successful closure. + * + * [lastChunkIndex] is present only when the sender is declaring a + * completeness bound; a live, open-ended feed omits it, and a stream that + * carried no chunks MUST omit it. + */ + data class Close( + override val envelope: ProgressEnvelope, + val lastChunkIndex: Long? = null, + ) : OpenStreamFrame + + data class Abort( + override val envelope: ProgressEnvelope, + val reason: String? = null, + ) : OpenStreamFrame + + companion object { + const val START = "start" + const val ACCEPT = "accept" + const val CHUNK = "chunk" + const val PING = "ping" + const val PONG = "pong" + const val CLOSE = "close" + const val ABORT = "abort" + + const val DATA = "data" + const val CHUNK_INDEX = "chunkIndex" + const val NONCE = "nonce" + const val LAST_CHUNK_INDEX = "lastChunkIndex" + const val REASON = "reason" + + /** Receivers SHOULD enforce this ceiling and MAY reject oversized nonces. */ + const val MAX_NONCE_BYTES = 64 + + /** Parses a CEP-41 frame, or null when the envelope is a different profile. */ + fun parseOrNull(envelope: ProgressEnvelope): OpenStreamFrame? { + if (envelope.type != ProgressEnvelope.TYPE_OPEN_STREAM) return null + val cvm = envelope.cvm + + return when (val frameType = envelope.frameType) { + START -> Start(envelope) + ACCEPT -> Accept(envelope) + + CHUNK -> + Chunk( + envelope = envelope, + data = + cvm[DATA]?.asStringOrNull() + ?: throw TransferFrameException("chunk requires string data"), + chunkIndex = + cvm[CHUNK_INDEX]?.asLongOrNull() + ?: throw TransferFrameException("chunk requires chunkIndex"), + ) + + PING -> Ping(envelope, requireNonce(cvm[NONCE]?.asStringOrNull(), PING)) + PONG -> Pong(envelope, requireNonce(cvm[NONCE]?.asStringOrNull(), PONG)) + + CLOSE -> Close(envelope, cvm[LAST_CHUNK_INDEX]?.asLongOrNull()) + + ABORT -> Abort(envelope, cvm[REASON]?.asStringOrNull()) + + else -> throw TransferFrameException("unknown open-stream frameType: $frameType") + } + } + + private fun requireNonce( + nonce: String?, + frameType: String, + ): String { + if (nonce == null) throw TransferFrameException("$frameType requires a nonce") + if (nonce.encodeToByteArray().size > MAX_NONCE_BYTES) { + throw TransferFrameException("$frameType nonce exceeds $MAX_NONCE_BYTES bytes") + } + return nonce + } + + fun start( + token: ProgressToken, + progress: Double, + ) = Start(envelopeOf(START, token, progress)) + + fun accept( + token: ProgressToken, + progress: Double, + ) = Accept(envelopeOf(ACCEPT, token, progress)) + + fun chunk( + token: ProgressToken, + progress: Double, + chunkIndex: Long, + data: String, + ) = Chunk( + envelope = + envelopeOf(CHUNK, token, progress) { + put(CHUNK_INDEX, JsonPrimitive(chunkIndex)) + put(DATA, JsonPrimitive(data)) + }, + data = data, + chunkIndex = chunkIndex, + ) + + fun ping( + token: ProgressToken, + progress: Double, + nonce: String, + ) = Ping( + envelope = envelopeOf(PING, token, progress) { put(NONCE, JsonPrimitive(nonce)) }, + nonce = nonce, + ) + + fun pong( + token: ProgressToken, + progress: Double, + nonce: String, + ) = Pong( + envelope = envelopeOf(PONG, token, progress) { put(NONCE, JsonPrimitive(nonce)) }, + nonce = nonce, + ) + + fun close( + token: ProgressToken, + progress: Double, + lastChunkIndex: Long? = null, + ) = Close( + envelope = + envelopeOf(CLOSE, token, progress) { + lastChunkIndex?.let { put(LAST_CHUNK_INDEX, JsonPrimitive(it)) } + }, + lastChunkIndex = lastChunkIndex, + ) + + fun abort( + token: ProgressToken, + progress: Double, + reason: String? = null, + ) = Abort( + envelope = + envelopeOf(ABORT, token, progress) { + reason?.let { put(REASON, JsonPrimitive(it)) } + }, + reason = reason, + ) + + private fun envelopeOf( + frameType: String, + token: ProgressToken, + progress: Double, + body: JsonObjectBuilder.() -> Unit = {}, + ) = ProgressEnvelope( + token = token, + progress = progress, + cvm = + buildJsonObject { + put(ProgressEnvelope.TYPE, JsonPrimitive(ProgressEnvelope.TYPE_OPEN_STREAM)) + put(ProgressEnvelope.FRAME_TYPE, JsonPrimitive(frameType)) + body() + }, + ) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep41OpenStreams/OpenStreamReceiver.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep41OpenStreams/OpenStreamReceiver.kt new file mode 100644 index 0000000000..251a11ce30 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/cep41OpenStreams/OpenStreamReceiver.kt @@ -0,0 +1,259 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.contextvm.cep41OpenStreams + +import com.vitorpamplona.quartz.contextvm.transfer.ProgressToken + +/** Local resource and keepalive policy for one stream. */ +data class OpenStreamPolicy( + /** + * Idle time before the peer must be probed with a `ping`. + * + * 30s matches what deployed clients use. A shorter window (the 20s some + * SDKs default to) turns a single lost relay round-trip into a stream abort. + */ + val idleTimeoutMs: Long = 30_000, + /** How long a `ping` may go unanswered before the stream is failed. */ + val probeTimeoutMs: Long = 30_000, + /** Bounded buffering for chunks that arrive before their predecessors. */ + val maxPendingChunks: Int = 256, +) + +/** Outcome of feeding one inbound frame to [OpenStreamReceiver]. */ +sealed interface OpenStreamEvent { + /** Frame accepted; nothing to deliver or answer. */ + data object Continue : OpenStreamEvent + + /** The receiver must send an `accept` before the peer may send chunks. */ + data object SendAccept : OpenStreamEvent + + /** Payload fragments that became contiguous, in `chunkIndex` order. */ + data class Delivered( + val fragments: List, + ) : OpenStreamEvent + + /** + * The peer probed us and MUST receive a `pong` carrying the same nonce. + * + * The response rides our own outbound progress sequence, which is why the + * caller supplies the progress value rather than this class. + */ + data class Pong( + val nonce: String, + ) : OpenStreamEvent + + /** + * The peer closed the stream. + * + * This does **not** complete the originating JSON-RPC request: CEP-41 + * requires a final response as well, and a client must never synthesize + * success from `close` alone. + */ + data class Closed( + val lastChunkIndex: Long?, + ) : OpenStreamEvent + + data class Aborted( + val reason: String?, + ) : OpenStreamEvent +} + +/** Thrown when a stream violates CEP-41. The stream is terminal once this is raised. */ +class OpenStreamException( + message: String, +) : IllegalStateException(message) + +/** + * Receives one CEP-41 open-ended stream, keyed by its `progressToken`. + * + * Rules are §6.5 `CVM-41-*` in `quartz/plans/2026-09-17-cordn-interop.md`. + * + * @param requireAccept true in stateless bootstrap, where the peer must wait for + * our `accept` before its first chunk. + * @param now injectable clock (epoch millis) so keepalive is testable without + * real time passing. + */ +class OpenStreamReceiver( + val token: ProgressToken, + private val policy: OpenStreamPolicy = OpenStreamPolicy(), + private val requireAccept: Boolean = true, + private val now: () -> Long = { 0L }, +) { + private var started = false + private var startProgress: Double? = null + private var accepted = false + private var terminal = false + + /** Every inbound `progress` seen, to reject replays without assuming arrival order. */ + private val seenProgress = mutableSetOf() + + private val pending = mutableMapOf() + private var nextIndex = 0L + private var highestIndex: Long? = null + + /** Nonces of pings we sent that are still awaiting a pong, with their send time. */ + private val outstandingPings = mutableMapOf() + + // Starts now, not at zero. Against a real clock `now() - 0` is thirty-odd + // years, so a receiver that had not yet seen a single frame reported + // itself idle and due a probe the instant it was built. + private var lastActivityMs = now() + + val isTerminal get() = terminal + + fun accept(frame: OpenStreamFrame): OpenStreamEvent { + if (terminal) throw OpenStreamException("frame received after the stream terminated") + if (frame.token != token) throw OpenStreamException("frame belongs to another stream") + + lastActivityMs = now() + + if (frame is OpenStreamFrame.Abort) { + terminal = true + return OpenStreamEvent.Aborted(frame.reason) + } + + // A repeated progress value from the same peer is a replay or a bug. + // Arrival *order* is deliberately not checked -- relays reorder, and the + // CEP makes chunkIndex, not progress, the completeness test. + if (!seenProgress.add(frame.progress)) { + fail("duplicate progress ${frame.progress} from the peer") + } + + return when (frame) { + is OpenStreamFrame.Start -> onStart(frame) + is OpenStreamFrame.Accept -> OpenStreamEvent.Continue + is OpenStreamFrame.Chunk -> onChunk(frame) + is OpenStreamFrame.Ping -> OpenStreamEvent.Pong(frame.nonce) + is OpenStreamFrame.Pong -> onPong(frame) + is OpenStreamFrame.Close -> onClose(frame) + is OpenStreamFrame.Abort -> error("handled above") + } + } + + private fun onStart(frame: OpenStreamFrame.Start): OpenStreamEvent { + // A second start for a live token MUST fail the stream. + if (started) fail("a second start arrived for a live stream") + started = true + startProgress = frame.progress + return if (requireAccept) OpenStreamEvent.SendAccept else OpenStreamEvent.Continue + } + + private fun onChunk(frame: OpenStreamFrame.Chunk): OpenStreamEvent { + if (!started) fail("chunk arrived before start") + if (requireAccept && !accepted) fail("chunk arrived before accept in a bootstrap stream") + + val start = startProgress + if (start != null && frame.progress <= start) { + fail("chunk progress ${frame.progress} does not follow start at $start") + } + if (frame.chunkIndex < nextIndex) { + fail("chunkIndex ${frame.chunkIndex} was already delivered") + } + if (pending.containsKey(frame.chunkIndex)) { + fail("duplicate chunkIndex ${frame.chunkIndex}") + } + if (pending.size >= policy.maxPendingChunks) { + fail("buffered chunk count exceeds the limit ${policy.maxPendingChunks}") + } + + pending[frame.chunkIndex] = frame.data + highestIndex = maxOf(highestIndex ?: frame.chunkIndex, frame.chunkIndex) + + // Release the contiguous run. A gap is not an error while the stream is + // live -- it is a provisional gap the peer may still fill. + val ready = mutableListOf() + while (true) { + val next = pending.remove(nextIndex) ?: break + ready += next + nextIndex += 1 + } + + return if (ready.isEmpty()) OpenStreamEvent.Continue else OpenStreamEvent.Delivered(ready) + } + + private fun onPong(frame: OpenStreamFrame.Pong): OpenStreamEvent { + // A pong for a nonce we never sent, or already retired, is not evidence + // of liveness. It is ignored rather than fatal: the CEP allows local + // anti-abuse policy, and failing the stream would let a third party + // disrupt it by replaying a stale pong. + outstandingPings.remove(frame.nonce) + return OpenStreamEvent.Continue + } + + private fun onClose(frame: OpenStreamFrame.Close): OpenStreamEvent { + if (!started) fail("close arrived before start") + + val bound = frame.lastChunkIndex + if (bound != null) { + if (highestIndex == null) { + // "If the stream included no chunk frames, close.lastChunkIndex + // MUST be omitted." + fail("close declared lastChunkIndex $bound but the stream carried no chunks") + } + if (bound != (nextIndex - 1)) { + fail("close declared lastChunkIndex $bound but ${nextIndex - 1} is the last contiguous index") + } + if (pending.isNotEmpty()) { + fail("close declared a completeness bound while ${pending.size} chunks remain unresolved") + } + } + + terminal = true + return OpenStreamEvent.Closed(bound) + } + + // --- keepalive --- + + /** True when the idle timeout has elapsed and the peer must be probed. */ + fun needsProbe(atMs: Long = now()) = !terminal && outstandingPings.isEmpty() && atMs - lastActivityMs >= policy.idleTimeoutMs + + /** Records that we sent a `ping` with [nonce], starting its probe window. */ + fun markProbeSent( + nonce: String, + atMs: Long = now(), + ) { + require(nonce.encodeToByteArray().size <= OpenStreamFrame.MAX_NONCE_BYTES) { + "nonce exceeds ${OpenStreamFrame.MAX_NONCE_BYTES} bytes" + } + outstandingPings[nonce] = atMs + } + + /** + * True when a probe went unanswered past the probe timeout. + * + * The caller MUST then fail the stream, and SHOULD send `abort` if it still + * can. + */ + fun probeExpired(atMs: Long = now()) = outstandingPings.values.any { atMs - it >= policy.probeTimeoutMs } + + /** Records that we sent `accept`, unblocking the peer's chunk frames. */ + fun markAccepted() { + accepted = true + } + + /** Marks the stream terminal after a local policy failure. */ + fun failLocally(reason: String): Nothing = fail(reason) + + private fun fail(reason: String): Nothing { + terminal = true + throw OpenStreamException(reason) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/core/CvmKinds.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/core/CvmKinds.kt new file mode 100644 index 0000000000..d8a7f0764e --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/core/CvmKinds.kt @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.contextvm.core + +import com.vitorpamplona.quartz.nip01Core.core.Kind +import com.vitorpamplona.quartz.nip01Core.core.isEphemeral + +/** + * Nostr event kinds used by ContextVM. + * + * Implemented from the ContextVM specification and its CEPs, not from any + * reference SDK source. See `quartz/plans/2026-09-17-cordn-interop.md` §6-§7 + * for the sourcing rule and the revision this targets. + */ +object CvmKinds { + /** + * The single kind carrying every ContextVM message. `content` is the + * stringified MCP JSON-RPC message; addressing and correlation live in tags. + * + * This kind is **ephemeral**, so relays are not expected to retain it. A + * client must already be subscribed when the peer publishes, because there + * is no fetch-after-the-fact recovery (rule `CVM-CORE-06`). + */ + const val MESSAGE: Kind = 25910 + + /** + * NIP-59 gift wrap carrying an encrypted [MESSAGE] (CEP-4). + * + * Not ephemeral: relays may retain the encrypted envelope. [EPHEMERAL_GIFT_WRAP] + * exists to avoid that. + */ + const val GIFT_WRAP: Kind = 1059 + + /** + * Ephemeral gift wrap (CEP-19) — identical structure and semantics to + * [GIFT_WRAP], but in NIP-01's ephemeral range so relays do not store the + * envelope either. + */ + const val EPHEMERAL_GIFT_WRAP: Kind = 21059 + + /** Addressable server announcement (CEP-6). `content` is the initialize result. */ + const val SERVER_ANNOUNCEMENT: Kind = 11316 + + /** Addressable `tools/list` announcement (CEP-6). */ + const val TOOLS_LIST: Kind = 11317 + + /** Addressable `resources/list` announcement (CEP-6). */ + const val RESOURCES_LIST: Kind = 11318 + + /** Addressable `resources/templates/list` announcement (CEP-6). */ + const val RESOURCE_TEMPLATES_LIST: Kind = 11319 + + /** Addressable `prompts/list` announcement (CEP-6). */ + const val PROMPTS_LIST: Kind = 11320 + + /** NIP-65 relay list metadata, reused by CEP-17 for server reachability. */ + const val RELAY_LIST: Kind = 10002 + + /** NIP-01 profile metadata, optionally published by servers (CEP-23). */ + const val PROFILE_METADATA: Kind = 0 + + /** NIP-22 comment, reused by CEP-24 for server reviews. */ + const val REVIEW: Kind = 1111 + + /** The announcement kinds a client subscribes to when discovering a server (CEP-6). */ + val ANNOUNCEMENTS = + intArrayOf( + SERVER_ANNOUNCEMENT, + TOOLS_LIST, + RESOURCES_LIST, + RESOURCE_TEMPLATES_LIST, + PROMPTS_LIST, + ) + + /** + * Both gift wrap kinds. A client subscribes to both regardless of which it + * sends: CEP-19 requires falling back to [GIFT_WRAP] for peers that do not + * advertise ephemeral support, so either may arrive. + */ + val GIFT_WRAPS = intArrayOf(GIFT_WRAP, EPHEMERAL_GIFT_WRAP) + + fun isGiftWrap(kind: Kind) = kind == GIFT_WRAP || kind == EPHEMERAL_GIFT_WRAP + + /** + * True when relays are not expected to retain this kind. + * + * [MESSAGE] and [EPHEMERAL_GIFT_WRAP] are ephemeral; [GIFT_WRAP] is not, + * which is the whole reason CEP-19 exists. + */ + fun isTransient(kind: Kind) = kind.isEphemeral() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/core/CvmMessageEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/core/CvmMessageEvent.kt new file mode 100644 index 0000000000..181bc55cac --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/core/CvmMessageEvent.kt @@ -0,0 +1,142 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.contextvm.core + +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcCodec +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcMessage +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Tag +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * A typed view over a ContextVM message event: kind 25910, carrying a + * stringified MCP JSON-RPC message in `content`. + * + * This wraps an [Event] rather than extending it. Quartz mints event subclasses + * through its own kind-to-class factory, which knows nothing about 25910, so an + * event signed or parsed anywhere is always a plain [Event] — a subclass would + * only ever exist where we constructed one by hand, and claiming a signer could + * return one is simply false. + * + * The ContextVM layering is deliberately thin: the MCP message is preserved + * byte-for-byte and only addressing and correlation move into tags, `p` for the + * peer and `e` for the request a response answers. + * + * `content` is a **JSON string**, not an embedded JSON object. The spec's + * examples show it unstringified for readability, which is an easy trap; rule + * `CVM-CORE-02` asserts the stringified form. + * + * This kind is ephemeral, so a response can only be received by a subscription + * that was already live when the peer published it (`CVM-CORE-06`). + */ +class CvmMessageEvent( + val event: Event, +) { + val id: HexKey get() = event.id + val pubKey: HexKey get() = event.pubKey + val createdAt: Long get() = event.createdAt + val tags: TagArray get() = event.tags + val content: String get() = event.content + + /** The peer this message is addressed to, or null when untagged. */ + fun recipient(): HexKey? = event.tags.firstNotNullOfOrNull(PTag::parseKey) + + /** The request event id this message answers, or null when it is not a response. */ + fun inReplyTo(): HexKey? = event.tags.firstNotNullOfOrNull(ETag::parseId) + + /** + * The JSON-RPC message in `content`. + * + * @throws com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcFormatException when + * `content` is not a well-formed JSON-RPC 2.0 message. + */ + fun message(): JsonRpcMessage = JsonRpcCodec.decode(event.content) + + /** + * The discovery tags this message carries, per CEP-35. + * + * Routing tags are excluded; everything else is preserved, including tags we + * do not understand, because CEP-35 makes forward compatibility the default. + */ + fun discoveryTags(): List = event.tags.filter { it.isNotEmpty() && !CvmTags.isRouting(it[0]) } + + companion object { + const val KIND = CvmKinds.MESSAGE + + /** Wraps [event] when it is a ContextVM message, or returns null. */ + fun fromOrNull(event: Event) = if (event.kind == KIND) CvmMessageEvent(event) else null + + /** + * Template for a message addressed to [recipient], optionally answering + * [inReplyTo]. + * + * [extraTags] carries this side's CEP-35 discovery tags on the first + * direct message of a session; omit them afterwards. + */ + fun build( + message: JsonRpcMessage, + recipient: HexKey, + inReplyTo: HexKey? = null, + extraTags: List = emptyList(), + createdAt: Long = TimeUtils.now(), + ) = eventTemplate(KIND, JsonRpcCodec.encode(message), createdAt) { + addAll(assembleTags(recipient, inReplyTo, extraTags)) + } + + /** + * Signs a message for [recipient]. + * + * Returns a plain [Event] because that is what the signer produces; wrap + * it with [fromOrNull] when a typed view is wanted. + */ + suspend fun create( + message: JsonRpcMessage, + recipient: HexKey, + signer: NostrSigner, + inReplyTo: HexKey? = null, + extraTags: List = emptyList(), + createdAt: Long = TimeUtils.now(), + ): Event = + signer.sign( + createdAt, + KIND, + assembleTags(recipient, inReplyTo, extraTags), + JsonRpcCodec.encode(message), + ) + + private fun assembleTags( + recipient: HexKey, + inReplyTo: HexKey?, + extraTags: List, + ): TagArray = + buildList { + add(PTag.assemble(recipient, relayHint = null)) + inReplyTo?.let { add(ETag.assemble(it, relay = null, author = null)) } + addAll(extraTags) + }.toTypedArray() + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/core/CvmTags.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/core/CvmTags.kt new file mode 100644 index 0000000000..1d95e36d82 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/core/CvmTags.kt @@ -0,0 +1,119 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.contextvm.core + +import com.vitorpamplona.quartz.nip01Core.core.Tag +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag + +/** + * ContextVM tag vocabulary. + * + * Addressing (`p`) and correlation (`e`) reuse the NIP-01 tags quartz already + * models — [PTag] and [ETag] — because ContextVM assigns them their ordinary + * NIP-01 meanings. Only the ContextVM-specific names are defined here. + */ +object CvmTags { + /** Recipient public key. Same meaning as NIP-01; parse with [PTag]. */ + const val PUBKEY = PTag.TAG_NAME + + /** Correlates a response to its request event. Same meaning as NIP-01; parse with [ETag]. */ + const val EVENT_ID = ETag.TAG_NAME + + /** Relay URL hint (CEP-17 reuses the NIP-65 `r` tag). */ + const val RELAY = "r" + + // --- CEP-6 server identity metadata --- + + const val NAME = "name" + const val ABOUT = "about" + const val PICTURE = "picture" + const val WEBSITE = "website" + + // --- Transport capability advertisement --- + + /** CEP-4: presence alone indicates the peer supports encrypted messages. */ + const val SUPPORT_ENCRYPTION = "support_encryption" + + /** CEP-19: presence indicates the peer accepts kind 21059 gift wraps. */ + const val SUPPORT_ENCRYPTION_EPHEMERAL = "support_encryption_ephemeral" + + /** CEP-22: presence indicates support for bounded oversized payload transfer. */ + const val SUPPORT_OVERSIZED_TRANSFER = "support_oversized_transfer" + + /** CEP-41: presence indicates support for open-ended streams. */ + const val SUPPORT_OPEN_STREAM = "support_open_stream" + + // --- CEP-8 pricing and payment --- + + /** `["cap", "", "", ""]`. */ + const val CAPABILITY_PRICE = "cap" + + /** `["pmi", ""]`. */ + const val PAYMENT_METHOD = "pmi" + + /** `["payment_interaction", "transparent"|"explicit_gating"]`. */ + const val PAYMENT_INTERACTION = "payment_interaction" + + /** `["direct_payment", "", ""]` — bearer-asset optimization. */ + const val DIRECT_PAYMENT = "direct_payment" + + /** `["change", "", ""]` — overpayment remainder. */ + const val CHANGE = "change" + + // --- CEP-15 common tool schemas (NIP-73 external identity tags) --- + + /** `["i", "", ""]`. */ + const val EXTERNAL_ID = "i" + + /** `["k", "io.contextvm/common-schema"]`. */ + const val EXTERNAL_KIND = "k" + + /** The NIP-73 kind value CEP-15 uses in its [EXTERNAL_KIND] tag. */ + const val COMMON_SCHEMA_NAMESPACE = "io.contextvm/common-schema" + + /** + * Tags that carry routing rather than discovery information. + * + * CEP-35 requires unknown discovery tags to be preserved, but says routing + * tags SHOULD be excluded from the learned discovery surface. Keeping the + * exclusion set explicit (rather than an allow-list of known discovery tags) + * is what makes forward compatibility work: a tag we have never heard of is + * preserved by default instead of dropped. + */ + val ROUTING = setOf(PUBKEY, EVENT_ID) + + fun isRouting(tagName: String) = tagName in ROUTING + + /** A valueless capability tag, e.g. `["support_encryption"]`. */ + fun flag(name: String): Tag = arrayOf(name) + + /** + * True when [tags] contains the capability flag [name]. + * + * A flag is signalled by presence, so a tag carrying extra elements still + * counts — the spec defines meaning by the tag name, not by arity. + */ + fun hasFlag( + tags: Array, + name: String, + ) = tags.any { it.isNotEmpty() && it[0] == name } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/fixture/CvmFixtureServer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/fixture/CvmFixtureServer.kt new file mode 100644 index 0000000000..742181de5b --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/fixture/CvmFixtureServer.kt @@ -0,0 +1,306 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.contextvm.fixture + +import com.vitorpamplona.quartz.contextvm.cep04Encryption.CvmGiftWrap +import com.vitorpamplona.quartz.contextvm.core.CvmKinds +import com.vitorpamplona.quartz.contextvm.core.CvmMessageEvent +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcId +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcMessage +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcNotification +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcRequest +import com.vitorpamplona.quartz.contextvm.mcp.McpParams +import com.vitorpamplona.quartz.contextvm.transport.CvmRelayPool +import com.vitorpamplona.quartz.contextvm.transport.CvmSubscription +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Kind +import com.vitorpamplona.quartz.nip01Core.core.Tag +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject + +/** + * An in-memory relay that delivers to whoever is subscribed at publish time. + * + * Its most important property is a faithful one: an event published while + * nobody is subscribed is **dropped**, exactly as a real relay treats an + * ephemeral kind. A test double that queued it would hide the subscribe-before- + * publish bug this whole layer is designed to prevent. + */ +class InMemoryRelayPool : CvmRelayPool { + private data class Listener( + val pubKey: HexKey, + val kinds: Set, + val onEvent: (Event) -> Unit, + ) + + private val listeners = mutableListOf() + + /** Every event published, in order. For assertions about what went on the wire. */ + val published = mutableListOf() + + /** Events dropped because nothing was listening for them. */ + val dropped = mutableListOf() + + override fun subscribe( + pubKey: HexKey, + kinds: IntArray, + onEvent: (Event) -> Unit, + ): CvmSubscription { + val listener = Listener(pubKey, kinds.toSet(), onEvent) + listeners += listener + return object : CvmSubscription { + override fun close() { + listeners -= listener + } + } + } + + override suspend fun publish(event: Event) { + published += event + + val recipients = event.tags.filter { it.size >= 2 && it[0] == "p" }.map { it[1] } + val matched = + listeners.filter { listener -> + listener.kinds.contains(event.kind) && recipients.contains(listener.pubKey) + } + + if (matched.isEmpty()) dropped += event + matched.forEach { it.onEvent(event) } + } +} + +/** + * How the fixture should misbehave. + * + * This is the reason the fixture exists. No real server sends a duplicate + * `progress`, a stale `pong` nonce or a digest that does not match, yet a client + * MUST handle all of them correctly — several are outright MUST-fail rules. The + * only way to test that is a counterparty that can be told to break them. + */ +data class FixtureFaults( + /** Answer with a JSON-RPC id that does not match the request. */ + val mismatchedResponseId: Boolean = false, + /** Omit the `e` tag that correlates the response to the request event. */ + val omitCorrelationTag: Boolean = false, + /** Answer a different request event id entirely. */ + val wrongCorrelationTag: Boolean = false, + /** Send a response body that is not valid JSON-RPC. */ + val malformedResponse: Boolean = false, + /** Never answer at all. */ + val silent: Boolean = false, + /** Send this many junk notifications before the real response. */ + val noisePrefix: Int = 0, +) + +/** + * One call as a [CvmFixtureServer] handler sees it. + * + * [id] is here because a handler MUST echo it: the client correlates on it, so + * a fixture that answered every call with a constant works for the first call + * of a session and silently hangs on the second. That is a bug the fixture + * should surface in the code under test, not create — it created one once. + * + * [event] is the signed kind-25910 event that carried the call, after any gift + * wrap was removed. A real coordinator has this too, and cordn's depends on it: + * `spec/00.md` §7 has no KeyPackage event kind, so the "signed publication + * payload" a `kp_take` must serve back **is this event**, retained verbatim. + * A fixture that could not see it could not model `kp_publish` at all. + */ +data class CvmRequest( + val method: String, + val params: JsonObject?, + val id: JsonRpcId, + val event: Event, +) + +/** + * A ContextVM server that plays the peer role in tests (Tier C). + * + * Not hardened for deployment and deliberately so: for a real coordinator, + * `cordn-rs` already exists. This exists to be wrong on demand. + */ +class CvmFixtureServer( + private val relays: InMemoryRelayPool, + private val signer: NostrSigner, + private val crypto: CvmGiftWrap = CvmGiftWrap(), + private val faults: FixtureFaults = FixtureFaults(), + /** CEP-16: inject the caller's pubkey into `_meta` before handling. */ + private val injectClientPubkey: Boolean = false, + /** Discovery tags sent on the first direct message back, per CEP-35. */ + private val discoveryTags: List = emptyList(), + /** Answers a request. See [CvmRequest] for what a handler is given. */ + private val handler: suspend (CvmRequest) -> JsonRpcMessage, +) { + private var subscription: CvmSubscription? = null + private var sentFirstMessage = false + + /** Params as the handler saw them, for asserting CEP-16 injection. */ + val handledParams = mutableListOf() + + /** Starts listening. Call before the client publishes anything. */ + fun start(): CvmSubscription { + val sub = + relays.subscribe( + pubKey = signer.pubKey, + kinds = intArrayOf(CvmKinds.MESSAGE) + CvmKinds.GIFT_WRAPS, + onEvent = { event -> pending += event }, + ) + subscription = sub + return sub + } + + /** Events received but not yet answered. Drained by [pump]. */ + private val pending = mutableListOf() + + /** + * Handles everything received so far. + * + * Explicit rather than automatic because the relay callback cannot suspend, + * and because a test usually wants to control when the answer appears. + */ + suspend fun pump() { + val batch = pending.toList() + pending.clear() + batch.forEach { handle(it) } + } + + private suspend fun handle(event: Event) { + val plain = + if (CvmKinds.isGiftWrap(event.kind)) { + try { + crypto.unwrap(event, signer) + } catch (e: IllegalStateException) { + return + } + } else { + event + } + + val message = CvmMessageEvent.fromOrNull(plain) ?: return + val request = message.message() as? JsonRpcRequest ?: return + + if (faults.silent) return + + val clientPubKey = plain.pubKey + val params = if (injectClientPubkey) inject(request.params, clientPubKey) else request.params + handledParams += params + + repeat(faults.noisePrefix) { index -> + reply( + JsonRpcNotification("notifications/message", buildJsonObject { put("seq", JsonPrimitive(index)) }), + clientPubKey, + plain.id, + ) + } + + val response = handler(CvmRequest(request.method, params, request.id, plain)) + reply(response, clientPubKey, plain.id) + } + + /** Sends [message] back to [clientPubKey], answering [requestEventId]. */ + suspend fun reply( + message: JsonRpcMessage, + clientPubKey: HexKey, + requestEventId: HexKey, + ) { + val correlation = + when { + faults.omitCorrelationTag -> null + faults.wrongCorrelationTag -> "f".repeat(64) + else -> requestEventId + } + + val tags = if (sentFirstMessage) emptyList() else discoveryTags + sentFirstMessage = true + + val content = + if (faults.malformedResponse) { + MALFORMED + } else { + com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcCodec + .encode(faultInjected(message)) + } + + val inner = + signer.sign( + createdAt = + com.vitorpamplona.quartz.utils.TimeUtils + .now(), + kind = CvmKinds.MESSAGE, + tags = + buildList { + add(arrayOf("p", clientPubKey)) + correlation?.let { add(arrayOf("e", it)) } + addAll(tags) + }.toTypedArray(), + content = content, + ) + + relays.publish( + if (crypto.shouldEncrypt(peerSupportsEncryption = true)) { + crypto.wrap(inner, clientPubKey) + } else { + inner + }, + ) + } + + private fun faultInjected(message: JsonRpcMessage): JsonRpcMessage = + if (faults.mismatchedResponseId && message is com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcSuccess) { + message.copy( + id = + com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcId + .Num(999_999), + ) + } else { + message + } + + private fun inject( + params: JsonObject?, + clientPubKey: HexKey, + ): JsonObject = + buildJsonObject { + params?.forEach { (key, value) -> if (key != McpParams.META) put(key, value) } + put( + McpParams.META, + buildJsonObject { + (params?.get(McpParams.META) as? JsonObject)?.forEach { (key, value) -> put(key, value) } + // The client never supplies this: a client-supplied value + // would be a spoof, which is exactly why CEP-16 has the + // server derive it from the event signature. + put(McpParams.CLIENT_PUBKEY, JsonPrimitive(clientPubKey)) + }, + ) + } + + fun stop() { + subscription?.close() + subscription = null + } + + private companion object { + const val MALFORMED = """{"not":"json-rpc"}""" + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/json/PlainJson.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/json/PlainJson.kt new file mode 100644 index 0000000000..459b9fd4d9 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/json/PlainJson.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.contextvm.json + +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.longOrNull + +/** + * Converts a kotlinx JSON tree into the plain Kotlin types + * `JsonCanonicalization` works on. + * + * The canonicalizer deliberately takes plain types so it stays usable from any + * module regardless of serializer, and this is the bridge for our side. + * + * Numbers: an integral value becomes a [Long] and anything else a [Double]. + * Either way the canonicalizer renders it through the same ECMAScript path, so + * the distinction does not change the output — it just avoids widening large + * integers through [Double] any earlier than JCS already does. + */ +fun JsonElement.toPlainJson(): Any? = + when (this) { + is JsonNull -> null + is JsonObject -> mapValues { (_, value) -> value.toPlainJson() } + is JsonArray -> map { it.toPlainJson() } + is JsonPrimitive -> { + if (isString) { + content + } else { + booleanOrNull + ?: longOrNull + ?: doubleOrNull + ?: throw IllegalArgumentException("unsupported JSON primitive: $content") + } + } + } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/jsonrpc/JsonRpcCodec.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/jsonrpc/JsonRpcCodec.kt new file mode 100644 index 0000000000..a8ae3282a1 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/jsonrpc/JsonRpcCodec.kt @@ -0,0 +1,196 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.contextvm.jsonrpc + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.longOrNull + +/** + * Encodes and decodes the JSON-RPC 2.0 messages carried in a ContextVM event's + * `content`. + * + * Decoding is deliberately strict. ContextVM's only framing is "the content is a + * JSON-RPC message", so a malformed payload has to be rejected here or it becomes + * a confusing failure several layers up. Every rejection below is a rule in + * `quartz/plans/2026-09-17-cordn-interop.md` §6.5 (`CVM-CORE-*`) and has a test. + */ +object JsonRpcCodec { + private const val JSONRPC = "jsonrpc" + private const val ID = "id" + private const val METHOD = "method" + private const val PARAMS = "params" + private const val RESULT = "result" + private const val ERROR = "error" + private const val CODE = "code" + private const val MESSAGE = "message" + private const val DATA = "data" + + /** + * Lenient only about *unknown* members: MCP grows fields, and CEP-35 tells us + * to preserve what we do not understand rather than fail. The structural + * checks in [decode] are not relaxed. + */ + private val json = Json { ignoreUnknownKeys = true } + + fun encode(message: JsonRpcMessage): String = json.encodeToString(JsonObject.serializer(), toJsonObject(message)) + + fun toJsonObject(message: JsonRpcMessage): JsonObject = + buildJsonObject { + put(JSONRPC, JsonPrimitive(JsonRpcMessage.VERSION)) + when (message) { + is JsonRpcRequest -> { + put(ID, message.id.toPrimitive()) + put(METHOD, JsonPrimitive(message.method)) + message.params?.let { put(PARAMS, it) } + } + + is JsonRpcNotification -> { + put(METHOD, JsonPrimitive(message.method)) + message.params?.let { put(PARAMS, it) } + } + + is JsonRpcSuccess -> { + put(ID, message.id.toPrimitive()) + put(RESULT, message.result) + } + + is JsonRpcFailure -> { + put(ID, message.id?.toPrimitive() ?: JsonPrimitive(null as String?)) + put( + ERROR, + buildJsonObject { + put(CODE, JsonPrimitive(message.error.code)) + put(MESSAGE, JsonPrimitive(message.error.message)) + message.error.data?.let { put(DATA, it) } + }, + ) + } + } + } + + fun decode(text: String): JsonRpcMessage { + val root = + try { + json.parseToJsonElement(text) + } catch (e: IllegalArgumentException) { + throw JsonRpcFormatException("content is not valid JSON: ${e.message}") + } + + if (root !is JsonObject) throw JsonRpcFormatException("JSON-RPC message must be an object") + return decode(root) + } + + fun decode(root: JsonObject): JsonRpcMessage { + val version = root[JSONRPC]?.asStringOrNull() + if (version != JsonRpcMessage.VERSION) { + throw JsonRpcFormatException("unsupported jsonrpc version: $version") + } + + val hasMethod = root.containsKey(METHOD) + val hasResult = root.containsKey(RESULT) + val hasError = root.containsKey(ERROR) + + // A response is exactly one of result or error. Carrying both is + // ambiguous about whether the call succeeded, so it cannot be repaired + // by preferring one -- reject it. + if (hasResult && hasError) { + throw JsonRpcFormatException("response carries both result and error") + } + if (hasMethod && (hasResult || hasError)) { + throw JsonRpcFormatException("message carries both a method and a response body") + } + + // `id` may legitimately be JSON null on a failure, so distinguish + // "absent" (a notification) from "present but null". + val idElement = root[ID] + val id = if (idElement == null || idElement.isJsonNull()) null else parseId(idElement) + + return when { + hasMethod -> { + val method = + root[METHOD]?.asStringOrNull() + ?: throw JsonRpcFormatException("method must be a string") + val params = root[PARAMS]?.let { requireObject(it, PARAMS) } + if (id == null) { + JsonRpcNotification(method, params) + } else { + JsonRpcRequest(id, method, params) + } + } + + hasResult -> { + if (id == null) throw JsonRpcFormatException("success response requires an id") + JsonRpcSuccess(id, root.getValue(RESULT)) + } + + hasError -> JsonRpcFailure(id, parseError(root.getValue(ERROR))) + + else -> throw JsonRpcFormatException("message has no method, result or error") + } + } + + private fun parseError(element: JsonElement): JsonRpcError { + val obj = requireObject(element, ERROR) + val code = + obj[CODE]?.jsonPrimitive?.longOrNull + ?: throw JsonRpcFormatException("error.code must be a number") + val message = + obj[MESSAGE]?.asStringOrNull() + ?: throw JsonRpcFormatException("error.message must be a string") + return JsonRpcError(code.toInt(), message, obj[DATA]) + } + + private fun parseId(element: JsonElement): JsonRpcId { + val primitive = + (element as? JsonPrimitive) + ?: throw JsonRpcFormatException("id must be a string or a number") + + if (primitive.isString) return JsonRpcId.Text(primitive.content) + + return primitive.longOrNull?.let { JsonRpcId.Num(it) } + ?: throw JsonRpcFormatException("id must be a string or an integral number") + } + + private fun requireObject( + element: JsonElement, + field: String, + ): JsonObject = + element as? JsonObject + ?: throw JsonRpcFormatException("$field must be an object") + + private fun JsonRpcId.toPrimitive() = + when (this) { + is JsonRpcId.Num -> JsonPrimitive(value) + is JsonRpcId.Text -> JsonPrimitive(value) + } + + private fun JsonElement.isJsonNull() = this is JsonPrimitive && !isString && content == "null" + + private fun JsonElement.asStringOrNull(): String? { + val primitive = this as? JsonPrimitive ?: return null + return if (primitive.isString) primitive.content else null + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/jsonrpc/JsonRpcMessage.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/jsonrpc/JsonRpcMessage.kt new file mode 100644 index 0000000000..56031e7b2b --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/jsonrpc/JsonRpcMessage.kt @@ -0,0 +1,121 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.contextvm.jsonrpc + +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject + +/** + * A JSON-RPC 2.0 message, as carried in a ContextVM event's `content`. + * + * ContextVM transports MCP messages unmodified, so this models JSON-RPC itself + * rather than any MCP-specific shape: `params` and `result` stay as a JSON tree + * and pass through untouched. MCP semantics live a layer up. + */ +sealed interface JsonRpcMessage { + companion object { + /** The only version ContextVM carries. Decoding rejects anything else. */ + const val VERSION = "2.0" + } +} + +/** + * A JSON-RPC message id. + * + * JSON-RPC allows a string or a number, and MCP implementations use both — the + * TypeScript SDK numbers its requests while others use strings. Modelling the + * distinction (rather than normalising to String) keeps decode/encode a faithful + * round-trip, which rule `CVM-CORE-01` asserts. + * + * It also matters for CEP-8: the canonical invocation identity deliberately + * excludes the id, so a retry may legitimately change both its type and value + * and still match a paid authorization. + */ +sealed interface JsonRpcId { + data class Num( + val value: Long, + ) : JsonRpcId + + data class Text( + val value: String, + ) : JsonRpcId +} + +/** A call expecting exactly one matching [JsonRpcSuccess] or [JsonRpcFailure]. */ +data class JsonRpcRequest( + val id: JsonRpcId, + val method: String, + val params: JsonObject? = null, +) : JsonRpcMessage + +/** + * A one-way message with no id and no response. + * + * CEP-22 and CEP-41 both ride `notifications/progress` notifications, so this is + * the carrier for every transfer frame as well as for ordinary MCP notifications. + */ +data class JsonRpcNotification( + val method: String, + val params: JsonObject? = null, +) : JsonRpcMessage + +/** A successful response. `result` is opaque to this layer. */ +data class JsonRpcSuccess( + val id: JsonRpcId, + val result: JsonElement, +) : JsonRpcMessage + +/** + * An error response. + * + * [id] is nullable because JSON-RPC permits a null id when the request could not + * be parsed well enough to recover one. + */ +data class JsonRpcFailure( + val id: JsonRpcId?, + val error: JsonRpcError, +) : JsonRpcMessage + +data class JsonRpcError( + val code: Int, + val message: String, + val data: JsonElement? = null, +) { + companion object { + // JSON-RPC 2.0 reserved codes. + const val PARSE_ERROR = -32700 + const val INVALID_REQUEST = -32600 + const val METHOD_NOT_FOUND = -32601 + const val INVALID_PARAMS = -32602 + const val INTERNAL_ERROR = -32603 + + /** CEP-8 `explicit_gating`: payment is required before the call will run. */ + const val PAYMENT_REQUIRED = -32042 + + /** CEP-8 `explicit_gating`: payment is in flight but not yet verified. */ + const val PAYMENT_PENDING = -32043 + } +} + +/** Thrown when a payload is not a well-formed JSON-RPC 2.0 message. */ +class JsonRpcFormatException( + message: String, +) : IllegalArgumentException(message) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/mcp/CvmMcpClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/mcp/CvmMcpClient.kt new file mode 100644 index 0000000000..7f21cd7f32 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/mcp/CvmMcpClient.kt @@ -0,0 +1,260 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.contextvm.mcp + +import com.vitorpamplona.quartz.contextvm.cep22OversizedTransfer.OversizedFrame +import com.vitorpamplona.quartz.contextvm.cep22OversizedTransfer.OversizedLimits +import com.vitorpamplona.quartz.contextvm.cep22OversizedTransfer.OversizedProgressResult +import com.vitorpamplona.quartz.contextvm.cep22OversizedTransfer.OversizedTransferReceiver +import com.vitorpamplona.quartz.contextvm.cep41OpenStreams.OpenStreamEvent +import com.vitorpamplona.quartz.contextvm.cep41OpenStreams.OpenStreamFrame +import com.vitorpamplona.quartz.contextvm.cep41OpenStreams.OpenStreamPolicy +import com.vitorpamplona.quartz.contextvm.cep41OpenStreams.OpenStreamReceiver +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcFailure +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcId +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcMessage +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcNotification +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcRequest +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcSuccess +import com.vitorpamplona.quartz.contextvm.transfer.ProgressEnvelope +import com.vitorpamplona.quartz.contextvm.transfer.ProgressToken +import com.vitorpamplona.quartz.contextvm.transport.CvmTransport +import com.vitorpamplona.quartz.contextvm.transport.DualSigner +import com.vitorpamplona.quartz.contextvm.transport.TimeoutMode +import com.vitorpamplona.quartz.nip01Core.core.Tag +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject + +/** A tool call's outcome, with anything a transfer profile delivered alongside it. */ +data class ToolCallResult( + val result: JsonElement?, + val error: com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcError? = null, + /** Fragments a CEP-41 stream delivered while the call was in flight. */ + val streamed: List = emptyList(), + /** + * Whether the response arrived reassembled over CEP-22 rather than in the + * single event that carried the rest. + * + * A caller does not need this to read the result - that is the point of the + * profile. It is here so a live interop run can assert the profile actually + * fired, instead of passing whether or not the server ever chunked. + */ + val viaOversizedTransfer: Boolean = false, +) { + val isError get() = error != null +} + +/** + * A minimal MCP client over ContextVM. + * + * Implements the surface the CEPs define — lifecycle, tool listing and calling — + * rather than all of MCP. Sampling, roots and elicitation are deliberately out + * of scope; nothing in ContextVM or its CEPs needs them, and a partial version + * would be worse than none. + * + * Transfer profiles are handled here because they are request-scoped: a call + * carries a `progressToken`, and CEP-22 or CEP-41 frames for that token arrive + * as notifications while the call is open. + */ +class CvmMcpClient( + private val transport: CvmTransport, + private val clientName: String = "amethyst-contextvm", + private val clientVersion: String = "0.1.0", + private val oversizedLimits: OversizedLimits = OversizedLimits(), + private val streamPolicy: OpenStreamPolicy = OpenStreamPolicy(), +) { + private var nextId = 0L + + /** + * Performs the MCP handshake. + * + * Optional per the spec — servers may operate statelessly — but it is the + * natural place to exchange CEP-35 discovery tags, so a client that can + * afford the round trip should do it. + */ + suspend fun initialize( + capabilityTags: List = transport.selfDiscoveryTags(), + protocolVersion: String = PROTOCOL_VERSION, + ): JsonRpcMessage { + val response = + transport.request( + message = + JsonRpcRequest( + id = nextId(), + method = McpMethods.INITIALIZE, + params = + buildJsonObject { + put("protocolVersion", JsonPrimitive(protocolVersion)) + put("capabilities", buildJsonObject {}) + put( + "clientInfo", + buildJsonObject { + put("name", JsonPrimitive(clientName)) + put("version", JsonPrimitive(clientVersion)) + }, + ) + }, + ), + discoveryTags = capabilityTags, + ) + + // The server is only allowed to assume readiness after this. + transport.notify(JsonRpcNotification(McpMethods.INITIALIZED)) + return response + } + + suspend fun listTools(cursor: String? = null): JsonRpcMessage = + transport.request( + JsonRpcRequest( + id = nextId(), + method = McpMethods.TOOLS_LIST, + params = cursor?.let { buildJsonObject { put("cursor", JsonPrimitive(it)) } }, + ), + ) + + /** + * Calls a tool. + * + * A `progressToken` is always attached: without it a server MUST NOT start + * either transfer profile, so omitting it would silently cap every response + * at one relay event. + */ + suspend fun callTool( + name: String, + arguments: JsonObject = buildJsonObject {}, + identity: DualSigner.Identity = DualSigner.Identity.EPHEMERAL, + timeoutMs: Long = CvmTransport.DEFAULT_TIMEOUT_MS, + timeoutMode: TimeoutMode = TimeoutMode.IDLE, + onStreamFragment: (String) -> Unit = {}, + ): ToolCallResult { + val id = nextId() + val token = ProgressToken.Text("call-${id.value}") + + var oversized: OversizedTransferReceiver? = null + var stream: OpenStreamReceiver? = null + var reassembled: JsonRpcMessage? = null + val streamed = mutableListOf() + + val response = + try { + transport.request( + message = + JsonRpcRequest( + id = id, + method = McpMethods.TOOLS_CALL, + params = + buildJsonObject { + put(McpParams.NAME, JsonPrimitive(name)) + put(McpParams.ARGUMENTS, arguments) + put( + McpParams.META, + buildJsonObject { + put(McpParams.PROGRESS_TOKEN, JsonPrimitive("call-${id.value}")) + }, + ) + }, + ), + identity = identity, + timeoutMs = timeoutMs, + timeoutMode = timeoutMode, + ) { notification -> + val envelope = ProgressEnvelope.parseOrNull(notification) ?: return@request null + if (envelope.token != token) return@request null + + when (envelope.type) { + ProgressEnvelope.TYPE_OVERSIZED -> { + val receiver = + oversized ?: OversizedTransferReceiver(token, oversizedLimits, requireAccept = false) + .also { oversized = it } + OversizedFrame.parseOrNull(envelope)?.let { frame -> + val result = receiver.accept(frame) + if (result is OversizedProgressResult.Completed) { + reassembled = result.message + oversizedTransfersCompleted++ + } + } + } + + ProgressEnvelope.TYPE_OPEN_STREAM -> { + val receiver = + stream ?: OpenStreamReceiver(token, streamPolicy, requireAccept = false) + .also { stream = it } + OpenStreamFrame.parseOrNull(envelope)?.let { frame -> + val event = receiver.accept(frame) + if (event is OpenStreamEvent.Delivered) { + streamed += event.fragments + event.fragments.forEach(onStreamFragment) + } + } + } + + else -> Unit + } + + // A completed CEP-22 transfer IS the response, so handing it + // back ends the call. A CEP-41 stream never does: `close` says + // no more frames, not that the request is answered. + reassembled + } + } catch (e: TimeoutCancellationException) { + // Under TOTAL the timeout IS how the call ends. CEP-41 says a + // stream's `close` does not complete the JSON-RPC request, so + // an open-ended subscription has no other exit - treating the + // budget running out as a failure meant every subscription, + // however healthy, ended in an exception. + // + // Fragments were handed to onStreamFragment as they arrived, + // so nothing is lost by returning here. + if (timeoutMode == TimeoutMode.TOTAL) null else throw e + } + + // A CEP-22 transfer replaces the response that could not be published + // directly; a CEP-41 stream does not, since `close` never completes the + // JSON-RPC request. + val chunked = reassembled != null + // No response and no transfer: a subscription that ran its budget. + val effective = reassembled ?: response ?: return ToolCallResult(null, streamed = streamed) + return when (effective) { + is JsonRpcSuccess -> ToolCallResult(effective.result, streamed = streamed, viaOversizedTransfer = chunked) + is JsonRpcFailure -> ToolCallResult(null, effective.error, streamed, chunked) + else -> ToolCallResult(null, streamed = streamed, viaOversizedTransfer = chunked) + } + } + + private fun nextId(): JsonRpcId.Num = JsonRpcId.Num(nextId++) + + /** + * How many responses this client has reassembled over CEP-22. + * + * Diagnostics, not control flow. Nothing decides anything on it; it exists + * so a live run can tell a server that chunked from one that never had to. + */ + var oversizedTransfersCompleted: Int = 0 + private set + + companion object { + /** The MCP revision the ContextVM spec's examples use. */ + const val PROTOCOL_VERSION = "2025-07-02" + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/mcp/McpMethods.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/mcp/McpMethods.kt new file mode 100644 index 0000000000..ead0f8ab2b --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/mcp/McpMethods.kt @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.contextvm.mcp + +/** + * The MCP methods ContextVM carries. + * + * ContextVM is a transport: these names are MCP's, not ContextVM's, and travel + * unmodified inside the event `content`. + */ +object McpMethods { + const val INITIALIZE = "initialize" + const val INITIALIZED = "notifications/initialized" + const val PING = "ping" + + const val TOOLS_LIST = "tools/list" + const val TOOLS_CALL = "tools/call" + const val RESOURCES_LIST = "resources/list" + const val RESOURCE_TEMPLATES_LIST = "resources/templates/list" + const val PROMPTS_LIST = "prompts/list" + + /** + * The envelope CEP-22 and CEP-41 both ride. + * + * Their frames are ordinary MCP progress notifications with an extra `cvm` + * object in `params`; a peer that does not understand ContextVM transfer + * profiles still sees valid MCP. + */ + const val PROGRESS = "notifications/progress" + + /** CEP-8 transparent lifecycle notifications. */ + const val PAYMENT_REQUIRED = "notifications/payment_required" + const val PAYMENT_ACCEPTED = "notifications/payment_accepted" + const val PAYMENT_REJECTED = "notifications/payment_rejected" +} + +/** + * Well-known member names inside MCP `params`. + * + * `_meta` matters beyond convenience: CEP-8 excludes it from the canonical + * invocation identity (because MCP regenerates `progressToken` on every call) + * while still requiring it to reach the handler at execution time. + */ +object McpParams { + const val META = "_meta" + const val PROGRESS_TOKEN = "progressToken" + const val NAME = "name" + const val ARGUMENTS = "arguments" + + /** CEP-16: the caller identity a server transport injects into `_meta`. */ + const val CLIENT_PUBKEY = "clientPubkey" +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/transfer/ProgressEnvelope.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/transfer/ProgressEnvelope.kt new file mode 100644 index 0000000000..e4866a12f1 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/transfer/ProgressEnvelope.kt @@ -0,0 +1,142 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.contextvm.transfer + +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcNotification +import com.vitorpamplona.quartz.contextvm.mcp.McpMethods +import com.vitorpamplona.quartz.contextvm.mcp.McpParams +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.longOrNull + +/** + * An MCP progress token: the transfer/stream identifier for CEP-22 and CEP-41. + * + * MCP allows a string or a number. The distinction is kept rather than + * normalised for the same reason as `JsonRpcId`: a faithful round trip. + */ +sealed interface ProgressToken { + data class Num( + val value: Long, + ) : ProgressToken + + data class Text( + val value: String, + ) : ProgressToken +} + +/** Thrown when a `notifications/progress` payload is not a usable transfer frame. */ +class TransferFrameException( + message: String, +) : IllegalArgumentException(message) + +/** + * The `notifications/progress` envelope shared by CEP-22 and CEP-41. + * + * MCP owns `progressToken`, `progress`, `total` and `message`; ContextVM adds + * the `cvm` object carrying the frame. `total` and `message` are UX hints and + * explicitly do not define transfer correctness, so nothing below reads them + * for control flow. + */ +data class ProgressEnvelope( + val token: ProgressToken, + val progress: Double, + val total: Double? = null, + val message: String? = null, + val cvm: JsonObject, +) { + val type: String? get() = cvm[TYPE]?.asStringOrNull() + val frameType: String? get() = cvm[FRAME_TYPE]?.asStringOrNull() + + fun toNotification(): JsonRpcNotification = + JsonRpcNotification( + McpMethods.PROGRESS, + buildJsonObject { + put(McpParams.PROGRESS_TOKEN, token.toPrimitive()) + put(PROGRESS, JsonPrimitive(progress)) + total?.let { put(TOTAL, JsonPrimitive(it)) } + message?.let { put(MESSAGE, JsonPrimitive(it)) } + put(CVM, cvm) + }, + ) + + companion object { + const val PROGRESS = "progress" + const val TOTAL = "total" + const val MESSAGE = "message" + const val CVM = "cvm" + const val TYPE = "type" + const val FRAME_TYPE = "frameType" + + /** CEP-22's `cvm.type`. */ + const val TYPE_OVERSIZED = "oversized-transfer" + + /** CEP-41's `cvm.type`. */ + const val TYPE_OPEN_STREAM = "open-stream" + + /** + * Reads the envelope out of a notification, or returns null when this is + * an ordinary MCP progress notification rather than a ContextVM frame. + * + * Returning null rather than throwing is deliberate: a peer may send + * plain MCP progress for the same request, and that is not an error. + */ + fun parseOrNull(notification: JsonRpcNotification): ProgressEnvelope? { + if (notification.method != McpMethods.PROGRESS) return null + val params = notification.params ?: return null + val cvm = params[CVM] as? JsonObject ?: return null + + val token = params[McpParams.PROGRESS_TOKEN]?.let { parseToken(it) } ?: return null + val progress = + (params[PROGRESS] as? JsonPrimitive)?.doubleOrNull + ?: throw TransferFrameException("progress must be a number") + + return ProgressEnvelope( + token = token, + progress = progress, + total = (params[TOTAL] as? JsonPrimitive)?.doubleOrNull, + message = params[MESSAGE]?.asStringOrNull(), + cvm = cvm, + ) + } + + private fun parseToken(element: kotlinx.serialization.json.JsonElement): ProgressToken? { + val primitive = element as? JsonPrimitive ?: return null + if (primitive.isString) return ProgressToken.Text(primitive.content) + return primitive.longOrNull?.let { ProgressToken.Num(it) } + } + + internal fun ProgressToken.toPrimitive() = + when (this) { + is ProgressToken.Num -> JsonPrimitive(value) + is ProgressToken.Text -> JsonPrimitive(value) + } + + internal fun kotlinx.serialization.json.JsonElement.asStringOrNull(): String? { + val primitive = this as? JsonPrimitive ?: return null + return if (primitive.isString) primitive.content else null + } + + internal fun kotlinx.serialization.json.JsonElement.asLongOrNull(): Long? = (this as? JsonPrimitive)?.takeIf { !it.isString }?.longOrNull + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/transport/CvmRelayPool.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/transport/CvmRelayPool.kt new file mode 100644 index 0000000000..afd2a809b3 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/transport/CvmRelayPool.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.contextvm.transport + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** A live subscription. Closing it stops delivery. */ +interface CvmSubscription { + fun close() +} + +/** + * The relay surface ContextVM needs. + * + * Deliberately tiny and transport-agnostic: everything above it is pure + * protocol, and the Tier C fixture implements this same interface to play a + * misbehaving peer without any network. The production binding wraps quartz's + * relay client. + */ +interface CvmRelayPool { + /** + * Subscribes to events addressed to [pubKey] (`#p`) of the given [kinds]. + * + * Delivery starts when this returns. Because kind 25910 is ephemeral, a + * subscription opened after a peer published has missed the event + * permanently — [CvmTransport] is built so callers cannot make that mistake. + */ + fun subscribe( + pubKey: HexKey, + kinds: IntArray, + onEvent: (Event) -> Unit, + ): CvmSubscription + + /** Publishes [event] to the configured relays. */ + suspend fun publish(event: Event) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/transport/CvmTransport.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/transport/CvmTransport.kt new file mode 100644 index 0000000000..cfda426607 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/contextvm/transport/CvmTransport.kt @@ -0,0 +1,361 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.contextvm.transport + +import com.vitorpamplona.quartz.contextvm.cep04Encryption.CvmGiftWrap +import com.vitorpamplona.quartz.contextvm.cep35Discovery.SessionDiscovery +import com.vitorpamplona.quartz.contextvm.core.CvmKinds +import com.vitorpamplona.quartz.contextvm.core.CvmMessageEvent +import com.vitorpamplona.quartz.contextvm.core.CvmTags +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcFailure +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcId +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcMessage +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcNotification +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcRequest +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcSuccess +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Tag +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.withTimeout + +/** + * The two identities a ContextVM client uses. + * + * Splitting them is a privacy measure, not plumbing: the stable identity signs + * only what must be attributable, while everything else rides a throwaway key so + * a server cannot link a session's activity to an account. Making the choice an + * explicit parameter means a caller cannot leak the stable one by omission. + */ +class DualSigner( + /** The account identity. Used only where a call must be attributable. */ + val stable: NostrSigner, + /** A per-session throwaway. Used for everything else. */ + val ephemeral: NostrSigner, +) { + enum class Identity { + STABLE, + EPHEMERAL, + } + + fun signerFor(identity: Identity) = + when (identity) { + Identity.STABLE -> stable + Identity.EPHEMERAL -> ephemeral + } +} + +/** What a call's `timeoutMs` bounds. */ +enum class TimeoutMode { + /** + * Silence. The clock restarts on every message the peer sends, so a + * response still being delivered is never cut off. + * + * The right default for a request/response call: both transfer profiles + * deliver one logical response as a run of notifications, each its own + * signed, wrapped, published relay event, so how long a response takes is + * a function of its size and not something a caller can predict. + */ + IDLE, + + /** + * The whole call, from publish to return. + * + * For an open-ended subscription, where the timeout is not a failure but + * the budget after which the caller re-opens - a busy stream under [IDLE] + * would simply never come back. + */ + TOTAL, +} + +/** Thrown when a request cannot be completed at the transport layer. */ +class CvmTransportException( + message: String, +) : IllegalStateException(message) + +/** + * Correlates ContextVM requests with their responses over a [CvmRelayPool]. + * + * [request] subscribes before it publishes, always. Kind 25910 is ephemeral, so + * a subscription opened afterwards has missed the response permanently and the + * failure looks exactly like a flaky relay. Making the ordering the transport's + * job rather than the caller's removes the whole class of bug, which is why + * there is no public publish/subscribe pair to get wrong. + * + * Inbound notifications that are not responses — CEP-22/41 frames, CEP-8 payment + * notifications — are handed to `onNotification` while the request is still in + * flight. A notification never resolves a request: CEP-41 is explicit that a + * stream's `close` does not complete it. + */ +class CvmTransport( + private val relays: CvmRelayPool, + private val signers: DualSigner, + private val serverPubKey: HexKey, + private val crypto: CvmGiftWrap = CvmGiftWrap(), + private val discovery: SessionDiscovery = SessionDiscovery(), + /** + * What to assume about the peer **until it declares otherwise**. + * + * Not a fixed answer: once the peer sends a CEP-35 discovery surface, that + * surface wins (see [peerEncrypts]). These are only what to believe before + * the first message arrives, and for a peer that never declares anything. + * + * The default is optimistic on purpose. Assuming a peer cannot encrypt + * would have us send the first request of every session in the clear, which + * is the one message whose exposure we can still avoid. + */ + private val assumePeerSupportsEncryption: Boolean = true, + private val assumePeerSupportsEphemeralWrap: Boolean = true, +) { + /** + * This side's CEP-35 surface: what a peer may use against us. + * + * CEP-35 is symmetric, and the half we were not doing is the expensive one + * to skip. A spec-correct server only chunks a CEP-22 response, or opens a + * CEP-41 stream, for a client that declared it can take one — so declaring + * nothing does not make us conservative, it makes those profiles dead on + * every session and caps every response at one relay event. + * + * Both transfer profiles are unconditional: [CvmMcpClient] reassembles + * CEP-22 and collects CEP-41 fragments on every call, with no flag to turn + * either off. + */ + fun selfDiscoveryTags(): List = + (crypto.receivableWrapTags() + CvmTags.SUPPORT_OVERSIZED_TRANSFER + CvmTags.SUPPORT_OPEN_STREAM) + .map { arrayOf(it) } + + /** The peer's learned discovery baseline, once its first message has arrived. */ + val peer get() = discovery.peer + + /** + * Whether to encrypt to this peer, by what it has actually told us. + * + * A declared surface wins; silence leaves the assumption in place. That + * split is what makes [com.vitorpamplona.quartz.contextvm.cep04Encryption.EncryptionMode.REQUIRED] + * mean something: before this, its input was a constant, so its promise to + * fail loudly rather than downgrade could never fire. Now a peer that + * declares a surface without `support_encryption` gets a stated refusal + * instead of a wrap it cannot open and a request that times out with no + * reason. + */ + private fun peerEncrypts() = discovery.declaredPeer?.supportsEncryption ?: assumePeerSupportsEncryption + + /** As [peerEncrypts], for CEP-19's ephemeral wrap kind. */ + private fun peerTakesEphemeralWrap() = discovery.declaredPeer?.supportsEphemeralEncryption ?: assumePeerSupportsEphemeralWrap + + private var sentFirstMessage = false + + /** + * Sends [message] and waits for the correlated response. + * + * @param identity which key signs the request. Anything not required to be + * attributable should stay [DualSigner.Identity.EPHEMERAL]. + * @param onNotification every notification that arrives while the call is + * open. Returning a message **completes the call with it**, which is how + * CEP-22 finishes: a chunked response replaces the direct one rather than + * preceding it, so nothing else would ever arrive to end the wait. + * Returning null keeps waiting - CEP-41 is explicit that a stream's + * `close` does not complete the request. + * @param discoveryTags this side's CEP-35 baseline, sent on the session's + * first direct message only. Defaults to [selfDiscoveryTags] so the + * surface rides whatever that first message turns out to be - a session + * that opens with a tool call rather than a handshake still declares. + * Pass `emptyList()` to declare nothing. + */ + suspend fun request( + message: JsonRpcRequest, + identity: DualSigner.Identity = DualSigner.Identity.EPHEMERAL, + timeoutMs: Long = DEFAULT_TIMEOUT_MS, + timeoutMode: TimeoutMode = TimeoutMode.IDLE, + discoveryTags: List = selfDiscoveryTags(), + onNotification: (JsonRpcNotification) -> JsonRpcMessage? = { null }, + ): JsonRpcMessage { + val signer = signers.signerFor(identity) + val inbound = Channel(Channel.UNLIMITED) + + // Subscribe first, before anything is published. + val subscription = + relays.subscribe( + pubKey = signer.pubKey, + // Both wrap kinds plus the bare message: CEP-19's fallback means + // either wrap may arrive, and an unencrypted peer sends 25910. + kinds = intArrayOf(CvmKinds.MESSAGE) + CvmKinds.GIFT_WRAPS, + onEvent = { inbound.trySend(it) }, + ) + + try { + // CEP-35: the baseline rides the first direct message only; after + // that both sides omit repeated common discovery tags. + val tags = if (sentFirstMessage) emptyList() else discoveryTags + val request = CvmMessageEvent.create(message, serverPubKey, signer, extraTags = tags) + sentFirstMessage = true + + relays.publish(outbound(request)) + + return when (timeoutMode) { + TimeoutMode.IDLE -> awaitResponse(inbound, signer, request.id, message.id, timeoutMs, onNotification) + TimeoutMode.TOTAL -> + withTimeout(timeoutMs) { + awaitResponse(inbound, signer, request.id, message.id, Long.MAX_VALUE, onNotification) + } + } + } finally { + subscription.close() + inbound.close() + } + } + + /** Sends a notification. Nothing is awaited, so no subscription is opened. */ + suspend fun notify( + message: JsonRpcNotification, + identity: DualSigner.Identity = DualSigner.Identity.EPHEMERAL, + ) { + val signer = signers.signerFor(identity) + relays.publish(outbound(CvmMessageEvent.create(message, serverPubKey, signer))) + } + + /** + * Waits for the answer, allowing [idleMs] of **silence** between the peer's + * messages. [TimeoutMode.TOTAL] passes `Long.MAX_VALUE` and wraps the whole + * thing instead. + * + * A flat deadline cannot express what a CEP-22 or CEP-41 response is. Both + * arrive as a run of notifications, each its own signed, wrapped, published + * relay event, so a large response takes as long as it takes: a 100 KB + * oversized transfer from the reference coordinator ran well past the 20 s + * default and was killed mid-run, with the frames arriving and parsing + * correctly right up to the cancellation. + * + * So the clock measures a stalled peer, which is what a caller actually + * wants bounded, and only the peer can reset it - events from anyone else + * are dropped before this point, or a stranger could hold a call open + * indefinitely by publishing noise addressed to us. + */ + private suspend fun awaitResponse( + inbound: Channel, + signer: NostrSigner, + requestEventId: HexKey, + requestId: JsonRpcId, + idleMs: Long, + onNotification: (JsonRpcNotification) -> JsonRpcMessage?, + ): JsonRpcMessage { + while (true) { + // withTimeout, not a null-returning variant, so silence still + // surfaces as the TimeoutCancellationException callers already + // handle - only when the clock starts has changed. + // receiveCatching tells a closed subscription from a silent one. + val event = + withTimeout(idleMs) { inbound.receiveCatching() } + .getOrNull() + ?: throw CvmTransportException("subscription closed before a response arrived") + val plain = decryptOrNull(event, signer) ?: continue + val wrapped = CvmMessageEvent.fromOrNull(plain) ?: continue + if (wrapped.pubKey != serverPubKey) continue + + discovery.observe(wrapped.discoveryTags().toTypedArray()) + + val decoded = + try { + wrapped.message() + } catch (e: IllegalArgumentException) { + // A malformed payload from the peer is not our request's + // answer; keep waiting rather than failing the call on it. + continue + } + + when (decoded) { + is JsonRpcNotification -> onNotification(decoded)?.let { return it } + + // Correlate on both layers: the `e` tag ties the response to our + // request event, and the JSON-RPC id ties it to our call. Either + // alone is weaker -- a peer may omit the tag on a wrap, and ids + // are only unique within a session. + is JsonRpcSuccess -> + if (matches(wrapped, requestEventId, decoded.id, requestId)) return decoded + + is JsonRpcFailure -> + if (decoded.id == null || matches(wrapped, requestEventId, decoded.id, requestId)) return decoded + + else -> Unit + } + } + } + + /** + * Whether [message] is the answer to our call — from the peer we called. + * + * The sender check is the load-bearing one and it is checked first. This + * client subscribes to everything `p`-tagged to its own key, so **anyone** + * on the relay can gift-wrap a well-formed JSON-RPC response to us; the + * wrap's own signature proves only that its throwaway key signed it, and + * `CvmGiftWrap.unwrap` verifies the INNER signature without knowing who + * the inner signer ought to be. Without this, correlation rests on an `e` + * tag a forger simply omits (the check below skips a missing one) and a + * JSON-RPC id that is small and guessable — so a stranger could answer + * `kp_take` with their own KeyPackage, or `msg_fetch_many` with a stream + * of their choosing. + * + * `serverPubKey` is the coordinator's identity and the only thing that + * identifies it (`spec/00.md` §8.5), so it is exactly the right thing to + * compare against. + */ + private fun matches( + message: CvmMessageEvent, + requestEventId: HexKey, + responseId: JsonRpcId, + requestId: JsonRpcId, + ): Boolean { + if (message.pubKey != serverPubKey) return false + val inReplyTo = message.inReplyTo() + if (inReplyTo != null && inReplyTo != requestEventId) return false + return responseId == requestId + } + + private suspend fun decryptOrNull( + event: Event, + signer: NostrSigner, + ): Event? = + if (CvmKinds.isGiftWrap(event.kind)) { + try { + crypto.unwrap(event, signer) + } catch (e: IllegalStateException) { + // Not addressed to us, or forged. Ignoring is correct: a relay + // may deliver wraps we cannot open, and failing the request on + // one would let anyone disrupt a call. + null + } + } else { + event + } + + private suspend fun outbound(inner: Event): Event = + if (crypto.shouldEncrypt(peerEncrypts())) { + crypto.wrap(inner, serverPubKey, crypto.negotiatedWrapKind(peerTakesEphemeralWrap())) + } else { + inner + } + + companion object { + /** Covers signing, the relay round trip and the peer's own work. */ + const val DEFAULT_TIMEOUT_MS = 20_000L + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/README.md b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/README.md new file mode 100644 index 0000000000..03de70fe16 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/README.md @@ -0,0 +1,207 @@ +# `cordn/` + +**cordn** — MLS group messaging whose delivery service is an MCP server reached +over Nostr, rather than relays. + +Built from `Cordn-msg/cordn` at commit **`b465df0`**, read on 2026-09-18. +Where the prose and the reference implementation disagree, the implementation +wins, because that is what is on the wire — each such case is noted below and +in the KDoc of the code that implements it. + +## Where it sits + +Beside `marmot/` — the other MLS-over-Nostr binding — and on top of `mls/` (the +RFC 9420 engine) and `contextvm/` (MCP over Nostr). All four are quartz +packages, which is what lets cordn use the engine and the transport directly. + +It briefly lived in a `:cordn` Gradle module. The stated reason was that it +needed `:contextvm` and quartz could not depend on that without a cycle — true, +but only because `:contextvm` had been put outside quartz first, for reasons +that did not survive examination either. Marmot is the same shape and three +times the size and has always been a quartz package. + +## Layout + +Packages follow the spec documents, the way quartz follows NIPs. + +| Package | Spec | What | +| ------- | ---- | ---- | +| `spec00Coordinator/` | `spec/00.md` | The eleven coordinator tools, the identity model, KeyPackage publication and its verification | +| `spec01GroupMetadata/` | `spec/01.md` | `cordn_group_metadata` (`0xC04D`) | +| `spec02Envelopes/` | `spec/02.md` | The unsigned Nostr-shaped application envelope | +| `spec03Payloads/` | `spec/03.md` | The ChaCha20-Poly1305 outer seal | +| `appGroupRef/` | `applications/group-ref.md` | `cordn1…` bech32 TLV references | +| `groups/` | — | The MLS binding: credential encoding, capabilities, `CordnGroupPolicy` | +| `sync/` | `spec/00.md` §4-5 | Cursors, self-echo reconciliation, fetch-then-subscribe | + +## Making a cordn group + +One argument turns an `MlsGroup` into a cordn group — capabilities, extension +registry and payload exporter all come from the policy: + +```kotlin +val group = MlsGroup.create( + identity = CordnCredential.of(myPubKeyHex).identity, + policy = CordnGroupPolicy, + initialExtensions = listOf(CordnGroupMetadata(name = "Design").toExtension()), +) +``` + +## Interoperability + +Tested against **ts-mls**, the implementation cordn's own client runs, using +fixtures generated by it. `interop/CordnLifecycleInteropTest` walks a whole +group lifecycle from the other side of the wire: read their KeyPackage, unseal +their commit, join from their Welcome, agree on the epoch exporter byte for +byte, apply their metadata commit, and read their application message. + +Fixtures come from **Staircase** (, +MIT), an independent Kotlin cordn client that vendors this project's own MLS +engine. See `quartz/src/commonTest/resources/tsmls/README.md`. + +Agreeing on the epoch exporter is the assertion that carries the most: it sits +at the end of the entire key schedule, so a one-bit divergence in the tree, the +transcript hash or any epoch secret produces 32 completely different bytes. + +### The other direction + +Reading their output correctly is only half of it. A client can parse +everything and still emit something nobody accepts — and that failure keeps our +own tests green while every peer silently drops us. So ts-mls also checks +**our** output: + +```bash +quartz/interop/verify-with-ts-mls.sh # needs ../cordn and ../staircase checkouts +``` + +`KotlinArtifactProducerTest` builds a group under `CordnGroupPolicy`, adds a +real ts-mls KeyPackage, sends an application message and commits a metadata +change. Staircase's `verify.ts` then has ts-mls join from our Welcome, read our +metadata, derive the same epoch-1 and epoch-2 exporters, decrypt our message, +check the AAD sender and the envelope id, and apply our commit. Ten checks, +all passing. + +The producer test runs in the ordinary suite; only the ts-mls half needs the +extra checkouts, which is why it is a script rather than a test. + +**Use Node, not bun.** Staircase's own `run.sh` uses bun, and bun's WebCrypto +has no X25519 DHKEM, so ts-mls there cannot open a Welcome at all — not even +one it produced itself. It surfaces as `DecapError: The algorithm is not +supported` deep inside HPKE and looks exactly like a wire-format mismatch. The +script pins `node --experimental-strip-types`. + +## Six things that are easy to get wrong + +1. **A cordn group has no admins in the enforcement sense.** `spec/01.md` §5.3 + makes `admin_pubkeys` *presentation* metadata, and neither the spec nor the + reference coordinator restricts who may commit. Any member can commit + anything MLS permits, including removing others. An empty list means + **egalitarian**, permanently — Marmot reads the same empty set as "bootstrap, + gate still open", which is why no authorization code is shared between them. + A UI presenting a cordn admin list as an access-control boundary would be + lying. + +2. **The credential is 64 ASCII bytes of hex, not 32 raw bytes.** `spec/00.md` + §6 requires "the canonical encoded Nostr public key" and explicitly declines + to say which encoding. The reference fixed it as hex-ASCII; Marmot picked + raw. One KeyPackage cannot satisfy both, and it is a one-line change on + either side today. Still open upstream — plan §4.1. + +3. **A self-echo is confirmation, not work.** A Commit we posted comes back + through the same stream as everyone else's traffic. Feeding it through MLS + again advances the epoch twice, and nothing complains until messages stop + decrypting several epochs later. Matching is on the sealed ciphertext, which + is unique per posting because the nonce is fresh (`spec/03.md` §4). + +4. **The cursor advances past messages you skip.** Including undecryptable + ones. A message sealed under an epoch we never had is unreadable forever, so + a cursor that refused to move past it would stall that group permanently + with no error anywhere. Only a *second* catch-up reveals the bug. + +5. **An application message MUST carry `authenticated_data`.** cordn puts the + sender's account pubkey in MLS `authenticated_data` and **rejects** any + application message that arrives with it empty + (`packages/cli/src/groupSync.ts:247`). The spec never mentions this; it is + only in the reference implementation. Use `CordnApplicationMessage`, not + `MlsGroup.encrypt` directly. It also means the binding costs metadata: the + field is authenticated but not encrypted, so the coordinator — which holds + every ciphertext — can read who sent what. + +6. **Encryption must be pinned.** The ContextVM SDK defaults `encryptionMode` + to `OPTIONAL`, which resolves from negotiated session state, so a coordinator + that simply does not announce `support_encryption` gets plaintext JSON-RPC on + public relays — `gid`s, target pubkeys, KeyPackages and cursors, readable by + any relay operator. `contextvm/`'s `CvmGiftWrap` defaults to `REQUIRED` and + fails closed. Do not undo that. + +## What the coordinator learns + +Content: nothing. Double-sealed, and it cannot tell a Commit from a chat line. + +Metadata is the cost, and the stable/ephemeral split in `CoordinatorMethod` is +how it is managed — the identity is fixed per method, not a parameter, so +`msg_post` cannot accidentally ride your real npub. The split is real but +partial: + +- **Admission is in the clear on both ends.** `join_request_store` names your + real key and the `gid`; `welcome_store` names the target's and `welcome_take` + is called by it. For any group joined through a share link the coordinator + observes real-identity membership directly. Structural, not a bug. +- **The ephemeral key is per session, not per message.** One pseudonym touches + every group you hold on that coordinator, so that `gid` set is a stable + fingerprint linking them. `CoordinatorClient` takes its signers from the + caller precisely so the caller decides how often to rotate. + +Plan §8 has the full analysis. + +## Three spec/implementation divergences found + +1. **`spec/01.md` §3 contradicts itself**: "MLS variable-length vector encoding + conventions" and then `opaque Name<0..2^16-1>`, which are different + encodings. The reference emits a plain uint16 length. We match the + reference, and the test derives the expected bytes by hand from the spec + rather than round-tripping — a round trip agrees with itself whichever one + you picked. +2. **`authenticated_data` is a wire requirement the spec omits.** See item 5 + above. A client built from `spec/02.md` alone produces messages every cordn + peer discards. +3. **KeyPackage publication rides JSON-RPC envelope shape**, not a stable + schema: `spec/00.md` §7's "signed publication payload" is the `kp_publish` + request event, and the KeyPackage is recovered by parsing JSON-RPC out of its + `content`. The reference client already carries a fallback from one rename + (`kp_64 ?? keyPackageBase64`); so do we. Worth proposing upstream that + publication gets its own payload or kind — it would also let a cordn + KeyPackage be published to relays and consumed with no coordinator at all. + +## Testing + +```bash +./gradlew :quartz:jvmTest --tests '*.cordn.*' +./gradlew :quartz:testAndroidHostTest --tests '*.cordn.*' +``` + +Protocol-level tests are in `commonTest`; the ones needing real secp256k1 — +anything driving a coordinator over a live transport, and the ts-mls interop +suite — are in `jvmAndroidTest`, so they run on the JVM and the Android host. + +`fixture/CordnFixtureCoordinator` implements the eleven tools in memory and +**records which identity made each call**, which is the only way to test the +privacy claim: it is about what the coordinator learns, so you have to stand on +its side of the wire and look. + +The group-ref tests pin the three golden strings from the reference's own suite +(`packages/core/src/groupRef.test.ts`), where they are cross-checked against an +independent TLV+bech32 assembly. That makes them a genuine cross-implementation +vector — the plan's Tier D — rather than a record of our own output. + +Still open: + +- **Live integration** against `ghcr.io/cordn-msg/cordn:latest` (Tier B). +- **A ts-mls `ClientState` export.** `verify.ts` has an optional gate that + decodes a Kotlin-exported ts-mls state and sends from it; we write no such + file, so it is skipped. It would need our `MlsGroupState` to re-encode into + ts-mls's layout, which is a real piece of work and only matters for + multi-device — an explicit non-goal. +- **Multi-device** is an explicit non-goal — `spec/applications/multi-device.md` + ships a ts-mls-internal serialization, not an MLS wire format. There is + nothing to implement against. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/appEncryptedMedia/CordnBlobUpload.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/appEncryptedMedia/CordnBlobUpload.kt new file mode 100644 index 0000000000..bf4db8fb7f --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/appEncryptedMedia/CordnBlobUpload.kt @@ -0,0 +1,118 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.appEncryptedMedia + +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.utils.sha256.sha256 + +/** + * Everything a blob host is told about a cordn attachment — which is as close + * to nothing as an upload allows. + * + * A value rather than a call so it can be asserted. Each field below is a + * privacy decision, and every one of them fails *silently* if it regresses: + * the upload still succeeds, the group still sees the file, and the only + * difference is what a server learned. Nothing downstream would notice, so the + * only thing that can notice is a test — hence a descriptor that is computed + * once and forwarded verbatim, rather than seven literal arguments at a call + * site. + * + * | told | withheld | + * | --- | --- | + * | the ciphertext and its length | the plaintext | + * | `sha256(ciphertext)` | `sha256(plaintext)`, which rides in the `imeta` | + * | `application/octet-stream` | the real MIME type | + * | a hex filename | the real filename | + * | who uploaded it | which group it is for, and what it is | + * + * Blossom addresses a blob by the hash of the bytes it stores, so [hash] must + * be the **ciphertext** hash. [CordnMediaTag] carries the **plaintext** hash. + * Two hashes on purpose: the host needs one to name the blob, the group needs + * the other to know it received the file that was sent, and neither can be + * derived from the other. + */ +data class CordnBlobUpload( + /** What the host stores. */ + val bytes: ByteArray, + /** `sha256(bytes)`, hex — how Blossom names the blob. */ + val hash: String, + /** + * The name to upload under. The hex hash, never the real filename: a name + * like `bank-statement.pdf` describes the file to a server that is + * supposed to see opaque bytes. + */ + val baseFileName: String, + /** + * Always [OPAQUE]. The real type is in the sealed `imeta` descriptor; + * declaring `image/jpeg` here would tell the host what kind of file this + * is for no benefit to anybody. + */ + val contentType: String, + /** + * Always null. Alt text is plaintext on a Blossom upload, and an alt + * string describing a private photo *is* the photo's caption, handed to + * the one party that was supposed to see nothing. + */ + val alt: String?, + /** Always null, for the same reason as [alt]. */ + val sensitiveContent: String?, + /** + * Always false — `/upload`, never `/media`. The `/media` endpoint asks the + * server to re-encode, and re-encoding ciphertext destroys it. An account + * with "optimize uploads" turned on would otherwise break every + * attachment, and only the recipient would find out. + */ + val useMediaEndpoint: Boolean, +) { + val length: Long get() = bytes.size.toLong() + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is CordnBlobUpload) return false + return bytes.contentEquals(other.bytes) && + hash == other.hash && + baseFileName == other.baseFileName && + contentType == other.contentType && + alt == other.alt && + sensitiveContent == other.sensitiveContent && + useMediaEndpoint == other.useMediaEndpoint + } + + override fun hashCode(): Int = 31 * bytes.contentHashCode() + hash.hashCode() + + companion object { + /** What the blob host is told the type is. */ + const val OPAQUE = "application/octet-stream" + + fun of(media: CordnEncryptedMedia): CordnBlobUpload { + val hash = sha256(media.ciphertext).toHexKey() + return CordnBlobUpload( + bytes = media.ciphertext, + hash = hash, + baseFileName = hash, + contentType = OPAQUE, + alt = null, + sensitiveContent = null, + useMediaEndpoint = false, + ) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/appEncryptedMedia/CordnMediaCipher.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/appEncryptedMedia/CordnMediaCipher.kt new file mode 100644 index 0000000000..b73b85ccd4 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/appEncryptedMedia/CordnMediaCipher.kt @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.appEncryptedMedia + +import com.vitorpamplona.quartz.utils.ciphers.NostrCipher + +/** + * cordn's encrypted media as a [NostrCipher], so the ordinary media pipeline + * can open it. + * + * Registering one of these against a blob URL in the encryption key cache is + * what lets `EncryptedBlobInterceptor` decrypt the download in flight, which in + * turn lets a cordn attachment go through `ZoomableContentView` like every + * other image, video and voice note in the app. Before this existed the cordn + * chat fetched and decrypted blobs by hand and drew its own `Image`, an "Open + * " button and its own audio player — so the one chat with encrypted + * media was the one chat whose media did not look like the rest of the app. + * + * The same shape as `Mip04Cipher` and `EncryptedMediaV2Cipher`: a sending + * constructor that produces the nonce, and a receiving one that is handed the + * nonce and hash off the `imeta` tag. + */ +class CordnMediaCipher( + /** The group's epoch media key — see [CordnMediaEncryption.mediaKey]. */ + private val mediaKey: ByteArray, + /** Canonical media type: the `m` field, byte-for-byte, because it is in the AAD. */ + val mimeType: String, + /** The `filename` field, byte-for-byte, for the same reason. */ + val filename: String, +) : NostrCipher { + var nonce: ByteArray = ByteArray(0) + private set + + var plaintextHash: ByteArray = ByteArray(0) + private set + + /** + * The RECEIVING side, where the nonce and the plaintext hash come off the + * tag rather than out of [encrypt]. + * + * Without it the object could only decrypt what the same instance had just + * encrypted, which is the sender's case and nobody else's. + */ + constructor( + mediaKey: ByteArray, + attachment: CordnMediaAttachment, + ) : this(mediaKey, attachment.mimeType, attachment.filename) { + nonce = attachment.nonceBytes + plaintextHash = attachment.hashBytes + } + + override fun name(): String = CordnMediaTag.VERSION_V1 + + override fun encrypt(bytesToEncrypt: ByteArray): ByteArray { + val sealed = CordnMediaEncryption.encrypt(bytesToEncrypt, mediaKey, mimeType, filename) + nonce = sealed.nonce + plaintextHash = sealed.plaintextHash + return sealed.ciphertext + } + + override fun decrypt(bytesToDecrypt: ByteArray): ByteArray = + CordnMediaEncryption.decrypt( + ciphertext = bytesToDecrypt, + fileKey = mediaKey, + nonce = nonce, + plaintextHash = plaintextHash, + mimeType = mimeType, + filename = filename, + ) + + override fun decryptOrNull(bytesToDecrypt: ByteArray): ByteArray? = + try { + decrypt(bytesToDecrypt) + } catch (_: Exception) { + null + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/appEncryptedMedia/CordnMediaEncryption.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/appEncryptedMedia/CordnMediaEncryption.kt new file mode 100644 index 0000000000..c3d65ce272 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/appEncryptedMedia/CordnMediaEncryption.kt @@ -0,0 +1,207 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.appEncryptedMedia + +import com.vitorpamplona.quartz.cordn.groups.CordnGroupPolicy +import com.vitorpamplona.quartz.mls.group.MlsGroup +import com.vitorpamplona.quartz.nip44Encryption.crypto.ChaCha20Poly1305 +import com.vitorpamplona.quartz.utils.RandomInstance +import com.vitorpamplona.quartz.utils.sha256.sha256 + +/** + * cordn's encrypted-media codec. + * + * ## Not MIP-04, and not a refactor of it + * + * §4.5 of `quartz/plans/2026-09-17-cordn-interop.md` records the divergence. + * The primitives are shared — ChaCha20-Poly1305, NIP-92 `imeta`, Blossom — and + * the codecs are not: + * + * | | Marmot MIP-04 v2 | cordn | + * | --- | --- | --- | + * | file key | `HKDF-Expand(exporter, context)`, per file | `MLS-Exporter("cordn", "encrypted-media", 32)`, per epoch | + * | AAD | `"mip04-v2"‖0‖hash‖0‖mime‖0‖filename` | `mime‖0‖filename‖0‖hash` | + * + * Feeding one's blob to the other produces an authentication failure, which is + * the correct outcome and the reason this is a separate file rather than a + * flag on [com.vitorpamplona.quartz.marmot.mip04EncryptedMedia.Mip04MediaEncryption]. + * + * ## The key is the epoch exporter, not a per-file random + * + * This file used to mint a random key per file and ship it in the `imeta` tag, + * so that an attachment outlived the epoch it was sent in. It was a reasonable + * trade to want and the wrong one to make unilaterally: it is not what + * `spec/applications/encrypted-media.md` §3.1 specifies, the key it put on the + * wire is one §4 says is "never transmitted", and it omitted the required + * `v cordn-em-v1`. Measured against the live cordn.net client, every Amethyst + * attachment arrived as an empty bubble. + * + * The durability cost is real and is the spec's intent: the key "rotates + * automatically with each MLS epoch advance, inheriting the forward-secrecy + * and post-compromise-security properties of the group". Since + * `exporterSecret` answers only for the epoch the group is on now, media from + * an earlier epoch cannot currently be re-opened. Keeping old media readable + * is a matter of caching the derived key per epoch locally, which changes + * nothing on the wire — unlike the previous approach, which changed the wire + * and broke every other client. + */ +object CordnMediaEncryption { + const val KEY_LENGTH = 32 + const val NONCE_LENGTH = 12 + + private val NULL = byteArrayOf(0x00) + + /** + * The group's media key for its current epoch. + * + * `spec/applications/encrypted-media.md` §3.1 makes this a derivation, not + * a choice: `MLS-Exporter("cordn", "encrypted-media", 32)`. The key + * therefore rotates with every epoch and is never transmitted, which is + * what lets a cordn client that has only the group state open the blob. + * + * Losing older media at an epoch advance is the intended consequence, not + * a defect to design around: the spec says the key "rotates automatically + * with each MLS epoch advance, inheriting the forward-secrecy and + * post-compromise-security properties of the group". An earlier version of + * this file used a fresh random key per file and shipped it in the `imeta` + * tag to avoid that loss. It bought re-readable history at the price of + * both the forward secrecy and interoperability: cordn.net could not open + * a single Amethyst attachment, and rendered each as an empty bubble. + */ + fun mediaKey(group: MlsGroup): ByteArray = CordnGroupPolicy.MEDIA_EXPORTER.let { group.exporterSecret(it.label, it.context, it.length) } + + /** + * Encrypts [plaintext] for a group, binding it to its type and name. + * + * [mimeType] and [filename] are authenticated, not encrypted: a recipient + * who is handed the same bytes under a different name or type gets an + * authentication failure rather than a file that opens as something else. + */ + fun encrypt( + plaintext: ByteArray, + fileKey: ByteArray, + mimeType: String, + filename: String, + ): CordnEncryptedMedia { + require(fileKey.size == KEY_LENGTH) { "a cordn media key is $KEY_LENGTH bytes" } + + val hash = sha256(plaintext) + val nonce = RandomInstance.bytes(NONCE_LENGTH) + val ciphertext = ChaCha20Poly1305.encrypt(plaintext, aad(mimeType, filename, hash), nonce, fileKey) + + return CordnEncryptedMedia( + ciphertext = ciphertext, + nonce = nonce, + plaintextHash = hash, + mimeType = mimeType, + filename = filename, + ) + } + + /** + * Decrypts and then checks the plaintext against [plaintextHash]. + * + * The AEAD tag already proves the ciphertext was not altered, so the hash + * check catches the other thing: a sender whose declared hash does not + * describe what they actually encrypted. That matters because the hash is + * what a recipient would use to recognise or deduplicate a file, and + * because it is the one field the AAD binds that the blob store also sees. + */ + fun decrypt( + ciphertext: ByteArray, + fileKey: ByteArray, + nonce: ByteArray, + plaintextHash: ByteArray, + mimeType: String, + filename: String, + ): ByteArray { + require(fileKey.size == KEY_LENGTH) { "a cordn media key is $KEY_LENGTH bytes" } + require(nonce.size == NONCE_LENGTH) { "a cordn media nonce is $NONCE_LENGTH bytes" } + require(plaintextHash.size == 32) { "a sha256 hash is 32 bytes" } + + val plaintext = ChaCha20Poly1305.decrypt(ciphertext, aad(mimeType, filename, plaintextHash), nonce, fileKey) + check(sha256(plaintext).contentEquals(plaintextHash)) { + "the decrypted file does not match the hash it was sent with" + } + return plaintext + } + + /** + * `mime ‖ 0x00 ‖ filename ‖ 0x00 ‖ sha256(plaintext)`. + * + * The hash is last here and first in MIP-04. There is no reason to prefer + * either; matching cordn is the whole requirement, and a fixed-length + * field at the end means the two variable-length ones are still + * unambiguously separated by their NULs. + */ + private fun aad( + mimeType: String, + filename: String, + plaintextHash: ByteArray, + ): ByteArray { + val mime = mimeType.encodeToByteArray() + val name = filename.encodeToByteArray() + + val out = ByteArray(mime.size + 1 + name.size + 1 + plaintextHash.size) + var at = 0 + mime.copyInto(out, at) + at += mime.size + NULL.copyInto(out, at) + at += 1 + name.copyInto(out, at) + at += name.size + NULL.copyInto(out, at) + at += 1 + plaintextHash.copyInto(out, at) + return out + } +} + +/** One encrypted file, plus everything a recipient needs to open it. */ +data class CordnEncryptedMedia( + val ciphertext: ByteArray, + val nonce: ByteArray, + val plaintextHash: ByteArray, + val mimeType: String, + val filename: String, +) { + // Arrays, so the generated equals/hashCode would compare identity. Written + // out because a data class that silently means the wrong thing is worse + // than one without them. + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is CordnEncryptedMedia) return false + return ciphertext.contentEquals(other.ciphertext) && + nonce.contentEquals(other.nonce) && + plaintextHash.contentEquals(other.plaintextHash) && + mimeType == other.mimeType && + filename == other.filename + } + + override fun hashCode(): Int { + var result = ciphertext.contentHashCode() + result = 31 * result + nonce.contentHashCode() + result = 31 * result + plaintextHash.contentHashCode() + result = 31 * result + mimeType.hashCode() + result = 31 * result + filename.hashCode() + return result + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/appEncryptedMedia/CordnMediaTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/appEncryptedMedia/CordnMediaTag.kt new file mode 100644 index 0000000000..705dfc159d --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/appEncryptedMedia/CordnMediaTag.kt @@ -0,0 +1,193 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.appEncryptedMedia + +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey + +/** + * How a cordn message points at an encrypted file. + * + * A NIP-92 `imeta` tag, which is what every Nostr client already parses, with + * the fields a recipient needs to fetch and open the blob. It rides **inside** + * the MLS-encrypted envelope, so the coordinator and the blob host see none of + * it — the host sees an opaque upload, the coordinator sees a sealed payload. + * + * The hash is of the **plaintext**, not of the blob. That is deliberate: it is + * what the AAD binds ([CordnMediaEncryption]), so a recipient can tell an + * altered file from a re-encoded one, and it is useless to the blob host, + * which only ever holds the ciphertext. + * + * cordn has a `v` field too, and it is required: + * `spec/applications/encrypted-media.md` §4 fixes it at `cordn-em-v1` and says + * a client "MUST reject tags whose `v` field is absent or names an unknown + * version". An earlier version of this file asserted the opposite — that there + * was no cordn equivalent of Marmot's — and omitted it. cordn.net duly + * rejected every Amethyst attachment and drew an empty bubble. + */ +object CordnMediaTag { + const val TAG_NAME = "imeta" + + const val URL = "url" + const val MIME_TYPE = "m" + const val FILENAME = "filename" + const val HASH = "x" + const val NONCE = "n" + + /** + * The encryption version. Required, and only ever [VERSION_V1]. + * + * No key field accompanies it: §3.1 derives the key from the group's + * epoch exporter and §4 states it is "never transmitted, never stored in + * `imeta`". See [CordnMediaEncryption.mediaKey]. + */ + const val VERSION = "v" + + const val VERSION_V1 = "cordn-em-v1" + const val DIMENSIONS = "dim" + const val BLURHASH = "blurhash" + const val THUMBHASH = "thumbhash" + const val ALT = "alt" + + /** + * Amplitudes for the voice-note bars, space-separated floats. + * + * On `MediaRecorder.maxAmplitude`'s own scale (0..32767), which is what the + * rest of the app already puts on the wire for kind-1222 voice notes — see + * `VoiceAnonymizer`, which multiplies back up by 32768 to match. The + * renderer normalises whatever range it is given, so the absolute scale + * only has to be consistent with the app's other waveforms, and is. + * + * NOT in `spec/applications/encrypted-media.md` §5, which lists no such + * field. It is written as one more of the display hints the spec does + * define (`dim`, `blurhash`, `thumbhash`, `alt`) and describes as "passed + * through unchanged" — so a client that does not know it ignores it, and a + * client that does gets the bars the recorder already measured. The + * recorder produces them either way; without somewhere to put them they + * were thrown away at upload and the sender's own message came back + * bar-less. + * + * Kept out of the AAD deliberately: it is a hint about the file, not a + * claim the ciphertext is bound to, and putting it in the AAD would make + * the blob undecryptable to anyone who wrote the floats differently. + */ + const val WAVEFORM = "waveform" + + /** Builds the `imeta` tag for an uploaded [media] at [url]. */ + fun build( + media: CordnEncryptedMedia, + url: String, + dimensions: String? = null, + blurhash: String? = null, + thumbhash: String? = null, + alt: String? = null, + waveform: List? = null, + ): Array = + buildList { + add(TAG_NAME) + add("$URL $url") + add("$MIME_TYPE ${media.mimeType}") + add("$FILENAME ${media.filename}") + add("$HASH ${media.plaintextHash.toHexKey()}") + add("$NONCE ${media.nonce.toHexKey()}") + add("$VERSION $VERSION_V1") + dimensions?.let { add("$DIMENSIONS $it") } + blurhash?.let { add("$BLURHASH $it") } + thumbhash?.let { add("$THUMBHASH $it") } + // A newline in alt would split the tag field, so it is flattened. + alt?.takeIf { it.isNotBlank() }?.let { add("$ALT ${it.replace('\n', ' ')}") } + waveform?.takeIf { it.isNotEmpty() }?.let { add("$WAVEFORM ${it.joinToString(" ")}") } + }.toTypedArray() + + /** + * Reads every attachment in [tags], skipping any that is not usable. + * + * A tag missing a field, or carrying a nonce or hash of the wrong length, + * is dropped rather than returned half-formed: the only thing a caller + * could do with a partial descriptor is attempt a decrypt that must fail, + * and a message with one broken attachment should still show its other + * attachments and its text. + */ + fun parseAll(tags: TagArray): List = + tags.mapNotNull { tag -> + if (tag.getOrNull(0) != TAG_NAME) return@mapNotNull null + + val fields = + tag + .drop(1) + .mapNotNull { field -> + val at = field.indexOf(' ') + if (at <= 0) null else field.substring(0, at) to field.substring(at + 1) + }.toMap() + + val url = fields[URL] ?: return@mapNotNull null + val mime = fields[MIME_TYPE] ?: return@mapNotNull null + val filename = fields[FILENAME] ?: return@mapNotNull null + val hash = fields[HASH]?.takeIf { it.length == HASH_HEX_LENGTH } ?: return@mapNotNull null + val nonce = fields[NONCE]?.takeIf { it.length == NONCE_HEX_LENGTH } ?: return@mapNotNull null + // §4: an absent or unknown version is a rejection, not something to + // guess at — the bytes under it are a format we have not agreed on. + if (fields[VERSION] != VERSION_V1) return@mapNotNull null + + CordnMediaAttachment( + url = url, + mimeType = mime, + filename = filename, + plaintextHash = hash, + nonce = nonce, + dimensions = fields[DIMENSIONS], + blurhash = fields[BLURHASH], + thumbhash = fields[THUMBHASH], + alt = fields[ALT], + // A malformed number makes the whole hint useless rather than + // the message: drop the bars, keep the attachment. + waveform = + fields[WAVEFORM] + ?.split(' ') + ?.mapNotNull { it.toFloatOrNull() } + ?.takeIf { it.isNotEmpty() }, + ) + } + + private const val HASH_HEX_LENGTH = 64 + private const val NONCE_HEX_LENGTH = 24 +} + +/** One encrypted attachment, as a message advertises it. */ +data class CordnMediaAttachment( + val url: String, + val mimeType: String, + val filename: String, + val plaintextHash: String, + val nonce: String, + val dimensions: String? = null, + val blurhash: String? = null, + val thumbhash: String? = null, + val alt: String? = null, + val waveform: List? = null, +) { + val hashBytes: ByteArray get() = plaintextHash.hexToByteArray() + val nonceBytes: ByteArray get() = nonce.hexToByteArray() + + val isImage: Boolean get() = mimeType.startsWith("image/") + val isAudio: Boolean get() = mimeType.startsWith("audio/") +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/appGroupRef/CordnGroupRef.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/appGroupRef/CordnGroupRef.kt new file mode 100644 index 0000000000..e5a6978156 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/appGroupRef/CordnGroupRef.kt @@ -0,0 +1,160 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.appGroupRef + +import com.vitorpamplona.quartz.cordn.tlv.CordnStrictTlv +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip19Bech32.bech32.Bech32 +import com.vitorpamplona.quartz.nip19Bech32.tlv.TlvBuilder + +/** + * A `cordn1…` group reference — `spec/applications/group-ref.md`. + * + * One checksummed string carrying the three coordinates needed to reach a + * group: the delivery `gid`, optionally which coordinator serves it, and + * optionally where that coordinator is reachable. Bech32 with NIP-19's TLV + * layout, so existing Nostr tooling reads it. + * + * It is a locator and authorizes nothing. Holding one lets you ask to join + * (see `spec/applications/join-requests.md`); it does not make you a member. + */ +data class CordnGroupRef( + /** + * The delivery group identifier, exactly as the producing client uses it. + * + * Opaque: not a UUID, not a hash, and specifically **not** the MLS + * `group_id`. §4.1 requires it to round-trip byte for byte with no + * trimming or re-encoding, because the coordinator keys its cursor space + * on these bytes. + */ + val gid: String, + /** The coordinator serving this group, as lowercase hex. */ + val coordinatorPubKey: HexKey? = null, + /** Where to reach that coordinator. Meaningless, and invalid, without one. */ + val relays: List = emptyList(), +) { + init { + require(gid.isNotEmpty()) { "cordn group ref gid must not be empty" } + require(gid.encodeToByteArray().size <= MAX_TLV_VALUE) { + "cordn group ref gid must be at most $MAX_TLV_VALUE bytes (the TLV length field is one byte)" + } + require(coordinatorPubKey == null || coordinatorPubKey.length == PUBKEY_HEX_LENGTH) { + "cordn group ref coordinator pubkey must be 32 bytes" + } + require(relays.isEmpty() || coordinatorPubKey != null) { + "cordn group ref carries relays with no coordinator pubkey: a relay names where to reach a coordinator" + } + } + + /** + * Encodes as `cordn1…`. + * + * TLV elements go out in descending type order to match NIP-19's reference + * encoder, though §3 requires decoders to accept any order. + */ + fun encode(): String { + val builder = TlvBuilder() + relays.forEach { builder.addString(TLV_RELAY, it) } + coordinatorPubKey?.let { builder.addHex(TLV_COORDINATOR, it) } + builder.addString(TLV_GID, gid) + return Bech32.encodeBytes(HRP, builder.build(), Bech32.Encoding.Bech32) + } + + companion object { + /** Encoded refs begin `cordn1`. */ + const val HRP = "cordn" + + /** UTF-8 `gid`. Exactly one. */ + const val TLV_GID: Byte = 0 + + /** Raw 32-byte coordinator pubkey. At most one. */ + const val TLV_COORDINATOR: Byte = 1 + + /** UTF-8 relay URL. Any number, but only alongside a coordinator. */ + const val TLV_RELAY: Byte = 2 + + /** §2: the NIP-19 bound, present only to cap decoding work. */ + const val MAX_LENGTH = 5000 + + private const val MAX_TLV_VALUE = 255 + private const val PUBKEY_HEX_LENGTH = 64 + + /** Names this type in decode errors. */ + private const val SUBJECT = "cordn group ref" + private const val PUBKEY_SIZE = 32 + + /** Decodes a `cordn1…` reference, or throws with the rule it broke. */ + fun decode(encoded: String): CordnGroupRef { + require(encoded.length <= MAX_LENGTH) { + "cordn group ref is longer than $MAX_LENGTH characters" + } + // Bech32 forbids mixed case, and Bech32.decode lowercases rather + // than rejecting, so the check has to happen before the call. + require(encoded == encoded.lowercase() || encoded == encoded.uppercase()) { + "cordn group ref must be all lowercase or all uppercase" + } + + val (hrp, bytes, encoding) = Bech32.decodeBytes(encoded.lowercase(), false) + require(hrp == HRP) { "not a cordn group ref: prefix is '$hrp', expected '$HRP'" } + // §2 pins bech32, not bech32m. Accepting either would let two + // encodings of the same ref exist, and NIP-19 made the same choice. + require(encoding == Bech32.Encoding.Bech32) { + "cordn group ref must use bech32, not bech32m" + } + + val tlv = CordnStrictTlv.parse(bytes, SUBJECT) + + val gids = tlv[TLV_GID].orEmpty() + require(gids.size == 1) { "cordn group ref must carry exactly one gid, found ${gids.size}" } + require(gids[0].isNotEmpty()) { "cordn group ref gid must not be empty" } + + val coordinators = tlv[TLV_COORDINATOR].orEmpty() + require(coordinators.size <= 1) { "cordn group ref must carry at most one coordinator pubkey" } + coordinators.forEach { + require(it.size == PUBKEY_SIZE) { + "cordn group ref coordinator pubkey must be $PUBKEY_SIZE bytes, was ${it.size}" + } + } + + // §5 lets a consumer discard an empty relay rather than reject the + // whole reference, which is the kinder reading of a producer bug. + val relays = tlv[TLV_RELAY].orEmpty().map { CordnStrictTlv.utf8(it, SUBJECT, "relay") }.filter { it.isNotEmpty() } + require(relays.isEmpty() || coordinators.isNotEmpty()) { + "cordn group ref carries a relay with no coordinator pubkey" + } + + return CordnGroupRef( + gid = CordnStrictTlv.utf8(gids[0], SUBJECT, "gid"), + coordinatorPubKey = coordinators.firstOrNull()?.toHexKey(), + relays = relays, + ) + } + + /** Decodes, or null when the string is not a valid reference. */ + fun decodeOrNull(encoded: String): CordnGroupRef? = + try { + decode(encoded) + } catch (e: IllegalArgumentException) { + null + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/appMultiDevice/CordnDeviceDocument.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/appMultiDevice/CordnDeviceDocument.kt new file mode 100644 index 0000000000..184dfa77ed --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/appMultiDevice/CordnDeviceDocument.kt @@ -0,0 +1,409 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.appMultiDevice + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.long +import kotlinx.serialization.json.put + +/** + * The two sealed documents of `spec/applications/multi-device.md` §4. + * + * ## What this is for here + * + * The spec's subject is a fleet of devices sharing one MLS leaf and converging + * continuously. Amethyst uses the same documents for the narrow case that has + * no convergence problem: **moving an account to a new phone**. One device + * writes a snapshot, one device reads it, and the writer then stops (see the + * handoff lock). Nothing here implements §8.5 `prev` chains, §10 sibling-Commit + * convergence or §10.5's publish-on-every-Commit — those exist to keep two + * *live* devices in step, which migration does not ask for. + * + * ## The one field that is not interoperable + * + * `clientState` is `base64(serialized MLS ClientState)`, and §4.2 says outright + * that it is *library-serialized and intentionally not pinned to a wire format* + * — TLS is pinned only for the meta document's key package. So the field holds + * whatever the writing implementation's engine produces: ts-mls for the + * reference client, [CLIENT_STATE_FORMAT] for us. The two cannot read each + * other, by the spec's design rather than by omission. + * + * Because the spec gives no place to say which one a document carries, a reader + * would otherwise discover the mismatch as a deserialization crash deep inside + * its MLS engine. [CordnGroupDocument.clientStateFormat] is an additive field + * that lets a reader check first and refuse with a reason. Unknown fields are + * ignored by both sides, so it costs a foreign reader nothing. + */ +object CordnDeviceDocument { + const val SCHEMA_VERSION = 1 + + /** §4.1 `type`. */ + const val TYPE_GROUP = "group" + + /** §4.2 `type`. */ + const val TYPE_META = "meta" + + /** + * What Amethyst puts in `clientState`: our own `MlsGroupState` encoding. + * + * Not a spec field. See the class KDoc for why it exists. + */ + const val CLIENT_STATE_FORMAT = "amethyst.MlsGroupState.v1" + + private const val SCHEMA_VERSION_FIELD = "schemaVersion" + private const val TYPE = "type" + private const val ISSUED_AT = "issuedAt" + private const val PREV = "prev" + private const val GID = "gid" + private const val COORDINATOR = "coordinator" + private const val CLIENT_STATE = "clientState" + private const val CLIENT_STATE_FORMAT_FIELD = "clientStateFormat" + private const val CURSOR = "cursor" + private const val ROOM_STATE = "amethystRoomState" + private const val ECHO_STATE = "amethystEchoState" + private const val JOINED_VIA_REQUEST = "amethystJoinedViaRequest" + private const val MESSAGES = "amethystMessages" + private const val COORDINATOR_RELAYS = "amethystCoordinatorRelays" + private const val KEY_PACKAGES = "amethystKeyPackages" + private const val COORDINATOR_PUBKEY = "coordinator" + private const val REF = "ref" + private const val BUNDLE = "bundle" + private const val REMOVED = "removed" + private const val EPOCH = "epoch" + private const val LAST_RESORT_KEY_PACKAGE = "lastResortKeyPackage" + private const val KEY_PACKAGE = "keyPackage" + private const val PRIVATE_KEY_PACKAGE = "privateKeyPackage" + + private val json = Json { ignoreUnknownKeys = true } + + /** §5: the plaintext is UTF-8 JSON, with no canonical form required. */ + fun encode(document: CordnDeviceDoc): String = + when (document) { + is CordnGroupDocument -> encodeGroup(document) + is CordnMetaDocument -> encodeMeta(document) + } + + /** + * Reads a decrypted document. + * + * Throws [CordnDocumentException] rather than returning null: by the time a + * caller has a plaintext it has already fetched a blob whose hash matched + * the tip's advertised address, so a document that does not parse is a real + * fault worth a reason, not one candidate among many to skip. + */ + fun decode(plaintext: String): CordnDeviceDoc { + val root = + try { + json.parseToJsonElement(plaintext) as? JsonObject + } catch (e: IllegalArgumentException) { + throw CordnDocumentException("document is not valid JSON: ${e.message}") + } ?: throw CordnDocumentException("document is not a JSON object") + + val version = root.intOrNull(SCHEMA_VERSION_FIELD) + // §4.1/§4.2: "Clients MUST reject documents with an unknown schema version." + if (version != SCHEMA_VERSION) { + throw CordnDocumentException("unsupported document schemaVersion: $version") + } + + return when (val type = root.stringOrNull(TYPE)) { + TYPE_GROUP -> decodeGroup(root) + TYPE_META -> decodeMeta(root) + else -> throw CordnDocumentException("unknown document type: $type") + } + } + + private fun encodeGroup(document: CordnGroupDocument) = + buildJsonObject { + put(SCHEMA_VERSION_FIELD, SCHEMA_VERSION) + put(TYPE, TYPE_GROUP) + put(ISSUED_AT, document.issuedAt) + document.prev?.let { put(PREV, it) } + put(GID, document.gid) + put(COORDINATOR, document.coordinator) + put(CLIENT_STATE, document.clientState) + put(CLIENT_STATE_FORMAT_FIELD, document.clientStateFormat) + put(CURSOR, document.cursor) + document.roomState?.let { put(ROOM_STATE, it) } + document.messages + ?.takeIf { it.isNotEmpty() } + ?.let { entries -> put(MESSAGES, buildJsonArray { entries.forEach { add(JsonPrimitive(it)) } }) } + document.echoState?.let { put(ECHO_STATE, it) } + if (document.joinedViaRequest) put(JOINED_VIA_REQUEST, true) + if (document.coordinatorRelays.isNotEmpty()) { + put(COORDINATOR_RELAYS, buildJsonArray { document.coordinatorRelays.forEach { add(JsonPrimitive(it)) } }) + } + }.toString() + + private fun decodeGroup(root: JsonObject): CordnGroupDocument = + CordnGroupDocument( + gid = root.stringOrNull(GID) ?: throw CordnDocumentException("group document requires a gid"), + coordinator = + root.stringOrNull(COORDINATOR) + ?: throw CordnDocumentException("group document requires a coordinator"), + clientState = + root.stringOrNull(CLIENT_STATE) + ?: throw CordnDocumentException("group document requires a clientState"), + cursor = root.longOrNull(CURSOR) ?: throw CordnDocumentException("group document requires a cursor"), + issuedAt = root.longOrNull(ISSUED_AT) ?: 0L, + prev = root.stringOrNull(PREV), + // Absent means a writer that predates the marker, or the reference + // client. Either way it is not ours; say so rather than guess. + clientStateFormat = root.stringOrNull(CLIENT_STATE_FORMAT_FIELD), + roomState = root.stringOrNull(ROOM_STATE), + messages = (root[MESSAGES] as? JsonArray)?.map { (it as JsonPrimitive).content }, + echoState = root.stringOrNull(ECHO_STATE), + joinedViaRequest = root.boolOrNull(JOINED_VIA_REQUEST) ?: false, + coordinatorRelays = + (root[COORDINATOR_RELAYS] as? JsonArray) + ?.filterIsInstance() + ?.filter { it.isString } + ?.map { it.content } + .orEmpty(), + ) + + private fun encodeMeta(document: CordnMetaDocument) = + buildJsonObject { + put(SCHEMA_VERSION_FIELD, SCHEMA_VERSION) + put(TYPE, TYPE_META) + put(ISSUED_AT, document.issuedAt) + if (document.removed.isNotEmpty()) { + put( + REMOVED, + buildJsonArray { + document.removed.forEach { + add( + buildJsonObject { + put(GID, it.gid) + put(EPOCH, it.epoch) + }, + ) + } + }, + ) + } + if (document.keyPackages.isNotEmpty()) { + put( + KEY_PACKAGES, + buildJsonArray { + document.keyPackages.forEach { + add( + buildJsonObject { + put(COORDINATOR_PUBKEY, it.coordinatorPubKey) + put(REF, it.keyPackageRef) + put(BUNDLE, it.bundle) + }, + ) + } + }, + ) + } + document.lastResortKeyPackage?.let { + put( + LAST_RESORT_KEY_PACKAGE, + buildJsonObject { + put(KEY_PACKAGE, it.keyPackage) + put(PRIVATE_KEY_PACKAGE, it.privateKeyPackage) + }, + ) + } + }.toString() + + private fun decodeMeta(root: JsonObject): CordnMetaDocument { + val removed = + (root[REMOVED] as? JsonArray) + ?.filterIsInstance() + ?.mapNotNull { + val gid = it.stringOrNull(GID) ?: return@mapNotNull null + val epoch = it.longOrNull(EPOCH) ?: return@mapNotNull null + CordnTombstone(gid, epoch) + }.orEmpty() + + val keyPackage = + (root[LAST_RESORT_KEY_PACKAGE] as? JsonObject)?.let { + val public = it.stringOrNull(KEY_PACKAGE) + val private = it.stringOrNull(PRIVATE_KEY_PACKAGE) + if (public == null || private == null) { + // §4.2 needs both halves to be usable at join time; half an + // entry would resolve a Welcome and then fail to open it. + throw CordnDocumentException("lastResortKeyPackage requires both halves") + } + CordnLastResortKeyPackage(public, private) + } + + val keyPackages = + (root[KEY_PACKAGES] as? JsonArray) + ?.filterIsInstance() + ?.mapNotNull { + val coordinator = it.stringOrNull(COORDINATOR_PUBKEY) ?: return@mapNotNull null + val ref = it.stringOrNull(REF) ?: return@mapNotNull null + val bundle = it.stringOrNull(BUNDLE) ?: return@mapNotNull null + CordnCarriedKeyPackage(coordinator, ref, bundle) + }.orEmpty() + + return CordnMetaDocument( + removed = removed, + lastResortKeyPackage = keyPackage, + keyPackages = keyPackages, + issuedAt = root.longOrNull(ISSUED_AT) ?: 0L, + ) + } + + private fun JsonObject.stringOrNull(name: String) = (this[name] as? JsonPrimitive)?.takeIf { it.isString }?.content + + private fun JsonObject.longOrNull(name: String) = + (this[name] as? JsonPrimitive)?.takeIf { !it.isString }?.let { + try { + it.long + } catch (e: NumberFormatException) { + null + } + } + + private fun JsonObject.intOrNull(name: String) = longOrNull(name)?.toInt() + + private fun JsonObject.boolOrNull(name: String) = (this[name] as? JsonPrimitive)?.takeIf { !it.isString }?.content?.toBooleanStrictOrNull() +} + +/** A document that is not readable. See [CordnDeviceDocument.decode]. */ +class CordnDocumentException( + message: String, +) : Exception(message) + +/** One of the two §4 document types. */ +sealed interface CordnDeviceDoc + +/** + * §4.1 — one group's MLS state and delivery cursor. + * + * [cursor] and [clientState] must be a snapshot taken at the same instant: + * §4.1 requires that ingesting the stream up to and including [cursor] leaves + * the writer at the epoch encoded in [clientState]. A reader trusts that, + * because it has no way to check it. + */ +data class CordnGroupDocument( + val gid: String, + /** The coordinator serving [gid], so a seeded device knows where to fetch. */ + val coordinator: String, + /** `base64(serialized MLS ClientState)`, in [clientStateFormat]. */ + val clientState: String, + val cursor: Long, + val issuedAt: Long = 0L, + /** + * §4.1's per-`gid` chain link. + * + * Always null on a migration snapshot: the chain exists for a live fleet + * replaying skipped epochs (§8.5), and a handoff publishes one generation. + */ + val prev: String? = null, + /** See [CordnDeviceDocument.CLIENT_STATE_FORMAT]. Null when the writer said nothing. */ + val clientStateFormat: String? = CordnDeviceDocument.CLIENT_STATE_FORMAT, + /** + * `base64(CordnRoomStateCodec)` — the draft and the read position. + * + * Additive, like [clientStateFormat], and for the same reason: the spec's + * document carries group *state*, and these are the per-device reading + * position on top of it. A fleet syncing continuously can regard them as + * device-local; a phone being replaced cannot, because losing them is + * visible to the user as every conversation coming back unread with the + * half-typed message gone. + */ + val roomState: String? = null, + /** + * The conversation, as `CordnDeliveredMessageCodec` entries, oldest first. + * + * Additive like [roomState], and the most load-bearing of the additions. A + * cordn message is readable exactly once — at ingest — because both of its + * seal keys are epoch-derived and the [cursor] this document carries has + * already advanced past everything behind it. A device seeded without this + * cannot fetch the history back from anywhere: it would arrive holding + * every group and no conversation, permanently. + */ + val messages: List? = null, + /** + * `base64(EchoStateCodec)` — pending commits and own-message cursors. + * + * Without it the new device re-reports its predecessor's own traffic as a + * gap on first sync. Same additive justification as [roomState]. + */ + val echoState: String? = null, + /** Whether this group was entered by join request rather than invitation. */ + val joinedViaRequest: Boolean = false, + /** + * Where the coordinator is reachable. + * + * §4.1's `coordinator` field is an identity, and `spec/00.md` §8.5 says a + * coordinator has no address beyond its pubkey — so the relays its traffic + * runs on have to come from somewhere, and for a device that has never + * talked to it there is nowhere else. A seeded group whose coordinator has + * no relays is a group that cannot sync. + */ + val coordinatorRelays: List = emptyList(), +) : CordnDeviceDoc { + /** Whether this device's MLS engine can read [clientState] at all. */ + val isReadableHere: Boolean get() = clientStateFormat == CordnDeviceDocument.CLIENT_STATE_FORMAT +} + +/** §4.2 — identity-level state: tombstones and the account's last-resort key package. */ +data class CordnMetaDocument( + val removed: List = emptyList(), + val lastResortKeyPackage: CordnLastResortKeyPackage? = null, + /** + * Every key package bundle this account holds, one-use ones included. + * + * §11.5 carries only the last-resort package, because a *fleet* keeps + * one-use packages device-local: a sibling that cannot open one Welcome is + * an inconvenience while the publishing device is still around. A device + * being replaced is not still around, so a Welcome in flight against one of + * its one-use packages would be lost outright. Additive for that reason. + */ + val keyPackages: List = emptyList(), + val issuedAt: Long = 0L, +) : CordnDeviceDoc + +/** One key package bundle travelling with a migration. `bundle` is base64. */ +data class CordnCarriedKeyPackage( + val coordinatorPubKey: String, + val keyPackageRef: String, + val bundle: String, +) + +/** §4.2 `removed` — "stopped tracking [gid] while it was at [epoch]". */ +data class CordnTombstone( + val gid: String, + val epoch: Long, +) + +/** + * §4.2 / §11.5 — the account's one reusable key package, both halves. + * + * Unlike `clientState` this **is** pinned: §4.2 says TLS is the only MLS wire + * serialization, so both fields are base64 of the RFC 9420 TLS encoding and + * carry across implementations. + */ +data class CordnLastResortKeyPackage( + val keyPackage: String, + val privateKeyPackage: String, +) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/appMultiDevice/CordnDeviceTip.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/appMultiDevice/CordnDeviceTip.kt new file mode 100644 index 0000000000..4fb2efcc15 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/appMultiDevice/CordnDeviceTip.kt @@ -0,0 +1,164 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.appMultiDevice + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Kind +import com.vitorpamplona.quartz.nip01Core.core.Tag + +/** + * §6 — the inventory a device publishes so another device can find its documents. + * + * ## Two events, two jobs + * + * The **outer** event is an addressable kind-[OUTER_KIND] event signed by an + * ephemeral key that has nothing to do with the owner. It exists to be + * replaceable on a relay and to leak nothing: an observer sees an unknown + * account updating an opaque, randomly-`d`-tagged event. + * + * The **inner** event — this file's subject — is signed by the owner and sealed + * inside the outer one's content. It is the authenticity guarantee: the outer + * signature only says "whoever holds the ephemeral key put this here", which a + * leaked connection string also lets someone do. What makes a document + * inventory trustworthy is the owner's signature on the inner event, so a + * reader MUST verify it and MUST NOT act on the outer event alone. + * + * ## Why the ephemeral key is not derived from the owner + * + * §6 forbids deriving it. A public derivation would let anyone compute the + * signing pubkey from an `npub` and query for that person's tip, which is + * precisely the linkage the design exists to prevent. [CordnConnectionString] + * mints it randomly for the same reason. + */ +object CordnDeviceTip { + /** The outer, relayed, addressable event. A coordination detail (§14). */ + const val OUTER_KIND: Kind = 30078 + + /** The inner, sealed, never-relayed event. A coordination detail (§14). */ + const val INNER_KIND: Kind = 178 + + const val TAG_X = "x" + const val TAG_DEK = "dek" + const val TAG_SERVER = "server" + const val TAG_D = "d" + + /** The `x` tag's 3rd element for a group document. */ + const val KIND_GROUP = "group" + + /** The `x` tag's 3rd element for the meta document. */ + const val KIND_META = "meta" + + private const val DEK_HEX_LENGTH = 64 + + /** The tags of the inner event for [inventory]. */ + fun tags(inventory: CordnTipInventory): Array = + buildList { + inventory.groups.forEach { add(arrayOf(TAG_X, it.address, KIND_GROUP, it.gid)) } + inventory.meta?.let { add(arrayOf(TAG_X, it, KIND_META)) } + add(arrayOf(TAG_DEK, inventory.dekPrivateKey)) + // Ordered: §6 says a reader tries them in listed order, so the most + // reliable host goes first. + inventory.servers.forEach { add(arrayOf(TAG_SERVER, it)) } + }.toTypedArray() + + /** + * Reads a verified inner event. + * + * The caller is responsible for having decrypted the outer content and + * checked the inner signature first — this only reads tags, and a tag set + * says nothing about who wrote it. + * + * @throws CordnDocumentException when the inventory is unusable: no DEK, a + * malformed DEK, or a `group` entry with no `gid`. A tip that cannot name + * its documents is not a tip a device can act on partially. + */ + fun parse(inner: Event): CordnTipInventory { + if (inner.kind != INNER_KIND) { + throw CordnDocumentException("tip inner event has kind ${inner.kind}, expected $INNER_KIND") + } + + val groups = mutableListOf() + var meta: String? = null + var dek: String? = null + val servers = mutableListOf() + + inner.tags.forEach { tag -> + if (tag.size < 2) return@forEach + when (tag[0]) { + TAG_X -> + when (tag.getOrNull(2)) { + KIND_GROUP -> { + val gid = + tag.getOrNull(3) + ?: throw CordnDocumentException("a group x tag carries no gid") + groups += CordnTipEntry(address = tag[1], gid = gid) + } + // §4.3 allows exactly one meta entry. A second is a + // malformed tip; take the first and ignore the rest + // rather than fail, since either could be the real one + // and the address check still gates what we accept. + KIND_META -> if (meta == null) meta = tag[1] + else -> Unit + } + TAG_DEK -> if (dek == null) dek = tag[1] + TAG_SERVER -> servers += tag[1] + else -> Unit + } + } + + val dekKey = dek ?: throw CordnDocumentException("tip carries no dek tag") + if (dekKey.length != DEK_HEX_LENGTH || !dekKey.all { it.isHexDigit() }) { + throw CordnDocumentException("tip dek is not 64 hex chars") + } + + return CordnTipInventory( + groups = groups, + meta = meta, + dekPrivateKey = dekKey.lowercase(), + servers = servers, + ) + } + + private fun Char.isHexDigit() = this in '0'..'9' || this in 'a'..'f' || this in 'A'..'F' +} + +/** One `x`-tagged group document in a tip. */ +data class CordnTipEntry( + /** `sha256` of the sealed blob; also its key on the content store. */ + val address: String, + val gid: String, +) + +/** + * What a tip advertises. + * + * [dekPrivateKey] is the whole reason the inner event is sealed to the owner: + * it is a private key in the clear inside that seal, and it opens every + * document listed here. + */ +data class CordnTipInventory( + val groups: List, + val meta: String?, + val dekPrivateKey: HexKey, + /** Content-store hosts, in the order a reader should try them (§6). */ + val servers: List, +) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/appMultiDevice/CordnDocumentSeal.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/appMultiDevice/CordnDocumentSeal.kt new file mode 100644 index 0000000000..3ebae9e6bf --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/appMultiDevice/CordnDocumentSeal.kt @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.appMultiDevice + +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip44Encryption.Nip44v2 +import com.vitorpamplona.quartz.utils.sha256.sha256 + +/** + * §7 — sealing a document to the per-identity document encryption key. + * + * ## Why a DEK rather than sealing to the owner + * + * Sealing each document straight to the owner `npub` would bind every document + * decrypt to the owner `nsec` — one signer round-trip per document, which on a + * remote NIP-46 signer over a bad connection is the difference between a + * migration that finishes and one that does not. The DEK is a throwaway Nostr + * keypair; its private half rides inside the tip's owner-sealed inner event, so + * one NIP-44 decrypt of the tip yields a key every later document decrypt uses + * locally. + * + * ## What the seal does and does not give you + * + * Confidentiality only. It is a self-seal — sender and recipient are both the + * DEK — so anyone holding the DEK can encrypt as easily as decrypt. Documents + * carry no signature. **Authenticity comes from the tip**, whose inner event is + * signed by the owner, and integrity of a particular blob comes from the + * address: [address] is `sha256` of the sealed bytes, and §6 requires a reader + * to check it against what the tip advertised before trusting the content. + * + * Nothing here is a substitute for that check. A blob that decrypts is not a + * blob that anybody authorised. + */ +object CordnDocumentSeal { + private val nip44 = Nip44v2() + + /** A fresh DEK. One per identity, reused across every publish (§7). */ + fun newKey(): KeyPair = KeyPair() + + /** + * Seals [document] to [dek]. + * + * NIP-44 v2 uses a random nonce, so the same document seals to different + * bytes — and therefore a different [address] — every time. §5 says that + * explicitly: no canonical form is required, because the address is over + * ciphertext and could not enable dedup anyway. + */ + fun seal( + document: CordnDeviceDoc, + dek: KeyPair, + ): ByteArray = nip44.encrypt(CordnDeviceDocument.encode(document), dek.privKey!!, dek.pubKey).encodePayload().encodeToByteArray() + + /** + * Opens a sealed blob. + * + * @throws CordnDocumentException when the blob does not decrypt, or decrypts + * to something that is not a readable document. Both are the same class of + * fault to a caller that has already matched the address. + */ + fun open( + blob: ByteArray, + dek: KeyPair, + ): CordnDeviceDoc { + val plaintext = + try { + nip44.decrypt(blob.decodeToString(), dek.privKey!!, dek.pubKey) + } catch (e: Exception) { + throw CordnDocumentException("sealed document did not decrypt: ${e.message}") + } + return CordnDeviceDocument.decode(plaintext) + } + + /** + * §6 — a document's address is `sha256` of its **sealed** bytes, lowercase + * hex, and doubles as the content-addressed store key. + */ + fun address(blob: ByteArray): String = sha256(blob).toHexKey() + + /** + * The §6 check a reader MUST perform before trusting a fetched blob. + * + * Separate from [open] on purpose: the order matters. Verifying the address + * first means a blob that fails was never decrypted, so a store that serves + * the wrong bytes cannot get its plaintext in front of the parser at all. + */ + fun verifyAddress( + blob: ByteArray, + expected: String, + ): Boolean = address(blob).equals(expected, ignoreCase = true) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/appMultiDevice/CordnHandoffCode.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/appMultiDevice/CordnHandoffCode.kt new file mode 100644 index 0000000000..8eb19fa587 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/appMultiDevice/CordnHandoffCode.kt @@ -0,0 +1,190 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.appMultiDevice + +import com.vitorpamplona.quartz.cordn.tlv.CordnStrictTlv +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip19Bech32.bech32.Bech32 +import com.vitorpamplona.quartz.nip19Bech32.tlv.TlvBuilder + +/** + * Everything a new phone needs to find the old phone's tip — the §6/§11 + * connection string, as one scannable `cordndev1…` code. + * + * ## What it carries, and what it deliberately does not + * + * The tip lives at `(ephemeral pubkey, d)` on some relays, so the code carries + * those. It carries **no owner key material**: §4.3 is explicit that the + * documents do not provision identity, and neither does this. The user signs + * in on the new phone first, by whatever means they already use — nsec, Amber, + * a bunker — and only then migrates cordn state onto it. + * + * ## Why the write key is usually absent + * + * §11's connection string bundles the ephemeral `nsec` so a newly added device + * can publish its own tip moves. A device being *migrated to* has no such need: + * there is no fleet to stay in step with, and when that phone is itself + * replaced one day it mints a fresh ephemeral keypair for its own handoff. + * + * Leaving the key out shrinks what a photographed QR is worth. Without it the + * code is a locator for an event anyone could already fetch and nobody but the + * owner can decrypt — the tip's content is NIP-44-sealed to the owner `npub` + * — so a leak reveals nothing. With it, a leak also buys the ability to repoint + * the tip at a stale-but-valid inventory: denial of service, per §13, but a + * real one. [writeKey] therefore defaults to null and is populated only when a + * caller genuinely wants the §11 semantics. + */ +data class CordnHandoffCode( + /** The tip's author. Independent of the owner identity, per §6. */ + val ephemeralPubKey: HexKey, + /** The tip's `d` value: random, opaque, stable across republishes. */ + val dTag: String, + /** Where to look for the tip. */ + val relays: List, + /** The outer event kind, carried so a later change cannot strand old codes. */ + val kind: Int = CordnDeviceTip.OUTER_KIND, + /** The ephemeral private key, when this code grants tip writes. See the class doc. */ + val writeKey: HexKey? = null, +) { + init { + require(ephemeralPubKey.length == PUBKEY_HEX_LENGTH) { + "a handoff code's ephemeral pubkey must be 32 bytes of hex" + } + require(writeKey == null || writeKey.length == PUBKEY_HEX_LENGTH) { + "a handoff code's write key must be 32 bytes of hex" + } + require(dTag.isNotEmpty()) { "a handoff code must carry the tip's d tag" } + require(relays.isNotEmpty()) { "a handoff code with no relays cannot find the tip" } + } + + /** Whether this code lets its holder move the tip, not merely read it. */ + val grantsWrite: Boolean get() = writeKey != null + + /** + * The same code with the write key removed. + * + * What a device shares when it wants the other side to read and nothing + * more — which for a migration is every time. + */ + fun readOnly(): CordnHandoffCode = if (writeKey == null) this else copy(writeKey = null) + + /** Encodes as `cordndev1…`. TLV order mirrors NIP-19's reference encoder. */ + fun encode(): String { + val builder = TlvBuilder() + builder.addHexIfNotNull(TLV_WRITE_KEY, writeKey) + relays.forEach { builder.addString(TLV_RELAY, it) } + builder.addInt(TLV_KIND, kind) + builder.addString(TLV_D, dTag) + builder.addHex(TLV_PUBKEY, ephemeralPubKey) + return Bech32.encodeBytes(HRP, builder.build(), Bech32.Encoding.Bech32) + } + + companion object { + /** Encoded codes begin `cordndev1`. */ + const val HRP = "cordndev" + + /** Raw 32-byte ephemeral pubkey. Exactly one. */ + const val TLV_PUBKEY: Byte = 0 + + /** UTF-8 `d` value. Exactly one. */ + const val TLV_D: Byte = 1 + + /** UTF-8 relay URL. At least one. */ + const val TLV_RELAY: Byte = 2 + + /** Big-endian outer event kind. At most one; defaults when absent. */ + const val TLV_KIND: Byte = 3 + + /** Raw 32-byte ephemeral private key. At most one; usually absent. */ + const val TLV_WRITE_KEY: Byte = 4 + + const val MAX_LENGTH = 5000 + + private const val PUBKEY_HEX_LENGTH = 64 + private const val PUBKEY_SIZE = 32 + + /** Names this type in decode errors. */ + private const val SUBJECT = "handoff code" + + private fun ByteArray.toInt32(): Int? = + if (size != Int.SIZE_BYTES) { + null + } else { + (this[0].toInt() and 0xFF shl 24) or + (this[1].toInt() and 0xFF shl 16) or + (this[2].toInt() and 0xFF shl 8) or + (this[3].toInt() and 0xFF) + } + + /** Decodes a `cordndev1…` code, or throws with the rule it broke. */ + fun decode(encoded: String): CordnHandoffCode { + require(encoded.length <= MAX_LENGTH) { + "a handoff code is longer than $MAX_LENGTH characters" + } + // Bech32.decode lowercases rather than rejecting mixed case, so the + // check has to happen before the call. + require(encoded == encoded.lowercase() || encoded == encoded.uppercase()) { + "a handoff code must be all lowercase or all uppercase" + } + + val (hrp, bytes, encoding) = Bech32.decodeBytes(encoded.lowercase(), false) + require(hrp == HRP) { "not a $SUBJECT: prefix is '$hrp', expected '$HRP'" } + // bech32, never bech32m — the same choice CordnGroupRef makes, so + // one code never has two valid spellings. + require(encoding == Bech32.Encoding.Bech32) { "a $SUBJECT must use bech32, not bech32m" } + + val tlv = CordnStrictTlv.parse(bytes, SUBJECT) + + val pubKeys = tlv[TLV_PUBKEY].orEmpty() + require(pubKeys.size == 1) { "a $SUBJECT must carry exactly one ephemeral pubkey, found ${pubKeys.size}" } + require(pubKeys[0].size == PUBKEY_SIZE) { "a $SUBJECT ephemeral pubkey must be $PUBKEY_SIZE bytes" } + + val dTags = tlv[TLV_D].orEmpty() + require(dTags.size == 1) { "a $SUBJECT must carry exactly one d tag, found ${dTags.size}" } + + val writeKeys = tlv[TLV_WRITE_KEY].orEmpty() + require(writeKeys.size <= 1) { "a $SUBJECT must carry at most one write key" } + writeKeys.forEach { require(it.size == PUBKEY_SIZE) { "a $SUBJECT write key must be $PUBKEY_SIZE bytes" } } + + val kinds = tlv[TLV_KIND].orEmpty() + require(kinds.size <= 1) { "a $SUBJECT must carry at most one kind" } + + return CordnHandoffCode( + ephemeralPubKey = pubKeys[0].toHexKey(), + dTag = CordnStrictTlv.utf8(dTags[0], SUBJECT, "d tag"), + // An empty relay entry is a producer bug, not a reason to + // refuse the whole code — the same reading CordnGroupRef takes. + relays = tlv[TLV_RELAY].orEmpty().map { CordnStrictTlv.utf8(it, SUBJECT, "relay") }.filter { it.isNotEmpty() }, + kind = kinds.firstOrNull()?.toInt32() ?: CordnDeviceTip.OUTER_KIND, + writeKey = writeKeys.firstOrNull()?.toHexKey(), + ) + } + + /** Decodes, or null when [encoded] is not a well-formed handoff code. */ + fun decodeOrNull(encoded: String): CordnHandoffCode? = + try { + decode(encoded) + } catch (e: Exception) { + null + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/fixture/CordnFixtureCoordinator.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/fixture/CordnFixtureCoordinator.kt new file mode 100644 index 0000000000..58fadf944a --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/fixture/CordnFixtureCoordinator.kt @@ -0,0 +1,443 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.fixture + +import com.vitorpamplona.quartz.contextvm.cep41OpenStreams.OpenStreamFrame +import com.vitorpamplona.quartz.contextvm.fixture.CvmRequest +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcError +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcFailure +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcId +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcMessage +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcNotification +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcSuccess +import com.vitorpamplona.quartz.contextvm.transfer.ProgressToken +import com.vitorpamplona.quartz.cordn.spec00Coordinator.CoordinatorFields +import com.vitorpamplona.quartz.cordn.spec00Coordinator.CoordinatorMethod +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.messages.MlsKeyPackage +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.long +import kotlinx.serialization.json.put +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +/** + * An in-memory cordn coordinator for tests. + * + * It implements the eleven tools against maps, and — more usefully — it + * RECORDS which identity made each call. The privacy claim in `spec/00.md` §8 + * is not about what the coordinator stores, it is about what it learns, so the + * only way to test it is to be the coordinator and look. + * + * Deliberately not hardened; `cordn-rs` exists for real deployments. Like + * `:contextvm`'s fixture, this one can also be told to misbehave — see + * [rejectPublication] and [rejectRemoval]. + */ +@OptIn(ExperimentalEncodingApi::class) +class CordnFixtureCoordinator( + /** Serve `kp_publish` back as if it had been stored, keyed by ref. */ + private val publications: MutableMap = mutableMapOf(), + /** Reject every publication, as a coordinator enforcing §8 would. */ + var rejectPublication: Boolean = false, + /** Reject every withdrawal, as a coordinator that is simply down would. */ + var rejectRemoval: Boolean = false, +) { + data class StoredKeyPackage( + val pubKey: HexKey, + val keyPackageRef: String, + val lastResort: Boolean, + val at: Long, + val publicationEvent: Event?, + ) + + data class Call( + val method: String, + /** The caller pubkey the coordinator learned, via CEP-16 `_meta`. */ + val callerPubKey: HexKey?, + /** Exactly the arguments object that arrived, for wire-shape assertions. */ + val arguments: JsonObject = JsonObject(emptyMap()), + ) + + /** Every call seen, with who made it. The privacy surface, made observable. */ + val calls = mutableListOf() + + /** + * Answers keyed by tool name that replace this fixture's own bookkeeping, + * served verbatim as `structuredContent`. + * + * For replaying a result recorded from another implementation: the fixture + * models the coordinator well enough to drive a client, but a result it + * composed itself only proves we agree with ourselves. + */ + val scriptedResults = mutableMapOf() + + private val welcomes = mutableListOf() + private val joinRequests = mutableListOf() + private val messages = mutableMapOf>() + private var nextCursor = 1L + private var clock = 1_700_000_000L + + /** Messages posted to [gid], oldest first. */ + fun posted(gid: String): List = messages[gid].orEmpty().map { it[CoordinatorFields.MSG_64]!!.jsonPrimitive.content } + + /** Pre-seeds a group's stream, as history a client will catch up on. */ + fun seed( + gid: String, + sealedBase64: String, + ): Long { + val cursor = nextCursor++ + messages.getOrPut(gid) { mutableListOf() } += + buildJsonObject { + put(CoordinatorFields.GID, gid) + put(CoordinatorFields.CURSOR, cursor) + put(CoordinatorFields.MSG_64, sealedBase64) + put(CoordinatorFields.AT, clock++) + } + return cursor + } + + /** Pre-seeds a Welcome in an account's inbox. */ + fun seedWelcome( + keyPackageRef: String, + welcomeBase64: String, + after: Long? = null, + /** Who this Welcome is addressed to. A fetch by anyone else will not see it. */ + targetPubKey: HexKey, + ) { + welcomes += + buildJsonObject { + put(CoordinatorFields.TARGET_PK, targetPubKey) + put(CoordinatorFields.KP_REF, keyPackageRef) + put(CoordinatorFields.WELCOME_64, welcomeBase64) + put(CoordinatorFields.AT, clock++) + after?.let { put(CoordinatorFields.AFTER, it) } + } + } + + /** How many single-use KeyPackages this coordinator still holds for [pubKey]. */ + fun availableCount(pubKey: HexKey): Int = keyPackagesOf(pubKey).count { !it.lastResort } + + /** Every KeyPackage this coordinator holds for [pubKey], reusable ones included. */ + fun keyPackagesOf(pubKey: HexKey): List = publications.values.filter { it.pubKey == pubKey } + + /** Reads the last-resort marker out of a base64 KeyPackage, as a real one would. */ + private fun isLastResort(keyPackageBase64: String): Boolean = + try { + MlsKeyPackage.decodeTls(TlsReader(Base64.decode(keyPackageBase64))).isLastResort() + } catch (e: Exception) { + // Unparseable bytes are not our problem here: the client verifies + // the publication payload (§9), and a fixture that threw would hide + // that check behind a transport error. + false + } + + /** Stores a publication event so `kp_take` can serve it back verbatim (§7). */ + fun seedPublication( + keyPackageRef: String, + event: Event, + ) { + publications[keyPackageRef] = + StoredKeyPackage(event.pubKey, keyPackageRef, false, clock++, event) + } + + /** + * Sends CEP-41 stream frames back to the caller, for `msg_sub_many`. + * + * Null by default, and a subscription then returns an empty result rather + * than failing: a fixture that needs the streaming half wires this to the + * server's `reply`, and one that only drives catch-up should not have to + * know the difference. + */ + var emitStream: (suspend (frames: List, clientPubKey: HexKey, requestEventId: HexKey) -> Unit)? = null + + /** The `progressToken` CEP-41 frames must carry to reach the caller's receiver. */ + private fun progressToken(params: JsonObject?): ProgressToken? = + params + ?.get("_meta") + ?.jsonObject + ?.get("progressToken") + ?.jsonPrimitive + ?.content + ?.let { ProgressToken.Text(it) } + + /** The handler to hand to `CvmFixtureServer`. */ + suspend fun handle(request: CvmRequest): JsonRpcMessage { + val params = request.params + val id = request.id + val name = params?.get("name")?.jsonPrimitive?.content ?: request.method + val args = params?.get("arguments")?.jsonObject ?: buildJsonObject {} + val caller = + params + ?.get("_meta") + ?.jsonObject + ?.get("clientPubkey") + ?.jsonPrimitive + ?.content + calls += Call(name, caller, args) + + scriptedResults[name]?.let { return success(id, it) } + + val structured = + when (name) { + CoordinatorMethod.KP_PUBLISH.wire -> { + if (rejectPublication) { + return JsonRpcFailure(id, JsonRpcError(-32000, "publication rejected")) + } + val ref = args.str(CoordinatorFields.KP_REF) + // `spec/00.md` §7: cordn has no KeyPackage event kind, so the + // signed publication payload IS this request event. A real + // coordinator reaches back into its transport for it and + // stores it verbatim; storing anything else here would make + // `kp_take` unverifiable and hide the §9 checks from tests. + // Whether it is reusable is a property of the KeyPackage + // itself, so a real coordinator reads it out of the bytes + // rather than trusting a flag. Ours does the same, which is + // why `kp_list` can report it and a take can respect it. + val lastResort = isLastResort(args.str(CoordinatorFields.KP_64)) + publications[ref] = StoredKeyPackage(caller.orEmpty(), ref, lastResort, clock++, request.event) + buildJsonObject { + put(CoordinatorFields.KP_REF, ref) + put(CoordinatorFields.LAST_RESORT, lastResort) + put(CoordinatorFields.AT, clock) + } + } + + CoordinatorMethod.KP_LIST.wire -> + buildJsonObject { + put( + CoordinatorFields.KEY_PACKAGES, + buildJsonArray { + publications.values.forEach { + add( + buildJsonObject { + put(CoordinatorFields.PK, it.pubKey) + put(CoordinatorFields.KP_REF, it.keyPackageRef) + put(CoordinatorFields.LAST_RESORT, it.lastResort) + put(CoordinatorFields.AT, it.at) + }, + ) + } + }, + ) + } + + CoordinatorMethod.KP_TAKE.wire -> { + val id = args.str(CoordinatorFields.ID) + val stored = publications[id] ?: publications.values.firstOrNull { it.pubKey == id } + // A take is a take: the method is `consumeKeyPackage` and a + // single-use KeyPackage is gone once somebody has it, which + // is what makes a client's pool drain and need topping up. + // A last-resort package survives, by definition -- it can + // back several Welcomes. + if (stored != null && !stored.lastResort) publications.remove(stored.keyPackageRef) + buildJsonObject { + if (stored?.publicationEvent == null) { + put(CoordinatorFields.KEY_PACKAGE, JsonNull) + } else { + put( + CoordinatorFields.KEY_PACKAGE, + buildJsonObject { + put(CoordinatorFields.PK, stored.pubKey) + put(CoordinatorFields.KP_REF, stored.keyPackageRef) + put(CoordinatorFields.LAST_RESORT, stored.lastResort) + put(CoordinatorFields.AT, stored.at) + put( + CoordinatorFields.EVENT, + Json.parseToJsonElement(OptimizedJsonMapper.toJson(stored.publicationEvent)), + ) + }, + ) + } + } + } + + CoordinatorMethod.KP_REMOVE.wire -> { + if (rejectRemoval) { + return JsonRpcFailure(id, JsonRpcError(-32000, "removal rejected")) + } + val refs = args[CoordinatorFields.KP_REFS]!!.jsonArray.map { it.jsonPrimitive.content } + refs.forEach { publications.remove(it) } + buildJsonObject { + put(CoordinatorFields.KP_REFS, buildJsonArray { refs.forEach { add(Json.parseToJsonElement("\"$it\"")) } }) + } + } + + CoordinatorMethod.WELCOME_TAKE.wire -> { + val consumed = + args[CoordinatorFields.CONSUMED] + ?.jsonArray + ?.map { + it.jsonObject.str(CoordinatorFields.KP_REF) to it.jsonObject[CoordinatorFields.AT]!!.jsonPrimitive.long + }.orEmpty() + welcomes.removeAll { w -> + consumed.any { it.first == w.str(CoordinatorFields.KP_REF) && it.second == w[CoordinatorFields.AT]!!.jsonPrimitive.long } + } + // Addressed, not broadcast: `welcome-delivery.md` has the + // coordinator store a Welcome "addressed to a specific + // invited member" and serve it on that member's fetch. + // Returning everyone's would let one account join a group it + // was never invited to -- and would make a two-party test + // pass for the wrong reason. + val mine = welcomes.filter { it.str(CoordinatorFields.TARGET_PK) == caller } + buildJsonObject { put(CoordinatorFields.WELCOMES, buildJsonArray { mine.forEach { add(it) } }) } + } + + CoordinatorMethod.WELCOME_STORE.wire -> { + // The stored record is the arguments PLUS the `at` the + // coordinator assigns: `welcome_take` reports it, and + // `consumed` identifies a record by (kp_ref, at) because one + // last-resort KeyPackage can back several Welcomes. Storing + // the bare arguments loses it -- which only a real + // store-then-fetch round trip notices. + val at = clock++ + welcomes += JsonObject(args + mapOf(CoordinatorFields.AT to JsonPrimitive(at))) + buildJsonObject { put(CoordinatorFields.AT, at) } + } + + CoordinatorMethod.JOIN_REQUEST_STORE.wire -> { + // One `at`, stored and returned. Taking it once and using + // it twice is the point: the caller acks with what it was + // told, so a stored value that differs by even one can + // never be retired. + val at = clock++ + joinRequests += + buildJsonObject { + put(CoordinatorFields.GID, args.str(CoordinatorFields.GID)) + put(CoordinatorFields.PK, caller.orEmpty()) + put(CoordinatorFields.KP_REF, args.str(CoordinatorFields.KP_REF)) + put(CoordinatorFields.AT, at) + } + buildJsonObject { put(CoordinatorFields.AT, at) } + } + + CoordinatorMethod.JOIN_REQUEST_TAKE_MANY.wire -> { + // `consumed` retires, exactly as `welcome_take` does above. + // Ignoring it made retirement a no-op in every test that + // runs against this fixture, so an answered request came + // back forever and nothing could catch it. + val consumed = + args[CoordinatorFields.CONSUMED] + ?.jsonArray + ?.map { + Triple( + it.jsonObject.str(CoordinatorFields.GID), + it.jsonObject.str(CoordinatorFields.PK), + it.jsonObject[CoordinatorFields.AT]!!.jsonPrimitive.long, + ) + }.orEmpty() + joinRequests.removeAll { r -> + consumed.any { + it.first == r.str(CoordinatorFields.GID) && + it.second == r.str(CoordinatorFields.PK) && + it.third == r[CoordinatorFields.AT]!!.jsonPrimitive.long + } + } + + val gids = args[CoordinatorFields.GROUPS]!!.jsonArray.map { it.jsonObject.str(CoordinatorFields.GID) } + buildJsonObject { + put( + CoordinatorFields.REQUESTS, + buildJsonArray { joinRequests.filter { it.str(CoordinatorFields.GID) in gids }.forEach { add(it) } }, + ) + } + } + + CoordinatorMethod.MSG_POST.wire -> { + val gid = args.str(CoordinatorFields.GID) + val cursor = seed(gid, args.str(CoordinatorFields.MSG_64)) + buildJsonObject { + put(CoordinatorFields.GID, gid) + put(CoordinatorFields.CURSOR, cursor) + put(CoordinatorFields.AT, clock) + } + } + + CoordinatorMethod.MSG_FETCH_MANY.wire -> + buildJsonObject { + put(CoordinatorFields.MESSAGES, buildJsonArray { after(args).forEach { add(it) } }) + } + + CoordinatorMethod.MSG_SUB_MANY.wire -> { + // The one tool whose answer is not its result. A real + // coordinator holds the call open and pushes each message + // as a CEP-41 stream fragment; the result arrives when the + // stream ends. [emitStream] is how a fixture reaches the + // transport to do that -- the handler signature returns one + // message, which is exactly what a subscription is not. + val token = progressToken(params) + val emit = emitStream + if (token != null && emit != null) { + var progress = 1.0 + val frames = mutableListOf() + frames += OpenStreamFrame.start(token, progress).envelope.toNotification() + after(args).forEachIndexed { index, message -> + progress += 1.0 + frames += + OpenStreamFrame + .chunk(token, progress, index.toLong(), Json.encodeToString(JsonObject.serializer(), message)) + .envelope + .toNotification() + } + frames += OpenStreamFrame.close(token, progress + 1.0).envelope.toNotification() + emit(frames, caller.orEmpty(), request.event.id) + } + buildJsonObject {} + } + + else -> buildJsonObject {} + } + + return success(id, structured) + } + + private fun success( + id: JsonRpcId, + structured: JsonObject, + ) = JsonRpcSuccess( + id, + buildJsonObject { + put("content", buildJsonArray {}) + put(CoordinatorFields.STRUCTURED_CONTENT, structured) + }, + ) + + /** The messages each requested group has after its cursor. */ + fun after(args: JsonObject): List = + args[CoordinatorFields.GROUPS]!!.jsonArray.flatMap { entry -> + val group = entry.jsonObject + val gid = group.str(CoordinatorFields.GID) + val cursor = group[CoordinatorFields.AFTER]?.jsonPrimitive?.long ?: 0L + messages[gid].orEmpty().filter { it[CoordinatorFields.CURSOR]!!.jsonPrimitive.long > cursor } + } + + private fun JsonObject.str(key: String) = this[key]!!.jsonPrimitive.content +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/groups/CordnCredential.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/groups/CordnCredential.kt new file mode 100644 index 0000000000..8b198457b7 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/groups/CordnCredential.kt @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.groups + +import com.vitorpamplona.quartz.mls.group.MlsGroup +import com.vitorpamplona.quartz.mls.tree.Credential +import com.vitorpamplona.quartz.mls.tree.LeafNode +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** + * How cordn writes a Nostr pubkey into an MLS BasicCredential. + * + * **64 ASCII bytes of lowercase hex, not the 32 raw bytes.** `spec/00.md` §6 + * requires "the canonical encoded Nostr public key" and then explicitly + * declines to say which encoding, leaving it "an application-wide convention + * to be fixed uniformly by implementations". The reference implementation + * fixed it as UTF-8 of the hex string + * (`packages/cli/src/utils/mlsIdentity.ts:createCredential`), and the + * coordinator reads it back with a `TextDecoder` and string-compares it to the + * calling event's pubkey (`packages/server/src/coordinatorMethods.ts:128`). + * + * Marmot picked the other one — raw 32 bytes — so a Marmot KeyPackage and a + * cordn KeyPackage are not interchangeable, and this is the single line where + * that divergence lives. It is a one-line change on either side today and + * unfixable once either has users at scale; see §4.1 of + * `quartz/plans/2026-09-17-cordn-interop.md`, which is still open upstream. + * + * A credential is a CLAIM, never proof. `spec/00.md` §6 is explicit that + * BasicCredential alone establishes nothing; the binding comes from the signed + * publication payload, which [com.vitorpamplona.quartz.cordn.spec00Coordinator] + * verifies. + */ +object CordnCredential { + /** The BasicCredential a cordn leaf carries for [pubKeyHex]. */ + fun of(pubKeyHex: HexKey): Credential.Basic { + require(isCanonical(pubKeyHex)) { + "cordn credential identity must be 64 lowercase hex chars, was '$pubKeyHex'" + } + return Credential.Basic(pubKeyHex.encodeToByteArray()) + } + + /** + * The account hex this credential claims, or null if it is not a cordn + * one. + * + * Null rather than an exception: a leaf in a mixed or malformed tree is an + * ordinary thing to walk past, and the callers that care about the + * difference check it explicitly. + */ + fun identityOrNull(credential: Credential?): HexKey? { + val identity = (credential as? Credential.Basic)?.identity ?: return null + if (identity.size != HEX_LENGTH) return null + val hex = + try { + identity.decodeToString(throwOnInvalidSequence = true) + } catch (e: CharacterCodingException) { + return null + } + return if (isCanonical(hex)) hex else null + } + + /** The account hex claimed by [leaf], or null. */ + fun identityOrNull(leaf: LeafNode?): HexKey? = identityOrNull(leaf?.credential) + + /** + * Every member's account hex, by leaf index. + * + * Use this rather than `MlsGroup.memberIdentityHex`, which hex-encodes the + * credential bytes — correct for a binding that stores a raw key, and for + * cordn it returns 128 characters of hex-of-hex. Leaves whose credential is + * not a cordn identity are skipped rather than reported as garbage. + */ + fun membersOf(group: MlsGroup): Map = group.members().mapNotNull { (index, leaf) -> identityOrNull(leaf)?.let { index to it } }.toMap() + + /** The set of accounts holding at least one leaf. One account may hold several. */ + fun memberIdentities(group: MlsGroup): Set = membersOf(group).values.toSet() + + private fun isCanonical(hex: String) = hex.length == HEX_LENGTH && hex.all { it in HEX_ALPHABET } + + private const val HEX_LENGTH = 64 + private const val HEX_ALPHABET = "0123456789abcdef" +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/groups/CordnGroupPolicy.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/groups/CordnGroupPolicy.kt new file mode 100644 index 0000000000..a83066cf05 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/groups/CordnGroupPolicy.kt @@ -0,0 +1,264 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.groups + +import com.vitorpamplona.quartz.cordn.spec01GroupMetadata.CordnGroupMetadata +import com.vitorpamplona.quartz.mls.codec.TlsWriter +import com.vitorpamplona.quartz.mls.components.AppDataDictionary +import com.vitorpamplona.quartz.mls.components.ComponentData +import com.vitorpamplona.quartz.mls.components.ComponentsList +import com.vitorpamplona.quartz.mls.group.GroupView +import com.vitorpamplona.quartz.mls.group.MlsExporterLabel +import com.vitorpamplona.quartz.mls.group.MlsGroup +import com.vitorpamplona.quartz.mls.group.MlsGroupPolicy +import com.vitorpamplona.quartz.mls.group.PendingProposal +import com.vitorpamplona.quartz.mls.messages.Proposal +import com.vitorpamplona.quartz.mls.tree.Capabilities +import com.vitorpamplona.quartz.mls.tree.Credential +import com.vitorpamplona.quartz.mls.tree.Extension +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.toHexKey + +/** + * cordn's profile, as an [MlsGroupPolicy]. + * + * Pass it wherever an [MlsGroup] is created, joined or restored and the group + * gets cordn's capabilities, its `required_capabilities`, its extension + * registry and its payload exporter in one argument: + * + * ```kotlin + * val group = MlsGroup.create( + * CordnCredential.of(myPubKeyHex).identity, + * policy = CordnGroupPolicy, + * initialExtensions = listOf(metadata.toExtension()), + * ) + * ``` + * + * ## Admin authorization + * + * `spec/01.md` §5.3 leaves the meaning of `admin_pubkeys` to the application + * and says only that an EMPTY list is egalitarian mode. The reference client + * fills that in, and because MLS has no server that can police membership, + * it does so on both sides of every commit: it refuses to build an add, a + * remove or a metadata change unless the local member is an admin, and it + * rejects an inbound commit carrying one of those from a member who is not. + * + * [authorizeCommit] implements exactly that rule, and the engine calls it in + * both directions, so one check covers both. Matching it is not cosmetic — + * accepting a commit the rest of the group rejects forks the epoch, and MLS + * does not recover from a fork. Being *stricter* than the reference would fork + * it the other way, which is why the gate is the reference's three proposal + * types and nothing else: an Update, a SelfRemove or a PSK from any member + * stays allowed, so nobody can be trapped in a group they may not leave. + * + * Egalitarian mode is a permanent choice, not a bootstrap window: an empty + * list means every member administers, so the gate opens rather than closes. + */ +object CordnGroupPolicy : MlsGroupPolicy { + /** + * `spec/01.md` §7: a client claiming support for `cordn_group_metadata` + * MUST advertise `0xC04D`, and a group using the extension must not add a + * member who does not. + * + * Nothing else is listed. RFC 9420 §7.2 forbids advertising DEFAULT types, + * and cordn requires no non-default proposal — in particular no + * `self_remove`, which Marmot requires and cordn does not. + */ + override val defaultLeafCapabilities: Capabilities + get() = Capabilities(extensions = listOf(CordnGroupMetadata.EXTENSION_TYPE)) + + /** + * None, deliberately. + * + * `spec/01.md` §6 requires a member of a group using `0xC04D` to advertise + * it, and the reference client does — so RFC 9420 §13.4 can be enforced + * here from capabilities alone, with no exemption to weaken it. cordn has + * no deployed groups predating that rule to be kind to. + */ + override val knownExtensionTypes: Set = emptySet() + + /** + * None. + * + * `spec/01.md` §6 says a group MAY omit the metadata extension entirely + * and stay valid, so requiring it at epoch 0 would refuse groups the spec + * allows. The reference client agrees: it advertises `0xC04D` in + * capabilities and installs no `required_capabilities` + * (`packages/cli/src/utils/mlsIdentity.ts:createKeyPackageCapabilities`). + * + * A caller that wants the stricter group passes + * [requiredCapabilitiesExtension] explicitly. + */ + override val defaultRequiredCapabilities: Extension? get() = null + + /** + * `MLS-Exporter("cordn", "group-payload", 32)` — `spec/03.md` §4. + * + * Identical machinery to Marmot's seal and a different key by one label, + * which is the whole reason the two never decrypt each other's traffic. + */ + override val commitExporter: MlsExporterLabel get() = PAYLOAD_EXPORTER + + /** `MLS-Exporter("cordn", "group-payload", 32)`, for application messages too. */ + val PAYLOAD_EXPORTER = MlsExporterLabel("cordn", "group-payload".encodeToByteArray(), 32) + + /** + * `MLS-Exporter("cordn", "encrypted-media", 32)`. + * + * `spec/applications/encrypted-media.md` §3.1: the media key is derived + * from the epoch's exporter secret and is never transmitted. A separate + * context from [PAYLOAD_EXPORTER] because the two layers are independent — + * the spec is explicit that they "use distinct exporter contexts and do not + * interact". + */ + val MEDIA_EXPORTER = MlsExporterLabel("cordn", "encrypted-media".encodeToByteArray(), 32) + + /** + * An explicit `required_capabilities` naming `0xC04D`, for a group that + * wants every member to be able to read its metadata. + * + * Optional by design (see [defaultRequiredCapabilities]). Installing it + * makes the group refuse any leaf that does not advertise `0xC04D` — + * including, today, every Marmot KeyPackage. + */ + fun requiredCapabilitiesExtension(): Extension { + val writer = TlsWriter() + val exts = TlsWriter() + exts.putUint16(CordnGroupMetadata.EXTENSION_TYPE) + writer.putOpaqueVarInt(exts.toByteArray()) + writer.putOpaqueVarInt(ByteArray(0)) + val creds = TlsWriter() + creds.putUint16(Credential.CREDENTIAL_TYPE_BASIC) + writer.putOpaqueVarInt(creds.toByteArray()) + return Extension(MlsGroup.REQUIRED_CAPABILITIES_EXTENSION_TYPE, writer.toByteArray()) + } + + /** + * The KeyPackage-level `app_data_dictionary` marking a last-resort + * KeyPackage. + * + * `spec/00.md` §11 defers to "the last-resort extension defined by MLS", + * and the reference implementation reads that as the extensions-draft + * component `0x0004` inside `app_data_dictionary` (`0x0006`), with empty + * data (`packages/core/src/lastResortKeyPackage.ts`). Marmot's MIP-era + * profile instead sets bare extension `0x000A`, so the two disagree about + * how a last-resort KeyPackage is even recognised — our + * `MlsKeyPackage.isLastResort()` accepts both carriers, which is why it + * reads a cordn KeyPackage correctly. + */ + fun lastResortExtension(): Extension = AppDataDictionary(listOf(ComponentData(ComponentsList.LAST_RESORT_KEY_PACKAGE_ID, ByteArray(0)))).toExtension() + + /** + * Leaf capabilities for a last-resort KeyPackage: [defaultLeafCapabilities] + * plus `app_data_dictionary`, since the leaf must say it understands the + * carrier it is using. + */ + fun lastResortLeafCapabilities(): Capabilities = + Capabilities( + extensions = listOf(CordnGroupMetadata.EXTENSION_TYPE, AppDataDictionary.EXTENSION_TYPE), + ) + + /** + * Refuses a commit that adds, removes or rewrites metadata on behalf of a + * member `admin_pubkeys` does not name. See the class KDoc for why this + * matches the reference client exactly rather than approximately. + * + * [committerLeafIndex] is the committer, which is who the reference checks + * for a commit. A proposal one member sent by reference and an admin then + * committed is therefore allowed — on both implementations, the admin who + * committed it is the one answering for it. + */ + override fun authorizeCommit( + group: GroupView, + proposals: List, + committerLeafIndex: Int, + ) { + if (proposals.isEmpty()) return + + if (adminIdentitiesIn(group.extensions).isEmpty()) return + if (proposals.none { it.proposal.needsAdmin() }) return + + check(isAdminLeaf(group, committerLeafIndex)) { + "cordn: only admin_pubkeys may add, remove or rewrite group metadata; leaf " + + "$committerLeafIndex is not an admin" + } + } + + /** + * The admin set [extensions] names, or empty for egalitarian. + * + * A metadata extension too malformed to decode reads as egalitarian rather + * than taking the group down with it — the same call Marmot's policy makes + * about its own components. It is not a way in: installing metadata takes a + * GroupContextExtensions commit, which this gate already covers, so nobody + * outside the admin set can put a broken extension there in the first + * place. A group whose metadata was malformed from creation was never + * administrable by anyone. + */ + fun adminIdentitiesIn(extensions: List): Set = + runCatching { CordnGroupMetadata.fromExtensions(extensions)?.adminPubkeys } + .getOrNull() + .orEmpty() + .toSet() + + /** True if the account [pubKey] may add, remove or rewrite metadata here. */ + fun isAdmin( + group: GroupView, + pubKey: HexKey, + ): Boolean { + val admins = adminIdentitiesIn(group.extensions) + return admins.isEmpty() || pubKey in admins + } + + /** + * True if the member at [leafIndex] may. + * + * The comparison happens in credential-bytes space, not account space, and + * deliberately: [GroupView.memberIdentityHex] hexes whatever the credential + * holds, and a cordn credential holds the account key as its 64 ASCII hex + * characters (see [CordnCredential]), so that accessor returns 128 + * characters which are never a pubkey. Marmot stores raw bytes and can + * compare directly; comparing an account pubkey against this without + * converting would match nothing and reject every commit in a group that + * names admins. Mapping the admin list forwards rather than decoding the + * leaf backwards keeps untrusted bytes out of the decoder entirely. + */ + fun isAdminLeaf( + group: GroupView, + leafIndex: Int, + ): Boolean { + val admins = adminIdentitiesIn(group.extensions) + if (admins.isEmpty()) return true + + val credentialHex = group.memberIdentityHex(leafIndex) ?: return false + return credentialHex in admins.mapTo(mutableSetOf()) { it.encodeToByteArray().toHexKey() } + } + + /** True if the local member may. */ + fun isLocalAdmin(group: GroupView): Boolean = isAdminLeaf(group, group.myLeafIndex) + + /** + * The three proposal types the reference client gates, and only those: + * `add`, `remove` and `group_context_extensions`, matching its + * `addMember` / `removeMember` / `updateGroupMetadata`. + */ + private fun Proposal.needsAdmin(): Boolean = this is Proposal.Add || this is Proposal.Remove || this is Proposal.GroupContextExtensions +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/spec00Coordinator/CoordinatorAdvertisement.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/spec00Coordinator/CoordinatorAdvertisement.kt new file mode 100644 index 0000000000..56e241d201 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/spec00Coordinator/CoordinatorAdvertisement.kt @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.spec00Coordinator + +import com.vitorpamplona.quartz.contextvm.cep06Announcements.AnnouncedTools + +/** + * Recognising a cordn coordinator in a crowd of ContextVM servers. + * + * ## There is no cordn marker + * + * cordn defines no announcement kind, no `t` tag and no registry — a + * coordinator announces itself with the same CEP-6 pair (kind 11316 + 11317) + * as any other MCP server. So "is this a coordinator?" can only be answered + * the way MCP answers every question about a server: by what it serves. + * + * Measured against the public relays on 2026-09-22: of the newest 100 kind + * 11317 announcements, 42 advertised exactly this toolset and 58 were + * unrelated MCP servers (metadata, CI, currency). The names separate them + * cleanly, which is what makes this predicate worth having rather than a + * guess. + * + * ## What it does NOT establish + * + * An announcement is a claim, signed only by the key that was going to sign it + * anyway. Matching here means a server *says* it serves these eleven tools; it + * does not mean the server is reachable, is still running, implements them + * correctly, or is trustworthy. Nothing in the protocol should branch on it — + * it is a filter for a list a human then chooses from, and `spec/00.md` §8.5 + * still holds that a coordinator's identity is its pubkey and nothing else. + */ +object CoordinatorAdvertisement { + /** + * The eleven tools of `spec/00.md`, by wire name. + * + * Derived from [CoordinatorMethod] rather than written out, so a tool added + * to the protocol cannot be left out of the predicate. + */ + val REQUIRED_TOOLS: Set = CoordinatorMethod.entries.mapTo(LinkedHashSet()) { it.wire } + + /** + * Whether an announced tool list is a cordn coordinator's. + * + * Extra tools do not disqualify: a server may serve cordn alongside + * anything else, and a client that refused those would exclude a + * coordinator for offering more than the minimum. + */ + fun matches(tools: AnnouncedTools): Boolean = tools.serves(REQUIRED_TOOLS) + + /** The tools of [REQUIRED_TOOLS] this server does not advertise. */ + fun missingFrom(tools: AnnouncedTools): Set = REQUIRED_TOOLS - tools.names +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/spec00Coordinator/CoordinatorClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/spec00Coordinator/CoordinatorClient.kt new file mode 100644 index 0000000000..8e2044f6de --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/spec00Coordinator/CoordinatorClient.kt @@ -0,0 +1,392 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.spec00Coordinator + +import com.vitorpamplona.quartz.contextvm.mcp.CvmMcpClient +import com.vitorpamplona.quartz.contextvm.transport.CvmTransport +import com.vitorpamplona.quartz.contextvm.transport.TimeoutMode +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.long +import kotlinx.serialization.json.put + +/** + * The cordn coordinator's eleven tools, typed. + * + * ## Identity is not a parameter + * + * Each method's signing identity comes from [CoordinatorMethod], not from the + * call site. That is the point: `msg_post` under the stable key would tie every + * message you send to your real npub, forever, on a coordinator that keeps + * ordered history — and it would look exactly like a working call. There is no + * override, so the mistake is not available. + * + * Read [CoordinatorMethod]'s KDoc before assuming the split buys more than it + * does. Admission (`join_request_store`, `welcome_store`/`welcome_take`) names + * real keys on both ends by design. + * + * ## Encryption is pinned + * + * The transport must be configured to require encryption. The ContextVM SDK + * defaults to `OPTIONAL`, which resolves from negotiated session state, so a + * coordinator that simply does not announce `support_encryption` gets + * plaintext JSON-RPC on public relays — `gid`s, target pubkeys, KeyPackages and + * cursors, readable by any relay operator. `:contextvm`'s `CvmGiftWrap` + * defaults to `REQUIRED` and fails closed; this class does not undo that. + */ +class CoordinatorClient( + private val mcp: CvmMcpClient, + /** Applied to every call. Coordinator work is storage, not computation. */ + private val timeoutMs: Long = CvmTransport.DEFAULT_TIMEOUT_MS, +) : ICoordinator { + /** Performs the MCP handshake. Optional, but it is where CEP-35 tags ride. */ + suspend fun initialize() = mcp.initialize() + + override val oversizedTransfers get() = mcp.oversizedTransfersCompleted + + /** + * Handshakes and reports what the coordinator says about itself. + * + * Null when the server answered but named nothing, which MCP allows. Every + * field is a claim the coordinator makes about itself — see + * [CoordinatorServerInfo]; the pubkey is the identity (§8.5). + */ + suspend fun serverInfo(): CoordinatorServerInfo? = CoordinatorServerInfo.from(initialize())?.takeIf { !it.isEmpty } + + // ---- stable identity ------------------------------------------------- + + /** + * Publishes a KeyPackage. + * + * The signed publication payload §7 requires IS this call's request event, + * which the coordinator stores verbatim and serves back from [takeKeyPackage]. + * Nothing extra is signed here, and nothing else can be: the transport owns + * the event. + */ + override suspend fun publishKeyPackage( + keyPackageRef: String, + keyPackageBase64: String, + ): PublishedKeyPackage = + call(CoordinatorMethod.KP_PUBLISH, KeyPackagePublication.arguments(keyPackageRef, keyPackageBase64)).let { + PublishedKeyPackage( + keyPackageRef = it.str(CoordinatorFields.KP_REF), + lastResort = it.bool(CoordinatorFields.LAST_RESORT), + at = it.num(CoordinatorFields.AT), + ) + } + + /** Withdraws published KeyPackages. Authorized by us being their owner (§12). */ + override suspend fun removeKeyPackages(keyPackageRefs: List): List { + require(keyPackageRefs.isNotEmpty()) { "kp_remove needs at least one ref" } + val args = + buildJsonObject { + put(CoordinatorFields.KP_REFS, buildJsonArray { keyPackageRefs.forEach { add(JsonPrimitive(it)) } }) + } + return call(CoordinatorMethod.KP_REMOVE, args)[CoordinatorFields.KP_REFS] + ?.jsonArray + ?.map { it.jsonPrimitive.content } + .orEmpty() + } + + /** + * Drains this account's pending Welcomes, acknowledging any already joined. + * + * [consumed] retires records the coordinator would otherwise keep serving. + * A last-resort KeyPackage can back several Welcomes, which is why a record + * is identified by `(kp_ref, at)` rather than by `kp_ref` alone. + */ + override suspend fun takeWelcomes(consumed: List): List { + val args = + buildJsonObject { + if (consumed.isNotEmpty()) { + put( + CoordinatorFields.CONSUMED, + buildJsonArray { + consumed.forEach { + add( + buildJsonObject { + put(CoordinatorFields.KP_REF, it.keyPackageRef) + put(CoordinatorFields.AT, it.at) + }, + ) + } + }, + ) + } + } + return call(CoordinatorMethod.WELCOME_TAKE, args).list(CoordinatorFields.WELCOMES) { + PendingWelcome( + keyPackageRef = it.str(CoordinatorFields.KP_REF), + welcomeBase64 = it.str(CoordinatorFields.WELCOME_64), + at = it.num(CoordinatorFields.AT), + after = it.numOrNull(CoordinatorFields.AFTER), + ) + } + } + + /** + * Asks to join [gid] with [keyPackageRef]. + * + * Rides the stable identity because the request is "npub X wants into group + * G" — there is no version of this call that does not name the asker. + */ + override suspend fun storeJoinRequest( + gid: String, + keyPackageRef: String, + ): Long = + call( + CoordinatorMethod.JOIN_REQUEST_STORE, + buildJsonObject { + put(CoordinatorFields.GID, gid) + put(CoordinatorFields.KP_REF, keyPackageRef) + }, + ).num(CoordinatorFields.AT) + + // ---- ephemeral identity ---------------------------------------------- + + /** Every KeyPackage this coordinator holds. */ + override suspend fun listKeyPackages(): List = + call(CoordinatorMethod.KP_LIST, buildJsonObject {}).list(CoordinatorFields.KEY_PACKAGES) { + AvailableKeyPackage( + pubKey = it.str(CoordinatorFields.PK), + keyPackageRef = it.str(CoordinatorFields.KP_REF), + lastResort = it.bool(CoordinatorFields.LAST_RESORT), + at = it.num(CoordinatorFields.AT), + ) + } + + /** + * Takes a KeyPackage by ref or by account hex (`id` accepts either). + * + * The result is NOT usable until [KeyPackagePublication.verify] has passed + * on its publication event. Null means the coordinator holds nothing + * matching. + */ + override suspend fun takeKeyPackage(id: String): TakenKeyPackage? { + val result = call(CoordinatorMethod.KP_TAKE, buildJsonObject { put(CoordinatorFields.ID, id) }) + val entry = result[CoordinatorFields.KEY_PACKAGE]?.takeIf { it is JsonObject }?.jsonObject ?: return null + val eventJson = + entry[CoordinatorFields.EVENT]?.jsonObject + ?: throw CoordinatorException("kp_take result carries no publication event") + return TakenKeyPackage( + pubKey = entry.str(CoordinatorFields.PK), + keyPackageRef = entry.str(CoordinatorFields.KP_REF), + lastResort = entry.bool(CoordinatorFields.LAST_RESORT), + at = entry.num(CoordinatorFields.AT), + publicationEvent = parseEvent(eventJson), + ) + } + + /** + * Leaves a Welcome for [targetPubKey]. + * + * [after] tells the joiner which cursor to start their history from, so + * they do not replay epochs they cannot decrypt. + */ + override suspend fun storeWelcome( + targetPubKey: HexKey, + keyPackageRef: String, + welcomeBase64: String, + after: Long?, + ): Long = + call( + CoordinatorMethod.WELCOME_STORE, + buildJsonObject { + put(CoordinatorFields.TARGET_PK, targetPubKey) + put(CoordinatorFields.KP_REF, keyPackageRef) + put(CoordinatorFields.WELCOME_64, welcomeBase64) + after?.let { put(CoordinatorFields.AFTER, it) } + }, + ).num(CoordinatorFields.AT) + + /** Drains join requests for the groups we administer, acknowledging handled ones. */ + override suspend fun takeJoinRequests( + gids: List, + consumed: List, + ): List { + require(gids.isNotEmpty()) { "join_request_take_many needs at least one group" } + val args = + buildJsonObject { + put( + CoordinatorFields.GROUPS, + buildJsonArray { gids.forEach { gid -> add(buildJsonObject { put(CoordinatorFields.GID, gid) }) } }, + ) + if (consumed.isNotEmpty()) { + put( + CoordinatorFields.CONSUMED, + buildJsonArray { + consumed.forEach { + add( + buildJsonObject { + put(CoordinatorFields.GID, it.gid) + put(CoordinatorFields.PK, it.pubKey) + put(CoordinatorFields.AT, it.at) + }, + ) + } + }, + ) + } + } + return call(CoordinatorMethod.JOIN_REQUEST_TAKE_MANY, args).list(CoordinatorFields.REQUESTS) { + JoinRequest( + gid = it.str(CoordinatorFields.GID), + pubKey = it.str(CoordinatorFields.PK), + keyPackageRef = it.str(CoordinatorFields.KP_REF), + at = it.num(CoordinatorFields.AT), + ) + } + } + + /** Posts one sealed payload to a group's stream. */ + override suspend fun postMessage( + gid: String, + sealedBase64: String, + ): PostedMessage = + call( + CoordinatorMethod.MSG_POST, + buildJsonObject { + put(CoordinatorFields.GID, gid) + put(CoordinatorFields.MSG_64, sealedBase64) + }, + ).let { + PostedMessage( + gid = it.str(CoordinatorFields.GID), + cursor = it.num(CoordinatorFields.CURSOR), + at = it.num(CoordinatorFields.AT), + ) + } + + /** + * Fetches history for several groups, each after its own cursor. + * + * One page. The coordinator decides how many it returns, so a caller + * catching up loops until a page comes back empty — see + * [com.vitorpamplona.quartz.cordn.sync.GroupSync]. + */ + override suspend fun fetchMessages(cursors: Map): List { + require(cursors.isNotEmpty()) { "msg_fetch_many needs at least one group" } + val args = buildJsonObject { put(CoordinatorFields.GROUPS, groupsArray(cursors)) } + return call(CoordinatorMethod.MSG_FETCH_MANY, args).list(CoordinatorFields.MESSAGES, ::groupMessage) + } + + /** + * Subscribes to live delivery. + * + * Each message arrives as a CEP-41 open-stream fragment whose payload is + * one `GroupMessage` JSON object. The call does not return until the + * coordinator closes the stream, so run it in its own coroutine; `close` + * does not complete the request, so the returned list is the whole run's + * traffic, not a partial view. + */ + override suspend fun subscribeMessages( + cursors: Map, + timeoutMs: Long, + onMessage: (GroupMessage) -> Unit, + ) { + require(cursors.isNotEmpty()) { "msg_sub_many needs at least one group" } + val args = buildJsonObject { put(CoordinatorFields.GROUPS, groupsArray(cursors)) } + val result = + mcp.callTool( + name = CoordinatorMethod.MSG_SUB_MANY.wire, + arguments = args, + identity = CoordinatorMethod.MSG_SUB_MANY.identity, + timeoutMs = timeoutMs, + // A budget, not a failure: the caller re-opens on it, and a + // busy stream bounded by silence would never come back. + timeoutMode = TimeoutMode.TOTAL, + onStreamFragment = { fragment -> + // A malformed frame is the coordinator's problem, not a + // reason to tear down a live subscription over other groups. + runCatching { groupMessage(Json.parseToJsonElement(fragment).jsonObject) } + .getOrNull() + ?.let(onMessage) + }, + ) + if (result.isError) throw CoordinatorException("msg_sub_many failed: ${result.error?.message}") + } + + // ---- plumbing -------------------------------------------------------- + + private fun groupsArray(cursors: Map) = + buildJsonArray { + cursors.forEach { (gid, after) -> + add( + buildJsonObject { + put(CoordinatorFields.GID, gid) + // The schema types `after` as a positive int, so a + // first-ever fetch omits it rather than sending 0. + after?.takeIf { it > 0 }?.let { put(CoordinatorFields.AFTER, it) } + }, + ) + } + } + + private fun groupMessage(json: JsonObject) = + GroupMessage( + gid = json.str(CoordinatorFields.GID), + cursor = json.num(CoordinatorFields.CURSOR), + sealedBase64 = json.str(CoordinatorFields.MSG_64), + at = json.num(CoordinatorFields.AT), + ) + + private fun parseEvent(json: JsonObject): Event = + try { + OptimizedJsonMapper.fromJson(json.toString()) + } catch (e: Exception) { + throw CoordinatorException("coordinator served a publication event we cannot parse", e) + } + + private suspend fun call( + method: CoordinatorMethod, + arguments: JsonObject, + ): JsonObject { + val result = mcp.callTool(method.wire, arguments, method.identity, timeoutMs) + if (result.isError) { + throw CoordinatorException("${method.wire} failed: ${result.error?.message ?: "unknown error"}") + } + val body = result.result as? JsonObject ?: throw CoordinatorException("${method.wire} returned no result object") + return body[CoordinatorFields.STRUCTURED_CONTENT]?.jsonObject + ?: throw CoordinatorException("${method.wire} returned no structuredContent") + } + + private fun JsonObject.list( + key: String, + map: (JsonObject) -> T, + ): List = this[key]?.jsonArray?.map { map(it.jsonObject) }.orEmpty() + + private fun JsonObject.str(key: String) = this[key]?.jsonPrimitive?.content ?: throw CoordinatorException("coordinator result is missing `$key`") + + private fun JsonObject.num(key: String) = this[key]?.jsonPrimitive?.long ?: throw CoordinatorException("coordinator result is missing `$key`") + + private fun JsonObject.numOrNull(key: String) = this[key]?.jsonPrimitive?.long + + private fun JsonObject.bool(key: String) = this[key]?.jsonPrimitive?.content?.toBoolean() ?: false +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/spec00Coordinator/CoordinatorMethods.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/spec00Coordinator/CoordinatorMethods.kt new file mode 100644 index 0000000000..d3c7ed7d89 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/spec00Coordinator/CoordinatorMethods.kt @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.spec00Coordinator + +import com.vitorpamplona.quartz.contextvm.transport.DualSigner + +/** + * The coordinator's eleven MCP tools, and which identity each one rides. + * + * Names and argument keys are from + * `packages/core/src/contracts.ts` — the reference implementation's zod + * schemas, which are the only normative statement of the wire shape + * (`spec/00.md` describes the model, not the field names). + * + * ## The identity column is the privacy model + * + * Every call carries an authenticated caller pubkey that the coordinator + * derives from the signed inner 25910 event, so "which key signed this" is not + * something a client can decline to answer — only something it can choose. + * cordn-web splits them deliberately, and this enum records that split so a + * caller cannot get it wrong by omission: a stable-identity call is one where + * the coordinator MUST know who you are (it is your inbox, or your KeyPackage, + * or your request to join), and everything else rides a throwaway. + * + * The split is real but partial, and §8 of the plan has the detail. Two things + * worth knowing at the call site: + * + * - **Admission is in the clear on both ends.** `join_request_store` names + * your real key and the `gid`; `welcome_store` names the target's real key + * and `welcome_take` is called by it. For any group joined through a share + * link the coordinator observes real-identity membership directly. That is + * structural, not a bug to route around. + * - **The ephemeral key is per session, not per message.** One pseudonym posts, + * fetches and subscribes across every group you hold on that coordinator for + * the life of the session, so the `gid` set it touches is a stable + * fingerprint linking those groups together. [CoordinatorClient] takes the + * throwaway signer from its caller precisely so the caller can decide how + * often to rotate it. + */ +enum class CoordinatorMethod( + val wire: String, + val identity: DualSigner.Identity, +) { + /** Publish a KeyPackage. The publication payload IS this call's signed event. */ + KP_PUBLISH("kp_publish", DualSigner.Identity.STABLE), + + /** Withdraw published KeyPackages. Authorized by the caller being their owner. */ + KP_REMOVE("kp_remove", DualSigner.Identity.STABLE), + + /** Drain this account's pending Welcomes. It is our own inbox. */ + WELCOME_TAKE("welcome_take", DualSigner.Identity.STABLE), + + /** Ask to join a group. The whole point is to name who is asking. */ + JOIN_REQUEST_STORE("join_request_store", DualSigner.Identity.STABLE), + + /** Look up someone's KeyPackages. Reveals "who is being added to a group". */ + KP_LIST("kp_list", DualSigner.Identity.EPHEMERAL), + + /** Consume a KeyPackage by ref. */ + KP_TAKE("kp_take", DualSigner.Identity.EPHEMERAL), + + /** Leave a Welcome for someone. Names the target, not the sender. */ + WELCOME_STORE("welcome_store", DualSigner.Identity.EPHEMERAL), + + /** Drain join requests for groups we administer. */ + JOIN_REQUEST_TAKE_MANY("join_request_take_many", DualSigner.Identity.EPHEMERAL), + + /** Post a sealed payload. */ + MSG_POST("msg_post", DualSigner.Identity.EPHEMERAL), + + /** Catch up on a group's history. */ + MSG_FETCH_MANY("msg_fetch_many", DualSigner.Identity.EPHEMERAL), + + /** Live delivery, over a CEP-41 open stream. */ + MSG_SUB_MANY("msg_sub_many", DualSigner.Identity.EPHEMERAL), +} + +/** Argument and result field names, from `packages/core/src/contracts.ts`. */ +object CoordinatorFields { + const val KP_REF = "kp_ref" + const val KP_REFS = "kp_refs" + const val KP_64 = "kp_64" + const val ID = "id" + const val PK = "pk" + const val LAST_RESORT = "last_resort" + const val AT = "at" + const val EVENT = "event" + const val KEY_PACKAGE = "keyPackage" + const val KEY_PACKAGES = "keyPackages" + const val TARGET_PK = "target_pk" + const val WELCOME_64 = "welcome_64" + const val WELCOMES = "welcomes" + const val CONSUMED = "consumed" + const val GID = "gid" + const val GROUPS = "groups" + const val REQUESTS = "requests" + const val MSG_64 = "msg_64" + const val MESSAGES = "messages" + const val CURSOR = "cursor" + const val AFTER = "after" + const val SUBSCRIBED = "subscribed" + + /** MCP puts a tool's typed output here; `content` carries the display form. */ + const val STRUCTURED_CONTENT = "structuredContent" +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/spec00Coordinator/CoordinatorServerInfo.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/spec00Coordinator/CoordinatorServerInfo.kt new file mode 100644 index 0000000000..93cf6d69cc --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/spec00Coordinator/CoordinatorServerInfo.kt @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.spec00Coordinator + +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcMessage +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcSuccess +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +/** + * What a coordinator says about itself in the MCP `initialize` handshake. + * + * ## Every field is a claim + * + * A coordinator's name and version are strings it chose, signed with nothing + * but the key that was already going to sign the response. They are useful for + * telling two coordinators apart in a list and for reporting a bug against the + * right software; they are **not** identity, which is the pubkey and only the + * pubkey (`spec/00.md` §8.5). A UI must not present them as verified, and + * nothing in the protocol should branch on them. + * + * [protocolVersion] is the one field worth acting on: a coordinator answering + * a version this client does not implement is a coordinator whose later + * answers may not mean what they appear to. + */ +data class CoordinatorServerInfo( + val name: String?, + val version: String?, + val protocolVersion: String?, + /** The capability object verbatim, for a screen that wants to show it raw. */ + val capabilities: JsonObject?, +) { + /** Nothing usable came back, so there is nothing worth showing. */ + val isEmpty: Boolean get() = name == null && version == null && protocolVersion == null + + companion object { + /** + * Reads [response] if it is a successful `initialize` result. + * + * Every field is optional and a missing one becomes null rather than an + * error: MCP allows a server to omit `serverInfo` entirely, and a + * handshake that worked should not be reported as a failure because + * the server declined to name itself. + */ + fun from(response: JsonRpcMessage): CoordinatorServerInfo? { + val result = (response as? JsonRpcSuccess)?.result as? JsonObject ?: return null + val info = result["serverInfo"] as? JsonObject + return CoordinatorServerInfo( + name = info?.get("name")?.jsonPrimitive?.contentOrNullSafe(), + version = info?.get("version")?.jsonPrimitive?.contentOrNullSafe(), + protocolVersion = result["protocolVersion"]?.jsonPrimitive?.contentOrNullSafe(), + capabilities = result["capabilities"]?.jsonObject, + ) + } + } +} + +/** + * The string content, or null where the value is JSON `null`. + * + * `jsonPrimitive.content` renders a JSON null as the four-character string + * "null", which would put the word "null" on a settings screen as a + * coordinator's name. + */ +private fun JsonPrimitive.contentOrNullSafe(): String? = if (this is JsonNull) null else content.takeIf { it.isNotEmpty() } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/spec00Coordinator/CoordinatorTypes.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/spec00Coordinator/CoordinatorTypes.kt new file mode 100644 index 0000000000..7e8618fdb4 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/spec00Coordinator/CoordinatorTypes.kt @@ -0,0 +1,126 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.spec00Coordinator + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** A KeyPackage the coordinator is holding, as `kp_list` reports it. */ +data class AvailableKeyPackage( + val pubKey: HexKey, + val keyPackageRef: String, + val lastResort: Boolean, + val at: Long, +) + +/** + * A KeyPackage taken from the coordinator, with the signed publication payload + * that binds it to its owner. + * + * [publicationEvent] is the `kp_publish` request event itself, stored verbatim + * and served back (`spec/00.md` §7). Verify it with + * [KeyPackagePublication.verify] before touching [keyPackageBase64]: an + * unverified pair is a coordinator's word about whose key this is, which §10 + * explicitly refuses to rely on. + */ +data class TakenKeyPackage( + val pubKey: HexKey, + val keyPackageRef: String, + val lastResort: Boolean, + val at: Long, + val publicationEvent: Event, +) { + /** + * The KeyPackage bytes as base64, read back out of the publication event + * rather than from a coordinator-supplied field. + * + * Deliberately not a stored property: the point of §9 is that the KeyPackage + * you use is the one inside the signed payload, not one handed over + * alongside it. + */ + fun keyPackageBase64(): String? = KeyPackagePublication.keyPackageBase64Of(publicationEvent) +} + +/** A Welcome waiting in our inbox. */ +data class PendingWelcome( + val keyPackageRef: String, + val welcomeBase64: String, + val at: Long, + /** The cursor to resume a group's history from, when the inviter set one. */ + val after: Long? = null, +) + +/** Someone asking to join a group we administer. */ +data class JoinRequest( + val gid: String, + val pubKey: HexKey, + val keyPackageRef: String, + val at: Long, +) + +/** One sealed payload from a group's ordered stream. */ +data class GroupMessage( + val gid: String, + /** + * This coordinator's ordering primitive for this group. + * + * `spec/00.md` §4-5: scoped to one group on one coordinator, and + * explicitly NOT a message identity. The canonical id is the envelope's + * (`spec/02.md` §7); a cursor cannot survive a coordinator change and two + * coordinators will not agree on one. + */ + val cursor: Long, + val sealedBase64: String, + val at: Long, +) + +/** What a welcome the caller has already joined looks like when acknowledging it. */ +data class ConsumedWelcomeRef( + val keyPackageRef: String, + val at: Long, +) + +/** A join request the caller has handled and wants retired. */ +data class ConsumedJoinRequestRef( + val gid: String, + val pubKey: HexKey, + val at: Long, +) + +/** Result of publishing a KeyPackage. */ +data class PublishedKeyPackage( + val keyPackageRef: String, + val lastResort: Boolean, + val at: Long, +) + +/** Result of posting a message. */ +data class PostedMessage( + val gid: String, + val cursor: Long, + val at: Long, +) + +/** Raised when the coordinator answers with a JSON-RPC error or an unusable body. */ +class CoordinatorException( + message: String, + cause: Throwable? = null, +) : IllegalStateException(message, cause) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/spec00Coordinator/ICoordinator.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/spec00Coordinator/ICoordinator.kt new file mode 100644 index 0000000000..36746a4d04 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/spec00Coordinator/ICoordinator.kt @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.spec00Coordinator + +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** + * The eleven coordinator tools, as a contract. + * + * [CoordinatorClient] is the only real implementation and this interface adds + * nothing to it — no defaults, no behaviour. It exists so the layers above + * (`CordnGroupSync`, and the group manager in `commons`) can be exercised + * without standing up a relay, a transport and a server, which is otherwise the + * price of testing a cursor loop. + * + * What it deliberately does **not** expose is identity. Which key signs which + * call is fixed by [CoordinatorMethod] per `spec/00.md` §8, and a substitute + * implementation cannot widen that, because there is no parameter to widen. + */ +interface ICoordinator { + suspend fun publishKeyPackage( + keyPackageRef: String, + keyPackageBase64: String, + ): PublishedKeyPackage + + suspend fun removeKeyPackages(keyPackageRefs: List): List + + suspend fun listKeyPackages(): List + + suspend fun takeKeyPackage(id: String): TakenKeyPackage? + + suspend fun storeWelcome( + targetPubKey: HexKey, + keyPackageRef: String, + welcomeBase64: String, + after: Long? = null, + ): Long + + suspend fun takeWelcomes(consumed: List = emptyList()): List + + suspend fun storeJoinRequest( + gid: String, + keyPackageRef: String, + ): Long + + suspend fun takeJoinRequests( + gids: List, + consumed: List = emptyList(), + ): List + + suspend fun postMessage( + gid: String, + sealedBase64: String, + ): PostedMessage + + suspend fun fetchMessages(cursors: Map): List + + suspend fun subscribeMessages( + cursors: Map, + timeoutMs: Long, + onMessage: (GroupMessage) -> Unit, + ) + + /** + * How many responses this coordinator chunked over CEP-22 so far. + * + * Diagnostics, and the one thing here that is not a tool. Nothing in the + * binding reads it; a live interop run does, to tell a server that had to + * chunk from one that never did - which is otherwise invisible, because a + * reassembled response is indistinguishable from a direct one by design. + * + * Defaulted so a substitute implementation is not forced to fake a number + * about a transfer profile it does not have. + */ + val oversizedTransfers: Int get() = 0 +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/spec00Coordinator/KeyPackagePublication.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/spec00Coordinator/KeyPackagePublication.kt new file mode 100644 index 0000000000..6cd39f6d84 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/spec00Coordinator/KeyPackagePublication.kt @@ -0,0 +1,167 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.spec00Coordinator + +import com.vitorpamplona.quartz.cordn.groups.CordnCredential +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.messages.MlsKeyPackage +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.crypto.verify +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +/** + * Verifying that a coordinator-served KeyPackage really belongs to the account + * it claims (`spec/00.md` §7 and §9). + * + * ## The payload is the request event + * + * cordn has no KeyPackage event kind. §7 requires a "signed publication + * payload" binding publisher, bytes and lookup fields, and the reference + * implementation satisfies it with the **`kp_publish` ContextVM request event + * itself**: the coordinator reaches back into the transport for the signed + * kind-25910 event, stores it verbatim, and returns it from `kp_take` + * (`packages/server/src/coordinatorServer.ts` → + * `transport.getNostrRequestEvent`). + * + * So the KeyPackage is recovered by parsing JSON-RPC out of an event's + * `content`, and the identity binding is "the credential inside the KeyPackage + * equals the event's `pubkey`". That means the invariant rides the shape of a + * JSON-RPC envelope rather than a stable event schema — a rename in the + * arguments object changes the wire format, and the reference client already + * carries a fallback from exactly that happening once + * (`kp_64 ?? keyPackageBase64`). We accept both for the same reason. + * + * Worth proposing upstream: give publication its own signed payload or its own + * kind. It would also let a cordn KeyPackage be published to relays and + * consumed with no coordinator at all. + * + * ## Why the client checks at all + * + * §8 makes the coordinator validate the same things. §9 makes us do it anyway, + * and §10 says why: coordinator checks are admission control, not a trust + * anchor. A coordinator that skipped them — or was modified to — could + * otherwise hand us any account's name over any account's key material, and we + * would invite the wrong person into a group. + */ +object KeyPackagePublication { + private const val LEGACY_KP_FIELD = "keyPackageBase64" + + /** The arguments a `kp_publish` call carries. */ + fun arguments( + keyPackageRef: String, + keyPackageBase64: String, + ): JsonObject = + buildJsonObject { + put(CoordinatorFields.KP_REF, keyPackageRef) + put(CoordinatorFields.KP_64, keyPackageBase64) + } + + /** + * The base64 KeyPackage inside a publication event, or null when the event + * is not a `kp_publish` call in a shape we recognise. + */ + fun keyPackageBase64Of(publicationEvent: Event): String? { + val params = + try { + Json.parseToJsonElement(publicationEvent.content).jsonObject["params"]?.jsonObject + } catch (e: IllegalArgumentException) { + return null + } ?: return null + val arguments = params["arguments"]?.jsonObject ?: return null + return (arguments[CoordinatorFields.KP_64] ?: arguments[LEGACY_KP_FIELD])?.jsonPrimitive?.content + } + + /** + * Runs every §9 check and returns the decoded KeyPackage. + * + * @throws CoordinatorException naming the check that failed. Each one is a + * different lie: a bad signature means the payload was not written by its + * claimed author, a missing KeyPackage means the coordinator substituted + * its own representation (§7 forbids it), and a credential mismatch means + * someone published another account's key under their own name. + */ + @OptIn(ExperimentalEncodingApi::class) + fun verify(publicationEvent: Event): VerifiedKeyPackage { + if (!publicationEvent.verify()) { + throw CoordinatorException("KeyPackage publication payload signature is invalid") + } + + val base64 = + keyPackageBase64Of(publicationEvent) + ?: throw CoordinatorException("KeyPackage publication payload carries no KeyPackage") + + val bytes = + try { + Base64.decode(base64) + } catch (e: IllegalArgumentException) { + throw CoordinatorException("KeyPackage publication payload KeyPackage is not valid base64", e) + } + + val keyPackage = + try { + MlsKeyPackage.decodeTls(TlsReader(bytes)) + } catch (e: Exception) { + throw CoordinatorException("KeyPackage publication payload KeyPackage does not decode", e) + } + + val claimed = CordnCredential.identityOrNull(keyPackage.leafNode) + if (claimed == null) { + throw CoordinatorException( + "KeyPackage credential is not a cordn identity: expected 64 hex ASCII bytes in a BasicCredential", + ) + } + if (claimed != publicationEvent.pubKey) { + throw CoordinatorException( + "KeyPackage credential identity $claimed does not match its publisher ${publicationEvent.pubKey}", + ) + } + + return VerifiedKeyPackage(publicationEvent.pubKey, keyPackage, bytes) + } + + /** Verifies, or returns null. For walking a list where one bad entry is not fatal. */ + fun verifyOrNull(publicationEvent: Event): VerifiedKeyPackage? = + try { + verify(publicationEvent) + } catch (e: CoordinatorException) { + null + } +} + +/** A KeyPackage whose identity binding has been checked against its signed payload. */ +data class VerifiedKeyPackage( + /** The account that signed the publication, and whose credential this carries. */ + val pubKey: HexKey, + val keyPackage: MlsKeyPackage, + val bytes: ByteArray, +) { + override fun equals(other: Any?): Boolean = this === other || (other is VerifiedKeyPackage && pubKey == other.pubKey && bytes.contentEquals(other.bytes)) + + override fun hashCode(): Int = pubKey.hashCode() * 31 + bytes.contentHashCode() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/spec01GroupMetadata/CordnGroupMetadata.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/spec01GroupMetadata/CordnGroupMetadata.kt new file mode 100644 index 0000000000..0d9a232066 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/spec01GroupMetadata/CordnGroupMetadata.kt @@ -0,0 +1,152 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.spec01GroupMetadata + +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.codec.TlsWriter +import com.vitorpamplona.quartz.mls.tree.Extension +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey + +/** + * The `cordn_group_metadata` GroupContext extension (`0xC04D`), from + * `spec/01.md`. + * + * Shared presentation metadata that rides inside MLS group state, so it is + * authenticated by ordinary Proposal/Commit processing rather than by + * coordinator-local storage. It is optional: a group may carry none and remain + * valid. + * + * ## The admin model is not Marmot's + * + * `spec/01.md` §5.3: an EMPTY [adminPubkeys] means **egalitarian** — every + * member has the same administrative level — and a non-empty one means only + * those accounts are admins. Marmot's MIP-03 reads an empty admin set as + * "bootstrap, gate open", which looks the same and means something else: there + * it is a transient state before an admin is named, here it is a permanent + * choice. Do not reuse either side's authorization code for the other. + */ +data class CordnGroupMetadata( + /** UTF-8 display name. May be empty. */ + val name: String, + /** UTF-8 description. Empty means absent. */ + val description: String = "", + /** + * Admin accounts as lowercase 64-char hex, encoded on the wire as + * concatenated raw 32-byte keys. Empty means egalitarian mode. + */ + val adminPubkeys: List = emptyList(), + /** A short glyph or emoji standing in for an image. Empty means absent. */ + val icon: String = "", + /** Advisory image URL, possibly a `data:` URL. Empty means absent. */ + val imageUrl: String = "", +) { + init { + adminPubkeys.forEach { + require(it.length == PUBKEY_HEX_LENGTH && it.all { c -> c in HEX_ALPHABET }) { + "cordn group metadata admin pubkey must be 64 lowercase hex chars, was '$it'" + } + } + require(adminPubkeys.size == adminPubkeys.toSet().size) { + "cordn group metadata admin pubkeys must not contain duplicates" + } + } + + /** True when the group names no admins, i.e. every member is equally privileged. */ + val isEgalitarian: Boolean get() = adminPubkeys.isEmpty() + + fun encode(): ByteArray { + val writer = TlsWriter() + writer.putUint16(VERSION) + writer.putOpaque2(name.encodeToByteArray()) + writer.putOpaque2(description.encodeToByteArray()) + writer.putOpaque2(adminPubkeys.fold(ByteArray(0)) { acc, key -> acc + key.hexToByteArray() }) + writer.putOpaque2(icon.encodeToByteArray()) + writer.putOpaque2(imageUrl.encodeToByteArray()) + return writer.toByteArray() + } + + fun toExtension(): Extension = Extension(EXTENSION_TYPE, encode()) + + companion object { + /** `cordn_group_metadata`, in the MLS private-use extension range. */ + const val EXTENSION_TYPE = 0xC04D + + /** Version 0 is reserved and MUST be rejected (`spec/01.md` §4). */ + const val VERSION = 1 + + private const val PUBKEY_HEX_LENGTH = 64 + private const val HEX_ALPHABET = "0123456789abcdef" + + /** + * Decodes the extension payload. + * + * Each field is a plain uint16 length prefix, NOT an MLS varint vector. + * `spec/01.md` §3 says "TLS presentation language with MLS + * variable-length vector encoding conventions" and then writes + * `opaque Name<0..2^16-1>`, which are two different encodings; the + * reference implementation + * (`packages/cli/src/groupMetadata.ts:encodeField`) emits uint16, so + * that is what interoperates. Worth raising upstream — the prose and + * the code disagree and only one of them is on the wire. + */ + fun decode(data: ByteArray): CordnGroupMetadata { + val reader = TlsReader(data) + val version = reader.readUint16() + require(version == VERSION) { "unsupported cordn group metadata version: $version" } + + val name = reader.readOpaque2().decodeToStringStrict("name") + val description = reader.readOpaque2().decodeToStringStrict("description") + val adminBytes = reader.readOpaque2() + val icon = reader.readOpaque2().decodeToStringStrict("icon") + val imageUrl = reader.readOpaque2().decodeToStringStrict("image_url") + + require(!reader.hasRemaining) { "unexpected trailing bytes in cordn group metadata" } + require(adminBytes.size % KEY_SIZE == 0) { + "cordn group metadata admin_pubkeys length must be a multiple of $KEY_SIZE, was ${adminBytes.size}" + } + + val admins = + (0 until adminBytes.size / KEY_SIZE).map { + adminBytes.copyOfRange(it * KEY_SIZE, (it + 1) * KEY_SIZE).toHexKey() + } + return CordnGroupMetadata(name, description, admins, icon, imageUrl) + } + + /** The group's metadata, or null when it carries none. */ + fun fromExtensions(extensions: List): CordnGroupMetadata? = extensions.firstOrNull { it.extensionType == EXTENSION_TYPE }?.let { decode(it.extensionData) } + + private const val KEY_SIZE = 32 + + /** + * `spec/01.md` §9 requires rejecting invalid UTF-8 rather than + * substituting replacement characters, which is what + * `decodeToString()` does by default. + */ + private fun ByteArray.decodeToStringStrict(field: String): String = + try { + decodeToString(throwOnInvalidSequence = true) + } catch (e: CharacterCodingException) { + throw IllegalArgumentException("cordn group metadata $field is not valid UTF-8", e) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/spec02Envelopes/CordnAnnotationIndex.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/spec02Envelopes/CordnAnnotationIndex.kt new file mode 100644 index 0000000000..b74c86238b --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/spec02Envelopes/CordnAnnotationIndex.kt @@ -0,0 +1,185 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.spec02Envelopes + +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** + * One delivered message: the envelope, and where the coordinator put it. + * + * The cursor is carried only for tie-breaking (see [CordnAnnotationIndex]) and + * is never an identity — `spec/02.md` §7 and `spec/00.md` §4-5 are both explicit + * that a cursor is scoped to one coordinator and one group. + */ +data class CordnDeliveredMessage( + val envelope: CordnEnvelope, + val cursor: Long, +) + +/** + * Reactions, edits, deletions and pins folded onto the messages they modify. + * + * Annotations can target any message in the group, so resolving them per row + * while rendering is quadratic in a list that is usually virtualised. This + * folds them once. + * + * ## The authorization rules are here, not in the UI + * + * They differ per annotation and they are the whole reason this is protocol + * code rather than view-model code: + * + * - **Edits and deletions are author-only.** The sender must be the sender of + * the target. Without that check anyone in the group can rewrite anyone's + * words, which is worse than no edit feature at all. + * - **A deletion must name the target's kind.** A `k` tag that disagrees with + * the stored message is a mismatch, not a match with a typo. + * - **Pins are any-member.** Deliberately, and matching the reference client: + * pinning is a shared act in a group where `admin_pubkeys` is usually empty + * (`spec/01.md` — an empty admin set means egalitarian, permanently). + * - **Deletion beats edit.** A deleted message stops accepting edits, so a + * late edit cannot resurrect text its author already withdrew. That is why + * deletions are folded before edits and the order is load-bearing. + * + * Everything here is authenticated before it arrives: the envelope's `pubKey` + * was bound to the MLS sender by [CordnApplicationMessage.open], so "same + * author" is a real check rather than a claim comparison. + */ +class CordnAnnotationIndex private constructor( + /** Every message by envelope id, annotations included. */ + val byId: Map, + /** target id → emoji → the senders who reacted with it. */ + val reactions: Map>>, + /** target id → the winning edit. */ + val edits: Map, + /** Targets whose deletion passed every check. */ + val deleted: Set, + /** target id → the winning pin op. Only `ADD` entries are actually pinned. */ + val pins: Map, +) { + /** Who pinned a message and when, kept so a pin list can be ordered. */ + data class PinState( + val targetId: HexKey, + val op: CordnMessageReferences.PinOp, + val pinnedBy: HexKey, + val pinnedAt: Long, + val cursor: Long, + ) + + /** The text to show for [id]: its newest accepted edit, or the original. */ + fun contentOf(id: HexKey): String? = edits[id]?.envelope?.content ?: byId[id]?.envelope?.content + + fun isEdited(id: HexKey): Boolean = edits.containsKey(id) + + fun isDeleted(id: HexKey): Boolean = deleted.contains(id) + + fun isPinned(id: HexKey): Boolean = pins[id]?.op == CordnMessageReferences.PinOp.ADD + + /** Currently pinned targets, newest pin first. */ + fun pinnedIds(): List = + pins.values + .filter { it.op == CordnMessageReferences.PinOp.ADD } + .sortedWith(compareByDescending { it.pinnedAt }.thenByDescending { it.cursor }) + .map { it.targetId } + + companion object { + /** + * Folds [messages] — a whole group's stream, annotations included. + * + * Four passes, and the order matters exactly once: deletions before + * edits, so a withdrawn message cannot be edited back into existence. + * The rest are independent. + */ + fun of(messages: List): CordnAnnotationIndex { + val byId = messages.associateBy { it.envelope.id } + + val reactions = mutableMapOf>>() + messages.forEach { message -> + val ref = + CordnMessageReferences.reaction( + message.envelope.kind, + message.envelope.content, + message.envelope.tags, + ) ?: return@forEach + reactions + .getOrPut(ref.targetId) { mutableMapOf() } + .getOrPut(ref.reaction) { mutableSetOf() } + .add(message.envelope.pubKey) + } + + val deleted = mutableSetOf() + messages.forEach { message -> + val ref = CordnMessageReferences.delete(message.envelope.kind, message.envelope.tags) ?: return@forEach + val target = byId[ref.targetId] ?: return@forEach + // A `k` that disagrees with the stored message is a mismatch. + if (ref.targetKind != target.envelope.kind) return@forEach + // Author-only. The pubkeys are MLS-authenticated, not claimed. + if (target.envelope.pubKey != message.envelope.pubKey) return@forEach + deleted.add(ref.targetId) + } + + val edits = mutableMapOf() + messages.forEach { message -> + val ref = + CordnMessageReferences.edit( + message.envelope.kind, + message.envelope.content, + message.envelope.tags, + ) ?: return@forEach + if (ref.targetId in deleted) return@forEach + val target = byId[ref.targetId] ?: return@forEach + if (target.envelope.pubKey != message.envelope.pubKey) return@forEach + + val current = edits[ref.targetId] + // Newest wins. Ties break on cursor, which the coordinator makes + // monotonic (spec/00.md §4) -- two edits in the same second are + // otherwise a coin flip that two clients could call differently. + if (current == null || + message.envelope.createdAt > current.envelope.createdAt || + (message.envelope.createdAt == current.envelope.createdAt && message.cursor > current.cursor) + ) { + edits[ref.targetId] = message + } + } + + val pins = mutableMapOf() + messages.forEach { message -> + val ref = CordnMessageReferences.pin(message.envelope.kind, message.envelope.tags) ?: return@forEach + // No author check: pinning is any-member. See the class KDoc. + val current = pins[ref.targetId] + if (current == null || + message.envelope.createdAt > current.pinnedAt || + (message.envelope.createdAt == current.pinnedAt && message.cursor > current.cursor) + ) { + pins[ref.targetId] = + PinState( + targetId = ref.targetId, + op = ref.op, + pinnedBy = message.envelope.pubKey, + pinnedAt = message.envelope.createdAt, + cursor = message.cursor, + ) + } + } + + return CordnAnnotationIndex(byId, reactions, edits, deleted, pins) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/spec02Envelopes/CordnApplicationMessage.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/spec02Envelopes/CordnApplicationMessage.kt new file mode 100644 index 0000000000..dd84dfffa6 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/spec02Envelopes/CordnApplicationMessage.kt @@ -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.quartz.cordn.spec02Envelopes + +import com.vitorpamplona.quartz.cordn.groups.CordnGroupPolicy +import com.vitorpamplona.quartz.cordn.spec03Payloads.SealedPayload +import com.vitorpamplona.quartz.mls.group.DecryptedMessage +import com.vitorpamplona.quartz.mls.group.MlsGroup +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** + * Sending and receiving a cordn application message: envelope, MLS, seal. + * + * ## The authenticated-sender binding + * + * `spec/02.md` §5 says the envelope's `pubkey` must equal "the authenticated + * sender identity derived from the sender's MLS credential" and stops there. + * The reference implementation does something more specific that the spec never + * mentions: it puts the account pubkey in MLS **`authenticated_data`** + * (`packages/cli/src/session.ts:1000`) and, on receive, **rejects any + * application message whose AAD is empty** before even looking at the envelope + * (`packages/cli/src/groupSync.ts:247`). + * + * So a message without it is not merely unattributed, it is refused. That makes + * the AAD a wire requirement rather than an optimisation, and it is the reason + * this class exists instead of callers reaching for `MlsGroup.encrypt` directly. + * + * Using the AAD rather than the leaf credential also has a reason: the + * credential names whichever key holds the leaf, and a linked device's leaf is + * not the account. The AAD says who is *speaking*. + * + * `authenticated_data` is authenticated but **not encrypted**, so this binding + * costs metadata: any party holding the ciphertext can read the sender's + * account pubkey. The coordinator holds every ciphertext. cordn's reference + * client accepts that trade; it is worth knowing it was made. + */ +object CordnApplicationMessage { + /** + * Builds, frames and seals an application message. + * + * @return the base64 sealed payload to hand to `msg_post`. + */ + fun seal( + group: MlsGroup, + senderPubKey: HexKey, + envelope: CordnEnvelope, + ): String { + require(envelope.pubKey == senderPubKey) { + "envelope pubkey ${envelope.pubKey} does not match the sender $senderPubKey" + } + val mlsMessage = group.encrypt(envelope.encode(), authenticatedData = senderPubKey.encodeToByteArray()) + return SealedPayload.seal(mlsMessage, SealedPayload.applicationKey(group)) + } + + /** + * Opens a sealed application message and returns its envelope. + * + * Every check `spec/02.md` §5 and the reference client apply, in the order + * that makes each one meaningful: open the seal, let MLS authenticate the + * sender, read the sender from the AAD, then hold the envelope to it. + * + * @throws IllegalArgumentException naming the check that failed. + */ + fun open( + group: MlsGroup, + sealedBase64: String, + ): ReceivedMessage { + val mlsMessage = SealedPayload.open(sealedBase64, SealedPayload.applicationKey(group)) + val decrypted = group.decrypt(mlsMessage) + return open(decrypted) + } + + /** As [open], for a message some other path has already decrypted. */ + fun open(decrypted: DecryptedMessage): ReceivedMessage { + // Empty is a rejection, not a default. Treating it as "unknown sender" + // would let anyone drop the field and post as nobody in particular, + // which the envelope's own `pubkey` would then be free to fill in. + require(decrypted.authenticatedData.isNotEmpty()) { + "cordn application message carries no authenticated sender" + } + val sender = + try { + decrypted.authenticatedData.decodeToString(throwOnInvalidSequence = true) + } catch (e: CharacterCodingException) { + throw IllegalArgumentException("cordn authenticated sender is not valid UTF-8", e) + } + + return ReceivedMessage( + sender = sender, + senderLeafIndex = decrypted.senderLeafIndex, + epoch = decrypted.epoch, + // Holds the envelope to the MLS-authenticated sender, which is the + // only thing making an unsigned envelope trustworthy at all. + envelope = CordnEnvelope.decode(decrypted.content, senderIdentity = sender), + ) + } + + /** The exporter binding both directions use, for callers that need the key itself. */ + val exporter get() = CordnGroupPolicy.PAYLOAD_EXPORTER +} + +/** An application message that passed every authentication check. */ +data class ReceivedMessage( + /** The account pubkey MLS authenticated, from `authenticated_data`. */ + val sender: HexKey, + /** Which leaf sent it. Not the same as [sender] for a linked device. */ + val senderLeafIndex: Int, + val epoch: Long, + val envelope: CordnEnvelope, +) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/spec02Envelopes/CordnDeliveredMessageCodec.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/spec02Envelopes/CordnDeliveredMessageCodec.kt new file mode 100644 index 0000000000..11879dd57b --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/spec02Envelopes/CordnDeliveredMessageCodec.kt @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.spec02Envelopes + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.long +import kotlinx.serialization.json.put + +/** + * The on-disk form of one delivered message. + * + * ## Why the plaintext is what gets stored + * + * A cordn payload is sealed twice — the MLS application message, then + * [com.vitorpamplona.quartz.cordn.spec03Payloads.SealedPayload] under an + * exporter key — and both keys are derived per epoch. Once the group ratchets + * forward, the ciphertext the coordinator still holds is unreadable to us, and + * the cursor has in any case advanced past it. Ingestion is the only moment the + * message is in the clear, so this is not a cache in front of a source of + * truth: it is the only copy. + * + * ## Why the cursor rides along + * + * A room orders on the cursor and the unread divider compares against it, and + * it is not derivable from the envelope — the envelope's `created_at` is the + * sender's clock, which is a claim, while the cursor is the coordinator's + * sequence. Storing the envelope alone would lose the ordering the room is + * built on. + */ +object CordnDeliveredMessageCodec { + const val VERSION = 1 + + private const val V = "v" + private const val CURSOR = "c" + private const val ENVELOPE = "e" + + fun encode(message: CordnDeliveredMessage): String = + Json.encodeToString( + JsonObject.serializer(), + buildJsonObject { + put(V, VERSION) + put(CURSOR, message.cursor) + put(ENVELOPE, message.envelope.toJsonObject()) + }, + ) + + /** + * Reads one entry back. + * + * Throws on anything malformed rather than returning null, so a caller + * reading a log decides for itself whether one bad entry drops the line or + * the conversation. [decodeOrNull] is the "drop the line" form. + */ + fun decode(entry: String): CordnDeliveredMessage { + val json = + Json.parseToJsonElement(entry) as? JsonObject + ?: throw IllegalArgumentException("a stored cordn message must be a JSON object") + + val version = + json[V]?.jsonPrimitive?.content?.toIntOrNull() + ?: throw IllegalArgumentException("a stored cordn message is missing `$V`") + require(version == VERSION) { "unknown stored cordn message version: $version" } + + val cursor = + json[CURSOR]?.jsonPrimitive?.long + ?: throw IllegalArgumentException("a stored cordn message is missing `$CURSOR`") + + val envelope = + json[ENVELOPE]?.jsonObject + ?: throw IllegalArgumentException("a stored cordn message is missing `$ENVELOPE`") + + return CordnDeliveredMessage(CordnEnvelope.fromJsonObject(envelope), cursor) + } + + /** + * [decode], or null when the entry cannot be read. + * + * Loading a room uses this: one corrupted entry — a half-written segment, a + * format from a version that did not ship — should cost that message, not + * every message behind it in the log. + */ + fun decodeOrNull(entry: String): CordnDeliveredMessage? = + try { + decode(entry) + } catch (e: Exception) { + null + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/spec02Envelopes/CordnEnvelope.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/spec02Envelopes/CordnEnvelope.kt new file mode 100644 index 0000000000..03eccdeadd --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/spec02Envelopes/CordnEnvelope.kt @@ -0,0 +1,194 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.spec02Envelopes + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.long +import kotlinx.serialization.json.put + +/** + * The Nostr-shaped application payload a cordn message carries, from + * `spec/02.md`. + * + * A NIP-01 event with no `sig`. The signature is not missing by oversight — + * §9 removes it deliberately, because MLS has already authenticated the sender + * and a second signature would mean a second signer round trip (possibly to a + * remote signer) for a proof nobody needs. Reusing the event shape buys the + * `kind`/`tags` vocabulary: kind 9 chat, 1111 threaded replies, 7 reactions. + * + * ## `pubkey` is a claim until you check it + * + * The envelope is not self-authenticating. `spec/02.md` §5 requires the + * receiver to reject any envelope whose `pubkey` differs from the MLS + * authenticated sender of the enclosing application message — which is why + * [decode] demands that identity rather than offering it as an option. A + * decoder that skipped it would let any member of a group post as any other, + * with no signature anywhere to contradict them. + */ +data class CordnEnvelope( + val id: HexKey, + val pubKey: HexKey, + val createdAt: Long, + val kind: Int, + val tags: Array>, + val content: String, +) { + /** The id these fields actually hash to, per NIP-01. */ + fun computedId(): HexKey = EventHasher.hashId(pubKey, createdAt, kind, tags, content) + + fun toJson(): String = Json.encodeToString(JsonObject.serializer(), toJsonObject()) + + fun toJsonObject(): JsonObject = + buildJsonObject { + put(ID, id) + put(PUBKEY, pubKey) + put(CREATED_AT, createdAt) + put(KIND, kind) + put( + TAGS, + buildJsonArray { + tags.forEach { tag -> add(buildJsonArray { tag.forEach { add(JsonPrimitive(it)) } }) } + }, + ) + put(CONTENT, content) + } + + /** The UTF-8 bytes that go inside the MLS application message (`spec/02.md` §3). */ + fun encode(): ByteArray = toJson().encodeToByteArray() + + // Array fields, so the generated equals would compare references. + override fun equals(other: Any?): Boolean = + this === other || + ( + other is CordnEnvelope && + id == other.id && + pubKey == other.pubKey && + createdAt == other.createdAt && + kind == other.kind && + content == other.content && + tags.size == other.tags.size && + tags.indices.all { tags[it].contentEquals(other.tags[it]) } + ) + + override fun hashCode(): Int = id.hashCode() + + companion object { + private const val ID = "id" + private const val PUBKEY = "pubkey" + private const val CREATED_AT = "created_at" + private const val KIND = "kind" + private const val TAGS = "tags" + private const val CONTENT = "content" + private const val SIG = "sig" + + /** Builds an envelope, deriving its [id] from the rest (`spec/02.md` §4). */ + fun build( + pubKey: HexKey, + createdAt: Long, + kind: Int, + tags: Array> = emptyArray(), + content: String, + ) = CordnEnvelope(EventHasher.hashId(pubKey, createdAt, kind, tags, content), pubKey, createdAt, kind, tags, content) + + /** + * Decodes an MLS application payload into an envelope. + * + * @param senderIdentity the account hex the MLS sender's credential + * binds, from [com.vitorpamplona.quartz.cordn.groups.CordnCredential]. Not + * optional: see the class KDoc. + */ + fun decode( + payload: ByteArray, + senderIdentity: HexKey, + ): CordnEnvelope { + val json = + try { + Json.parseToJsonElement(payload.decodeToString(throwOnInvalidSequence = true)) as? JsonObject + ?: throw IllegalArgumentException("cordn envelope must be a JSON object") + } catch (e: CharacterCodingException) { + throw IllegalArgumentException("cordn envelope is not valid UTF-8", e) + } + + require(SIG !in json) { "cordn envelope must not carry a `sig` (spec/02.md §2)" } + + val envelope = fromJsonObject(json) + + // Both checks are MUSTs, and each covers a different lie: the id + // check catches a rewritten body, the pubkey check catches a member + // posting under someone else's name. fromJsonObject does the first, + // because a body that does not hash to its id is malformed wherever + // it came from; only the second needs the MLS sender. + require(envelope.pubKey == senderIdentity) { + "cordn envelope claims pubkey ${envelope.pubKey} but the MLS sender is $senderIdentity" + } + return envelope + } + + /** + * Reads an envelope out of its JSON form, checking that the body hashes + * to the id it carries. + * + * Separate from [decode] because a re-read from our own storage has no + * MLS sender to compare against — the sender was checked when the + * message was first ingested, and the plaintext has been ours since. + * What is still worth checking on the way back in is integrity, which + * the id covers. + */ + fun fromJsonObject(json: JsonObject): CordnEnvelope { + val envelope = + CordnEnvelope( + id = json.str(ID), + pubKey = json.str(PUBKEY), + createdAt = + json[CREATED_AT]?.jsonPrimitive?.long + ?: throw IllegalArgumentException("cordn envelope is missing `$CREATED_AT`"), + kind = + json[KIND]?.jsonPrimitive?.content?.toIntOrNull() + ?: throw IllegalArgumentException("cordn envelope is missing `$KIND`"), + tags = json.tagArray(), + content = json.str(CONTENT), + ) + + require(envelope.id == envelope.computedId()) { + "cordn envelope id ${envelope.id} does not match its contents (${envelope.computedId()})" + } + return envelope + } + + private fun JsonObject.str(key: String): String = this[key]?.jsonPrimitive?.content ?: throw IllegalArgumentException("cordn envelope is missing `$key`") + + private fun JsonObject.tagArray(): Array> { + val tags = this[TAGS] as? JsonArray ?: throw IllegalArgumentException("cordn envelope is missing `$TAGS`") + return Array(tags.size) { i -> + val tag = tags[i] as? JsonArray ?: throw IllegalArgumentException("cordn envelope tag $i is not an array") + Array(tag.size) { j -> tag[j].jsonPrimitive.content } + } + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/spec02Envelopes/CordnMessageKinds.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/spec02Envelopes/CordnMessageKinds.kt new file mode 100644 index 0000000000..e82af46f5d --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/spec02Envelopes/CordnMessageKinds.kt @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.spec02Envelopes + +/** + * The `kind` values a cordn chat uses, and what they mean. + * + * `spec/02.md` §6 names three of these (9 chat, 1111 threaded reply, 7 + * reaction) as conventions to reuse "when their semantics match", and then says + * plainly that there is **no required set**. Edits, deletions and pins are + * therefore not protocol at all — they are application conventions, and the + * reference client picked numbers for them. + * + * **We follow cordn-web.** Being the second implementation of an unspecified + * convention is how it becomes a specification; inventing a third numbering + * would leave two clients that silently no-op on each other's edits. The cost + * is that these differ from Marmot's, which is fine — the two protocols do not + * interoperate and each carries its own table: + * + * | Purpose | cordn (here) | Marmot | + * | ------- | ------------ | ------ | + * | Chat | 9 | 9 | + * | Threaded reply | 1111 | 1111 | + * | Reaction | 7 | 7 | + * | Edit | **1010** | 1009 | + * | Deletion | 5 | — | + * | Pin | **1011** | — | + * | System row | derived, never sent | 1210, on the wire | + * + * Worth asking upstream to write the last three down. + */ +object CordnMessageKinds { + /** NIP-C7 chat message. `spec/02.md` §6. */ + const val TEXT = 9 + + /** NIP-22 comment. §6 prefers this over NIP-C7's reply convention. */ + const val THREAD_REPLY = 1111 + + /** NIP-25 reaction; `content` is the emoji. §6. */ + const val REACTION = 7 + + /** In-place replacement of a message's text. Not in any spec. */ + const val EDIT = 1010 + + /** NIP-09-shaped deletion of one message. Not in any spec. */ + const val DELETION = 5 + + /** Group-scoped pin/unpin, carrying an `op` tag. Not in any spec. */ + const val PIN = 1011 + + /** + * A row derived from an MLS Commit — "X added Y", "the name changed". + * + * Negative because it is **not a wire kind**: nothing sends it, and nothing + * should. cordn-web derives these client-side from the Commits it applies, + * and that is strictly better than Marmot's on-the-wire kind 1210 for one + * reason — a derived row cannot disagree with the MLS state it describes, + * because it *is* that state. It also costs no bytes and cannot be forged + * by a member who simply sends one. + */ + const val SYSTEM = -1 + + /** + * Kinds that modify another message rather than being one. + * + * An annotation never gets its own row in the stream; it is folded into its + * target by [CordnAnnotationIndex]. Getting this set wrong shows up as + * reactions rendering as blank messages. + */ + fun isAnnotation(kind: Int): Boolean = kind == REACTION || kind == EDIT || kind == DELETION || kind == PIN + + fun isSystem(kind: Int): Boolean = kind == SYSTEM +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/spec02Envelopes/CordnMessageReferences.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/spec02Envelopes/CordnMessageReferences.kt new file mode 100644 index 0000000000..803fb7f622 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/spec02Envelopes/CordnMessageReferences.kt @@ -0,0 +1,290 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.spec02Envelopes + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.core.fastFirstOrNull + +/** + * How one cordn message points at another, in both directions. + * + * `spec/02.md` §7 fixes the only part of this the protocol cares about: a + * reference names the target's **envelope `id`**, never a coordinator cursor, + * which is a delivery-order primitive that two coordinators will not agree on. + * Everything below that — which tags, in what shape — follows the NIPs §6 names + * and, for the kinds no spec covers, the reference client. + * + * Parse and build live in one file deliberately. They are two halves of one + * format, and the failure mode of splitting them is a client that emits tags + * its own parser rejects. + */ +object CordnMessageReferences { + // ---- inbound ------------------------------------------------------- + + /** A NIP-22 thread position: the root of the thread and the direct parent. */ + data class ThreadReference( + val rootId: HexKey, + val rootPubKey: HexKey, + val rootKind: Int, + val parentId: HexKey, + val parentPubKey: HexKey, + val parentKind: Int, + ) + + /** A NIP-25 reaction, with the emoji the sender chose. */ + data class ReactionReference( + val targetId: HexKey, + val targetPubKey: HexKey, + val targetKind: Int, + val reaction: String, + ) + + /** An edit. Carries only the target — the new text is the envelope's content. */ + data class EditReference( + val targetId: HexKey, + ) + + /** A deletion. [targetKind] is checked against the target before it counts. */ + data class DeleteReference( + val targetId: HexKey, + val targetKind: Int, + ) + + /** A pin or unpin. */ + data class PinReference( + val targetId: HexKey, + val op: PinOp, + ) + + enum class PinOp( + val wire: String, + ) { + ADD("add"), + REMOVE("remove"), + ; + + companion object { + fun of(wire: String?): PinOp? = entries.firstOrNull { it.wire == wire } + } + } + + /** + * Where [tags] places a message in a thread, or null if it places it nowhere. + * + * NIP-22 splits root from parent by case: uppercase `E`/`K`/`P` for the + * thread root, lowercase `e`/`k`/`p` for the message being replied to. All + * four of `E`/`K`/`e`/`k` must be present and the kinds must be numbers — + * a half-formed thread tag is treated as no thread rather than guessed at, + * because guessing reparents a reply under the wrong message. + * + * The pubkeys fall back to the `e`-tag's 4th element, which is where NIP-10 + * style tags carry them. + */ + fun thread(tags: TagArray): ThreadReference? { + val rootEvent = tags.tag("E") ?: return null + val rootKind = tags.tagValue("K")?.toIntOrNull() ?: return null + val parentEvent = tags.tag("e") ?: return null + val parentKind = tags.tagValue("k")?.toIntOrNull() ?: return null + + val rootId = rootEvent.getOrNull(1)?.ifEmpty { null } ?: return null + val parentId = parentEvent.getOrNull(1)?.ifEmpty { null } ?: return null + val rootPubKey = (tags.tagValue("P") ?: rootEvent.getOrNull(3))?.ifEmpty { null } ?: return null + val parentPubKey = (tags.tagValue("p") ?: parentEvent.getOrNull(3))?.ifEmpty { null } ?: return null + + return ThreadReference(rootId, rootPubKey, rootKind, parentId, parentPubKey, parentKind) + } + + /** The reaction [kind]/[content]/[tags] express, or null if they do not. */ + fun reaction( + kind: Int, + content: String, + tags: TagArray, + ): ReactionReference? { + if (kind != CordnMessageKinds.REACTION) return null + + val targetId = tags.tagValue("e")?.ifEmpty { null } ?: return null + val targetPubKey = tags.tagValue("p")?.ifEmpty { null } ?: return null + val targetKind = tags.tagValue("k")?.toIntOrNull() ?: return null + // An empty reaction is not a reaction. NIP-25 gives `content` meaning + // here, and a blank one would render as an invisible chip. + val reaction = content.trim().ifEmpty { null } ?: return null + + return ReactionReference(targetId, targetPubKey, targetKind, reaction) + } + + /** + * The edit [kind]/[content]/[tags] express. + * + * Blank content is refused: an edit to nothing is a deletion, and those are + * a different kind with a different authorization rule. + */ + fun edit( + kind: Int, + content: String, + tags: TagArray, + ): EditReference? { + if (kind != CordnMessageKinds.EDIT || content.isBlank()) return null + val targetId = tags.tagValue("e")?.ifEmpty { null } ?: return null + return EditReference(targetId) + } + + /** The deletion [kind]/[tags] express. */ + fun delete( + kind: Int, + tags: TagArray, + ): DeleteReference? { + if (kind != CordnMessageKinds.DELETION) return null + val targetId = tags.tagValue("e")?.ifEmpty { null } ?: return null + val targetKind = tags.tagValue("k")?.toIntOrNull() ?: return null + return DeleteReference(targetId, targetKind) + } + + /** The pin or unpin [kind]/[tags] express. An unknown `op` is neither. */ + fun pin( + kind: Int, + tags: TagArray, + ): PinReference? { + if (kind != CordnMessageKinds.PIN) return null + val targetId = tags.tagValue("e")?.ifEmpty { null } ?: return null + val op = PinOp.of(tags.tagValue("op")) ?: return null + return PinReference(targetId, op) + } + + // ---- outbound ------------------------------------------------------ + + /** The fields of a message being replied to, reacted to, edited or deleted. */ + data class Target( + val id: HexKey, + val pubKey: HexKey, + val kind: Int, + /** The target's own tags, read to find the thread root. Empty is fine. */ + val tags: TagArray = emptyArray(), + ) + + /** What one outbound message will be, once its kind and tags are decided. */ + data class Outbound( + val kind: Int, + val content: String, + val tags: TagArray, + ) + + /** + * Resolves a send into its kind, content and tags. + * + * One function rather than a kind switch beside a tag switch, because the + * two have to agree and keeping them apart is how they stop agreeing. The + * ordering below is the precedence: a pin is a pin even if content was + * typed, and a delete ignores content entirely. + */ + fun outbound( + content: String, + extraTags: TagArray = emptyArray(), + replyTo: Target? = null, + reactionTo: Target? = null, + editTo: Target? = null, + deleteTo: Target? = null, + pinTo: Target? = null, + pinOp: PinOp = PinOp.ADD, + ): Outbound { + pinTo?.let { + return Outbound(CordnMessageKinds.PIN, "", pinTags(it, pinOp)) + } + reactionTo?.let { + // Not trimmed: an emoji is content, and trimming a reaction that is + // deliberately whitespace-adjacent would change what was sent. + return Outbound(CordnMessageKinds.REACTION, content, reactionTags(it)) + } + deleteTo?.let { + return Outbound(CordnMessageKinds.DELETION, "", deleteTags(it)) + } + editTo?.let { + return Outbound(CordnMessageKinds.EDIT, content.trim(), editTags(it) + extraTags) + } + return Outbound( + kind = if (replyTo != null) CordnMessageKinds.THREAD_REPLY else CordnMessageKinds.TEXT, + content = content.trim(), + tags = (replyTo?.let(::replyTags) ?: emptyArray()) + extraTags, + ) + } + + /** + * NIP-22 reply tags. + * + * The root is read out of the target's own `E`/`K`/`P` when it has them — + * replying to a reply keeps the original root — and is the target itself + * otherwise, which is the first reply in a thread. + */ + fun replyTags(target: Target): TagArray { + val rootEvent = target.tags.tag("E") + val rootId = rootEvent?.getOrNull(1)?.ifEmpty { null } ?: target.id + val rootPubKey = + (target.tags.tagValue("P") ?: rootEvent?.getOrNull(3))?.ifEmpty { null } ?: target.pubKey + val rootKind = target.tags.tagValue("K") ?: target.kind.toString() + + return arrayOf( + arrayOf("E", rootId, "", rootPubKey), + arrayOf("K", rootKind), + arrayOf("P", rootPubKey), + arrayOf("e", target.id, "", target.pubKey), + arrayOf("k", target.kind.toString()), + arrayOf("p", target.pubKey), + ) + } + + fun reactionTags(target: Target): TagArray = + arrayOf( + arrayOf("e", target.id, "", target.pubKey), + arrayOf("p", target.pubKey), + arrayOf("k", target.kind.toString()), + ) + + fun editTags(target: Target): TagArray = + arrayOf( + arrayOf("e", target.id, "", target.pubKey), + arrayOf("p", target.pubKey), + arrayOf("k", target.kind.toString()), + ) + + /** No `p`: a deletion names what is being removed, not who to notify. */ + fun deleteTags(target: Target): TagArray = + arrayOf( + arrayOf("e", target.id, "", target.pubKey), + arrayOf("k", target.kind.toString()), + ) + + fun pinTags( + target: Target, + op: PinOp, + ): TagArray = + arrayOf( + arrayOf("e", target.id, "", target.pubKey), + arrayOf("p", target.pubKey), + arrayOf("k", target.kind.toString()), + arrayOf("op", op.wire), + ) + + // ---- plumbing ------------------------------------------------------ + + private fun TagArray.tag(name: String): Array? = fastFirstOrNull { it.isNotEmpty() && it[0] == name } + + private fun TagArray.tagValue(name: String): String? = tag(name)?.getOrNull(1) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/spec03Payloads/SealedPayload.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/spec03Payloads/SealedPayload.kt new file mode 100644 index 0000000000..dd1d770b41 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/spec03Payloads/SealedPayload.kt @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.spec03Payloads + +import com.vitorpamplona.quartz.cordn.groups.CordnGroupPolicy +import com.vitorpamplona.quartz.mls.group.MlsGroup +import com.vitorpamplona.quartz.nip44Encryption.crypto.ChaCha20Poly1305 +import com.vitorpamplona.quartz.utils.RandomInstance +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +/** + * The outer seal every cordn message carries, from `spec/03.md` §4. + * + * ``` + * base64( nonce[12] || ChaCha20-Poly1305(key, nonce, mlsMessageBytes, aad = "") ) + * key = MLS-Exporter("cordn", "group-payload", 32) + * ``` + * + * This is why a coordinator sees nothing. MLS has already encrypted the + * message; this second layer means the coordinator cannot even tell a Commit + * from a chat line, so it cannot derive group state from traffic it is + * forbidden to parse. + * + * ## Byte-identical to Marmot's kind-445 seal, and deliberately separate code + * + * Same AEAD, same 12-byte random nonce, same empty AAD, same + * `base64(nonce‖ct‖tag)` layout as + * `marmot/mip03GroupMessages/GroupEventEncryption`. Only the exporter label + * differs, which is the entire reason the two protocols never read each + * other's traffic. Importing the Marmot one here would save a few lines and + * put one protocol's seal on another's wire, where a later Marmot-side change + * would silently break cordn interop. + */ +object SealedPayload { + /** 12-byte nonce plus a 16-byte tag, before any ciphertext (`spec/03.md` §4). */ + const val MIN_SIZE = 28 + + private const val NONCE_SIZE = 12 + private val EMPTY_AAD = ByteArray(0) + + /** + * Seals [mlsMessageBytes] under [key]. + * + * The nonce is fresh per payload and MUST NOT repeat under one key. + * `spec/03.md` §4 makes that a requirement rather than advice: ChaCha20 + * is a stream cipher, so a repeat under the same epoch key leaks the XOR + * of two plaintexts to anyone holding both — including the coordinator, + * which holds every payload by construction. + */ + @OptIn(ExperimentalEncodingApi::class) + fun seal( + mlsMessageBytes: ByteArray, + key: ByteArray, + ): String { + val nonce = RandomInstance.bytes(NONCE_SIZE) + return Base64.encode(nonce + ChaCha20Poly1305.encrypt(mlsMessageBytes, EMPTY_AAD, nonce, key)) + } + + /** Opens a sealed payload, or throws if it is malformed or fails AEAD verification. */ + @OptIn(ExperimentalEncodingApi::class) + fun open( + sealedBase64: String, + key: ByteArray, + ): ByteArray { + val payload = + try { + Base64.decode(sealedBase64) + } catch (e: IllegalArgumentException) { + throw IllegalArgumentException("cordn sealed payload is not valid base64", e) + } + require(payload.size >= MIN_SIZE) { + "cordn sealed payload must be at least $MIN_SIZE bytes, was ${payload.size}" + } + return ChaCha20Poly1305.decrypt( + payload.copyOfRange(NONCE_SIZE, payload.size), + EMPTY_AAD, + payload.copyOfRange(0, NONCE_SIZE), + key, + ) + } + + /** + * The epoch key for an APPLICATION message: the sender's current epoch + * (`spec/03.md` §5). + */ + fun applicationKey(group: MlsGroup): ByteArray = CordnGroupPolicy.PAYLOAD_EXPORTER.let { group.exporterSecret(it.label, it.context, it.length) } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/sync/CordnGroupSync.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/sync/CordnGroupSync.kt new file mode 100644 index 0000000000..8c3af2af25 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/sync/CordnGroupSync.kt @@ -0,0 +1,181 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.sync + +import com.vitorpamplona.quartz.cordn.spec00Coordinator.GroupMessage +import com.vitorpamplona.quartz.cordn.spec00Coordinator.ICoordinator +import com.vitorpamplona.quartz.cordn.spec00Coordinator.PostedMessage + +/** + * Fetch-then-subscribe over a set of groups on one coordinator. + * + * ## Why the order matters + * + * `msg_sub_many` takes a cursor per group and delivers from there, so it looks + * like subscribing alone would do. It would not: a subscription is a live + * stream, and the catch-up it performs at open is bounded by what the + * coordinator is willing to push in one go. Draining history with + * `msg_fetch_many` first, then subscribing from the freshest cursor, is what + * makes the two paths meet exactly once — which matters because MLS is a + * sequence, not a set. A Commit processed out of order is a Commit that fails, + * and the epoch it would have produced never arrives. + * + * Both paths feed [GroupInbox.accept], so a message seen twice — over the tail + * of catch-up and the head of the subscription — is recognised the same way + * either time. + */ +class CordnGroupSync( + private val client: ICoordinator, + /** Inbox per `gid`. The caller owns them, because they outlive a sync run. */ + private val inboxes: MutableMap = mutableMapOf(), +) { + /** The inbox for [gid], created at cursor 0 if this is the first time. */ + fun inbox(gid: String): GroupInbox = inboxes.getOrPut(gid) { GroupInbox() } + + /** Resumes [gid] from a persisted cursor. */ + fun restore( + gid: String, + cursor: GroupCursor, + echoes: EchoState = EchoState(), + ) { + inboxes[gid] = GroupInbox(cursor, echoes) + } + + /** The cursors to persist. */ + fun cursors(): Map = inboxes.mapValues { it.value.cursor } + + /** + * The echo bookkeeping to persist, per group. + * + * Saved on the same beat as [cursors] and for the same reason: a cursor + * that outlives the process while the record of what is ours does not + * leaves a client re-reading its own traffic with no way to recognise it. + * See [EchoState]. + */ + fun echoes(): Map = inboxes.mapValues { it.value.echoes() } + + /** + * Drains history for [gids] until every group is current. + * + * The coordinator decides page size, so "current" means a page came back + * with nothing new for any group. [maxPages] bounds that loop: a group with + * years of history should not block startup forever, and a coordinator that + * kept returning the same page would otherwise spin. + * + * @return every message drained, in the order it was delivered. + */ + suspend fun catchUp( + gids: Collection, + maxPages: Int = DEFAULT_MAX_PAGES, + onMessage: (String, Ingestion) -> Unit, + ): Int { + require(gids.isNotEmpty()) { "catchUp needs at least one group" } + var delivered = 0 + + repeat(maxPages) { + val page = client.fetchMessages(gids.associateWith { inbox(it).cursor.afterOrNull() }) + // An empty page is the only honest "you are current" signal: the + // coordinator does not say how much is left. + if (page.isEmpty()) return delivered + + val before = gids.associateWith { inbox(it).cursor.lastCursor } + page.forEach { message -> onMessage(message.gid, inbox(message.gid).accept(message)) } + delivered += page.size + + // A page that moved no cursor means the coordinator is serving the + // same records back. Stopping beats looping: the alternative is a + // silent infinite fetch against a broken or hostile server. + if (gids.none { inbox(it).cursor.lastCursor > (before[it] ?: 0) }) return delivered + } + return delivered + } + + /** + * Subscribes from each group's current cursor and delivers until the + * coordinator closes the stream. + * + * Call [catchUp] first. Suspends for the life of the subscription, so give + * it its own coroutine. + */ + suspend fun subscribe( + gids: Collection, + timeoutMs: Long, + onMessage: (String, Ingestion) -> Unit, + ) { + require(gids.isNotEmpty()) { "subscribe needs at least one group" } + client.subscribeMessages( + cursors = gids.associateWith { inbox(it).cursor.afterOrNull() }, + timeoutMs = timeoutMs, + ) { message -> + onMessage(message.gid, inbox(message.gid).accept(message)) + } + } + + /** + * Posts a sealed application message and records it as ours. + * + * Recording it by the cursor the coordinator assigns is what stops the echo + * being re-ingested as somebody else's message a moment later. + */ + suspend fun postMessage( + gid: String, + sealedBase64: String, + ): PostedMessage = + client.postMessage(gid, sealedBase64).also { + inbox(gid).recordOwnMessage(it.cursor) + } + + /** + * Posts a sealed Commit and registers it as a pending epoch operation. + * + * @param localStateApplied whether the new epoch is already adopted locally. + * Pass false when posting before adopting — the echo then becomes the + * instruction to apply it, which is how a client that died mid-post + * recovers. + */ + suspend fun postCommit( + gid: String, + sealedBase64: String, + localStateApplied: Boolean = true, + ): PostedMessage { + // Registered BEFORE the call returns, because the subscription can + // deliver the echo while `postMessage` is still awaiting its own + // response. Registering afterwards leaves a window in which our own + // Commit looks like a stranger's and gets applied twice. + inbox(gid).expectEcho(PendingEpochOperation(sealedBase64, localStateApplied)) + return client.postMessage(gid, sealedBase64) + } + + /** Groups with a Commit posted but not yet seen coming back. */ + fun unconfirmed(): Map> = inboxes.mapValues { it.value.pending() }.filterValues { it.isNotEmpty() } + + companion object { + /** Enough for a long history, short of letting startup hang. */ + const val DEFAULT_MAX_PAGES = 64 + } +} + +/** One message drained from a group, with what the inbox decided about it. */ +data class DeliveredMessage( + val gid: String, + val message: GroupMessage, + val ingestion: Ingestion, +) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/sync/GroupCursor.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/sync/GroupCursor.kt new file mode 100644 index 0000000000..19f6fc71ce --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/sync/GroupCursor.kt @@ -0,0 +1,233 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.sync + +import com.vitorpamplona.quartz.cordn.spec00Coordinator.GroupMessage + +/** + * Where a group's catch-up has reached on one coordinator. + * + * A cursor is coordinator-local and group-scoped (`spec/00.md` §4): it orders + * this group's stream on this coordinator and means nothing anywhere else. + * Persist it per (coordinator, gid) pair and never treat it as a message + * identity — the canonical id is the envelope's (`spec/02.md` §7). + */ +data class GroupCursor( + /** + * The value to pass as `after` on the next fetch. + * + * Advances past every message the stream delivers, INCLUDING ones we could + * not or chose not to process. That is not sloppiness: a message sealed + * under an epoch we never had is undecryptable forever, so a cursor that + * refused to move past it would re-fetch it on every catch-up and never + * reach the messages behind it. + */ + val fetchCursor: Long = 0, + /** The highest cursor ever seen, which only ever moves forward. */ + val lastCursor: Long = 0, +) { + fun advancedTo(cursor: Long) = GroupCursor(fetchCursor = cursor, lastCursor = maxOf(lastCursor, cursor)) + + /** What to send as `after`, or null on a first-ever fetch. */ + fun afterOrNull(): Long? = fetchCursor.takeIf { it > 0 } +} + +/** + * What to do with one inbound message. + * + * The decision is separated from the doing because it is the part that is easy + * to get wrong and hard to notice: every branch advances the cursor, and the + * difference between them is only whether MLS state moves. + */ +sealed interface Ingestion { + /** The cursor to advance to, whatever the outcome. */ + val cursor: Long + + /** + * Our own Commit coming back, already applied locally when we posted it. + * + * Skip it. Feeding our own Commit back through the engine would advance + * the epoch a second time and desynchronize us from every other member — + * the failure looks like "my messages stopped decrypting" several epochs + * later, with nothing pointing at the cause. + */ + data class SelfEchoConfirmed( + override val cursor: Long, + val sealedBase64: String, + ) : Ingestion + + /** + * Our own Commit coming back, NOT yet applied locally. + * + * Process it. This is the crash-recovery case: we posted, the coordinator + * accepted, and we died before adopting the new epoch. The echo is the only + * copy of that Commit we will ever be offered. + */ + data class SelfEchoUnapplied( + override val cursor: Long, + val sealedBase64: String, + ) : Ingestion + + /** An application message we sent. Already in our own history. */ + data class OwnMessage( + override val cursor: Long, + ) : Ingestion + + /** Someone else's traffic. Hand it to MLS. */ + data class Process( + override val cursor: Long, + val sealedBase64: String, + ) : Ingestion +} + +/** + * A Commit we have posted and are waiting to see come back. + * + * Keyed by the exact sealed base64, which is what makes the match reliable: + * `spec/03.md` §4 requires a fresh random nonce per payload, so the sealed form + * of a Commit is unique to the one posting of it. Matching on cursor instead + * would break the moment a coordinator renumbered, and matching on the + * plaintext would need us to decrypt our own traffic to recognise it. + */ +data class PendingEpochOperation( + val sealedBase64: String, + /** + * False when we posted but had not yet adopted the post-Commit state — + * the case [Ingestion.SelfEchoUnapplied] exists for. + */ + val localStateApplied: Boolean = true, +) + +/** + * Per-group ingestion state: the cursor, our pending Commits, and the cursors + * of application messages we sent. + * + * One path for catch-up and live delivery, as the reference requires: a message + * arriving over `msg_sub_many` and the same message arriving over + * `msg_fetch_many` must be treated identically, or a client that reconnects + * mid-stream processes something twice. + */ +class GroupInbox( + var cursor: GroupCursor = GroupCursor(), + echoes: EchoState = EchoState(), +) { + private val pendingOperations = echoes.pendingCommits.associateByTo(mutableMapOf()) { it.sealedBase64 } + private val ownMessageCursors = echoes.ownMessageCursors.toMutableSet() + + /** Records a Commit we just posted, so its echo is recognised. */ + fun expectEcho(operation: PendingEpochOperation) { + pendingOperations[operation.sealedBase64] = operation + } + + /** Records an application message we sent, by the cursor the coordinator gave it. */ + fun recordOwnMessage(cursor: Long) { + ownMessageCursors += cursor + } + + /** Pending Commits still unconfirmed. */ + fun pending(): List = pendingOperations.values.toList() + + /** + * Classifies [message] and advances the cursor. + * + * Advancing here rather than at each call site is the whole point: three of + * the four outcomes do nothing else, and a caller that forgot one would + * silently re-fetch forever. + */ + fun accept(message: GroupMessage): Ingestion { + cursor = cursor.advancedTo(message.cursor) + + val pending = pendingOperations[message.sealedBase64] + if (pending != null) { + pendingOperations.remove(message.sealedBase64) + return if (pending.localStateApplied) { + Ingestion.SelfEchoConfirmed(message.cursor, message.sealedBase64) + } else { + Ingestion.SelfEchoUnapplied(message.cursor, message.sealedBase64) + } + } + + if (ownMessageCursors.remove(message.cursor)) { + return Ingestion.OwnMessage(message.cursor) + } + + return Ingestion.Process(message.cursor, message.sealedBase64) + } + + /** + * Advances past a message MLS could not process. + * + * Undecryptable is not always an error worth stopping for: a message from + * an epoch we joined after, or one from a generation whose ratchet has + * moved on, is permanently unreadable and will be just as unreadable next + * time. The cursor has already advanced in [accept]; this exists so the + * intent is written down at the call site rather than inferred from its + * absence. + */ + fun skipUnprocessable(cursor: Long) { + this.cursor = this.cursor.advancedTo(cursor) + } + + /** + * The bookkeeping to persist beside [cursor]. + * + * Own-message cursors at or below the fetch cursor are dropped: the stream + * has already delivered them, so they can never come round again, and + * keeping them would grow the record for the life of the group. + * + * Pending Commits are kept until their echo matches, however long that + * takes, because the echo is the only copy that will ever be offered and + * [Ingestion.SelfEchoUnapplied] is how a client that died mid-post applies + * its own Commit. + */ + fun echoes(): EchoState = + EchoState( + pendingCommits = pendingOperations.values.toList(), + ownMessageCursors = ownMessageCursors.filter { it > cursor.fetchCursor }.sorted(), + ) +} + +/** + * The part of a [GroupInbox] that has to outlive the process. + * + * Found by running against a real coordinator, not by reading. Posting + * deliberately does not advance the cursor — a lower cursor may still hold + * somebody else's unprocessed message — so a client that exits between + * posting and ingesting comes back with a cursor that will re-read its own + * traffic. Without this record it cannot tell that it is its own: its Commit + * was sealed under an epoch key it has since left, and its message came from a + * ratchet generation already consumed, so both arrive as + * [Ingestion.Process] and fail to open. + * + * The visible damage is a gap in the sender's own conversation. The worse, + * narrower damage is that [Ingestion.SelfEchoUnapplied] — the recovery path + * for a client that posted a Commit and died before adopting it — depends on + * exactly the record that dying used to destroy. + * + * Every client needs this, not only a process-per-command one: a phone killed + * between sending a message and syncing is the ordinary case, not a corner. + */ +data class EchoState( + val pendingCommits: List = emptyList(), + val ownMessageCursors: List = emptyList(), +) { + val isEmpty: Boolean get() = pendingCommits.isEmpty() && ownMessageCursors.isEmpty() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/tlv/CordnStrictTlv.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/tlv/CordnStrictTlv.kt new file mode 100644 index 0000000000..ee1cdac086 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cordn/tlv/CordnStrictTlv.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.tlv + +/** + * A strict TLV parse for cordn's bech32 payloads. + * + * `Tlv.parse` in quartz stops silently at a malformed tuple, which is right for + * NIP-19 — a truncated `nprofile` still names a usable pubkey, and half an + * answer beats none. It is wrong for cordn's payloads, where a dropped tail + * changes where the reader goes: a group ref quietly losing its coordinator + * reaches for a default instead, and a handoff code quietly losing its relays + * looks for the tip in the wrong place. + * + * Unknown TYPES are still ignored. That is forward compatibility, and a + * different thing from a truncated payload. + */ +object CordnStrictTlv { + /** + * Parses [data], or throws naming [subject] and the rule it broke. + * + * @param subject what the caller is decoding, for the error message — + * "cordn group ref", "handoff code". + */ + fun parse( + data: ByteArray, + subject: String, + ): Map> { + val result = mutableMapOf>() + var pos = 0 + while (pos < data.size) { + require(pos + 2 <= data.size) { "$subject has a truncated TLV header" } + val type = data[pos] + val length = data[pos + 1].toUByte().toInt() + require(pos + 2 + length <= data.size) { + "$subject TLV type $type declares $length bytes but only ${data.size - pos - 2} remain" + } + result.getOrPut(type) { mutableListOf() }.add(data.copyOfRange(pos + 2, pos + 2 + length)) + pos += 2 + length + } + return result + } + + /** Decodes UTF-8, or throws naming [subject] and [field]. */ + fun utf8( + bytes: ByteArray, + subject: String, + field: String, + ): String = + try { + bytes.decodeToString(throwOnInvalidSequence = true) + } catch (e: CharacterCodingException) { + throw IllegalArgumentException("$subject $field is not valid UTF-8", e) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/assistant/CurationCopy.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/assistant/CurationCopy.kt index bb6d37a238..2245686765 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/assistant/CurationCopy.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/assistant/CurationCopy.kt @@ -37,7 +37,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip01Core.tags.hashtags.HashtagTag import com.vitorpamplona.quartz.nip01Core.tags.kinds.KindTag import com.vitorpamplona.quartz.nip01Core.tags.people.PTag -import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.sha256.sha256 @@ -109,7 +109,7 @@ object CurationCopy { fun buildRemoval( copy: AddressableListItemEvent, createdAt: Long = TimeUtils.now(), - ) = eventTemplate(DeletionEvent.KIND, "", createdAt) { + ) = eventTemplate(DeletionRequestEvent.KIND, "", createdAt) { add(ATag.assemble(copy.address(), null)) add(ETag.assemble(copy.id, null, null)) add(KindTag.assemble(AddressableListItemEvent.KIND)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/taggings/EventTagging.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/taggings/EventTagging.kt index 1e157b23e6..69d42d9956 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/taggings/EventTagging.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/taggings/EventTagging.kt @@ -32,29 +32,80 @@ import com.vitorpamplona.quartz.experimental.decentralizedLists.taggings.tags.Po import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.fastFirstNotNullOfOrNull +import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.utils.TimeUtils +import com.vitorpamplona.quartz.utils.sha256.sha256 /** What an event tagging is about: an addressable event (`a`) or a plain one (`e`). */ @Immutable sealed interface TaggingTarget { - /** The first 8 characters used in the assertion `d`. */ + /** + * What stands for this target in the assertion `d`: `` for a plain event, + * `--` for an addressable one. Kind 39999 is addressable, so two + * taggings that share a `d` do not sit side by side — the later one replaces the earlier. + * + * The draft fixes these widths, so the separation is 32 bits of hash rather than a guarantee: + * it removes the old rule's *certain* collision between any two of an author's events, and + * leaves a birthday-bound residue that the draft accepts. Widening it is an upstream change, + * not ours. + */ val prefix: String @Immutable data class ByAddress( val address: Address, ) : TaggingTarget { - // the author segment of the coordinate, not its kind - override val prefix get() = address.pubKeyHex.take(8) + /** + * `--`. Only `hash8` carries uniqueness — it is taken over the WHOLE + * coordinate, because no single segment identifies the target: the author repeats across + * everything they write, and the kind across everything of a type. The other two are + * decoration the draft asks for so a `d` stays readable. + * + * An empty `dTag` yields an empty `d16`, i.e. `--`, which the draft spells + * out. A plain event uses its own id (below): that already covers the whole event. + */ + override val prefix get() = "${address.pubKeyHex.take(AUTHOR_CHARS)}-${d16()}-${hash8()}" + + /** + * The draft counts `d16` in UTF-16 code units, which can cut an astral character in half + * and leave a dangling high surrogate. That is not survivable: the id is hashed from the + * intact char, but UTF-8 encoding the event for the wire has no encoding for half a + * character and substitutes `?`, so the relay re-hashes different bytes, gets a different + * id, and rejects the event. Verified end to end — see the test. + * + * So the half character is dropped, giving 15 units instead of 16. `d16` is decoration + * the draft says carries no uniqueness, and dropping it stays deterministic, so nothing + * that matters is lost; `hash8` still separates the targets. + */ + private fun d16(): String { + val cut = address.dTag.take(D_TAG_CHARS) + return if (cut.lastOrNull()?.isHighSurrogate() == true) cut.dropLast(1) else cut + } + + // Lowercase hex of the UTF-8 coordinate exactly as the `a` tag carries it: no trimming, + // no normalization, no reordering. Hex.encode already emits lower case. + private fun hash8() = sha256(address.toValue().encodeToByteArray()).toHexKey().take(HASH_CHARS) } @Immutable data class ByEventId( val eventId: HexKey, ) : TaggingTarget { - override val prefix get() = eventId.take(8) + override val prefix get() = eventId.take(EVENT_ID_CHARS) + } + + companion object { + const val EVENT_ID_CHARS = 8 + const val AUTHOR_CHARS = 8 + const val HASH_CHARS = 8 + + /** + * UTF-16 code units, which is what the draft pins ("as JavaScript `String.prototype.slice` + * counts them") and what [String.take] counts. + */ + const val D_TAG_CHARS = 16 } } @@ -105,7 +156,8 @@ object TaggingHeader { * [TaggingHeader]: * * ``` - * ["d", "event-tag---"] + * ["d", "event-tag---"] // = , or + * // -- for an `a` * ["a", ] or ["e", ] * ["z", ] * ["z", ] @@ -113,6 +165,10 @@ object TaggingHeader { * ``` * * An `a`/`e` reference without such a `z` is not a tagging and must not be read as one. + * + * The `d` is a replaceability key, never a source of truth: the draft forbids parsing it back into + * its fields, because `d16` is user-influenced text that may itself contain hyphens. [parse] reads + * the target from the `a`/`e` tag, which is authoritative. */ @Immutable data class EventTagging( diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/graperank/GrapeRankCrawler.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/graperank/GrapeRankCrawler.kt index 4cba6e0c9f..628df753e7 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/graperank/GrapeRankCrawler.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/graperank/GrapeRankCrawler.kt @@ -35,7 +35,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.store.IEventStore import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent -import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent import com.vitorpamplona.quartz.nip56Reports.ReportEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent @@ -951,7 +951,7 @@ class GrapeRankCrawler( // deleter-must-be-author check the caller runs rejects it. val chunkIds = authorChunk.flatMap { idsByAuthor[it].orEmpty() } for (idChunk in chunkIds.chunked(AUTHORS_PER_FILTER)) { - add(Filter(kinds = listOf(DeletionEvent.KIND), authors = authorChunk, tags = mapOf("e" to idChunk))) + add(Filter(kinds = listOf(DeletionRequestEvent.KIND), authors = authorChunk, tags = mapOf("e" to idChunk))) } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/graperank/GrapeRankPublisher.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/graperank/GrapeRankPublisher.kt index 89b90b79a4..bad314d356 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/graperank/GrapeRankPublisher.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/graperank/GrapeRankPublisher.kt @@ -29,8 +29,8 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.store.IEventStore -import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent -import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent +import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent +import com.vitorpamplona.quartz.nip85TrustedAssertions.users.UserAssertionEvent import com.vitorpamplona.quartz.nip85TrustedAssertions.users.followerCount import com.vitorpamplona.quartz.nip85TrustedAssertions.users.hops import com.vitorpamplona.quartz.nip85TrustedAssertions.users.rank @@ -45,7 +45,7 @@ import kotlinx.coroutines.coroutineScope import kotlin.coroutines.cancellation.CancellationException /** - * Persists a set of GrapeRank scores as NIP-85 kind:30382 [ContactCardEvent] + * Persists a set of GrapeRank scores as NIP-85 kind:30382 [UserAssertionEvent] * trusted assertions in the local [IEventStore] — the durable, reusable form of a * score run — and pushes that local card set out to the operator's relays on * demand. The store is the source of truth; the two halves are separable: @@ -168,10 +168,10 @@ class GrapeRankPublisher( idleTimeoutMs: Long = 30_000L, publishTimeoutSecs: Long = 15, ): SyncResult { - val cards = store.query(Filter(kinds = listOf(ContactCardEvent.KIND), authors = listOf(providerPubkey))) - val deletions = store.query(Filter(kinds = listOf(DeletionEvent.KIND), authors = listOf(providerPubkey))) + val cards = store.query(Filter(kinds = listOf(UserAssertionEvent.KIND), authors = listOf(providerPubkey))) + val deletions = store.query(Filter(kinds = listOf(DeletionRequestEvent.KIND), authors = listOf(providerPubkey))) - val filter = Filter(kinds = listOf(ContactCardEvent.KIND, DeletionEvent.KIND), authors = listOf(providerPubkey)) + val filter = Filter(kinds = listOf(UserAssertionEvent.KIND, DeletionRequestEvent.KIND), authors = listOf(providerPubkey)) val groups = NegentropyStoreSync( client = client, @@ -214,10 +214,10 @@ class GrapeRankPublisher( * store. Locally-retracted cards never show up — inserting their kind:5 * removed them — so a target can be re-carded later without fighting a ghost. */ - private suspend fun existingCards(providerPubkey: HexKey): Map = + private suspend fun existingCards(providerPubkey: HexKey): Map = store - .query(Filter(kinds = listOf(ContactCardEvent.KIND), authors = listOf(providerPubkey))) - .filterIsInstance() + .query(Filter(kinds = listOf(UserAssertionEvent.KIND), authors = listOf(providerPubkey))) + .filterIsInstance() .groupBy { it.aboutUser() } .mapNotNull { (target, cards) -> val t = target.ifBlank { return@mapNotNull null } @@ -230,7 +230,7 @@ class GrapeRankPublisher( * produces no new signature; a missing tag reads null and so differs from a * desired non-null value (the one-time migration onto the new tags). */ - private fun cardValues(card: ContactCardEvent): Triple = Triple(card.rank(), card.followerCount(), card.hops()) + private fun cardValues(card: UserAssertionEvent): Triple = Triple(card.rank(), card.followerCount(), card.hops()) /** The (rank, followers, hops) triple a [ScoredCard] would write. */ private fun desiredValues(card: ScoredCard): Triple = Triple(card.rank, card.followers, card.hops) @@ -254,7 +254,7 @@ class GrapeRankPublisher( batch .map { card -> async(Dispatchers.Default) { - ContactCardEvent.create( + UserAssertionEvent.create( targetUser = card.target, signer = signer, createdAt = createdAt, @@ -285,13 +285,13 @@ class GrapeRankPublisher( */ private suspend fun insertRetractions( signer: NostrSigner, - cards: List, + cards: List, createdAt: Long, ): Int { if (cards.isEmpty()) return 0 var retracted = 0 for (chunk in cards.chunked(DELETE_PER_EVENT)) { - val deletion = signer.sign(DeletionEvent.build(chunk, createdAt)) + val deletion = signer.sign(DeletionRequestEvent.build(chunk, createdAt)) if (insertQuietly(deletion)) retracted += chunk.size } return retracted diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nip82SoftwareApps/release/Nip82ReleaseDetector.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nip82SoftwareApps/release/Nip82ReleaseDetector.kt index a8932e8ec0..6ed0e63cf5 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nip82SoftwareApps/release/Nip82ReleaseDetector.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nip82SoftwareApps/release/Nip82ReleaseDetector.kt @@ -24,22 +24,29 @@ import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.tags.AppI import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.tags.VersionTag import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.hasTagWithContent +import com.vitorpamplona.quartz.nip51Lists.releaseArtifactSet.ReleaseArtifactSetEvent /** * NIP-82 reuses kind 30063 which was already taken by NIP-51's - * `ReleaseArtifactSetEvent`. Both are valid; they coexist on the wire. + * [ReleaseArtifactSetEvent]. Both are valid; they coexist on the wire and the + * same class parses both. * * We disambiguate by inspecting the tag set: a NIP-82 Software Release MUST * carry both `i` (app identifier) and `version` tags. NIP-51 release artifact * sets do not. */ fun Event.isNip82SoftwareRelease() = - kind == SoftwareReleaseEvent.KIND && + kind == ReleaseArtifactSetEvent.KIND && tags.hasTagWithContent(AppIdTag.TAG_NAME) && tags.hasTagWithContent(VersionTag.TAG_NAME) /** - * Reinterprets `this` as a [SoftwareReleaseEvent]. Use [isNip82SoftwareRelease] + * Reinterprets `this` as a [ReleaseArtifactSetEvent]. Use [isNip82SoftwareRelease] * to gate the call. */ -fun Event.asSoftwareRelease() = SoftwareReleaseEvent(id, pubKey, createdAt, tags, content, sig) +@Deprecated( + "EventFactory already builds kind 30063 as ReleaseArtifactSetEvent, which exposes the NIP-82 fields. " + + "Re-wrapping an event of another kind yields one whose kind no longer matches its id and signature.", + ReplaceWith("this as ReleaseArtifactSetEvent", "com.vitorpamplona.quartz.nip51Lists.releaseArtifactSet.ReleaseArtifactSetEvent"), +) +fun Event.asSoftwareRelease() = this as? ReleaseArtifactSetEvent ?: ReleaseArtifactSetEvent(id, pubKey, createdAt, tags, content, sig) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nip82SoftwareApps/release/SoftwareReleaseEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nip82SoftwareApps/release/SoftwareReleaseEvent.kt index e48460d7c7..872b86630e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nip82SoftwareApps/release/SoftwareReleaseEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nip82SoftwareApps/release/SoftwareReleaseEvent.kt @@ -20,91 +20,47 @@ */ package com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release -import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.asset.SoftwareAssetEvent -import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.tags.AssetTag -import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent -import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle -import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider -import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate -import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag -import com.vitorpamplona.quartz.nip50Search.IndexableFieldVisitor -import com.vitorpamplona.quartz.nip50Search.SearchableEvent +import com.vitorpamplona.quartz.nip51Lists.releaseArtifactSet.ReleaseArtifactSetEvent import com.vitorpamplona.quartz.utils.TimeUtils /** - * NIP-82 Software Release (kind 30063). - * - * A parameterizable replaceable event grouping a set of [SoftwareAssetEvent]s - * under a named release version. The `d` tag is `@` and the - * event's `created_at` must be the release date. - * - * NOTE: kind 30063 was also used by NIP-51 `ReleaseArtifactSetEvent`. This - * experimental NIP-82 event is intentionally not registered in `EventFactory` - * to avoid colliding with the existing registration. Callers can instantiate - * or wrap [SoftwareReleaseEvent] directly when they know the event is a NIP-82 - * release. + * NIP-82 software releases share kind 30063 with NIP-51 release artifact sets, and + * [ReleaseArtifactSetEvent] now parses both. This used to be a second class for the same + * kind that `EventFactory` never built. */ -@Immutable -class SoftwareReleaseEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, -) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), - EventHintProvider, - SearchableEvent { - // content carries the release notes (free-text). NOTE: kind 30063 collides - // with NIP-51 ReleaseArtifactSetEvent, which is what EventFactory builds for - // stored events — so in practice this override is not exercised at runtime. - override fun indexableContent() = content +@Deprecated( + "Kind 30063 is parsed by ReleaseArtifactSetEvent, which also exposes the NIP-82 fields. Use ReleaseArtifactSetEvent.buildSoftwareRelease to build one.", + ReplaceWith("ReleaseArtifactSetEvent", "com.vitorpamplona.quartz.nip51Lists.releaseArtifactSet.ReleaseArtifactSetEvent"), +) +typealias SoftwareReleaseEvent = ReleaseArtifactSetEvent - // The read path: the same fields indexableContent() joins, handed over without - // building the joined string a scan would throw away. - override fun forEachIndexableField(visitor: IndexableFieldVisitor) { - visitor.visit(content) - } +/** The old `SoftwareReleaseEvent.buildDTag`. */ +@Deprecated( + "Use ReleaseArtifactSetEvent.buildSoftwareReleaseDTag.", + ReplaceWith("ReleaseArtifactSetEvent.buildSoftwareReleaseDTag(appId, version)", "com.vitorpamplona.quartz.nip51Lists.releaseArtifactSet.ReleaseArtifactSetEvent"), +) +fun ReleaseArtifactSetEvent.Companion.buildDTag( + appId: String, + version: String, +) = buildSoftwareReleaseDTag(appId, version) - override fun eventHints() = tags.mapNotNull(AssetTag::parseAsHint) - - override fun linkedEventIds() = tags.mapNotNull(AssetTag::parseId) - - fun appId() = tags.appId() - - fun version() = tags.version() - - fun channel() = tags.channel() - - fun assets() = tags.assets() - - companion object { - const val KIND = 30063 - - /** NIP-82 requires `d = @`. */ - fun buildDTag( - appId: String, - version: String, - ) = "$appId@$version" - - fun build( - appId: String, - version: String, - channel: String, - assets: List>, - releaseNotes: String = "", - createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, - ) = eventTemplate(KIND, releaseNotes, createdAt) { - dTag(buildDTag(appId, version)) - appId(appId) - version(version) - channel(channel) - assets(assets) - initializer() - } - } -} +/** The old `SoftwareReleaseEvent.build`: a NIP-82 release, not the NIP-51 set builder. */ +@Deprecated( + "Use ReleaseArtifactSetEvent.buildSoftwareRelease.", + ReplaceWith( + "ReleaseArtifactSetEvent.buildSoftwareRelease(appId, version, channel, assets, releaseNotes, createdAt, initializer)", + "com.vitorpamplona.quartz.nip51Lists.releaseArtifactSet.ReleaseArtifactSetEvent", + ), +) +fun ReleaseArtifactSetEvent.Companion.build( + appId: String, + version: String, + channel: String, + assets: List>, + releaseNotes: String = "", + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, +) = buildSoftwareRelease(appId, version, channel, assets, releaseNotes, createdAt, initializer) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nip82SoftwareApps/release/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nip82SoftwareApps/release/TagArrayBuilderExt.kt index 42d9195f63..25801b8d75 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nip82SoftwareApps/release/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nip82SoftwareApps/release/TagArrayBuilderExt.kt @@ -27,13 +27,14 @@ import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.tags.Chan import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.tags.VersionTag import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip51Lists.releaseArtifactSet.ReleaseArtifactSetEvent -fun TagArrayBuilder.appId(appId: String) = addUnique(AppIdTag.assemble(appId)) +fun TagArrayBuilder.appId(appId: String) = addUnique(AppIdTag.assemble(appId)) -fun TagArrayBuilder.version(version: String) = addUnique(VersionTag.assemble(version)) +fun TagArrayBuilder.version(version: String) = addUnique(VersionTag.assemble(version)) -fun TagArrayBuilder.channel(channel: String) = addUnique(ChannelTag.assemble(channel)) +fun TagArrayBuilder.channel(channel: String) = addUnique(ChannelTag.assemble(channel)) -fun TagArrayBuilder.asset(asset: EventHintBundle) = add(AssetTag.assemble(asset)) +fun TagArrayBuilder.asset(asset: EventHintBundle) = add(AssetTag.assemble(asset)) -fun TagArrayBuilder.assets(assets: List>) = addAll(AssetTag.assemble(assets)) +fun TagArrayBuilder.assets(assets: List>) = addAll(AssetTag.assemble(assets)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/publications/PublicationSectionRef.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/publications/PublicationSectionRef.kt index c28219fe8b..bb264bc95f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/publications/PublicationSectionRef.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/publications/PublicationSectionRef.kt @@ -28,8 +28,8 @@ import com.vitorpamplona.quartz.nip01Core.core.Tag import com.vitorpamplona.quartz.nip01Core.core.TagArray import com.vitorpamplona.quartz.nip01Core.core.has import com.vitorpamplona.quartz.nip01Core.core.isValid -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent -import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent +import com.vitorpamplona.quartz.nip54Wiki.WikiArticleEvent /** * One entry in a kind-30040 publication's table of contents. @@ -72,8 +72,8 @@ data class PublicationSectionRef( setOf( PublicationContentEvent.KIND, PublicationIndexEvent.KIND, - LongTextNoteEvent.KIND, - WikiNoteEvent.KIND, + LongFormContentEvent.KIND, + WikiArticleEvent.KIND, NipTextEvent.KIND, ) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt index b1ff7a9483..a4f4695cd4 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt @@ -88,8 +88,8 @@ import com.vitorpamplona.quartz.marmot.mip05PushNotifications.TokenRequestEvent import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent -import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent -import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip04Dm.messages.EncryptedDmEvent +import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip15Marketplace.auction.AuctionEvent import com.vitorpamplona.quartz.nip15Marketplace.bid.BidEvent @@ -99,35 +99,35 @@ import com.vitorpamplona.quartz.nip15Marketplace.product.ProductEvent import com.vitorpamplona.quartz.nip15Marketplace.stall.StallEvent import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent -import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.nip17Dm.settings.DmRelayListEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent import com.vitorpamplona.quartz.nip25Reactions.ExternalReactionEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelHideMessageEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMuteUserEvent -import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent +import com.vitorpamplona.quartz.nip28PublicChat.list.PublicChatListEvent import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent 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.GroupParticipantsEvent -import com.vitorpamplona.quartz.nip29RelayGroups.metadata.SupportedRolesEvent +import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupRolesEvent import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateGroupEvent -import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateInviteEvent -import com.vitorpamplona.quartz.nip29RelayGroups.moderation.DeleteEventEvent import com.vitorpamplona.quartz.nip29RelayGroups.moderation.DeleteGroupEvent -import com.vitorpamplona.quartz.nip29RelayGroups.moderation.EditMetadataEvent -import com.vitorpamplona.quartz.nip29RelayGroups.moderation.PutUserEvent -import com.vitorpamplona.quartz.nip29RelayGroups.moderation.RemoveUserEvent -import com.vitorpamplona.quartz.nip29RelayGroups.request.JoinRequestEvent -import com.vitorpamplona.quartz.nip29RelayGroups.request.LeaveRequestEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.GroupCreateInviteEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.GroupDeleteEventEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.GroupEditMetadataEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.GroupPutUserEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.GroupRemoveUserEvent +import com.vitorpamplona.quartz.nip29RelayGroups.request.GroupJoinRequestEvent +import com.vitorpamplona.quartz.nip29RelayGroups.request.GroupLeaveRequestEvent import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent -import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEvent +import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiListEvent import com.vitorpamplona.quartz.nip32Labeling.LabelEvent import com.vitorpamplona.quartz.nip34Git.grasp.UserGraspListEvent import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent @@ -141,7 +141,7 @@ import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayListEvent -import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent +import com.vitorpamplona.quartz.nip38UserStatus.UserStatusEvent import com.vitorpamplona.quartz.nip39ExtIdentities.ExternalIdentitiesEvent import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent import com.vitorpamplona.quartz.nip43RelayMembers.addMember.RelayAddMemberEvent @@ -151,45 +151,45 @@ import com.vitorpamplona.quartz.nip43RelayMembers.leaveRequest.RelayLeaveRequest import com.vitorpamplona.quartz.nip43RelayMembers.list.RelayMembershipListEvent import com.vitorpamplona.quartz.nip43RelayMembers.removeMember.RelayRemoveMemberEvent import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent -import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent -import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcInfoEvent import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcNotificationEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcRequestEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcResponseEvent import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent import com.vitorpamplona.quartz.nip51Lists.PinListEvent import com.vitorpamplona.quartz.nip51Lists.appCurationSet.AppCurationSetEvent import com.vitorpamplona.quartz.nip51Lists.articleCurationSet.ArticleCurationSetEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkSet.BookmarkSetEvent import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListEvent -import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import com.vitorpamplona.quartz.nip51Lists.followSet.FollowSetEvent import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent import com.vitorpamplona.quartz.nip51Lists.gitAuthorList.GitAuthorListEvent import com.vitorpamplona.quartz.nip51Lists.gitRepositoryList.GitRepositoryListEvent import com.vitorpamplona.quartz.nip51Lists.goodWikiAuthorList.GoodWikiAuthorListEvent import com.vitorpamplona.quartz.nip51Lists.goodWikiRelayList.GoodWikiRelayListEvent -import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent +import com.vitorpamplona.quartz.nip51Lists.interestList.InterestListEvent import com.vitorpamplona.quartz.nip51Lists.interestSet.InterestSetEvent import com.vitorpamplona.quartz.nip51Lists.kindMuteSet.KindMuteSetEvent -import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.LabeledBookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.mediaFollowList.MediaFollowListEvent import com.vitorpamplona.quartz.nip51Lists.mediaStarterPack.MediaStarterPackEvent import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent -import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import com.vitorpamplona.quartz.nip51Lists.pictureCurationSet.PictureCurationSetEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BroadcastRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.FavoriteRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent -import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relaySets.RelaySetEvent import com.vitorpamplona.quartz.nip51Lists.releaseArtifactSet.ReleaseArtifactSetEvent import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.SimpleGroupListEvent +import com.vitorpamplona.quartz.nip51Lists.starterPack.StarterPackEvent import com.vitorpamplona.quartz.nip51Lists.videoCurationSet.VideoCurationSetEvent import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent -import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent +import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarCollectionEvent import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent @@ -199,19 +199,19 @@ import com.vitorpamplona.quartz.nip53LiveActivities.nestsServers.NestsServersEve import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent import com.vitorpamplona.quartz.nip53LiveActivities.raid.LiveActivitiesRaidEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent +import com.vitorpamplona.quartz.nip54Wiki.WikiArticleEvent import com.vitorpamplona.quartz.nip54Wiki.WikiMergeAcceptanceEvent import com.vitorpamplona.quartz.nip54Wiki.WikiMergeRequestEvent -import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip54Wiki.WikiRedirectEvent import com.vitorpamplona.quartz.nip56Reports.ReportEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import com.vitorpamplona.quartz.nip57Zaps.PrivateZapEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapRequestEvent import com.vitorpamplona.quartz.nip58Badges.accepted.AcceptedBadgeSetEvent import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent import com.vitorpamplona.quartz.nip58Badges.profile.ProfileBadgesEvent -import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent +import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.EphemeralGiftWrapEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip5aStaticWebsites.NamedSiteEvent @@ -238,15 +238,15 @@ import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.RelayDiscoveryEvent import com.vitorpamplona.quartz.nip66RelayMonitor.monitor.RelayMonitorEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent import com.vitorpamplona.quartz.nip69P2pOrderEvents.P2POrderEvent -import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent +import com.vitorpamplona.quartz.nip71Video.AddressableNormalVideoEvent +import com.vitorpamplona.quartz.nip71Video.AddressableShortVideoEvent import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent import com.vitorpamplona.quartz.nip71Video.VideoShortEvent -import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent import com.vitorpamplona.quartz.nip72ModCommunities.rules.CommunityRulesEvent -import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent +import com.vitorpamplona.quartz.nip75ZapGoals.ZapGoalEvent import com.vitorpamplona.quartz.nip78AppData.AppDataEvent import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent @@ -255,7 +255,7 @@ import com.vitorpamplona.quartz.nip85TrustedAssertions.addressables.AddressableA import com.vitorpamplona.quartz.nip85TrustedAssertions.events.EventAssertionEvent import com.vitorpamplona.quartz.nip85TrustedAssertions.externalIds.ExternalIdAssertionEvent import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent -import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent +import com.vitorpamplona.quartz.nip85TrustedAssertions.users.UserAssertionEvent import com.vitorpamplona.quartz.nip87Ecash.cashu.CashuMintEvent import com.vitorpamplona.quartz.nip87Ecash.fedimint.FedimintEvent import com.vitorpamplona.quartz.nip87Ecash.recommendation.MintRecommendationEvent @@ -263,46 +263,46 @@ import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.AppRecommendationEvent -import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryRequest.NIP90ContentDiscoveryRequestEvent -import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent -import com.vitorpamplona.quartz.nip90Dvms.contentSearch.NIP90ContentSearchRequestEvent -import com.vitorpamplona.quartz.nip90Dvms.contentSearch.NIP90ContentSearchResponseEvent -import com.vitorpamplona.quartz.nip90Dvms.eventCount.NIP90EventCountRequestEvent -import com.vitorpamplona.quartz.nip90Dvms.eventCount.NIP90EventCountResponseEvent -import com.vitorpamplona.quartz.nip90Dvms.eventPowDelegation.NIP90EventPowDelegationRequestEvent -import com.vitorpamplona.quartz.nip90Dvms.eventPowDelegation.NIP90EventPowDelegationResponseEvent -import com.vitorpamplona.quartz.nip90Dvms.eventPublishSchedule.NIP90EventPublishScheduleRequestEvent -import com.vitorpamplona.quartz.nip90Dvms.eventPublishSchedule.NIP90EventPublishScheduleResponseEvent -import com.vitorpamplona.quartz.nip90Dvms.eventTimestamping.NIP90EventTimestampingRequestEvent -import com.vitorpamplona.quartz.nip90Dvms.eventTimestamping.NIP90EventTimestampingResponseEvent -import com.vitorpamplona.quartz.nip90Dvms.imageGeneration.NIP90ImageGenerationRequestEvent -import com.vitorpamplona.quartz.nip90Dvms.imageGeneration.NIP90ImageGenerationResponseEvent -import com.vitorpamplona.quartz.nip90Dvms.imageToVideo.NIP90ImageToVideoRequestEvent -import com.vitorpamplona.quartz.nip90Dvms.imageToVideo.NIP90ImageToVideoResponseEvent -import com.vitorpamplona.quartz.nip90Dvms.malwareScanning.NIP90MalwareScanRequestEvent -import com.vitorpamplona.quartz.nip90Dvms.malwareScanning.NIP90MalwareScanResponseEvent -import com.vitorpamplona.quartz.nip90Dvms.opReturn.NIP90OpReturnRequestEvent -import com.vitorpamplona.quartz.nip90Dvms.opReturn.NIP90OpReturnResponseEvent -import com.vitorpamplona.quartz.nip90Dvms.peopleSearch.NIP90PeopleSearchRequestEvent -import com.vitorpamplona.quartz.nip90Dvms.peopleSearch.NIP90PeopleSearchResponseEvent -import com.vitorpamplona.quartz.nip90Dvms.status.NIP90StatusEvent -import com.vitorpamplona.quartz.nip90Dvms.summarization.NIP90SummarizationRequestEvent -import com.vitorpamplona.quartz.nip90Dvms.summarization.NIP90SummarizationResponseEvent -import com.vitorpamplona.quartz.nip90Dvms.textExtraction.NIP90TextExtractionRequestEvent -import com.vitorpamplona.quartz.nip90Dvms.textExtraction.NIP90TextExtractionResponseEvent -import com.vitorpamplona.quartz.nip90Dvms.textGeneration.NIP90TextGenerationRequestEvent -import com.vitorpamplona.quartz.nip90Dvms.textGeneration.NIP90TextGenerationResponseEvent -import com.vitorpamplona.quartz.nip90Dvms.textToSpeech.NIP90TextToSpeechRequestEvent -import com.vitorpamplona.quartz.nip90Dvms.textToSpeech.NIP90TextToSpeechResponseEvent -import com.vitorpamplona.quartz.nip90Dvms.translation.NIP90TranslationRequestEvent -import com.vitorpamplona.quartz.nip90Dvms.translation.NIP90TranslationResponseEvent -import com.vitorpamplona.quartz.nip90Dvms.userDiscoveryRequest.NIP90UserDiscoveryRequestEvent -import com.vitorpamplona.quartz.nip90Dvms.userDiscoveryResponse.NIP90UserDiscoveryResponseEvent -import com.vitorpamplona.quartz.nip90Dvms.videoConversion.NIP90VideoConversionRequestEvent -import com.vitorpamplona.quartz.nip90Dvms.videoConversion.NIP90VideoConversionResponseEvent -import com.vitorpamplona.quartz.nip90Dvms.videoTranslation.NIP90VideoTranslationRequestEvent -import com.vitorpamplona.quartz.nip90Dvms.videoTranslation.NIP90VideoTranslationResponseEvent -import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent +import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryRequest.DvmContentDiscoveryRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.DvmContentDiscoveryResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.contentSearch.DvmContentSearchRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.contentSearch.DvmContentSearchResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.eventCount.DvmEventCountRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.eventCount.DvmEventCountResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.eventPowDelegation.DvmEventPowDelegationRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.eventPowDelegation.DvmEventPowDelegationResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.eventPublishSchedule.DvmEventPublishScheduleRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.eventPublishSchedule.DvmEventPublishScheduleResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.eventTimestamping.DvmEventTimestampingRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.eventTimestamping.DvmEventTimestampingResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.imageGeneration.DvmImageGenerationRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.imageGeneration.DvmImageGenerationResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.imageToVideo.DvmImageToVideoRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.imageToVideo.DvmImageToVideoResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.malwareScanning.DvmMalwareScanRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.malwareScanning.DvmMalwareScanResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.opReturn.DvmOpReturnRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.opReturn.DvmOpReturnResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.peopleSearch.DvmPeopleSearchRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.peopleSearch.DvmPeopleSearchResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.status.DvmStatusEvent +import com.vitorpamplona.quartz.nip90Dvms.summarization.DvmSummarizationRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.summarization.DvmSummarizationResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.textExtraction.DvmTextExtractionRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.textExtraction.DvmTextExtractionResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.textGeneration.DvmTextGenerationRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.textGeneration.DvmTextGenerationResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.textToSpeech.DvmTextToSpeechRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.textToSpeech.DvmTextToSpeechResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.translation.DvmTranslationRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.translation.DvmTranslationResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.userDiscoveryRequest.DvmUserDiscoveryRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.userDiscoveryResponse.DvmUserDiscoveryResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.videoConversion.DvmVideoConversionRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.videoConversion.DvmVideoConversionResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.videoTranslation.DvmVideoTranslationRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.videoTranslation.DvmVideoTranslationResponseEvent +import com.vitorpamplona.quartz.nip94FileMetadata.FileMetadataEvent import com.vitorpamplona.quartz.nip96FileStorage.config.FileServersEvent import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent @@ -387,12 +387,12 @@ object KindNames { BookmarkListEvent.KIND to KindName("Bookmark List", "51"), OldBookmarkListEvent.KIND to KindName("Old Bookmark List", "51"), CalendarDateSlotEvent.KIND to KindName("Day Appointment", "52"), - CalendarEvent.KIND to KindName("Calendar", "52"), + CalendarCollectionEvent.KIND to KindName("Calendar", "52"), CalendarTimeSlotEvent.KIND to KindName("Appointment", "52"), CalendarRSVPEvent.KIND to KindName("Appt RSVP", "52"), ChessGameEvent.KIND to KindName("Chess Games", "64"), JesterEvent.KIND to KindName("Chess Auth", "64"), - RelayFeedsListEvent.KIND to KindName("Favorite Relays", "51"), + FavoriteRelayListEvent.KIND to KindName("Favorite Relays", "51"), LiveChessGameChallengeEvent.KIND to KindName("Chess Challenges", "64"), LiveChessGameAcceptEvent.KIND to KindName("Chess Game Accept", "64"), LiveChessMoveEvent.KIND to KindName("Chess Move", "64"), @@ -400,13 +400,13 @@ object KindNames { LiveChessDrawOfferEvent.KIND to KindName("Chess Draw Offer", "64"), ChannelCreateEvent.KIND to KindName("Channel Definition", "28"), ChannelHideMessageEvent.KIND to KindName("Channel Hide Msg", "28"), - ChannelListEvent.KIND to KindName("Channel List", "28"), + PublicChatListEvent.KIND to KindName("Channel List", "28"), ChannelMessageEvent.KIND to KindName("Channel Message", "28"), ChannelMetadataEvent.KIND to KindName("Channel Metadata", "28"), ChannelMuteUserEvent.KIND to KindName("Channel Mute User", "28"), ChatMessageEncryptedFileHeaderEvent.KIND to KindName("DM File", "17"), ChatMessageEvent.KIND to KindName("DM Message", "17"), - ChatMessageRelayListEvent.KIND to KindName("DM Relays", "17"), + DmRelayListEvent.KIND to KindName("DM Relays", "17"), ClassifiedsEvent.KIND to KindName("Classifieds", "99"), CommentEvent.KIND to KindName("Comments", "22"), GeocacheListingEvent.KIND to KindName("Geocache", "CC"), @@ -417,19 +417,19 @@ object KindNames { CommunityListEvent.KIND to KindName("Community List", "72"), CommunityPostApprovalEvent.KIND to KindName("Community Post", "72"), ContactListEvent.KIND to KindName("Follow List", "02"), - DeletionEvent.KIND to KindName("Deletions", "09"), + DeletionRequestEvent.KIND to KindName("Deletions", "09"), DraftWrapEvent.KIND to KindName("Drafts", "37"), EmojiPackEvent.KIND to KindName("Emoji Packs", "30"), - EmojiPackSelectionEvent.KIND to KindName("Emoji Pack List", "30"), + EmojiListEvent.KIND to KindName("Emoji Pack List", "30"), EphemeralChatEvent.KIND to KindName("Ephemeral Chat", null), EphemeralChatListEvent.KIND to KindName("Ephemeral Chatrooms", null), - FileHeaderEvent.KIND to KindName("File Headers", "94"), + FileMetadataEvent.KIND to KindName("File Headers", "94"), ProfileGalleryEntryEvent.KIND to KindName("Profile Gallery", null), FileServersEvent.KIND to KindName("File Servers", "96"), FileStorageEvent.KIND to KindName("Blob Data", null), FileStorageHeaderEvent.KIND to KindName("Blob Headers", null), FhirResourceEvent.KIND to KindName("Medical Data", null), - FollowListEvent.KIND to KindName("Follow Packs", "51"), + StarterPackEvent.KIND to KindName("Follow Packs", "51"), GenericRepostEvent.KIND to KindName("Reposts (16)", "18"), GeohashListEvent.KIND to KindName("Geohash Follows", "51"), GiftWrapEvent.KIND to KindName("GiftWraps", "59"), @@ -438,23 +438,23 @@ object KindNames { GitPatchEvent.KIND to KindName("Git Patch", "34"), GitRepositoryEvent.KIND to KindName("Git Repo", "34"), GitReplyEvent.KIND to KindName("Git Reply", "34"), - GoalEvent.KIND to KindName("Zap Goals", "75"), - HashtagListEvent.KIND to KindName("Hashtag Follows", "51"), + ZapGoalEvent.KIND to KindName("Zap Goals", "75"), + InterestListEvent.KIND to KindName("Hashtag Follows", "51"), HighlightEvent.KIND to KindName("Highlights", "84"), HTTPAuthorizationEvent.KIND to KindName("Http Auth", "98"), IndexerRelayListEvent.KIND to KindName("Index Relay List", "51"), InteractiveStoryPrologueEvent.KIND to KindName("Adventure Prologue", null), InteractiveStorySceneEvent.KIND to KindName("Adventure Scene", null), InteractiveStoryReadingStateEvent.KIND to KindName("Adventure Reading", null), - LabeledBookmarkListEvent.KIND to KindName("Named Bookmarks", "51"), + BookmarkSetEvent.KIND to KindName("Named Bookmarks", "51"), LiveActivitiesChatMessageEvent.KIND to KindName("Live Chats", "53"), LiveActivitiesEvent.KIND to KindName("Live Streams", "53"), - LnZapEvent.KIND to KindName("Zaps", "57"), - LnZapPaymentRequestEvent.KIND to KindName("NWC Request", "47"), - LnZapPaymentResponseEvent.KIND to KindName("NWC Response", "47"), - LnZapPrivateEvent.KIND to KindName("Private Zaps", "57"), - LnZapRequestEvent.KIND to KindName("Zap Req", "57"), - LongTextNoteEvent.KIND to KindName("Blogs", "23"), + ZapReceiptEvent.KIND to KindName("Zaps", "57"), + NwcRequestEvent.KIND to KindName("NWC Request", "47"), + NwcResponseEvent.KIND to KindName("NWC Response", "47"), + PrivateZapEvent.KIND to KindName("Private Zaps", "57"), + ZapRequestEvent.KIND to KindName("Zap Req", "57"), + LongFormContentEvent.KIND to KindName("Blogs", "23"), MeetingRoomEvent.KIND to KindName("Meeting Room", "53"), MeetingRoomPresenceEvent.KIND to KindName("Room Presence", "53"), MeetingSpaceEvent.KIND to KindName("Meeting Space", "53"), @@ -463,14 +463,14 @@ object KindNames { NNSEvent.KIND to KindName("NNS", null), NipTextEvent.KIND to KindName("NIP", null), NostrConnectEvent.KIND to KindName("Nostr Connect", "46"), - NIP90StatusEvent.KIND to KindName("DVM Status", "90"), - NIP90ContentDiscoveryRequestEvent.KIND to KindName("DVM Content Req", "90"), - NIP90ContentDiscoveryResponseEvent.KIND to KindName("DVM Content Resp", "90"), - NIP90UserDiscoveryRequestEvent.KIND to KindName("DVM User Req", "90"), - NIP90UserDiscoveryResponseEvent.KIND to KindName("DVM User Resp", "90"), + DvmStatusEvent.KIND to KindName("DVM Status", "90"), + DvmContentDiscoveryRequestEvent.KIND to KindName("DVM Content Req", "90"), + DvmContentDiscoveryResponseEvent.KIND to KindName("DVM Content Resp", "90"), + DvmUserDiscoveryRequestEvent.KIND to KindName("DVM User Req", "90"), + DvmUserDiscoveryResponseEvent.KIND to KindName("DVM User Resp", "90"), OtsEvent.KIND to KindName("OTS", "03"), PaymentTargetsEvent.KIND to KindName("PayTo", null), - PeopleListEvent.KIND to KindName("People Lists", "51"), + FollowSetEvent.KIND to KindName("People Lists", "51"), ProfileBadgesEvent.KIND to KindName("Profile Badges", "58"), PictureEvent.KIND to KindName("Pictures", "68"), WorkoutRecordEvent.KIND to KindName("Workouts", null), @@ -478,12 +478,12 @@ object KindNames { ZapPollEvent.KIND to KindName("Zap Poll", null), PollEvent.KIND to KindName("Poll", "88"), PollResponseEvent.KIND to KindName("Poll Response", "88"), - PrivateDmEvent.KIND to KindName("NIP-04 DMs", "04"), + EncryptedDmEvent.KIND to KindName("NIP-04 DMs", "04"), PrivateOutboxRelayListEvent.KIND to KindName("Private Relays", "37"), ProxyRelayListEvent.KIND to KindName("Proxy Relays", "51"), PublicMessageEvent.KIND to KindName("Public Message", "A4"), ReactionEvent.KIND to KindName("Reactions", "25"), - ContactCardEvent.KIND to KindName("Contact Card", "85"), + UserAssertionEvent.KIND to KindName("Contact Card", "85"), RelayAuthEvent.KIND to KindName("Relay Auth", "42"), RelayDiscoveryEvent.KIND to KindName("Relay Discovery", "66"), RelayMonitorEvent.KIND to KindName("Relay Monitor Announcement", "66"), @@ -491,24 +491,24 @@ object KindNames { ReportEvent.KIND to KindName("Reports", "56"), RepostEvent.KIND to KindName("Reposts", "18"), RequestToVanishEvent.KIND to KindName("User Delete", "62"), - SealedRumorEvent.KIND to KindName("Seals", "59"), + SealEvent.KIND to KindName("Seals", "59"), SearchRelayListEvent.KIND to KindName("Search Relays", "50"), - StatusEvent.KIND to KindName("User Status", "38"), + UserStatusEvent.KIND to KindName("User Status", "38"), TextNoteEvent.KIND to KindName("Notes", "10"), TextNoteModificationEvent.KIND to KindName("Edits", null), TorrentEvent.KIND to KindName("Torrents", "35"), TorrentCommentEvent.KIND to KindName("Torrent Comments", "35"), TrustedRelayListEvent.KIND to KindName("Trusted Relays", "51"), TrustProviderListEvent.KIND to KindName("Trusted Providers", "85"), - VideoHorizontalEvent.KIND to KindName("Video (Repl)", "71"), - VideoVerticalEvent.KIND to KindName("Shorts (Repl)", "71"), + AddressableNormalVideoEvent.KIND to KindName("Video (Repl)", "71"), + AddressableShortVideoEvent.KIND to KindName("Shorts (Repl)", "71"), VideoNormalEvent.KIND to KindName("Video", "71"), VideoShortEvent.KIND to KindName("Shorts", "71"), VoiceEvent.KIND to KindName("Voice Msg", "A0"), VoiceReplyEvent.KIND to KindName("Voice Reply", "A0"), WakeUpEvent.KIND to KindName("WakeUp", null), WebBookmarkEvent.KIND to KindName("Web Bookmark", "B0"), - WikiNoteEvent.KIND to KindName("Wiki", "54"), + WikiArticleEvent.KIND to KindName("Wiki", "54"), WikiMergeRequestEvent.KIND to KindName("Wiki Merge Request", "54"), WikiMergeAcceptanceEvent.KIND to KindName("Wiki Merge Accepted", null), WikiRedirectEvent.KIND to KindName("Wiki Redirect", "54"), @@ -544,41 +544,41 @@ object KindNames { LabelEvent.KIND to KindName("Label", "32"), SoftwareAssetEvent.KIND to KindName("Software Asset", "82"), AdminCommandEvent.KIND to KindName("Nests Admin Command", null), - NIP90TextExtractionRequestEvent.KIND to KindName("DVM Text Extraction Req", "90"), - NIP90SummarizationRequestEvent.KIND to KindName("DVM Summarization Req", "90"), - NIP90TranslationRequestEvent.KIND to KindName("DVM Translation Req", "90"), - NIP90TextGenerationRequestEvent.KIND to KindName("DVM Text Generation Req", "90"), - NIP90ImageGenerationRequestEvent.KIND to KindName("DVM Image Generation Req", "90"), + DvmTextExtractionRequestEvent.KIND to KindName("DVM Text Extraction Req", "90"), + DvmSummarizationRequestEvent.KIND to KindName("DVM Summarization Req", "90"), + DvmTranslationRequestEvent.KIND to KindName("DVM Translation Req", "90"), + DvmTextGenerationRequestEvent.KIND to KindName("DVM Text Generation Req", "90"), + DvmImageGenerationRequestEvent.KIND to KindName("DVM Image Generation Req", "90"), NappletSnapshotEvent.KIND to KindName("Napplet Snapshot", "5D"), - NIP90VideoConversionRequestEvent.KIND to KindName("DVM Video Conversion Req", "90"), - NIP90VideoTranslationRequestEvent.KIND to KindName("DVM Video Translation Req", "90"), - NIP90ImageToVideoRequestEvent.KIND to KindName("DVM Image To Video Req", "90"), - NIP90TextToSpeechRequestEvent.KIND to KindName("DVM Text To Speech Req", "90"), - NIP90ContentSearchRequestEvent.KIND to KindName("DVM Content Search Req", "90"), - NIP90PeopleSearchRequestEvent.KIND to KindName("DVM People Search Req", "90"), - NIP90EventCountRequestEvent.KIND to KindName("DVM Event Count Req", "90"), - NIP90MalwareScanRequestEvent.KIND to KindName("DVM Malware Scan Req", "90"), - NIP90EventTimestampingRequestEvent.KIND to KindName("DVM Timestamping Req", "90"), - NIP90OpReturnRequestEvent.KIND to KindName("DVM OpReturn Req", "90"), - NIP90EventPublishScheduleRequestEvent.KIND to KindName("DVM Publish Schedule Req", "90"), - NIP90EventPowDelegationRequestEvent.KIND to KindName("DVM PoW Delegation Req", "90"), - NIP90TextExtractionResponseEvent.KIND to KindName("DVM Text Extraction Resp", "90"), - NIP90SummarizationResponseEvent.KIND to KindName("DVM Summarization Resp", "90"), - NIP90TranslationResponseEvent.KIND to KindName("DVM Translation Resp", "90"), - NIP90TextGenerationResponseEvent.KIND to KindName("DVM Text Generation Resp", "90"), - NIP90ImageGenerationResponseEvent.KIND to KindName("DVM Image Generation Resp", "90"), - NIP90VideoConversionResponseEvent.KIND to KindName("DVM Video Conversion Resp", "90"), - NIP90VideoTranslationResponseEvent.KIND to KindName("DVM Video Translation Resp", "90"), - NIP90ImageToVideoResponseEvent.KIND to KindName("DVM Image To Video Resp", "90"), - NIP90TextToSpeechResponseEvent.KIND to KindName("DVM Text To Speech Resp", "90"), - NIP90ContentSearchResponseEvent.KIND to KindName("DVM Content Search Resp", "90"), - NIP90PeopleSearchResponseEvent.KIND to KindName("DVM People Search Resp", "90"), - NIP90EventCountResponseEvent.KIND to KindName("DVM Event Count Resp", "90"), - NIP90MalwareScanResponseEvent.KIND to KindName("DVM Malware Scan Resp", "90"), - NIP90EventTimestampingResponseEvent.KIND to KindName("DVM Timestamping Resp", "90"), - NIP90OpReturnResponseEvent.KIND to KindName("DVM OpReturn Resp", "90"), - NIP90EventPublishScheduleResponseEvent.KIND to KindName("DVM Publish Schedule Resp", "90"), - NIP90EventPowDelegationResponseEvent.KIND to KindName("DVM PoW Delegation Resp", "90"), + DvmVideoConversionRequestEvent.KIND to KindName("DVM Video Conversion Req", "90"), + DvmVideoTranslationRequestEvent.KIND to KindName("DVM Video Translation Req", "90"), + DvmImageToVideoRequestEvent.KIND to KindName("DVM Image To Video Req", "90"), + DvmTextToSpeechRequestEvent.KIND to KindName("DVM Text To Speech Req", "90"), + DvmContentSearchRequestEvent.KIND to KindName("DVM Content Search Req", "90"), + DvmPeopleSearchRequestEvent.KIND to KindName("DVM People Search Req", "90"), + DvmEventCountRequestEvent.KIND to KindName("DVM Event Count Req", "90"), + DvmMalwareScanRequestEvent.KIND to KindName("DVM Malware Scan Req", "90"), + DvmEventTimestampingRequestEvent.KIND to KindName("DVM Timestamping Req", "90"), + DvmOpReturnRequestEvent.KIND to KindName("DVM OpReturn Req", "90"), + DvmEventPublishScheduleRequestEvent.KIND to KindName("DVM Publish Schedule Req", "90"), + DvmEventPowDelegationRequestEvent.KIND to KindName("DVM PoW Delegation Req", "90"), + DvmTextExtractionResponseEvent.KIND to KindName("DVM Text Extraction Resp", "90"), + DvmSummarizationResponseEvent.KIND to KindName("DVM Summarization Resp", "90"), + DvmTranslationResponseEvent.KIND to KindName("DVM Translation Resp", "90"), + DvmTextGenerationResponseEvent.KIND to KindName("DVM Text Generation Resp", "90"), + DvmImageGenerationResponseEvent.KIND to KindName("DVM Image Generation Resp", "90"), + DvmVideoConversionResponseEvent.KIND to KindName("DVM Video Conversion Resp", "90"), + DvmVideoTranslationResponseEvent.KIND to KindName("DVM Video Translation Resp", "90"), + DvmImageToVideoResponseEvent.KIND to KindName("DVM Image To Video Resp", "90"), + DvmTextToSpeechResponseEvent.KIND to KindName("DVM Text To Speech Resp", "90"), + DvmContentSearchResponseEvent.KIND to KindName("DVM Content Search Resp", "90"), + DvmPeopleSearchResponseEvent.KIND to KindName("DVM People Search Resp", "90"), + DvmEventCountResponseEvent.KIND to KindName("DVM Event Count Resp", "90"), + DvmMalwareScanResponseEvent.KIND to KindName("DVM Malware Scan Resp", "90"), + DvmEventTimestampingResponseEvent.KIND to KindName("DVM Timestamping Resp", "90"), + DvmOpReturnResponseEvent.KIND to KindName("DVM OpReturn Resp", "90"), + DvmEventPublishScheduleResponseEvent.KIND to KindName("DVM Publish Schedule Resp", "90"), + DvmEventPowDelegationResponseEvent.KIND to KindName("DVM PoW Delegation Resp", "90"), CashuMintQuoteEvent.KIND to KindName("Cashu Mint Quote", "60"), CashuTokenEvent.KIND to KindName("Cashu Token", "60"), CashuSpendingHistoryEvent.KIND to KindName("Cashu History", "60"), @@ -587,15 +587,15 @@ object KindNames { OnchainZapEvent.KIND to KindName("Onchain Zap", "BC"), Bolt12ZapEvent.KIND to KindName("Bolt12 Zap", "B1"), Bolt12OfferListEvent.KIND to KindName("Bolt12 Offers", "B1"), - PutUserEvent.KIND to KindName("Group Put User", "29"), - RemoveUserEvent.KIND to KindName("Group Remove User", "29"), - EditMetadataEvent.KIND to KindName("Group Edit Metadata", "29"), - DeleteEventEvent.KIND to KindName("Group Delete Event", "29"), + GroupPutUserEvent.KIND to KindName("Group Put User", "29"), + GroupRemoveUserEvent.KIND to KindName("Group Remove User", "29"), + GroupEditMetadataEvent.KIND to KindName("Group Edit Metadata", "29"), + GroupDeleteEventEvent.KIND to KindName("Group Delete Event", "29"), CreateGroupEvent.KIND to KindName("Group Create", "29"), DeleteGroupEvent.KIND to KindName("Group Delete", "29"), - CreateInviteEvent.KIND to KindName("Group Create Invite", "29"), - JoinRequestEvent.KIND to KindName("Group Join Request", "29"), - LeaveRequestEvent.KIND to KindName("Group Leave Request", "29"), + GroupCreateInviteEvent.KIND to KindName("Group Create Invite", "29"), + GroupJoinRequestEvent.KIND to KindName("Group Join Request", "29"), + GroupLeaveRequestEvent.KIND to KindName("Group Leave Request", "29"), NutzapEvent.KIND to KindName("Nutzap", "61"), SimpleGroupListEvent.KIND to KindName("Group List", "51"), ExternalIdentitiesEvent.KIND to KindName("External Identities", "39"), @@ -668,7 +668,7 @@ object KindNames { GroupMetadataEvent.KIND to KindName("Group Metadata", "29"), GroupAdminsEvent.KIND to KindName("Group Admins", "29"), GroupMembersEvent.KIND to KindName("Group Members", "29"), - SupportedRolesEvent.KIND to KindName("Group Roles", "29"), + GroupRolesEvent.KIND to KindName("Group Roles", "29"), GroupParticipantsEvent.KIND to KindName("Group Participants", "29"), MediaStarterPackEvent.KIND to KindName("Media Starter Pack", "51"), ) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotInboundProcessor.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotInboundProcessor.kt index b1cb1f8f79..4110efe500 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotInboundProcessor.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotInboundProcessor.kt @@ -21,25 +21,25 @@ package com.vitorpamplona.quartz.marmot import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotAppEvent +import com.vitorpamplona.quartz.marmot.groups.MlsGroupManager import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRotationManager import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEventEncryption -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.framing.ContentType -import com.vitorpamplona.quartz.marmot.mls.framing.MlsMessage -import com.vitorpamplona.quartz.marmot.mls.framing.PrivateMessage -import com.vitorpamplona.quartz.marmot.mls.framing.PublicMessage -import com.vitorpamplona.quartz.marmot.mls.framing.WireFormat -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupManager -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupState -import com.vitorpamplona.quartz.marmot.mls.messages.KeyPackageBundle -import com.vitorpamplona.quartz.marmot.mls.messages.Welcome import com.vitorpamplona.quartz.marmot.protocolCore.ConvergenceAdmission import com.vitorpamplona.quartz.marmot.protocolCore.ConvergenceResolution import com.vitorpamplona.quartz.marmot.protocolCore.ConvergenceStatus import com.vitorpamplona.quartz.marmot.protocolCore.GroupLifecycleState import com.vitorpamplona.quartz.marmot.protocolCore.MarmotConvergenceEngine +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.framing.ContentType +import com.vitorpamplona.quartz.mls.framing.MlsMessage +import com.vitorpamplona.quartz.mls.framing.PrivateMessage +import com.vitorpamplona.quartz.mls.framing.PublicMessage +import com.vitorpamplona.quartz.mls.framing.WireFormat +import com.vitorpamplona.quartz.mls.group.MlsGroupState +import com.vitorpamplona.quartz.mls.messages.KeyPackageBundle +import com.vitorpamplona.quartz.mls.messages.Welcome import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey @@ -379,7 +379,7 @@ class MarmotInboundProcessor( /** * Process a WelcomeEvent after NIP-59 gift wrap unwrapping. * - * Called by the platform layer after unwrapping a GiftWrap → SealedRumor → WelcomeEvent. + * Called by the platform layer after unwrapping a GiftWrap → Seal → WelcomeEvent. * * Flow: * 1. Extract welcome bytes and KeyPackage event ID diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotOutboundProcessor.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotOutboundProcessor.kt index 4674d4bfbd..a8f2136f3c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotOutboundProcessor.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotOutboundProcessor.kt @@ -21,10 +21,11 @@ package com.vitorpamplona.quartz.marmot import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotAppEvent +import com.vitorpamplona.quartz.marmot.groups.MlsGroupManager +import com.vitorpamplona.quartz.marmot.groups.currentGroupState import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEventEncryption -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupManager import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey @@ -160,15 +161,15 @@ class MarmotOutboundProcessor( * The commit bytes are already MLS-formatted (PublicMessage envelope). * * @param nostrGroupId the Nostr group ID - * @param commitBytes the framed MLS commit bytes from [com.vitorpamplona.quartz.marmot.mls.group.StagedCommit.framedCommitBytes] + * @param commitBytes the framed MLS commit bytes from [com.vitorpamplona.quartz.mls.group.StagedCommit.framedCommitBytes] * @param exporterKey optional explicit outer-encryption key. Callers * publishing a Commit MUST pass the **pre-commit** exporter secret - * (from [com.vitorpamplona.quartz.marmot.mls.group.StagedCommit.preCommitExporterSecret]) + * (from [com.vitorpamplona.quartz.mls.group.StagedCommit.preCommitExporterSecret]) * so that other existing members at epoch N can decrypt and process * the commit. If null, falls back to the current epoch's exporter * secret — which is only correct when the commit has *not* been * applied locally yet (i.e. this call is made before - * [com.vitorpamplona.quartz.marmot.mls.group.MlsGroup.mergeStagedCommit]). + * [com.vitorpamplona.quartz.mls.group.MlsGroup.mergeStagedCommit]). * @return the signed GroupEvent ready for relay publishing */ suspend fun buildCommitEvent( diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotWelcomeSender.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotWelcomeSender.kt index 6de406ed73..d83d539c10 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotWelcomeSender.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotWelcomeSender.kt @@ -21,7 +21,7 @@ package com.vitorpamplona.quartz.marmot import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeGiftWrap -import com.vitorpamplona.quartz.marmot.mls.messages.CommitResult +import com.vitorpamplona.quartz.mls.messages.CommitResult import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner @@ -50,7 +50,7 @@ data class WelcomeDelivery( * * Delivery pipeline: * Welcome bytes → base64 → WelcomeEvent (kind:444, unsigned rumor) - * → SealedRumorEvent (kind:13, encrypted with sender's key) + * → SealEvent (kind:13, encrypted with sender's key) * → GiftWrapEvent (kind:1059, encrypted with ephemeral key) */ class MarmotWelcomeSender( diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/RecipientRelayFetcher.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/RecipientRelayFetcher.kt index 846b6c1237..56d268fda5 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/RecipientRelayFetcher.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/RecipientRelayFetcher.kt @@ -26,7 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAll import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.nip17Dm.settings.DmRelayListEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent /** @@ -100,7 +100,7 @@ object RecipientRelayFetcher { Filter( kinds = listOf( - ChatMessageRelayListEvent.KIND, + DmRelayListEvent.KIND, KeyPackageRelayListEvent.KIND, AdvertisedRelayListEvent.KIND, ), @@ -113,7 +113,7 @@ object RecipientRelayFetcher { idleTimeoutMs = idleTimeoutMs, ) - var dm: ChatMessageRelayListEvent? = null + var dm: DmRelayListEvent? = null var kp: KeyPackageRelayListEvent? = null var nip65: AdvertisedRelayListEvent? = null for (event in events) { @@ -122,7 +122,7 @@ object RecipientRelayFetcher { // route someone else's wrapped welcome to the wrong inbox. if (event.pubKey != pubKey) continue when (event) { - is ChatMessageRelayListEvent -> { + is DmRelayListEvent -> { if (dm == null || event.createdAt > dm.createdAt) dm = event } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/AdminPolicyV1.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/AdminPolicyV1.kt index b3041004a3..87e659e327 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/AdminPolicyV1.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/AdminPolicyV1.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.quartz.marmot.appComponents -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.codec.TlsWriter import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/AppComponentIds.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/AppComponentIds.kt index 5604b8f24b..29ae1ce609 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/AppComponentIds.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/AppComponentIds.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.quartz.marmot.appComponents +import com.vitorpamplona.quartz.mls.components.ComponentsList + /** * Marmot app-component ids, from the spec's `foundation/registries.md`. * @@ -39,11 +41,14 @@ package com.vitorpamplona.quartz.marmot.appComponents object AppComponentIds { // ---- upstream, draft-ietf-mls-extensions-10 ---- + // These three are the draft's, not Marmot's, so they are defined in the + // engine beside the dictionary that carries them and re-exposed here. + /** `app_components`: the supported (LeafNode) or required (GroupContext) id list. */ - const val APP_COMPONENTS = 0x0001 + const val APP_COMPONENTS = ComponentsList.APP_COMPONENTS_ID /** `safe_aad`: component-separated framing for MLS `authenticated_data`. */ - const val SAFE_AAD = 0x0002 + const val SAFE_AAD = ComponentsList.SAFE_AAD_ID /** * `last_resort_key_package`: empty-data marker in a KeyPackage's own @@ -51,7 +56,7 @@ object AppComponentIds { * MIP-era profile marked last resort with extension `0x000a`, which is now * the `self_remove` PROPOSAL type. */ - const val LAST_RESORT_KEY_PACKAGE = 0x0004 + const val LAST_RESORT_KEY_PACKAGE = ComponentsList.LAST_RESORT_KEY_PACKAGE_ID // ---- Marmot private range ---- diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/CurrentProfileGroupFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/CurrentProfileGroupFactory.kt index a8661a747b..0ee1543459 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/CurrentProfileGroupFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/CurrentProfileGroupFactory.kt @@ -22,15 +22,17 @@ package com.vitorpamplona.quartz.marmot.appComponents import com.vitorpamplona.quartz.marmot.appComponents.accountIdentityProof.AccountIdentityProofV2 import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.AgentTextStreamQuicPolicyV1 +import com.vitorpamplona.quartz.marmot.groups.MarmotCapabilities +import com.vitorpamplona.quartz.marmot.groups.MarmotGroupPolicy import com.vitorpamplona.quartz.marmot.mip01Groups.MlsCiphersuite -import com.vitorpamplona.quartz.marmot.mls.components.AppDataDictionary -import com.vitorpamplona.quartz.marmot.mls.components.ComponentData -import com.vitorpamplona.quartz.marmot.mls.components.ComponentsList -import com.vitorpamplona.quartz.marmot.mls.crypto.Ed25519 -import com.vitorpamplona.quartz.marmot.mls.crypto.Ed25519KeyPair -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup -import com.vitorpamplona.quartz.marmot.mls.messages.KeyPackageBundle -import com.vitorpamplona.quartz.marmot.mls.tree.Extension +import com.vitorpamplona.quartz.mls.components.AppDataDictionary +import com.vitorpamplona.quartz.mls.components.ComponentData +import com.vitorpamplona.quartz.mls.components.ComponentsList +import com.vitorpamplona.quartz.mls.crypto.Ed25519 +import com.vitorpamplona.quartz.mls.crypto.Ed25519KeyPair +import com.vitorpamplona.quartz.mls.group.MlsGroup +import com.vitorpamplona.quartz.mls.messages.KeyPackageBundle +import com.vitorpamplona.quartz.mls.tree.Extension import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner @@ -161,7 +163,7 @@ object CurrentProfileGroupFactory { signingKey = leaf.signatureKeyPair.privateKey, leafSignatureKeyPair = leaf.signatureKeyPair, leafExtensions = leaf.leafExtensions, - capabilities = MlsGroup.currentProfileLeafCapabilities(), + capabilities = MarmotCapabilities.currentProfileLeaf(), keyPackageExtensions = keyPackageExtensions, ) } @@ -218,8 +220,11 @@ object CurrentProfileGroupFactory { signingKey = leaf.signatureKeyPair.privateKey, initialExtensions = listOf(dictionary.toExtension()), leafExtensions = leaf.leafExtensions, - capabilities = MlsGroup.currentProfileLeafCapabilities(), - requiredCapabilities = MlsGroup.buildCurrentProfileRequiredCapabilitiesExtension(), + // The current profile shares Marmot's authorization rules but + // advertises a different capability set, so both are named here. + policy = MarmotGroupPolicy, + capabilities = MarmotCapabilities.currentProfileLeaf(), + requiredCapabilities = MarmotCapabilities.currentProfileRequired(), ) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/EncryptedMediaPolicyV2.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/EncryptedMediaPolicyV2.kt index 8b5cb8d6a6..b642fe72d4 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/EncryptedMediaPolicyV2.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/EncryptedMediaPolicyV2.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.quartz.marmot.appComponents -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.codec.TlsWriter /** One blob-store endpoint and the locator kind it serves. */ data class BlobStoreEndpointV2( diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/EncryptedMediaV2.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/EncryptedMediaV2.kt index faed1c71a4..d9c3bd76a8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/EncryptedMediaV2.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/EncryptedMediaV2.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.quartz.marmot.appComponents -import com.vitorpamplona.quartz.marmot.mls.crypto.MlsCryptoProvider +import com.vitorpamplona.quartz.mls.crypto.MlsCryptoProvider import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip44Encryption.crypto.ChaCha20Poly1305 import com.vitorpamplona.quartz.utils.RandomInstance diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/GroupAvatarUrlV1.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/GroupAvatarUrlV1.kt index 5466ae18c3..48612365b6 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/GroupAvatarUrlV1.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/GroupAvatarUrlV1.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.quartz.marmot.appComponents -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.codec.TlsWriter /** * `marmot.group.avatar-url.v1`, component `0x8007` — a group avatar behind an diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/GroupBlossomImageV1.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/GroupBlossomImageV1.kt index 395c6d403f..6f6a24c958 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/GroupBlossomImageV1.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/GroupBlossomImageV1.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.quartz.marmot.appComponents -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.codec.TlsWriter import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/GroupProfileV1.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/GroupProfileV1.kt index 3bec7f7365..fd82ed0d64 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/GroupProfileV1.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/GroupProfileV1.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.quartz.marmot.appComponents -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.codec.TlsWriter /** * `marmot.group.profile.v1`, component `0x8001` — the group's display name and diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/MarmotGroupState.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/MarmotGroupState.kt index 9bf7ccd3ea..681abf3628 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/MarmotGroupState.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/MarmotGroupState.kt @@ -21,9 +21,9 @@ package com.vitorpamplona.quartz.marmot.appComponents import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.AgentTextStreamQuicPolicyV1 -import com.vitorpamplona.quartz.marmot.mls.components.AppDataDictionary -import com.vitorpamplona.quartz.marmot.mls.components.ComponentsList -import com.vitorpamplona.quartz.marmot.mls.tree.Extension +import com.vitorpamplona.quartz.mls.components.AppDataDictionary +import com.vitorpamplona.quartz.mls.components.ComponentsList +import com.vitorpamplona.quartz.mls.tree.Extension /** * The current profile's read view of a GroupContext `app_data_dictionary` — the diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/MessageRetentionV1.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/MessageRetentionV1.kt index 4b240ee781..b229c2e073 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/MessageRetentionV1.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/MessageRetentionV1.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.quartz.marmot.appComponents -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.codec.TlsWriter /** * `marmot.group.message-retention.v1`, component `0x8005` — disappearing diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/NostrRoutingV1.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/NostrRoutingV1.kt index 6f407002ea..863a19f4a9 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/NostrRoutingV1.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/NostrRoutingV1.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.quartz.marmot.appComponents -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.codec.TlsWriter import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/agentTextStream/AgentTextStreamCrypto.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/agentTextStream/AgentTextStreamCrypto.kt index f42e44e7e7..af7450e93d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/agentTextStream/AgentTextStreamCrypto.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/agentTextStream/AgentTextStreamCrypto.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.quartz.marmot.appComponents.agentTextStream -import com.vitorpamplona.quartz.marmot.mls.crypto.MlsCryptoProvider +import com.vitorpamplona.quartz.mls.crypto.MlsCryptoProvider import com.vitorpamplona.quartz.nip44Encryption.crypto.ChaCha20Poly1305 /** diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/agentTextStream/AgentTextStreamQuicPolicyV1.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/agentTextStream/AgentTextStreamQuicPolicyV1.kt index cb9e29d73a..416d95b79e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/agentTextStream/AgentTextStreamQuicPolicyV1.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/agentTextStream/AgentTextStreamQuicPolicyV1.kt @@ -21,7 +21,7 @@ package com.vitorpamplona.quartz.marmot.appComponents.agentTextStream import com.vitorpamplona.quartz.marmot.appComponents.AppComponentIds -import com.vitorpamplona.quartz.marmot.mls.components.ComponentData +import com.vitorpamplona.quartz.mls.components.ComponentData /** * `marmot.group.agent-text-stream.quic.v1` (component `0x8006`). diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/agentTextStream/AgentTextStreamTranscriptV1.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/agentTextStream/AgentTextStreamTranscriptV1.kt index 300838f9e5..b38fcde197 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/agentTextStream/AgentTextStreamTranscriptV1.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/agentTextStream/AgentTextStreamTranscriptV1.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.quartz.marmot.appComponents.agentTextStream -import com.vitorpamplona.quartz.marmot.mls.crypto.MlsCryptoProvider +import com.vitorpamplona.quartz.mls.crypto.MlsCryptoProvider /** * The rolling hash a stream's final kind-9 chat publishes as `stream-hash`, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/foundation/authorizationProofs/MarmotAuthorizationProof.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/foundation/authorizationProofs/MarmotAuthorizationProof.kt index e5c72045b1..e6816f5ec7 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/foundation/authorizationProofs/MarmotAuthorizationProof.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/foundation/authorizationProofs/MarmotAuthorizationProof.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.quartz.marmot.foundation.authorizationProofs -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.codec.TlsWriter import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/groups/MarmotCapabilities.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/groups/MarmotCapabilities.kt new file mode 100644 index 0000000000..8f16cce811 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/groups/MarmotCapabilities.kt @@ -0,0 +1,165 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.marmot.groups + +import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.AgentTextStreamRoles +import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData +import com.vitorpamplona.quartz.mls.codec.TlsWriter +import com.vitorpamplona.quartz.mls.components.AppDataDictionary +import com.vitorpamplona.quartz.mls.group.MlsGroup +import com.vitorpamplona.quartz.mls.messages.MlsKeyPackage +import com.vitorpamplona.quartz.mls.tree.Capabilities +import com.vitorpamplona.quartz.mls.tree.Credential +import com.vitorpamplona.quartz.mls.tree.Extension + +/** + * The MLS capability sets that identify a group as Marmot's. + * + * These used to be defaults baked into `MlsGroup.create`, which is why a + * plain RFC 9420 group could not be created at all: every group came out + * requiring `marmot_group_data`. They are unchanged, only relocated, and the + * engine now reaches them through [MarmotGroupPolicy]. + * + * Marmot has two profiles and a client must be able to read either, so both + * sets live here: + * + * - **MIP-era** — `0xF2EE` carries all group state, `self_remove` is required. + * - **current** — `app_data_dictionary` (`0x0006`) carries it as components, + * with `app_data_update` (`0x0008`) to change them. + */ +object MarmotCapabilities { + /** Marmot Group Data Extension type (MIP-01). */ + const val MARMOT_GROUP_DATA_EXTENSION_TYPE = 0xF2EE + + /** + * Default MLS leaf Capabilities that advertise support for Marmot's + * required extensions and proposals so new members can join a group + * whose `required_capabilities` lists them. + */ + fun mipLeaf(): Capabilities = + Capabilities( + extensions = listOf(MARMOT_GROUP_DATA_EXTENSION_TYPE), + proposals = listOf(MlsGroup.SELF_REMOVE_PROPOSAL_TYPE), + ) + + /** + * The MIP-era leaf set as a published KeyPackage advertises it. + * + * Same as [mipLeaf] plus `0x000A` as an EXTENSION, which OpenMLS validation + * requires on a last-resort KeyPackage. Note `0x000A` appears in both lists + * meaning different things: as an extension it is `last_resort`, as a + * proposal it is `self_remove`. + * + * The order is load-bearing and must not be tidied: these bytes go into + * published KeyPackages, and `KeyPackageBundleStore`'s v4 snapshot format + * is defined by them. + */ + fun mipKeyPackageLeaf(): Capabilities = + Capabilities( + extensions = listOf(MlsKeyPackage.LAST_RESORT_EXTENSION_TYPE, MARMOT_GROUP_DATA_EXTENSION_TYPE), + proposals = listOf(MlsGroup.SELF_REMOVE_PROPOSAL_TYPE), + ) + + /** + * Build an MLS `required_capabilities` extension that marks Marmot's + * mandatory interop set as required for all members (RFC 9420 §7.2): + * extensions = [marmot_group_data (0xF2EE)] + * proposals = [self_remove (0x000A)] + * credentials = [Basic (0x0001)] + */ + fun mipRequired(): Extension = + requiredCapabilities( + extensions = listOf(MARMOT_GROUP_DATA_EXTENSION_TYPE), + proposals = listOf(MlsGroup.SELF_REMOVE_PROPOSAL_TYPE), + ) + + /** + * Leaf capabilities for the current profile. + * + * RFC 9420 §7.2 forbids advertising DEFAULT extension types, so only + * the draft `app_data_dictionary` extension and the `app_data_update` + * proposal appear — `required_capabilities` support is implicit. + * + * The legacy `0xF2EE` group-data extension is advertised alongside + * them, and that is not a hedge. A capability says "this client can + * handle it", not "this group uses it", and a group that REQUIRES + * `0xF2EE` refuses to add a leaf that does not advertise it. Without + * this line a current-profile KeyPackage would be un-addable to every + * legacy group that already exists — the exact mirror of the interop + * failure the current profile was adopted to fix. + * + * `0xF2D1` is the agent-text-stream RECEIVE role, for the same reason: + * a group carrying component `0x8006` with `required_member_roles` + * naming `receive` refuses a leaf that does not advertise it. The + * reference client puts exactly that policy into EVERY group it + * creates, so without this line an Amethyst KeyPackage cannot be + * invited into one at all. + * + * We stop at receive. `send` and `fanout` are not here because we do + * not originate previews from the app, and a capability is a standing + * promise rather than a hedge. + */ + fun currentProfileLeaf(): Capabilities = + Capabilities( + extensions = + listOf( + AppDataDictionary.EXTENSION_TYPE, + MarmotGroupData.EXTENSION_ID_INT, + AgentTextStreamRoles.RECEIVE_CAPABILITY, + ), + proposals = listOf(MlsGroup.APP_DATA_UPDATE_PROPOSAL_TYPE, MlsGroup.SELF_REMOVE_PROPOSAL_TYPE), + ) + + /** + * `required_capabilities` for a new current-profile group: extension + * `0x0006` and proposal `0x0008`. + * + * The Marmot components a group requires are negotiated in the + * upstream `app_components` component INSIDE the dictionary, not here — + * MLS `RequiredCapabilities` carries only MLS-level primitives. + */ + fun currentProfileRequired(): Extension = + requiredCapabilities( + extensions = listOf(AppDataDictionary.EXTENSION_TYPE), + proposals = listOf(MlsGroup.APP_DATA_UPDATE_PROPOSAL_TYPE), + ) + + /** Encodes an RFC 9420 §7.2 `required_capabilities` extension over Basic credentials. */ + private fun requiredCapabilities( + extensions: List, + proposals: List, + ): Extension { + val writer = TlsWriter() + // extensions: uint16 each + val exts = TlsWriter() + extensions.forEach { exts.putUint16(it) } + writer.putOpaqueVarInt(exts.toByteArray()) + // proposals: uint16 each + val props = TlsWriter() + proposals.forEach { props.putUint16(it) } + writer.putOpaqueVarInt(props.toByteArray()) + // credentials: uint16 each + val creds = TlsWriter() + creds.putUint16(Credential.CREDENTIAL_TYPE_BASIC) + writer.putOpaqueVarInt(creds.toByteArray()) + return Extension(MlsGroup.REQUIRED_CAPABILITIES_EXTENSION_TYPE, writer.toByteArray()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/groups/MarmotGroupPolicy.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/groups/MarmotGroupPolicy.kt new file mode 100644 index 0000000000..7001abc36c --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/groups/MarmotGroupPolicy.kt @@ -0,0 +1,276 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.marmot.groups + +import com.vitorpamplona.quartz.marmot.appComponents.AdminPolicyV1 +import com.vitorpamplona.quartz.marmot.appComponents.AppComponentIds +import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.AgentTextStreamQuicPolicyV1 +import com.vitorpamplona.quartz.marmot.groups.MarmotCapabilities +import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData +import com.vitorpamplona.quartz.mls.components.AppDataDictionary +import com.vitorpamplona.quartz.mls.group.GroupView +import com.vitorpamplona.quartz.mls.group.MlsExporterLabel +import com.vitorpamplona.quartz.mls.group.MlsGroupPolicy +import com.vitorpamplona.quartz.mls.group.PendingProposal +import com.vitorpamplona.quartz.mls.messages.Proposal +import com.vitorpamplona.quartz.mls.tree.Capabilities +import com.vitorpamplona.quartz.mls.tree.Extension + +/** + * Marmot's authorization rules (MIP-01 and MIP-03), as an [MlsGroupPolicy]. + * + * These used to live inside `MlsGroup`, which meant the RFC 9420 engine knew + * the word "admin" — a concept RFC 9420 does not have. They are unchanged + * here; only where they run has moved. + * + * Stateless, so one instance serves every group. + */ +object MarmotGroupPolicy : MlsGroupPolicy { + /** + * The MIP-era leaf set. A current-profile group names + * [MarmotCapabilities.currentProfileLeaf] explicitly instead: the two + * profiles differ in what they advertise but share these authorization + * rules, so the policy carries the older default and the factory that + * knows it is building a current-profile group overrides it. + */ + override val defaultLeafCapabilities: Capabilities get() = MarmotCapabilities.mipLeaf() + + override val defaultRequiredCapabilities: Extension get() = MarmotCapabilities.mipRequired() + + /** + * Marmot's two group-context extension types, exempted from the §13.4 + * all-members-support check. + * + * Both are advertised by [MarmotCapabilities.mipLeaf] and by the current + * profile, so a group built entirely by this client passes the check on + * capabilities alone. The exemption is for the groups that are already out + * there, whose older leaves predate the advertisement - dropping it would + * make this client refuse to apply a GroupContextExtensions proposal in a + * group it is happily a member of. + */ + override val knownExtensionTypes: Set = + setOf(MarmotCapabilities.MARMOT_GROUP_DATA_EXTENSION_TYPE, AppDataDictionary.EXTENSION_TYPE) + + /** + * `MLS-Exporter("marmot", "group-event", 32)` — the outer + * ChaCha20-Poly1305 key for a kind:445 GroupEvent. A commit must be sealed + * under the PRE-commit epoch so members still at epoch N can open it. + */ + override val commitExporter: MlsExporterLabel + get() = MlsExporterLabel("marmot", "group-event".encodeToByteArray(), 32) + + override fun authorizeCommit( + group: GroupView, + proposals: List, + committerLeafIndex: Int, + ) { + enforceAuthorizedProposalSet(group, proposals, committerLeafIndex) + enforceNoAdminDepletion(group, proposals) + } + + override fun authorizeSelfRemove(group: GroupView) { + check(!isLocalAdmin(group)) { + "Admin must self-demote via GroupContextExtensions before SelfRemove (MIP-01)" + } + } + + override fun validateJoin(group: GroupView) { + requireAgentTextStreamRoles(group) + } + + /** + * The admin set named by [extensions], preferring the current profile. + * + * Decodes ONLY the admin policy, never the whole component set. Authorization + * must not depend on the validity of components it does not read: a + * malformed group profile is a defect worth surfacing where the profile is + * used, but it must not make the group un-committable by taking the admin + * check down with it. + * + * Reads whichever profile this group is on: the current profile's + * `marmot.group.admin-policy.v1` component (`0x8003`) when present, + * otherwise MIP-01's `admin_pubkeys` field inside `marmot_group_data` + * (`0xF2EE`). Empty means the group names no admins at all, which happens + * during bootstrap and in groups that carry neither. + */ + fun adminIdentitiesIn(extensions: List): Set { + val policyBytes = AppDataDictionary.fromExtensionsOrEmpty(extensions)[AdminPolicyV1.COMPONENT_ID] + if (policyBytes != null) return AdminPolicyV1.decode(policyBytes).adminHexKeys.toSet() + return MarmotGroupData + .fromExtensions(extensions) + ?.adminPubkeys + ?.toSet() + .orEmpty() + } + + /** True if the member at [leafIndex] is an ACTIVE admin: named in the admin set and still holding a leaf. */ + fun isLeafAdmin( + group: GroupView, + leafIndex: Int, + ): Boolean { + val id = group.memberIdentityHex(leafIndex) ?: return false + return id in adminIdentitiesIn(group.extensions) + } + + /** True if the local member is an active admin. */ + fun isLocalAdmin(group: GroupView): Boolean = isLeafAdmin(group, group.myLeafIndex) + + /** + * MIP-03: a non-admin may commit only a single self-Update, or a set made + * entirely of their own SelfRemoves. + * + * The "self-only" rule is checked against the committer; when the committer + * is an admin the rule is skipped entirely so admin-folded inbound proposals + * (e.g. another member's `SelfRemove` referenced by an admin's GCE commit) + * are accepted. + */ + internal fun enforceAuthorizedProposalSet( + group: GroupView, + proposals: List, + committerLeafIndex: Int, + ) { + if (proposals.isEmpty()) return + // Reads whichever profile the group is on: the admin-policy component + // (0x8003) for current-profile groups, `marmot_group_data` (0xF2EE) + // for legacy ones. An empty set means bootstrap — no admins named yet — + // and the gate stays open, mirroring MlsGroupManager.updateGroupExtensions. + val admins = adminIdentitiesIn(group.extensions) + if (admins.isEmpty() || isLeafAdmin(group, committerLeafIndex)) return + + val allSelfRemove = + proposals.all { it.proposal is Proposal.SelfRemove && it.senderLeafIndex == committerLeafIndex } + if (allSelfRemove) return + + val singleSelfUpdate = + proposals.size == 1 && + proposals[0].proposal is Proposal.Update && + proposals[0].senderLeafIndex == committerLeafIndex + if (singleSelfUpdate) return + + throw IllegalStateException( + "MIP-03: non-admin members may only commit a single self-Update or SelfRemove-only " + + "proposals; got ${proposals.map { it.proposal::class.simpleName }} from leaf $committerLeafIndex", + ) + } + + /** + * Reject any commit that would leave the group without at least one member + * still listed in `admin_pubkeys` (MIP-03 admin depletion guard). + * + * We simulate the post-commit member set and the post-commit `admin_pubkeys` + * list, then require a non-empty intersection. The guard is only active + * once the group has a configured admin set — it does not kick in during + * bootstrap before any admin is named. + */ + internal fun enforceNoAdminDepletion( + group: GroupView, + proposals: List, + ) { + val currentAdmins = adminIdentitiesIn(group.extensions) + if (currentAdmins.isEmpty()) return // Bootstrap: no admins yet, nothing to deplete. + + // Resolve the effective admin list after this commit. Three carriers can + // change it, and they are checked in the order the commit applies them: + // an AppDataUpdate on 0x8003 (current profile), then a + // GroupContextExtensions proposal replacing the whole extension list + // (either profile). AppDataUpdate is resolved last because + // `applyAppDataUpdateProposals` runs after the rest of the list. + val gce = + proposals + .asSequence() + .map { it.proposal } + .filterIsInstance() + .lastOrNull() + val extensionsAfterGce = gce?.extensions ?: group.extensions + + val adminUpdate = + proposals + .asSequence() + .map { it.proposal } + .filterIsInstance() + .lastOrNull { it.componentId == AdminPolicyV1.COMPONENT_ID } + + val adminSet = + when (val operation = adminUpdate?.operation) { + is Proposal.AppDataUpdate.Operation.Update -> + AdminPolicyV1.decode(operation.data).adminHexKeys.toSet() + + // Removing the admin policy is never valid — it is the sole + // admin authority for the group's lifetime — so an empty set + // here trips the depletion check below, which is the outcome + // we want. + Proposal.AppDataUpdate.Operation.Remove -> emptySet() + + null -> adminIdentitiesIn(extensionsAfterGce) + } + check(adminSet.isNotEmpty()) { + "commit would leave the group with no admins (admin depletion)" + } + + // Compute which leaves remain after applying Removes/SelfRemoves. + val removedLeaves = mutableSetOf() + for (pending in proposals) { + when (val p = pending.proposal) { + is Proposal.Remove -> removedLeaves.add(p.removedLeafIndex) + is Proposal.SelfRemove -> removedLeaves.add(pending.senderLeafIndex) + else -> Unit + } + } + + val remainingAdminIdentities = mutableSetOf() + for (i in 0 until group.leafCount) { + if (i in removedLeaves) continue + val id = group.memberIdentityHex(i) ?: continue + if (id in adminSet) remainingAdminIdentities.add(id) + } + + check(remainingAdminIdentities.isNotEmpty()) { + "MIP-03: commit would leave the group without any admin members" + } + } + + /** + * Enforce the `0x8006` component's `required_member_roles` mask over + * the joining tree. + * + * A group carrying the agent-text-stream component requires each named + * role as an MLS leaf capability (`0xF2D1` receive, `0xF2D2` send, + * `0xF2D4` fanout). Advertising the component id alone is not enough — + * that only says "understands the component"; the role capability says + * "can actually do this". + */ + private fun requireAgentTextStreamRoles(group: GroupView) { + val policy = + AppDataDictionary + .fromExtensionsOrEmpty(group.extensions)[AgentTextStreamQuicPolicyV1.COMPONENT_ID] + ?.let { AgentTextStreamQuicPolicyV1.decode(it) } ?: return + val required = policy.requiredRoleCapabilities() + if (required.isEmpty()) return + + val myLeaf = group.leafCapabilities(group.myLeafIndex) + requireNotNull(myLeaf) { "Joiner's leaf is blank after tree reconstruction" } + val missing = required.filterNot { myLeaf.extensions.contains(it) } + require(missing.isEmpty()) { + "Joiner does not advertise agent text stream roles this group requires: " + + missing.joinToString { AppComponentIds.toHex(it) } + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/groups/MarmotGroupViews.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/groups/MarmotGroupViews.kt new file mode 100644 index 0000000000..db32823fd2 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/groups/MarmotGroupViews.kt @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.marmot.groups + +import com.vitorpamplona.quartz.marmot.appComponents.MarmotGroupState +import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.AgentTextStreamCrypto +import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData +import com.vitorpamplona.quartz.mls.group.MlsGroup +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +// Marmot's reads of an MlsGroup's GroupContext. +// +// These were methods on MlsGroup itself, which put MIP-01 extension parsing and +// Nostr routing ids inside an RFC 9420 engine. None of them needs the group's +// internals — every one goes through the public extensions / exporterSecret / +// view() surface — so they live here as extensions, and the engine no longer +// knows Marmot exists. + +/** Parsed Marmot Group Data Extension from the current GroupContext, or null. */ +fun MlsGroup.currentMarmotData(): MarmotGroupData? = MarmotGroupData.fromExtensions(extensions) + +/** The current profile's component view of this GroupContext. */ +fun MlsGroup.currentGroupState(): MarmotGroupState = MarmotGroupState.fromExtensions(extensions) + +/** + * The `nostr_group_id` this group routes kind-445 traffic under, from + * whichever profile the group is actually using. + * + * A current-profile group carries it in the `marmot.transport.nostr.routing.v1` + * component (`0x8004`); a legacy group carries it inside the monolithic + * `0xF2EE` extension. Reading only the legacy one leaves us unable to join + * any group a current-profile client created — the routing id is required + * to subscribe at all, so the failure is total rather than partial. + */ +fun MlsGroup.currentNostrGroupId(): HexKey? = + currentGroupState().routing?.nostrGroupIdHex + ?: currentMarmotData()?.nostrGroupId + +/** + * The group's configured admin account identities, as lowercase hex. + * + * Empty means the group names no admins at all, which happens during + * bootstrap and in groups that carry neither carrier. + */ +fun MlsGroup.currentAdminIdentities(): Set = MarmotGroupPolicy.adminIdentitiesIn(extensions) + +/** True if the local member is an active admin. */ +fun MlsGroup.isLocalAdmin(): Boolean = MarmotGroupPolicy.isLocalAdmin(view()) + +/** + * `MLS-Exporter("marmot", "agent-text-stream-quic", 32)` — the secret every + * member of this epoch derives per-stream record keys from. Per-stream and + * per-record separation is entirely in the HKDF key context, so this one + * secret covers every stream in the epoch. + */ +fun MlsGroup.agentTextStreamSecret(): ByteArray = + exporterSecret( + AgentTextStreamCrypto.EXPORTER_LABEL, + AgentTextStreamCrypto.EXPORTER_CONTEXT, + AgentTextStreamCrypto.SECRET_LENGTH, + ) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MarmotMessageStore.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/groups/MarmotMessageStore.kt similarity index 99% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MarmotMessageStore.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/groups/MarmotMessageStore.kt index 4af30c5c3c..0aa6701074 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MarmotMessageStore.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/groups/MarmotMessageStore.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.group +package com.vitorpamplona.quartz.marmot.groups /** * Encrypted local storage for decrypted Marmot inner event JSONs. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroupManager.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/groups/MlsGroupManager.kt similarity index 95% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroupManager.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/groups/MlsGroupManager.kt index eac5c5b10a..86fd4bca74 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroupManager.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/groups/MlsGroupManager.kt @@ -18,22 +18,30 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.group +package com.vitorpamplona.quartz.marmot.groups import com.vitorpamplona.quartz.marmot.appComponents.AdminPolicyV1 import com.vitorpamplona.quartz.marmot.appComponents.GroupLifecycleV1 -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter -import com.vitorpamplona.quartz.marmot.mls.components.ComponentsList -import com.vitorpamplona.quartz.marmot.mls.crypto.MlsCryptoProvider -import com.vitorpamplona.quartz.marmot.mls.framing.PublicMessage -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupManager.Companion.EPOCH_RETENTION_WINDOW -import com.vitorpamplona.quartz.marmot.mls.messages.CommitResult -import com.vitorpamplona.quartz.marmot.mls.messages.ExternalJoinResult -import com.vitorpamplona.quartz.marmot.mls.messages.KeyPackageBundle -import com.vitorpamplona.quartz.marmot.mls.schedule.KeySchedule -import com.vitorpamplona.quartz.marmot.mls.schedule.SecretTree -import com.vitorpamplona.quartz.marmot.mls.tree.Extension +import com.vitorpamplona.quartz.marmot.groups.MlsGroupManager.Companion.EPOCH_RETENTION_WINDOW +import com.vitorpamplona.quartz.marmot.groups.currentAdminIdentities +import com.vitorpamplona.quartz.marmot.groups.currentNostrGroupId +import com.vitorpamplona.quartz.marmot.groups.isLocalAdmin +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.codec.TlsWriter +import com.vitorpamplona.quartz.mls.components.ComponentsList +import com.vitorpamplona.quartz.mls.crypto.MlsCryptoProvider +import com.vitorpamplona.quartz.mls.framing.PublicMessage +import com.vitorpamplona.quartz.mls.group.DecryptedMessage +import com.vitorpamplona.quartz.mls.group.MlsGroup +import com.vitorpamplona.quartz.mls.group.MlsGroupState +import com.vitorpamplona.quartz.mls.group.OwnSenderRatchet +import com.vitorpamplona.quartz.mls.group.RetainedEpochSecrets +import com.vitorpamplona.quartz.mls.messages.CommitResult +import com.vitorpamplona.quartz.mls.messages.ExternalJoinResult +import com.vitorpamplona.quartz.mls.messages.KeyPackageBundle +import com.vitorpamplona.quartz.mls.schedule.KeySchedule +import com.vitorpamplona.quartz.mls.schedule.SecretTree +import com.vitorpamplona.quartz.mls.tree.Extension import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.sync.Mutex @@ -137,7 +145,9 @@ class MlsGroupManager( continue } val state = MlsGroupState.decodeTls(stateBytes) - val group = MlsGroup.restore(state) + // main needs the local binding for the sender-ratchet + // restore below; this branch supplies Marmot's policy. + val group = MlsGroup.restore(state, MarmotGroupPolicy) groups[nostrGroupId] = group Log.d(TAG) { "restoreAll(): restored group $nostrGroupId (${stateBytes.size} bytes)" } @@ -227,7 +237,7 @@ class MlsGroupManager( if (!changed) return@withLock val outgoing = current?.retainedSecrets() - groups[nostrGroupId] = MlsGroup.restore(state) + groups[nostrGroupId] = MlsGroup.restore(state, MarmotGroupPolicy) // Retain by outgoing epoch even when the epoch NUMBER is unchanged: a // same-epoch rewind swaps one epoch-N state for a different one, and // the abandoned N still has traffic addressed to it. @@ -287,11 +297,11 @@ class MlsGroupManager( nostrGroupId: HexKey, identity: ByteArray, signingKey: ByteArray? = null, - initialExtensions: List = emptyList(), + initialExtensions: List = emptyList(), ): MlsGroup = mutex.withLock { Log.d(TAG) { "createGroup($nostrGroupId): creating new MLS group" } - val group = MlsGroup.create(identity, signingKey, initialExtensions) + val group = MlsGroup.create(identity, signingKey, initialExtensions, policy = MarmotGroupPolicy) groups[nostrGroupId] = group persistGroup(nostrGroupId) Log.d(TAG) { "createGroup($nostrGroupId): done, in-memory group count=${groups.size}" } @@ -322,7 +332,7 @@ class MlsGroupManager( hintNostrGroupId: HexKey? = null, ): Pair = mutex.withLock { - val group = MlsGroup.processWelcome(welcomeBytes, bundle) + val group = MlsGroup.processWelcome(welcomeBytes, bundle, MarmotGroupPolicy) val derivedId = group.currentNostrGroupId() @@ -362,7 +372,7 @@ class MlsGroupManager( signingKey: ByteArray? = null, ): ExternalJoinResult = mutex.withLock { - val result = MlsGroup.externalJoin(groupInfoBytes, identity, signingKey) + val result = MlsGroup.externalJoin(groupInfoBytes, identity, signingKey, policy = MarmotGroupPolicy) groups[nostrGroupId] = result.group persistGroup(nostrGroupId) result @@ -426,7 +436,7 @@ class MlsGroupManager( mutex.withLock { val live = requireGroup(nostrGroupId) val priorState = live.saveState() - val clone = MlsGroup.restore(priorState) + val clone = MlsGroup.restore(priorState, MarmotGroupPolicy) val result = prepare(clone) StagedCommit(result, priorState, clone.saveState()) } @@ -620,7 +630,7 @@ class MlsGroupManager( senderLeafIndex: Int, confirmationTag: ByteArray, signature: ByteArray = ByteArray(0), - wireFormat: com.vitorpamplona.quartz.marmot.mls.framing.WireFormat = com.vitorpamplona.quartz.marmot.mls.framing.WireFormat.PUBLIC_MESSAGE, + wireFormat: com.vitorpamplona.quartz.mls.framing.WireFormat = com.vitorpamplona.quartz.mls.framing.WireFormat.PUBLIC_MESSAGE, ) = mutex.withLock { val group = requireGroup(nostrGroupId) @@ -724,7 +734,7 @@ class MlsGroupManager( // command, so we MUST persist here or reloaded state // silently reverts to the pre-commit extensions (including // admin list). - if (result.contentType == com.vitorpamplona.quartz.marmot.mls.framing.ContentType.COMMIT && group.epoch != preEpoch) { + if (result.contentType == com.vitorpamplona.quartz.mls.framing.ContentType.COMMIT && group.epoch != preEpoch) { pushRetainedEpoch(nostrGroupId, retainedBefore) persistGroup(nostrGroupId) } @@ -1049,15 +1059,15 @@ class MlsGroupManager( try { val secretTree = SecretTree(retained.encryptionSecret, retained.leafCount) val mlsMsg = - com.vitorpamplona.quartz.marmot.mls.framing.MlsMessage + com.vitorpamplona.quartz.mls.framing.MlsMessage .decodeTls(TlsReader(messageBytes)) - if (mlsMsg.wireFormat != com.vitorpamplona.quartz.marmot.mls.framing.WireFormat.PRIVATE_MESSAGE) { + if (mlsMsg.wireFormat != com.vitorpamplona.quartz.mls.framing.WireFormat.PRIVATE_MESSAGE) { return null } val privMsg = - com.vitorpamplona.quartz.marmot.mls.framing.PrivateMessage + com.vitorpamplona.quartz.mls.framing.PrivateMessage .decodeTls(TlsReader(mlsMsg.payload)) if (privMsg.epoch != retained.epoch) return null diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroupStateStore.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/groups/MlsGroupStateStore.kt similarity index 99% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroupStateStore.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/groups/MlsGroupStateStore.kt index 5a9307b614..0fc5016446 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroupStateStore.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/groups/MlsGroupStateStore.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.group +package com.vitorpamplona.quartz.marmot.groups /** * Interface for encrypted local storage of MLS group state. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageEvent.kt index 5686cd69d1..e377378aeb 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageEvent.kt @@ -24,9 +24,9 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.marmot.mip00KeyPackages.tags.AppComponentsTag import com.vitorpamplona.quartz.marmot.mip00KeyPackages.tags.EncodingTag import com.vitorpamplona.quartz.marmot.mip00KeyPackages.tags.MlsProposalsTag -import com.vitorpamplona.quartz.marmot.mls.components.AppDataDictionary -import com.vitorpamplona.quartz.marmot.mls.components.ComponentsList -import com.vitorpamplona.quartz.marmot.mls.messages.MlsKeyPackage +import com.vitorpamplona.quartz.mls.components.AppDataDictionary +import com.vitorpamplona.quartz.mls.components.ComponentsList +import com.vitorpamplona.quartz.mls.messages.MlsKeyPackage import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageRelayListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageRelayListEvent.kt index e9eb595b97..3f98a91dec 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageRelayListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageRelayListEvent.kt @@ -42,7 +42,7 @@ import com.vitorpamplona.quartz.utils.TimeUtils * Replaceable event that advertises which relays hold this user's MLS KeyPackages. * Other clients query this to discover where to fetch KeyPackages for group invitations. * - * Uses the same "relay" tag format as NIP-17's ChatMessageRelayListEvent (kind 10050) + * Uses the same "relay" tag format as NIP-17's DmRelayListEvent (kind 10050) * but serves a different purpose: KeyPackage discovery vs DM inbox routing. */ @Immutable diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageRotationManager.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageRotationManager.kt index d125828abf..72e12f6a1a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageRotationManager.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageRotationManager.kt @@ -21,21 +21,21 @@ package com.vitorpamplona.quartz.marmot.mip00KeyPackages import com.vitorpamplona.quartz.marmot.appComponents.CurrentProfileGroupFactory +import com.vitorpamplona.quartz.marmot.groups.MarmotCapabilities import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRotationManager.Companion.SNAPSHOT_VERSION import com.vitorpamplona.quartz.marmot.mip01Groups.MlsCiphersuite -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter -import com.vitorpamplona.quartz.marmot.mls.crypto.Ed25519 -import com.vitorpamplona.quartz.marmot.mls.crypto.MlsCryptoProvider -import com.vitorpamplona.quartz.marmot.mls.crypto.X25519 -import com.vitorpamplona.quartz.marmot.mls.messages.KeyPackageBundle -import com.vitorpamplona.quartz.marmot.mls.messages.MlsKeyPackage -import com.vitorpamplona.quartz.marmot.mls.tree.Capabilities -import com.vitorpamplona.quartz.marmot.mls.tree.Credential -import com.vitorpamplona.quartz.marmot.mls.tree.Extension -import com.vitorpamplona.quartz.marmot.mls.tree.LeafNode -import com.vitorpamplona.quartz.marmot.mls.tree.LeafNodeSource -import com.vitorpamplona.quartz.marmot.mls.tree.Lifetime +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.codec.TlsWriter +import com.vitorpamplona.quartz.mls.crypto.Ed25519 +import com.vitorpamplona.quartz.mls.crypto.MlsCryptoProvider +import com.vitorpamplona.quartz.mls.crypto.X25519 +import com.vitorpamplona.quartz.mls.messages.KeyPackageBundle +import com.vitorpamplona.quartz.mls.messages.MlsKeyPackage +import com.vitorpamplona.quartz.mls.tree.Credential +import com.vitorpamplona.quartz.mls.tree.Extension +import com.vitorpamplona.quartz.mls.tree.LeafNode +import com.vitorpamplona.quartz.mls.tree.LeafNodeSource +import com.vitorpamplona.quartz.mls.tree.Lifetime import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.utils.Log @@ -700,18 +700,7 @@ class KeyPackageRotationManager( encryptionKey = encryptionKey, signatureKey = signatureKey, credential = Credential.Basic(identity), - capabilities = - Capabilities( - extensions = - listOf( - 0x000A, // LastResort (required by OpenMLS validation) - 0xF2EE, // NostrGroupData (required by group's RequiredCapabilities) - ), - proposals = - listOf( - 0x000A, // SelfRemove (required by group's RequiredCapabilities) - ), - ), + capabilities = MarmotCapabilities.mipKeyPackageLeaf(), leafNodeSource = LeafNodeSource.KEY_PACKAGE, lifetime = Lifetime(notBefore = now, notAfter = now + KEY_PACKAGE_LIFETIME_SECONDS), extensions = emptyList(), diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageUtils.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageUtils.kt index a5fd89b6ee..b46881df81 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageUtils.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageUtils.kt @@ -29,14 +29,14 @@ import com.vitorpamplona.quartz.marmot.mip00KeyPackages.tags.MlsCiphersuiteTag import com.vitorpamplona.quartz.marmot.mip00KeyPackages.tags.MlsProposalsTag import com.vitorpamplona.quartz.marmot.mip00KeyPackages.tags.MlsProtocolVersionTag import com.vitorpamplona.quartz.marmot.mip01Groups.MlsCiphersuite -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.components.AppDataDictionary -import com.vitorpamplona.quartz.marmot.mls.components.ComponentsList -import com.vitorpamplona.quartz.marmot.mls.crypto.MlsCryptoProvider -import com.vitorpamplona.quartz.marmot.mls.framing.MlsMessage -import com.vitorpamplona.quartz.marmot.mls.framing.WireFormat -import com.vitorpamplona.quartz.marmot.mls.messages.MlsKeyPackage -import com.vitorpamplona.quartz.marmot.mls.tree.Credential +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.components.AppDataDictionary +import com.vitorpamplona.quartz.mls.components.ComponentsList +import com.vitorpamplona.quartz.mls.crypto.MlsCryptoProvider +import com.vitorpamplona.quartz.mls.framing.MlsMessage +import com.vitorpamplona.quartz.mls.framing.WireFormat +import com.vitorpamplona.quartz.mls.messages.MlsKeyPackage +import com.vitorpamplona.quartz.mls.tree.Credential import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip01Groups/MarmotGroupData.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip01Groups/MarmotGroupData.kt index ae0e358e4d..2d583f637e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip01Groups/MarmotGroupData.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip01Groups/MarmotGroupData.kt @@ -22,9 +22,9 @@ package com.vitorpamplona.quartz.marmot.mip01Groups import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData.Companion.decodeTls -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter -import com.vitorpamplona.quartz.marmot.mls.tree.Extension +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.codec.TlsWriter +import com.vitorpamplona.quartz.mls.tree.Extension import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip01Groups/Mip01ImageCrypto.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip01Groups/Mip01ImageCrypto.kt index 9600161e76..20556c0db4 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip01Groups/Mip01ImageCrypto.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip01Groups/Mip01ImageCrypto.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.quartz.marmot.mip01Groups -import com.vitorpamplona.quartz.marmot.mls.crypto.MlsCryptoProvider +import com.vitorpamplona.quartz.mls.crypto.MlsCryptoProvider /** * MIP-01 image & Blossom upload key derivations. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip02Welcome/WelcomeGiftWrap.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip02Welcome/WelcomeGiftWrap.kt index 6ee4406313..9558c6fc4d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip02Welcome/WelcomeGiftWrap.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip02Welcome/WelcomeGiftWrap.kt @@ -26,7 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip59Giftwrap.rumors.Rumor import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler -import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent +import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.utils.TimeUtils @@ -35,7 +35,7 @@ import com.vitorpamplona.quartz.utils.TimeUtils * * The delivery flow: * WelcomeEvent (unsigned rumor, kind:444) - * → SealedRumorEvent (kind:13, encrypted with sender's key) + * → SealEvent (kind:13, encrypted with sender's key) * → GiftWrapEvent (kind:1059, encrypted with ephemeral key to recipient) * * CRITICAL: The Commit that adds the new member MUST be confirmed by relays @@ -64,7 +64,7 @@ object WelcomeGiftWrap { ): GiftWrapEvent { // Step 1: Build the WelcomeEvent template directly as an unsigned rumor. // Per NIP-59 rumors MUST have an empty sig field, so we skip the outer - // signature entirely and let the SealedRumorEvent carry authorship. + // signature entirely and let the SealEvent carry authorship. val welcomeTemplate = WelcomeEvent.build( welcomeBase64 = welcomeBase64, @@ -81,8 +81,8 @@ object WelcomeGiftWrap { // Step 2: Create a Rumor from the unsigned event and seal it (kind:13) val rumor = Rumor.create(welcomeRumor) - val sealedRumor = - SealedRumorEvent.create( + val sealEvent = + SealEvent.create( rumor = rumor, encryptTo = recipientPubKey, signer = signer, @@ -90,7 +90,7 @@ object WelcomeGiftWrap { // Step 3: Gift wrap (kind:1059) with an ephemeral key to the recipient return GiftWrapEvent.create( - event = sealedRumor, + event = sealEvent, recipientPubKey = recipientPubKey, ) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip04EncryptedMedia/Mip04MediaEncryption.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip04EncryptedMedia/Mip04MediaEncryption.kt index d4285db2a5..be5f751e03 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip04EncryptedMedia/Mip04MediaEncryption.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip04EncryptedMedia/Mip04MediaEncryption.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.quartz.marmot.mip04EncryptedMedia -import com.vitorpamplona.quartz.marmot.mls.crypto.MlsCryptoProvider +import com.vitorpamplona.quartz.mls.crypto.MlsCryptoProvider import com.vitorpamplona.quartz.nip44Encryption.crypto.ChaCha20Poly1305 import com.vitorpamplona.quartz.utils.RandomInstance import com.vitorpamplona.quartz.utils.sha256.sha256 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/protocolCore/MarmotConvergenceEngine.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/protocolCore/MarmotConvergenceEngine.kt index 91b0760946..bd2111dede 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/protocolCore/MarmotConvergenceEngine.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/protocolCore/MarmotConvergenceEngine.kt @@ -20,10 +20,12 @@ */ package com.vitorpamplona.quartz.marmot.protocolCore -import com.vitorpamplona.quartz.marmot.mls.framing.ContentType -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupManager -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupState +import com.vitorpamplona.quartz.marmot.groups.MarmotGroupPolicy +import com.vitorpamplona.quartz.marmot.groups.MlsGroupManager +import com.vitorpamplona.quartz.marmot.groups.currentGroupState +import com.vitorpamplona.quartz.mls.framing.ContentType +import com.vitorpamplona.quartz.mls.group.MlsGroup +import com.vitorpamplona.quartz.mls.group.MlsGroupState import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.utils.Log @@ -421,7 +423,9 @@ class MarmotConvergenceEngine( mutex.withLock { contexts[groupId]?.candidateStates?.values?.mapNotNull { state -> try { - MlsGroup.restore(state).exporterSecret("marmot", "group-event".encodeToByteArray(), 32) + MarmotGroupPolicy.commitExporter.let { + MlsGroup.restore(state, MarmotGroupPolicy).exporterSecret(it.label, it.context, it.length) + } } catch (_: Exception) { null } @@ -451,7 +455,7 @@ class MarmotConvergenceEngine( // A clone per attempt: decrypting advances the secret // tree, and a candidate state gets tried by every // message that failed canonically. - MlsGroup.restore(state).decrypt(mlsBytes) + MlsGroup.restore(state, MarmotGroupPolicy).decrypt(mlsBytes) } catch (_: Exception) { continue } @@ -460,7 +464,7 @@ class MarmotConvergenceEngine( stateId = stateId, epoch = decrypted.epoch, senderLeafIndex = decrypted.senderLeafIndex, - senderAccount = MlsGroup.restore(state).memberIdentityHex(decrypted.senderLeafIndex), + senderAccount = MlsGroup.restore(state, MarmotGroupPolicy).memberIdentityHex(decrypted.senderLeafIndex), content = decrypted.content, ) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/protocolCore/MarmotPublishGate.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/protocolCore/MarmotPublishGate.kt index 369233a9c1..e8f25d488c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/protocolCore/MarmotPublishGate.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/protocolCore/MarmotPublishGate.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.quartz.marmot.protocolCore -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupManager -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupState +import com.vitorpamplona.quartz.marmot.groups.MlsGroupManager +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.codec.TlsWriter +import com.vitorpamplona.quartz.mls.group.MlsGroupState import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.utils.sha256.sha256 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/protocolCore/MlsCandidateStateEngine.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/protocolCore/MlsCandidateStateEngine.kt index 9bd0fa79c9..a4399a029b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/protocolCore/MlsCandidateStateEngine.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/protocolCore/MlsCandidateStateEngine.kt @@ -20,13 +20,17 @@ */ package com.vitorpamplona.quartz.marmot.protocolCore -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.framing.ContentType -import com.vitorpamplona.quartz.marmot.mls.framing.MlsMessage -import com.vitorpamplona.quartz.marmot.mls.framing.PublicMessage -import com.vitorpamplona.quartz.marmot.mls.framing.WireFormat -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupState +import com.vitorpamplona.quartz.marmot.groups.MarmotGroupPolicy +import com.vitorpamplona.quartz.marmot.groups.currentAdminIdentities +import com.vitorpamplona.quartz.marmot.groups.currentGroupState +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.framing.ContentType +import com.vitorpamplona.quartz.mls.framing.MlsMessage +import com.vitorpamplona.quartz.mls.framing.PublicMessage +import com.vitorpamplona.quartz.mls.framing.WireFormat +import com.vitorpamplona.quartz.mls.group.MlsGroup +import com.vitorpamplona.quartz.mls.group.MlsGroupPolicy +import com.vitorpamplona.quartz.mls.group.MlsGroupState import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.utils.sha256.sha256 @@ -43,7 +47,21 @@ import com.vitorpamplona.quartz.utils.sha256.sha256 * "roll it back afterwards" is the kind of thing that works until the day an * exception escapes halfway through. */ -class MlsCandidateStateEngine : CandidateStateEngine { +class MlsCandidateStateEngine( + /** + * The rules a restored group is judged by. + * + * Passed on EVERY restore below, and that is the whole point of naming it + * here. `MlsGroup.restore` defaults to [MlsGroupPolicy.Permissive], whose + * `authorizeCommit` is a no-op — so a restore that forgets the policy + * turns [isAuthorized] into a function that returns true for every commit + * it can parse. The MIP-03 gate and the admin-depletion guard used to live + * inside `MlsGroup` and could not be left out; since they moved behind + * this seam, leaving it out is a silent authorization bypass rather than a + * compile error. It is a constructor parameter so a test can see it. + */ + private val policy: MlsGroupPolicy = MarmotGroupPolicy, +) : CandidateStateEngine { /** * Identity of a retained state. * @@ -68,7 +86,7 @@ class MlsCandidateStateEngine : CandidateStateEngine { if (pubMsg.epoch != state.groupContext.epoch) return false return try { - MlsGroup.restore(state).verifyPublicMessageCommitMembershipTag(pubMsg) + MlsGroup.restore(state, policy).verifyPublicMessageCommitMembershipTag(pubMsg) } catch (_: Exception) { false } @@ -81,7 +99,7 @@ class MlsCandidateStateEngine : CandidateStateEngine { try { // A clone, so the retained snapshot is untouched no matter how this // attempt ends. - val group = MlsGroup.restore(state) + val group = MlsGroup.restore(state, policy) group.processFramedCommit(commit) group.saveState() } catch (_: Exception) { @@ -94,7 +112,7 @@ class MlsCandidateStateEngine : CandidateStateEngine { ): ByteArray? { val pubMsg = publicMessageOrNull(commit) ?: return null return try { - MlsGroup.restore(parent).memberIdentity(pubMsg.sender.leafIndex) + MlsGroup.restore(parent, policy).memberIdentity(pubMsg.sender.leafIndex) } catch (_: Exception) { null } @@ -113,7 +131,7 @@ class MlsCandidateStateEngine : CandidateStateEngine { ): Boolean { val pubMsg = publicMessageOrNull(commit) ?: return false return try { - val group = MlsGroup.restore(parent) + val group = MlsGroup.restore(parent, policy) // A group that names no admins yet is bootstrapping; the gate is // open there for the same reason the local path leaves it open. if (group.currentAdminIdentities().isEmpty()) return true @@ -129,7 +147,7 @@ class MlsCandidateStateEngine : CandidateStateEngine { ): TipPriority { val pubMsg = publicMessageOrNull(commit) ?: return TipPriority.ORDINARY return try { - val group = MlsGroup.restore(parent) + val group = MlsGroup.restore(parent, policy) // Privileged exactly when the rule that applies REQUIRES an active // admin. A commit a non-admin could also have made is ordinary even // when an admin happened to send it. @@ -145,7 +163,7 @@ class MlsCandidateStateEngine : CandidateStateEngine { // is strict, so malformed or unsorted component bytes throw rather // than producing a lenient value. An admin policy that named nobody // would also fail its own constructor. - val group = MlsGroup.restore(resulting) + val group = MlsGroup.restore(resulting, policy) // Decoding the component set IS the validation: every component // decoder is strict, so malformed, unsorted or duplicated bytes // throw rather than yielding a lenient value, and an admin policy diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/README.md b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/README.md new file mode 100644 index 0000000000..17e85c0ffd --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/README.md @@ -0,0 +1,89 @@ +# `mls/` — the RFC 9420 engine + +A binding-agnostic MLS (RFC 9420) implementation: TLS presentation-language +codec, HPKE, X25519/Ed25519, the ratchet tree, the key schedule, the secret +tree, message framing, proposals, commits, Welcome, and the `app_data_dictionary` +component carrier from `draft-ietf-mls-extensions-10`. + +**It knows nothing about Marmot, Nostr, or cordn, and must stay that way.** The +invariant, over the shipped code: + +```bash +grep -rn 'import com.vitorpamplona.quartz.marmot' \ + --include='*.kt' quartz/src/{commonMain,jvmAndroid,appleMain,linuxMain}/kotlin/com/vitorpamplona/quartz/mls/ +``` + +That must print nothing. Tests are deliberately outside it: a couple under +`mls/components/` decode real Marmot components as fixtures, because the point +of an interop test is to exercise the engine against payloads that actually +exist. A fixture is data; an import in the engine is a dependency. + +It lived under `marmot/` until Stage 1 of +`quartz/plans/2026-09-17-cordn-interop.md`, and `:quic` was already importing +`crypto/X25519` from there for its TLS 1.3 handshake — a use that is neither +Marmot nor MLS, and the reason the extraction was overdue. + +## Using it + +```kotlin +val group = MlsGroup.create(identity) // a plain RFC 9420 group +val group = MlsGroup.create(identity, policy = MarmotGroupPolicy) // Marmot's profile +``` + +The default group requires nothing beyond RFC 9420: no `required_capabilities`, +no leaf capabilities (§7.2 forbids advertising DEFAULT types), no commit +exporter, and no limit on who may commit what. + +## The `MlsGroupPolicy` seam + +RFC 9420 says who may *send* a proposal and never who may *commit* one. Real +deployments need more — Marmot's MIP-03 names admin accounts and allows everyone +else only a self-Update or a SelfRemove — so the engine asks a policy wherever +the spec defers to the application: + +| Hook | Called from | Marmot uses it for | +| ---- | ----------- | ------------------ | +| `authorizeCommit` | `commit()` and both inbound commit paths | MIP-03 proposal-set rules + admin depletion | +| `authorizeSelfRemove` | `proposeSelfRemove`, `buildSelfRemoveProposalMessage` | admin must self-demote first | +| `validateJoin` | `processWelcome` | `required_member_roles` on the agent-text-stream component | +| `defaultLeafCapabilities` | every leaf the engine builds | `0xF2EE` + `self_remove` | +| `defaultRequiredCapabilities` | `create()`, epoch 0 | the MIP-era interop set | +| `knownExtensionTypes` | GroupContextExtensions validation | `0xF2EE` | +| `commitExporter` | `CommitResult.preCommitExporterSecret` | `MLS-Exporter("marmot", "group-event", 32)` | + +Three things to know before you add a binding: + +1. **One argument selects a whole profile.** `policy` supplies the rules *and* + the capability defaults *and* the exporter binding, so `capabilities` and + `requiredCapabilities` default from it. Adopting a profile is one argument, + not five. + +2. **A policy is behaviour, not state.** It is deliberately absent from + `MlsGroupState`, so whatever restores a group must pass the same policy it + was created with. A restore that forgets it gets a group that silently skips + the binding's rules. In Marmot only `MlsGroupManager` restores a group in + order to commit with it, which is what keeps that narrow. + +3. **A policy gets a `GroupView`, not the `MlsGroup`.** A policy holding the + group could commit or rotate keys from inside the check meant to gate exactly + that. + +The default is permissive rather than closed. Closed would make the engine +unusable without a policy and would push callers into writing an +allow-everything one anyway; open puts each restriction in the binding that +documents it. The cost is item 2 above. + +## Where the bindings live + +- `quartz/…/marmot/groups/` — `MarmotGroupPolicy`, `MarmotCapabilities`, + `MarmotGroupViews` (Marmot's reads of a GroupContext, as extension functions), + plus `MlsGroupManager` and the two stores, all keyed on `nostrGroupId`. +- `quartz/…/marmot/mipXX…/` — the Nostr event layer. + +## Tests + +`quartz/src/commonTest/…/mls/` runs everywhere; `quartz/src/jvmAndroidTest/…/mls/` +holds the tests needing real secp256k1/JNI. `mls/interop/` replays the RFC 9420 +test vectors. `group/MlsGroupPolicySeamTest` pins the seam itself with a +recording policy; `marmot/groups/MarmotPolicySeamTest` pins that Marmot's rules +travel with `MarmotGroupPolicy` and not with the engine. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/codec/TlsReader.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/codec/TlsReader.kt similarity index 99% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/codec/TlsReader.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/codec/TlsReader.kt index 75fcdebbe9..dfd2dffeee 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/codec/TlsReader.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/codec/TlsReader.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.codec +package com.vitorpamplona.quartz.mls.codec /** * Decoder for TLS presentation language (RFC 8446 Section 3). diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/codec/TlsSerializable.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/codec/TlsSerializable.kt similarity index 96% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/codec/TlsSerializable.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/codec/TlsSerializable.kt index bf8fbb95ca..00652cf6c5 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/codec/TlsSerializable.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/codec/TlsSerializable.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.codec +package com.vitorpamplona.quartz.mls.codec /** * Interface for types that can be serialized to TLS presentation language diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/codec/TlsWriter.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/codec/TlsWriter.kt similarity index 99% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/codec/TlsWriter.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/codec/TlsWriter.kt index e147ac4836..da611b9bed 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/codec/TlsWriter.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/codec/TlsWriter.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.codec +package com.vitorpamplona.quartz.mls.codec /** * Encoder for TLS presentation language (RFC 8446 Section 3). diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/components/AppDataDictionary.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/components/AppDataDictionary.kt similarity index 96% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/components/AppDataDictionary.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/components/AppDataDictionary.kt index 6faf55d423..88ad278666 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/components/AppDataDictionary.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/components/AppDataDictionary.kt @@ -18,12 +18,12 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.components +package com.vitorpamplona.quartz.mls.components -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.codec.TlsSerializable -import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter -import com.vitorpamplona.quartz.marmot.mls.tree.Extension +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.codec.TlsSerializable +import com.vitorpamplona.quartz.mls.codec.TlsWriter +import com.vitorpamplona.quartz.mls.tree.Extension /** * The `app_data_dictionary` MLS extension (draft-ietf-mls-extensions-10 §4.6), diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/components/ComponentData.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/components/ComponentData.kt similarity index 91% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/components/ComponentData.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/components/ComponentData.kt index 585adb9a8e..1f6103ac09 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/components/ComponentData.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/components/ComponentData.kt @@ -18,11 +18,11 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.components +package com.vitorpamplona.quartz.mls.components -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.codec.TlsSerializable -import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.codec.TlsSerializable +import com.vitorpamplona.quartz.mls.codec.TlsWriter /** * One entry in an [AppDataDictionary] (draft-ietf-mls-extensions-10 §4.6). diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/components/ComponentsList.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/components/ComponentsList.kt similarity index 90% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/components/ComponentsList.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/components/ComponentsList.kt index e3d58942e1..0f2ea86fe9 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/components/ComponentsList.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/components/ComponentsList.kt @@ -18,10 +18,10 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.components +package com.vitorpamplona.quartz.mls.components -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.codec.TlsWriter /** * The `ComponentsList` payload shared by the upstream `app_components` @@ -113,6 +113,14 @@ object ComponentsList { /** Component id of the upstream `safe_aad` list. */ const val SAFE_AAD_ID = 0x0002 + /** + * `last_resort_key_package`: empty-data marker in a KeyPackage's own + * dictionary. Note this is a component, NOT an MLS extension type — the + * MIP-era profile marked last resort with extension `0x000a`, which is now + * the `self_remove` PROPOSAL type. + */ + const val LAST_RESORT_KEY_PACKAGE_ID = 0x0004 + /** The supported/required id list carried by [dictionary], or empty when absent. */ fun supportedOrRequired(dictionary: AppDataDictionary): List = dictionary[APP_COMPONENTS_ID]?.let { decode(it) } ?: emptyList() } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/Curve25519Field.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/crypto/Curve25519Field.kt similarity index 99% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/Curve25519Field.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/crypto/Curve25519Field.kt index 97ee380680..5e7c0d776c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/Curve25519Field.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/crypto/Curve25519Field.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.crypto +package com.vitorpamplona.quartz.mls.crypto /** * Field arithmetic over GF(2^255-19) for Curve25519 and Ed25519. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/Ed25519.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/crypto/Ed25519.kt similarity index 82% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/Ed25519.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/crypto/Ed25519.kt index 168b586808..08a468fbdd 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/Ed25519.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/crypto/Ed25519.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.crypto +package com.vitorpamplona.quartz.mls.crypto /** * Ed25519 digital signature operations for MLS ciphersuite 0x0001. @@ -66,8 +66,16 @@ expect object Ed25519 { fun publicFromPrivate(privateKey: ByteArray): ByteArray /** - * Rebuild a key pair from a 32-byte seed (RFC 8032 §5.1.5), for keys stored as seeds only. - * @return pair of (privateKey: 64 bytes seed+public, publicKey: 32 bytes) + * Rebuilds a key pair from its 32-byte seed. + * + * [generateKeyPair] makes a fresh random one, which is right for a real + * client and useless for anything that has to reproduce a specific key: + * an interop fixture generated by another implementation, a test vector, or + * a private key stored in someone else's layout (ts-mls keeps the seed in a + * PKCS#8 blob, so the seed is all you get back out). + * + * @param seed exactly 32 bytes. The public half is derived, never supplied, + * so a mismatched pair cannot be constructed here. */ fun keyPairFromSeed(seed: ByteArray): Ed25519KeyPair } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/Hpke.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/crypto/Hpke.kt similarity index 99% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/Hpke.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/crypto/Hpke.kt index fcc7990cdd..3eee283298 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/Hpke.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/crypto/Hpke.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.crypto +package com.vitorpamplona.quartz.mls.crypto import com.vitorpamplona.quartz.utils.ciphers.AESGCM diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/MlsCryptoProvider.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/crypto/MlsCryptoProvider.kt similarity index 97% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/MlsCryptoProvider.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/crypto/MlsCryptoProvider.kt index 6f12ec5a71..712cc798d1 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/MlsCryptoProvider.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/crypto/MlsCryptoProvider.kt @@ -18,11 +18,11 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.crypto +package com.vitorpamplona.quartz.mls.crypto -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.codec.TlsSerializable -import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.codec.TlsSerializable +import com.vitorpamplona.quartz.mls.codec.TlsWriter import com.vitorpamplona.quartz.nip44Encryption.crypto.Hkdf import com.vitorpamplona.quartz.utils.RandomInstance import com.vitorpamplona.quartz.utils.ciphers.AESGCM diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/X25519.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/crypto/X25519.kt similarity index 98% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/X25519.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/crypto/X25519.kt index ea6f18ae98..7920ecefdd 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/X25519.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/crypto/X25519.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.crypto +package com.vitorpamplona.quartz.mls.crypto /** * X25519 Diffie-Hellman key exchange for MLS DHKEM (RFC 9180). diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/framing/ContentType.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/framing/ContentType.kt similarity index 97% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/framing/ContentType.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/framing/ContentType.kt index 0d23249604..da524ffd52 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/framing/ContentType.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/framing/ContentType.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.framing +package com.vitorpamplona.quartz.mls.framing /** * MLS ContentType (RFC 9420 Section 6.1). diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/framing/MlsMessage.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/framing/MlsMessage.kt similarity index 97% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/framing/MlsMessage.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/framing/MlsMessage.kt index f01a6f4658..fdbebe7ffa 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/framing/MlsMessage.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/framing/MlsMessage.kt @@ -18,13 +18,13 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.framing +package com.vitorpamplona.quartz.mls.framing -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.codec.TlsSerializable -import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter -import com.vitorpamplona.quartz.marmot.mls.messages.Commit -import com.vitorpamplona.quartz.marmot.mls.messages.Proposal +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.codec.TlsSerializable +import com.vitorpamplona.quartz.mls.codec.TlsWriter +import com.vitorpamplona.quartz.mls.messages.Commit +import com.vitorpamplona.quartz.mls.messages.Proposal /** * MLS MLSMessage (RFC 9420 Section 6). diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroup.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/group/MlsGroup.kt similarity index 88% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroup.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/group/MlsGroup.kt index 4a6c73ae6c..2c211240c4 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroup.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/group/MlsGroup.kt @@ -18,61 +18,53 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.group +package com.vitorpamplona.quartz.mls.group -import com.vitorpamplona.quartz.marmot.appComponents.AdminPolicyV1 -import com.vitorpamplona.quartz.marmot.appComponents.AppComponentIds -import com.vitorpamplona.quartz.marmot.appComponents.MarmotGroupState -import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.AgentTextStreamCrypto -import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.AgentTextStreamQuicPolicyV1 -import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.AgentTextStreamRoles -import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter -import com.vitorpamplona.quartz.marmot.mls.components.AppDataDictionary -import com.vitorpamplona.quartz.marmot.mls.crypto.Ed25519 -import com.vitorpamplona.quartz.marmot.mls.crypto.Ed25519KeyPair -import com.vitorpamplona.quartz.marmot.mls.crypto.Hpke -import com.vitorpamplona.quartz.marmot.mls.crypto.MlsCryptoProvider -import com.vitorpamplona.quartz.marmot.mls.crypto.X25519 -import com.vitorpamplona.quartz.marmot.mls.framing.ContentType -import com.vitorpamplona.quartz.marmot.mls.framing.MlsMessage -import com.vitorpamplona.quartz.marmot.mls.framing.PrivateMessage -import com.vitorpamplona.quartz.marmot.mls.framing.PublicMessage -import com.vitorpamplona.quartz.marmot.mls.framing.Sender -import com.vitorpamplona.quartz.marmot.mls.framing.SenderType -import com.vitorpamplona.quartz.marmot.mls.framing.WireFormat -import com.vitorpamplona.quartz.marmot.mls.framing.encodeSender -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup.Companion.externalJoin -import com.vitorpamplona.quartz.marmot.mls.messages.Commit -import com.vitorpamplona.quartz.marmot.mls.messages.CommitResult -import com.vitorpamplona.quartz.marmot.mls.messages.EncryptedGroupSecrets -import com.vitorpamplona.quartz.marmot.mls.messages.ExternalJoinResult -import com.vitorpamplona.quartz.marmot.mls.messages.GroupContext -import com.vitorpamplona.quartz.marmot.mls.messages.GroupInfo -import com.vitorpamplona.quartz.marmot.mls.messages.GroupSecrets -import com.vitorpamplona.quartz.marmot.mls.messages.KeyPackageBundle -import com.vitorpamplona.quartz.marmot.mls.messages.MlsKeyPackage -import com.vitorpamplona.quartz.marmot.mls.messages.Proposal -import com.vitorpamplona.quartz.marmot.mls.messages.ProposalOrRef -import com.vitorpamplona.quartz.marmot.mls.messages.UpdatePath -import com.vitorpamplona.quartz.marmot.mls.messages.Welcome -import com.vitorpamplona.quartz.marmot.mls.schedule.EpochSecrets -import com.vitorpamplona.quartz.marmot.mls.schedule.KeySchedule -import com.vitorpamplona.quartz.marmot.mls.schedule.SecretTree -import com.vitorpamplona.quartz.marmot.mls.schedule.SenderRatchetState -import com.vitorpamplona.quartz.marmot.mls.tree.BinaryTree -import com.vitorpamplona.quartz.marmot.mls.tree.Capabilities -import com.vitorpamplona.quartz.marmot.mls.tree.Credential -import com.vitorpamplona.quartz.marmot.mls.tree.Extension -import com.vitorpamplona.quartz.marmot.mls.tree.LeafNode -import com.vitorpamplona.quartz.marmot.mls.tree.LeafNodeSource -import com.vitorpamplona.quartz.marmot.mls.tree.Lifetime -import com.vitorpamplona.quartz.marmot.mls.tree.PathSecretAndKey -import com.vitorpamplona.quartz.marmot.mls.tree.RatchetTree -import com.vitorpamplona.quartz.marmot.mls.tree.UpdatePathNode -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.codec.TlsWriter +import com.vitorpamplona.quartz.mls.components.AppDataDictionary +import com.vitorpamplona.quartz.mls.crypto.Ed25519 +import com.vitorpamplona.quartz.mls.crypto.Ed25519KeyPair +import com.vitorpamplona.quartz.mls.crypto.Hpke +import com.vitorpamplona.quartz.mls.crypto.MlsCryptoProvider +import com.vitorpamplona.quartz.mls.crypto.X25519 +import com.vitorpamplona.quartz.mls.framing.ContentType +import com.vitorpamplona.quartz.mls.framing.MlsMessage +import com.vitorpamplona.quartz.mls.framing.PrivateMessage +import com.vitorpamplona.quartz.mls.framing.PublicMessage +import com.vitorpamplona.quartz.mls.framing.Sender +import com.vitorpamplona.quartz.mls.framing.SenderType +import com.vitorpamplona.quartz.mls.framing.WireFormat +import com.vitorpamplona.quartz.mls.framing.encodeSender +import com.vitorpamplona.quartz.mls.group.MlsGroup.Companion.externalJoin +import com.vitorpamplona.quartz.mls.messages.Commit +import com.vitorpamplona.quartz.mls.messages.CommitResult +import com.vitorpamplona.quartz.mls.messages.EncryptedGroupSecrets +import com.vitorpamplona.quartz.mls.messages.ExternalJoinResult +import com.vitorpamplona.quartz.mls.messages.GroupContext +import com.vitorpamplona.quartz.mls.messages.GroupInfo +import com.vitorpamplona.quartz.mls.messages.GroupSecrets +import com.vitorpamplona.quartz.mls.messages.KeyPackageBundle +import com.vitorpamplona.quartz.mls.messages.MlsKeyPackage +import com.vitorpamplona.quartz.mls.messages.Proposal +import com.vitorpamplona.quartz.mls.messages.ProposalOrRef +import com.vitorpamplona.quartz.mls.messages.UpdatePath +import com.vitorpamplona.quartz.mls.messages.Welcome +import com.vitorpamplona.quartz.mls.schedule.EpochSecrets +import com.vitorpamplona.quartz.mls.schedule.KeySchedule +import com.vitorpamplona.quartz.mls.schedule.SecretTree +import com.vitorpamplona.quartz.mls.schedule.SenderRatchetState +import com.vitorpamplona.quartz.mls.tree.BinaryTree +import com.vitorpamplona.quartz.mls.tree.Capabilities +import com.vitorpamplona.quartz.mls.tree.Credential +import com.vitorpamplona.quartz.mls.tree.Extension +import com.vitorpamplona.quartz.mls.tree.LeafNode +import com.vitorpamplona.quartz.mls.tree.LeafNodeSource +import com.vitorpamplona.quartz.mls.tree.Lifetime +import com.vitorpamplona.quartz.mls.tree.PathSecretAndKey +import com.vitorpamplona.quartz.mls.tree.RatchetTree +import com.vitorpamplona.quartz.mls.tree.UpdatePathNode +import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.mac.MacInstance @@ -105,8 +97,8 @@ import com.vitorpamplona.quartz.utils.mac.MacInstance * // Decrypt application message * val decrypted = group.decrypt(encrypted) * - * // Export key for Marmot outer encryption - * val key = group.exporterSecret("marmot", "group-event", 32) + * // Export key for a binding's own outer encryption + * val key = group.exporterSecret("myapp", "group-event".encodeToByteArray(), 32) * ``` */ private fun constantTimeEquals( @@ -133,7 +125,7 @@ class MlsGroup private constructor( private var interimTranscriptHash: ByteArray, private val pskStore: MutableMap = mutableMapOf(), private val pendingProposals: MutableList = mutableListOf(), - private val sentKeys: MutableMap = mutableMapOf(), + private val sentKeys: MutableMap = mutableMapOf(), /** Staged keys from proposeSigningKeyRotation — only promoted on successful commit */ private var pendingSigningKey: ByteArray? = null, private var pendingEncryptionKey: ByteArray? = null, @@ -151,11 +143,17 @@ class MlsGroup private constructor( * for us". */ private val pathPrivateKeys: MutableMap = mutableMapOf(), + /** + * The application's authorization rules. See [MlsGroupPolicy]: RFC 9420 + * itself places no limit on who may commit what, so the default allows + * everything the protocol allows and a binding supplies its own. + */ + private val policy: MlsGroupPolicy = MlsGroupPolicy.Permissive, ) { val groupId: ByteArray get() = groupContext.groupId val epoch: Long get() = groupContext.epoch val leafIndex: Int get() = myLeafIndex - val extensions: List get() = groupContext.extensions + val extensions: List get() = groupContext.extensions /** * True while our own leaf is still live in the tree. @@ -169,6 +167,22 @@ class MlsGroup private constructor( */ fun isLocalMember(): Boolean = myLeafIndex < tree.leafCount && tree.getLeaf(myLeafIndex) != null + /** + * The read-only projection this group hands to its [MlsGroupPolicy]. + * + * Public because a binding's own checks want the same view the engine + * gives the policy — Marmot's "only admins may change group extensions" + * gate runs in `MlsGroupManager`, before any commit is staged. + */ + fun view(): GroupView = + GroupView( + extensions = groupContext.extensions, + leafCount = tree.leafCount, + myLeafIndex = myLeafIndex, + identityAt = { memberIdentityHex(it) }, + capabilitiesAt = { tree.getLeaf(it)?.capabilities }, + ) + /** * Read-only snapshot of the staged-proposal pool. Exposed at module * scope so tests can inspect what `proposeAdd` / `proposeRemove` / @@ -210,92 +224,38 @@ class MlsGroup private constructor( return w.toByteArray() } - // --- Marmot admin helpers (MIP-01 / MIP-03) --- + // --- Member identity --- /** Raw BasicCredential identity bytes of the member at the given leaf, or null. */ fun memberIdentity(leafIndex: Int): ByteArray? = (tree.getLeaf(leafIndex)?.credential as? Credential.Basic)?.identity - /** Lowercase hex of the member's BasicCredential identity, or null. */ - fun memberIdentityHex(leafIndex: Int): String? = memberIdentity(leafIndex)?.toHexKey() + /** + * Lowercase hex OF THE CREDENTIAL BYTES at [leafIndex], or null. + * + * This hex-encodes whatever the credential holds, which is the account key + * only for a binding that stores it as raw bytes — Marmot does. A binding + * that stores an already-encoded identity gets the hex of that encoding: + * cordn writes 64 ASCII characters of hex, so this returns 128 characters + * of nothing useful. Such a binding should read the credential itself + * (`CordnCredential.identityOrNull`) rather than call this. + * + * Kept as-is because it is what Marmot means everywhere it is used, and + * because a function that guessed which encoding a credential used would + * be worse than one that says plainly what it does. + */ + fun memberIdentityHex(leafIndex: Int): String? = memberIdentity(leafIndex)?.let { Hex.encode(it) } /** Lowercase hex of the local member's BasicCredential identity, or null. */ fun myIdentityHex(): String? = memberIdentityHex(myLeafIndex) - /** Parsed Marmot Group Data Extension from the current GroupContext, or null. */ - fun currentMarmotData(): MarmotGroupData? = MarmotGroupData.fromExtensions(groupContext.extensions) - - /** The current profile's component view of this GroupContext. */ - fun currentGroupState(): MarmotGroupState = MarmotGroupState.fromExtensions(groupContext.extensions) - - /** - * The `nostr_group_id` this group routes kind-445 traffic under, from - * whichever profile the group is actually using. - * - * A current-profile group carries it in the `marmot.transport.nostr.routing.v1` - * component (`0x8004`); a legacy group carries it inside the monolithic - * `0xF2EE` extension. Reading only the legacy one leaves us unable to join - * any group a current-profile client created — the routing id is required - * to subscribe at all, so the failure is total rather than partial. - */ - fun currentNostrGroupId(): HexKey? = - currentGroupState().routing?.nostrGroupIdHex - ?: currentMarmotData()?.nostrGroupId - - /** - * The group's configured admin account identities, as lowercase hex. - * - * Reads whichever profile this group is on: the current profile's - * `marmot.group.admin-policy.v1` component (`0x8003`) when present, - * otherwise MIP-01's `admin_pubkeys` field inside `marmot_group_data` - * (`0xF2EE`). Empty means the group names no admins at all, which happens - * during bootstrap and in groups that carry neither. - * - * The current profile is checked first because a group can only be one of - * the two — MDK rejects a group that requires both proof profiles — and a - * current-profile group is the one whose authorization we must not skip. - */ - fun currentAdminIdentities(): Set = adminIdentitiesIn(groupContext.extensions) - - /** - * The admin set named by [extensions], preferring the current profile. - * - * Decodes ONLY the admin policy, never the whole component set. Authorization - * must not depend on the validity of components it does not read: a - * malformed group profile is a defect worth surfacing where the profile is - * used, but it must not make the group un-committable by taking the admin - * check down with it. - */ - private fun adminIdentitiesIn(extensions: List): Set { - val policyBytes = AppDataDictionary.fromExtensionsOrEmpty(extensions)[AdminPolicyV1.COMPONENT_ID] - if (policyBytes != null) return AdminPolicyV1.decode(policyBytes).adminHexKeys.toSet() - return MarmotGroupData - .fromExtensions(extensions) - ?.adminPubkeys - ?.toSet() - .orEmpty() - } - /** * Account identities holding at least one current member leaf, as hex. * - * Admin authority is per ACCOUNT, not per leaf: a multi-device account - * shares one admin entry across all of its leaves. + * A set of ACCOUNTS, not leaves: one account may hold several leaves (one + * per device), and every binding that asks this question means the account. */ fun currentMemberIdentities(): Set = (0 until tree.leafCount).mapNotNullTo(mutableSetOf()) { memberIdentityHex(it) } - /** True if the local member is an active admin. */ - fun isLocalAdmin(): Boolean = isLeafAdmin(myLeafIndex) - - /** - * True if the member at [leafIndex] is an ACTIVE admin: listed in the - * group's admin set and still holding a leaf. The leaf lookup satisfies - * the second half by construction. - */ - fun isLeafAdmin(leafIndex: Int): Boolean { - val id = memberIdentityHex(leafIndex) ?: return false - return id in currentAdminIdentities() - } - // --- State Persistence --- /** @@ -471,7 +431,7 @@ class MlsGroup private constructor( pskId: ByteArray, psk: ByteArray, ) { - pskStore[pskId.toHexKey()] = psk + pskStore[Hex.encode(pskId)] = psk } /** @@ -509,7 +469,7 @@ class MlsGroup private constructor( */ leafSignatureKeyPair: Ed25519KeyPair? = null, leafExtensions: List = emptyList(), - capabilities: Capabilities = marmotLeafCapabilities(), + capabilities: Capabilities = policy.defaultLeafCapabilities, keyPackageExtensions: List = emptyList(), /** The leaf's lifetime. Null uses the Marmot window (84 days, backdated an hour for clock skew). */ lifetime: Lifetime? = null, @@ -571,15 +531,14 @@ class MlsGroup private constructor( /** * Create a SelfRemove proposal. * - * Per MIP-01/MIP-03, members listed in `admin_pubkeys` MUST NOT issue a - * SelfRemove — they have to first publish a GroupContextExtensions proposal - * removing themselves from the admin list (self-demotion). This guard - * enforces that rule at the local sender. + * Gated by [MlsGroupPolicy.authorizeSelfRemove], because a binding may + * restrict who can leave unilaterally — Marmot makes an admin self-demote + * through a GroupContextExtensions proposal first. Catching it here rather + * than on arrival turns a commit every peer would refuse into a local + * error. */ fun proposeSelfRemove(): Proposal.SelfRemove { - check(!isLocalAdmin()) { - "Admin must self-demote via GroupContextExtensions before SelfRemove (MIP-01)" - } + policy.authorizeSelfRemove(view()) val proposal = Proposal.SelfRemove() pendingProposals.add(PendingProposal(proposal, myLeafIndex)) return proposal @@ -618,7 +577,7 @@ class MlsGroup private constructor( signingKey = newSigKp.privateKey, groupId = groupId, leafIndex = myLeafIndex, - capabilities = currentLeaf?.capabilities ?: marmotLeafCapabilities(), + capabilities = currentLeaf?.capabilities ?: policy.defaultLeafCapabilities, leafExtensions = currentLeaf?.extensions ?: emptyList(), ) @@ -710,25 +669,16 @@ class MlsGroup private constructor( fun commit(): CommitResult { val proposals = pendingProposals.toList() - // --- MIP-03 authorization gate ----------------------------------------- - // - // Non-admin senders may only issue one of two restricted commit shapes: - // (a) a single self-Update targeting their own leaf, or - // (b) one or more SelfRemove proposals, all by themselves (no mixing). - // - // Admins may commit any proposal type. - enforceAuthorizedProposalSet(proposals) - - // Reject commits that would leave the group without a usable admin - // (i.e. no remaining member appears in the post-commit admin list). - enforceNoAdminDepletion(proposals) + // The application's gate on who may commit what. RFC 9420 has none of + // its own, so a group with the default policy accepts any valid set. + policy.authorizeCommit(view(), proposals, myLeafIndex) // Capture the pre-commit exporter secret BEFORE any mutation. // Publishers of the outbound kind:445 MUST outer-encrypt with this // key (epoch N) so that other existing members at epoch N can decrypt // and process the commit. See CommitResult.preCommitExporterSecret. val preCommitExporterSecret = - exporterSecret("marmot", "group-event".encodeToByteArray(), 32) + policy.commitExporter?.let { exporterSecret(it.label, it.context, it.length) } ?: ByteArray(0) // Snapshot the pre-proposal extensions. GroupContextExtensions proposals // mutate `groupContext.extensions` the moment they're applied, but @@ -790,6 +740,7 @@ class MlsGroup private constructor( val leafIndex = applyProposalAdd(p) addedMembers.add(leafIndex to p.keyPackage) } + enforceRequiredCapabilities() // Generate new path secrets on the updated tree val leafSecret = MlsCryptoProvider.randomBytes(MlsCryptoProvider.HASH_OUTPUT_LENGTH) @@ -878,7 +829,7 @@ class MlsGroup private constructor( computeSenderParentHashes(myLeafIndex, preUpdateSiblingHashes) for (nodeIdx in filteredDp) { val existing = tree.getNode(nodeIdx) - if (existing is com.vitorpamplona.quartz.marmot.mls.tree.TreeNode.Parent) { + if (existing is com.vitorpamplona.quartz.mls.tree.TreeNode.Parent) { tree.setParent( nodeIdx, existing.parentNode.copy( @@ -923,7 +874,7 @@ class MlsGroup private constructor( groupId = groupId, leafIndex = myLeafIndex, parentHash = leafParentHash, - capabilities = previousLeaf?.capabilities ?: marmotLeafCapabilities(), + capabilities = previousLeaf?.capabilities ?: policy.defaultLeafCapabilities, leafExtensions = previousLeaf?.extensions ?: emptyList(), ) encryptionPrivateKey = newEncKp.privateKey @@ -953,11 +904,11 @@ class MlsGroup private constructor( val node = tree.getNode(resNode) ?: return@mapNotNull null val recipientPub = when (node) { - is com.vitorpamplona.quartz.marmot.mls.tree.TreeNode.Leaf -> { + is com.vitorpamplona.quartz.mls.tree.TreeNode.Leaf -> { node.leafNode.encryptionKey } - is com.vitorpamplona.quartz.marmot.mls.tree.TreeNode.Parent -> { + is com.vitorpamplona.quartz.mls.tree.TreeNode.Parent -> { node.parentNode.encryptionKey } } @@ -1128,6 +1079,21 @@ class MlsGroup private constructor( */ fun encrypt( plaintext: ByteArray, + /** + * MLS `authenticated_data`: authenticated but NOT encrypted. + * + * The AEAD covers it, so a recipient knows the sender wrote it and the + * delivery service cannot alter it — but the delivery service can read + * it, which is the whole trade. RFC 9420 §6 leaves the contents to the + * application. + * + * cordn puts the sender's account pubkey here and rejects any + * application message that arrives with this field empty, because that + * is what binds an unsigned envelope to an MLS sender. Marmot leaves it + * empty and carries the same binding elsewhere. Empty is the default + * because a binding that does not use the field should not be paying a + * metadata cost for it. + */ authenticatedData: ByteArray = ByteArray(0), ): ByteArray { // Trim sentKeys if it grows too large @@ -1785,10 +1751,9 @@ class MlsGroup private constructor( } // Resolve proposal references against our pending pool BEFORE - // applying anything, so MIP-03 authorization can run on a static - // snapshot of (proposal, original-sender-leaf) pairs and so the - // depletion guard can simulate the post-commit tree shape from the - // pre-commit state. + // applying anything, so the policy sees a static snapshot of + // (proposal, original-sender-leaf) pairs and can simulate the + // post-commit shape from pre-commit state. val resolvedPending = mutableListOf() for (proposalOrRef in commit.proposals) { when (proposalOrRef) { @@ -1822,17 +1787,16 @@ class MlsGroup private constructor( } } - // MIP-03 authorization & admin-depletion gates on inbound commits - // (mirror what `commit()` enforces locally — without these a peer - // could send us a non-admin GCE rename, a non-admin Remove, or a - // commit that empties `admin_pubkeys` and we'd silently apply it). + // The policy's gate on inbound commits, mirroring what `commit()` + // enforces locally. Without it a peer could send us anything its own + // copy of the rules would have refused and we would silently apply + // it — authorization has to run on both ends or it runs on neither. // External commits get a pass: the sender doesn't have a leaf yet, // so the admin lookup is moot, and an external joiner can't include // arbitrary proposals — only Add/Remove/PSK/ExternalInit per // RFC 9420 §12.4.3.2. if (!isExternalCommit) { - enforceAuthorizedProposalSet(resolvedPending, committerLeafIndex = senderLeafIndex) - enforceNoAdminDepletion(resolvedPending) + policy.authorizeCommit(view(), resolvedPending, senderLeafIndex) } // Apply the resolved proposals. Matches the committer's order: apply @@ -1874,6 +1838,7 @@ class MlsGroup private constructor( for ((add, _) in referenceAddSenders) { newLeavesInCommit.add(applyProposalAdd(add)) } + enforceRequiredCapabilities() // If the proposals just removed *us*, there is no path-decrypt to do // and no confirmation_tag to verify against our (now bogus) commit @@ -1946,7 +1911,7 @@ class MlsGroup private constructor( val (filteredDp, filteredCp) = tree.filteredDirectPath(senderLeafIndex) for (nodeIdx in filteredDp) { val existing = tree.getNode(nodeIdx) - if (existing is com.vitorpamplona.quartz.marmot.mls.tree.TreeNode.Parent) { + if (existing is com.vitorpamplona.quartz.mls.tree.TreeNode.Parent) { tree.setParent( nodeIdx, existing.parentNode.copy( @@ -2156,9 +2121,10 @@ class MlsGroup private constructor( /** * MLS-Exporter function for deriving application-specific keys. * - * Marmot uses: - * exporterSecret("marmot", "group-event".toByteArray(), 32) - * to derive the outer ChaCha20-Poly1305 key for GroupEvents. + * Marmot, for instance, derives the outer ChaCha20-Poly1305 key for its + * GroupEvents with label "marmot" and context "group-event" — see + * [MlsGroupPolicy.commitExporter], which is how the engine reaches it + * without naming any one binding. */ fun exporterSecret( label: String, @@ -2166,19 +2132,6 @@ class MlsGroup private constructor( length: Int, ): ByteArray = KeySchedule.mlsExporter(epochSecrets.exporterSecret, label, context, length) - /** - * `MLS-Exporter("marmot", "agent-text-stream-quic", 32)` — the secret every - * member of this epoch derives per-stream record keys from. Per-stream and - * per-record separation is entirely in the HKDF key context, so this one - * secret covers every stream in the epoch. - */ - fun agentTextStreamSecret(): ByteArray = - exporterSecret( - AgentTextStreamCrypto.EXPORTER_LABEL, - AgentTextStreamCrypto.EXPORTER_CONTEXT, - AgentTextStreamCrypto.SECRET_LENGTH, - ) - // --- External Join Support (RFC 9420 Section 8.3, 12.4.3.2) --- /** @@ -2289,8 +2242,8 @@ class MlsGroup private constructor( var pskSecret = zero for ((index, p) in pskProposals.withIndex()) { val pskValue = - pskStore[p.pskId.toHexKey()] - ?: throw IllegalStateException("PSK not found in store: ${p.pskId.toHexKey()}") + pskStore[Hex.encode(p.pskId)] + ?: throw IllegalStateException("PSK not found in store: ${Hex.encode(p.pskId)}") val pskExtracted = MlsCryptoProvider.hkdfExtract(zero, pskValue) val pskLabel = buildPskLabel(p, index, count) val pskInput = @@ -2395,7 +2348,7 @@ class MlsGroup private constructor( ): Pair, ByteArray> = computeSenderParentHashes(tree, senderLeafIndex, preUpdateSiblingHashes) private fun computeSenderParentHashes( - tree: com.vitorpamplona.quartz.marmot.mls.tree.RatchetTree, + tree: com.vitorpamplona.quartz.mls.tree.RatchetTree, senderLeafIndex: Int, preUpdateSiblingHashes: Map, ): Pair, ByteArray> { @@ -2426,7 +2379,7 @@ class MlsGroup private constructor( val parentIdx = filteredDp[i + 1] val parentLevel = filteredLevels[i + 1] val parentNode = tree.getNode(parentIdx) - if (parentNode !is com.vitorpamplona.quartz.marmot.mls.tree.TreeNode.Parent) { + if (parentNode !is com.vitorpamplona.quartz.mls.tree.TreeNode.Parent) { hashes[xIdx] = ByteArray(0) continue } @@ -2449,7 +2402,7 @@ class MlsGroup private constructor( val immediateParentLevel = filteredLevels.first() val immediateParent = tree.getNode(immediateParentIdx) val leafParentHash = - if (immediateParent is com.vitorpamplona.quartz.marmot.mls.tree.TreeNode.Parent) { + if (immediateParent is com.vitorpamplona.quartz.mls.tree.TreeNode.Parent) { val siblingTreeHash = preUpdateSiblingHashes[immediateParentLevel] ?: error("missing pre-update sibling tree hash at level $immediateParentLevel") @@ -2615,8 +2568,7 @@ class MlsGroup private constructor( fun isCommitAuthorized(pubMsg: PublicMessage): Boolean { val proposals = resolveCommitProposals(pubMsg) ?: return false return try { - enforceAuthorizedProposalSet(proposals, committerLeafIndex = pubMsg.sender.leafIndex) - enforceNoAdminDepletion(proposals) + policy.authorizeCommit(view(), proposals, pubMsg.sender.leafIndex) true } catch (_: Exception) { false @@ -2756,122 +2708,25 @@ class MlsGroup private constructor( } /** - * MIP-03 authorization gate. + * RFC 9420 §12.1.7: a commit is invalid if it leaves the group with a + * `required_capabilities` extension some member does not satisfy. * - * Once the group has at least one admin configured in `admin_pubkeys`, - * non-admin senders may only issue: - * - a single self-Update proposal, or - * - one-or-more SelfRemove proposals authored by the committer. + * Run AFTER every proposal in the commit has been applied, which is what + * makes the spec's parenthetical fall out for free: the tree already + * includes members added in this commit and excludes members removed by + * it, so iterating the current leaves is exactly the right set. * - * Admins may commit any proposal type. Before any admin is configured - * (group bootstrap) the check is relaxed, mirroring the bootstrap policy - * in [MlsGroupManager.updateGroupExtensions]. - * - * [committerLeafIndex] is the leaf that signed the commit — `myLeafIndex` - * for our own outbound commits, `pubMsg.sender.leafIndex` for inbound - * commits. The "self-only" rule is checked against the committer; when - * the committer is an admin the rule is skipped entirely so admin-folded - * inbound proposals (e.g. another member's `SelfRemove` referenced by - * an admin's GCE commit) are accepted. + * The failure this prevents is a split group. A GroupContextExtensions + * proposal that raises the bar above what a sitting member advertises is + * rejected by every peer that checks and accepted by every peer that does + * not, and the two halves diverge at the next epoch with nothing pointing + * at the cause. */ - internal fun enforceAuthorizedProposalSet( - proposals: List, - committerLeafIndex: Int = myLeafIndex, - ) { - if (proposals.isEmpty()) return - // Reads whichever profile the group is on: the admin-policy component - // (0x8003) for current-profile groups, `marmot_group_data` (0xF2EE) - // for legacy ones. An empty set means bootstrap — no admins named yet — - // and the gate stays open, mirroring MlsGroupManager.updateGroupExtensions. - val admins = currentAdminIdentities() - if (admins.isEmpty() || isLeafAdmin(committerLeafIndex)) return - - val allSelfRemove = - proposals.all { it.proposal is Proposal.SelfRemove && it.senderLeafIndex == committerLeafIndex } - if (allSelfRemove) return - - val singleSelfUpdate = - proposals.size == 1 && - proposals[0].proposal is Proposal.Update && - proposals[0].senderLeafIndex == committerLeafIndex - if (singleSelfUpdate) return - - throw IllegalStateException( - "MIP-03: non-admin members may only commit a single self-Update or SelfRemove-only " + - "proposals; got ${proposals.map { it.proposal::class.simpleName }} from leaf $committerLeafIndex", - ) - } - - /** - * Reject any commit that would leave the group without at least one member - * still listed in `admin_pubkeys` (MIP-03 admin depletion guard). - * - * We simulate the post-commit member set and the post-commit `admin_pubkeys` - * list, then require a non-empty intersection. The guard is only active - * once the group has a configured admin set — it does not kick in during - * bootstrap before any admin is named. - */ - internal fun enforceNoAdminDepletion(proposals: List) { - val currentAdmins = currentAdminIdentities() - if (currentAdmins.isEmpty()) return // Bootstrap: no admins yet, nothing to deplete. - - // Resolve the effective admin list after this commit. Three carriers can - // change it, and they are checked in the order the commit applies them: - // an AppDataUpdate on 0x8003 (current profile), then a - // GroupContextExtensions proposal replacing the whole extension list - // (either profile). AppDataUpdate is resolved last because - // `applyAppDataUpdateProposals` runs after the rest of the list. - val gce = - proposals - .asSequence() - .map { it.proposal } - .filterIsInstance() - .lastOrNull() - val extensionsAfterGce = gce?.extensions ?: groupContext.extensions - - val adminUpdate = - proposals - .asSequence() - .map { it.proposal } - .filterIsInstance() - .lastOrNull { it.componentId == AdminPolicyV1.COMPONENT_ID } - - val adminSet = - when (val operation = adminUpdate?.operation) { - is Proposal.AppDataUpdate.Operation.Update -> - AdminPolicyV1.decode(operation.data).adminHexKeys.toSet() - - // Removing the admin policy is never valid — it is the sole - // admin authority for the group's lifetime — so an empty set - // here trips the depletion check below, which is the outcome - // we want. - Proposal.AppDataUpdate.Operation.Remove -> emptySet() - - null -> adminIdentitiesIn(extensionsAfterGce) - } - check(adminSet.isNotEmpty()) { - "commit would leave the group with no admins (admin depletion)" - } - - // Compute which leaves remain after applying Removes/SelfRemoves. - val removedLeaves = mutableSetOf() - for (pending in proposals) { - when (val p = pending.proposal) { - is Proposal.Remove -> removedLeaves.add(p.removedLeafIndex) - is Proposal.SelfRemove -> removedLeaves.add(pending.senderLeafIndex) - else -> Unit - } - } - - val remainingAdminIdentities = mutableSetOf() + private fun enforceRequiredCapabilities() { + val required = findRequiredCapabilities(groupContext.extensions) ?: return for (i in 0 until tree.leafCount) { - if (i in removedLeaves) continue - val id = memberIdentityHex(i) ?: continue - if (id in adminSet) remainingAdminIdentities.add(id) - } - - check(remainingAdminIdentities.isNotEmpty()) { - "MIP-03: commit would leave the group without any admin members" + val leaf = tree.getLeaf(i) ?: continue + requireCapabilitiesMeetRequirements(leaf.capabilities, required, "Member leaf $i") } } @@ -2913,11 +2768,25 @@ class MlsGroup private constructor( } is Proposal.GroupContextExtensions -> { - // RFC 9420 §13.4: an extension in use by the group MUST be supported by all members. Types this - // implementation knows are accepted as before; any other type is accepted when every member's leaf - // advertises it in capabilities.extensions. + // RFC 9420 §12.1.7: a wholesale replacement, not a merge. Its + // own validity rule concerns `required_capabilities` and is + // checked in [enforceRequiredCapabilities] once every proposal + // in the commit has been applied -- the membership it must hold + // over is the post-commit one. + // + // §13.4 adds the rule enforced here: "an extension in use by + // the group MUST be supported by all members of the group". + // That is a statement about member capabilities, not about a + // list of types this implementation happens to recognise, so + // the escape hatch is the RFC's own default types (§7.2, which + // forbids listing those in capabilities at all) plus whatever + // the binding declares -- never "types we have heard of". for (ext in proposal.extensions) { - require(ext.extensionType in KNOWN_EXTENSION_TYPES || allMembersSupportExtension(ext.extensionType)) { + require( + ext.extensionType in DEFAULT_EXTENSION_TYPES || + ext.extensionType in policy.knownExtensionTypes || + allMembersSupportExtension(ext.extensionType), + ) { "Unsupported extension type: ${ext.extensionType}" } } @@ -3240,7 +3109,7 @@ class MlsGroup private constructor( * (without constructing an [MlsGroup] first). */ private fun computeExternalSenderParentHashes( - tree: com.vitorpamplona.quartz.marmot.mls.tree.RatchetTree, + tree: com.vitorpamplona.quartz.mls.tree.RatchetTree, senderLeafIndex: Int, preUpdateSiblingHashes: Map, ): Pair, ByteArray> { @@ -3253,7 +3122,7 @@ class MlsGroup private constructor( val xIdx = directPath[i] val parentIdx = directPath[i + 1] val parentNode = tree.getNode(parentIdx) - if (parentNode !is com.vitorpamplona.quartz.marmot.mls.tree.TreeNode.Parent) { + if (parentNode !is com.vitorpamplona.quartz.mls.tree.TreeNode.Parent) { hashes[xIdx] = ByteArray(0) continue } @@ -3269,7 +3138,7 @@ class MlsGroup private constructor( val immediateParentIdx = directPath.first() val immediateParent = tree.getNode(immediateParentIdx) val leafParentHash = - if (immediateParent is com.vitorpamplona.quartz.marmot.mls.tree.TreeNode.Parent) { + if (immediateParent is com.vitorpamplona.quartz.mls.tree.TreeNode.Parent) { val siblingTreeHash = preUpdateSiblingHashes[0] ?: error("missing pre-update sibling tree hash at leaf level") @@ -3301,19 +3170,42 @@ class MlsGroup private constructor( // (0x0002 is ratchet_tree — putting it here makes GroupContext // unreadable to OpenMLS/MDK, which type-validates extensions by // context.) - private const val REQUIRED_CAPABILITIES_EXTENSION_TYPE = 0x0003 + const val REQUIRED_CAPABILITIES_EXTENSION_TYPE = 0x0003 // RFC 9420 §13.3 IANA registry: 0x0004 is external_pub. // (0x0003 is required_capabilities — using it here makes // external-join GroupInfos unreadable to OpenMLS/MDK.) private const val EXTERNAL_PUB_EXTENSION_TYPE = 0x0004 - private const val EXTERNAL_SENDERS_EXTENSION_TYPE = 0x0004 + + // 0x0005, not 0x0004: the two sat on the same value here, which made + // the pair indistinguishable anywhere they were compared as a set. + private const val EXTERNAL_SENDERS_EXTENSION_TYPE = 0x0005 + + private const val APPLICATION_ID_EXTENSION_TYPE = 0x0001 + + /** + * The extension types RFC 9420 §7.2 calls "default". + * + * A capabilities field MUST NOT list them, so they can never satisfy + * the §13.4 all-members-support rule and have to be exempt from it + * instead. This is the whole exemption: a type being one the code + * happens to recognise is not a reason to skip the check, and a + * binding's own extension types come from [MlsGroupPolicy.knownExtensionTypes]. + */ + private val DEFAULT_EXTENSION_TYPES = + setOf( + APPLICATION_ID_EXTENSION_TYPE, + RATCHET_TREE_EXTENSION_TYPE, + REQUIRED_CAPABILITIES_EXTENSION_TYPE, + EXTERNAL_PUB_EXTENSION_TYPE, + EXTERNAL_SENDERS_EXTENSION_TYPE, + ) /** MLS self_remove proposal type (MIP-00 / MIP-03). */ - private const val SELF_REMOVE_PROPOSAL_TYPE = 0x000A + const val SELF_REMOVE_PROPOSAL_TYPE = 0x000A /** MLS extensions draft `app_data_update` proposal type. */ - private const val APP_DATA_UPDATE_PROPOSAL_TYPE = 0x0008 + const val APP_DATA_UPDATE_PROPOSAL_TYPE = 0x0008 /** How far back a fresh KeyPackage LeafNode's `not_before` is set. */ private const val LIFETIME_SKEW_SECONDS = 3_600L @@ -3325,50 +3217,6 @@ class MlsGroup private constructor( */ private const val LIFETIME_SPAN_SECONDS = 84L * 24 * 60 * 60 - /** Marmot Group Data Extension type (MIP-01). */ - private const val MARMOT_GROUP_DATA_EXTENSION_TYPE = 0xF2EE - - /** Known extension types that this implementation accepts. */ - private val KNOWN_EXTENSION_TYPES = - setOf( - RATCHET_TREE_EXTENSION_TYPE, - REQUIRED_CAPABILITIES_EXTENSION_TYPE, - EXTERNAL_PUB_EXTENSION_TYPE, - EXTERNAL_SENDERS_EXTENSION_TYPE, - MARMOT_GROUP_DATA_EXTENSION_TYPE, - // The current profile's carrier for all app-owned group state. - // A group can arrive at one either by being created with it or - // by a GroupContextExtensions proposal that installs it. - AppDataDictionary.EXTENSION_TYPE, - ) - - /** - * Build an MLS `required_capabilities` extension that marks Marmot's - * mandatory interop set as required for all members (RFC 9420 §7.2): - * extensions = [marmot_group_data (0xF2EE)] - * proposals = [self_remove (0x000A)] - * credentials = [Basic (0x0001)] - */ - private fun buildMarmotRequiredCapabilitiesExtension(): Extension { - val writer = TlsWriter() - // extensions: uint16 each - val exts = TlsWriter() - exts.putUint16(MARMOT_GROUP_DATA_EXTENSION_TYPE) - writer.putOpaqueVarInt(exts.toByteArray()) - // proposals: uint16 each - val props = TlsWriter() - props.putUint16(SELF_REMOVE_PROPOSAL_TYPE) - writer.putOpaqueVarInt(props.toByteArray()) - // credentials: uint16 each - val creds = TlsWriter() - creds.putUint16(Credential.CREDENTIAL_TYPE_BASIC) - writer.putOpaqueVarInt(creds.toByteArray()) - return Extension( - extensionType = REQUIRED_CAPABILITIES_EXTENSION_TYPE, - extensionData = writer.toByteArray(), - ) - } - /** * Parsed view of the RFC 9420 §7.2 `required_capabilities` extension. * @@ -3520,107 +3368,6 @@ class MlsGroup private constructor( return null } - /** - * Default MLS leaf Capabilities that advertise support for Marmot's - * required extensions and proposals so new members can join a group - * whose `required_capabilities` lists them. - */ - private fun marmotLeafCapabilities(): Capabilities = - Capabilities( - extensions = listOf(MARMOT_GROUP_DATA_EXTENSION_TYPE), - proposals = listOf(SELF_REMOVE_PROPOSAL_TYPE), - ) - - /** - * Enforce the `0x8006` component's `required_member_roles` mask over - * the joining tree. - * - * A group carrying the agent-text-stream component requires each named - * role as an MLS leaf capability (`0xF2D1` receive, `0xF2D2` send, - * `0xF2D4` fanout). Advertising the component id alone is not enough — - * that only says "understands the component"; the role capability says - * "can actually do this". - */ - private fun requireAgentTextStreamRoles( - extensions: List, - tree: RatchetTree, - myLeafIndex: Int, - ) { - val policy = - AppDataDictionary - .fromExtensionsOrEmpty(extensions)[AgentTextStreamQuicPolicyV1.COMPONENT_ID] - ?.let { AgentTextStreamQuicPolicyV1.decode(it) } ?: return - val required = policy.requiredRoleCapabilities() - if (required.isEmpty()) return - - val myLeaf = tree.getLeaf(myLeafIndex) - requireNotNull(myLeaf) { "Joiner's leaf is blank after tree reconstruction" } - val missing = required.filterNot { myLeaf.capabilities.extensions.contains(it) } - require(missing.isEmpty()) { - "Joiner does not advertise agent text stream roles this group requires: " + - missing.joinToString { AppComponentIds.toHex(it) } - } - } - - /** - * Leaf capabilities for the current profile. - * - * RFC 9420 §7.2 forbids advertising DEFAULT extension types, so only - * the draft `app_data_dictionary` extension and the `app_data_update` - * proposal appear — `required_capabilities` support is implicit. - * - * The legacy `0xF2EE` group-data extension is advertised alongside - * them, and that is not a hedge. A capability says "this client can - * handle it", not "this group uses it", and a group that REQUIRES - * `0xF2EE` refuses to add a leaf that does not advertise it. Without - * this line a current-profile KeyPackage would be un-addable to every - * legacy group that already exists — the exact mirror of the interop - * failure the current profile was adopted to fix. - * - * `0xF2D1` is the agent-text-stream RECEIVE role, for the same reason: - * a group carrying component `0x8006` with `required_member_roles` - * naming `receive` refuses a leaf that does not advertise it. The - * reference client puts exactly that policy into EVERY group it - * creates, so without this line an Amethyst KeyPackage cannot be - * invited into one at all. - * - * We stop at receive. `send` and `fanout` are not here because we do - * not originate previews from the app, and a capability is a standing - * promise rather than a hedge. - */ - fun currentProfileLeafCapabilities(): Capabilities = - Capabilities( - extensions = - listOf( - AppDataDictionary.EXTENSION_TYPE, - MarmotGroupData.EXTENSION_ID_INT, - AgentTextStreamRoles.RECEIVE_CAPABILITY, - ), - proposals = listOf(APP_DATA_UPDATE_PROPOSAL_TYPE, SELF_REMOVE_PROPOSAL_TYPE), - ) - - /** - * `required_capabilities` for a new current-profile group: extension - * `0x0006` and proposal `0x0008`. - * - * The Marmot components a group requires are negotiated in the - * upstream `app_components` component INSIDE the dictionary, not here — - * MLS `RequiredCapabilities` carries only MLS-level primitives. - */ - fun buildCurrentProfileRequiredCapabilitiesExtension(): Extension { - val writer = TlsWriter() - val exts = TlsWriter() - exts.putUint16(AppDataDictionary.EXTENSION_TYPE) - writer.putOpaqueVarInt(exts.toByteArray()) - val props = TlsWriter() - props.putUint16(APP_DATA_UPDATE_PROPOSAL_TYPE) - writer.putOpaqueVarInt(props.toByteArray()) - val creds = TlsWriter() - creds.putUint16(Credential.CREDENTIAL_TYPE_BASIC) - writer.putOpaqueVarInt(creds.toByteArray()) - return Extension(REQUIRED_CAPABILITIES_EXTENSION_TYPE, writer.toByteArray()) - } - /** * Create a new MLS group with a single member (the creator). */ @@ -3635,14 +3382,32 @@ class MlsGroup private constructor( * added later by a proposal. */ leafExtensions: List = emptyList(), - capabilities: Capabilities = marmotLeafCapabilities(), /** - * The `required_capabilities` extension for epoch 0. Defaults to - * the MIP-era set; a current-profile group passes - * [buildCurrentProfileRequiredCapabilitiesExtension]. + * The application's rules for this group. Also supplies the + * defaults below, so one argument selects a whole profile. + */ + policy: MlsGroupPolicy = MlsGroupPolicy.Permissive, + capabilities: Capabilities = policy.defaultLeafCapabilities, + /** + * The `required_capabilities` extension for epoch 0. Null means + * the group carries none, which is the RFC 9420 default; a Marmot + * current-profile group passes + * [com.vitorpamplona.quartz.marmot.groups.MarmotCapabilities.currentProfileRequired]. + */ + requiredCapabilities: Extension? = policy.defaultRequiredCapabilities, + /** + * The RFC 9420 `group_id` for the new group. Null generates a + * random 32-byte one, which is the right default: §8.1 only + * requires it to be unique, and a random id tells a receiver + * nothing. + * + * A binding may need to choose it. cordn's reference client sets + * `group_id = utf8(gid)` so that a joiner can recover the delivery + * id from a Welcome, which otherwise carries no way to learn it — + * see `CordnGroupManager`. Anything a binding puts here is visible + * to whoever handles the ciphertext, so it must carry nothing the + * group would not publish. */ - requiredCapabilities: Extension? = buildMarmotRequiredCapabilitiesExtension(), - /** The group's MLS `group_id`. Null picks 32 random bytes. */ groupId: ByteArray? = null, ): MlsGroup { val sigKp = @@ -3652,7 +3417,7 @@ class MlsGroup private constructor( } ?: Ed25519.generateKeyPair() val encKp = X25519.generateKeyPair() - val groupId = groupId ?: MlsCryptoProvider.randomBytes(32) + val newGroupId = groupId ?: MlsCryptoProvider.randomBytes(32) val leafNode = buildLeafNode( @@ -3669,14 +3434,14 @@ class MlsGroup private constructor( tree.setLeaf(0, leafNode) val treeHash = tree.treeHash() - // Start with required_capabilities + whatever the caller wants to - // bake into epoch 0 (e.g. the MIP-01 MarmotGroupData extension so - // new peers who join later can see the group name without first - // decrypting a pre-membership bootstrap commit — see MIP-03). + // Start with required_capabilities, when the profile has any, plus + // whatever the caller wants baked into epoch 0 — typically the + // binding's own group-metadata extension, so a later joiner can read + // it without first decrypting a pre-membership bootstrap commit. val baseExtensions = listOfNotNull(requiredCapabilities) val groupContext = GroupContext( - groupId = groupId, + groupId = newGroupId, epoch = 0, treeHash = treeHash, confirmedTranscriptHash = ByteArray(0), @@ -3702,6 +3467,7 @@ class MlsGroup private constructor( signingPrivateKey = sigKp.privateKey, encryptionPrivateKey = encKp.privateKey, interimTranscriptHash = ByteArray(0), + policy = policy, ) } @@ -3714,6 +3480,7 @@ class MlsGroup private constructor( fun processWelcome( welcomeBytes: ByteArray, bundle: KeyPackageBundle, + policy: MlsGroupPolicy = MlsGroupPolicy.Permissive, ): MlsGroup { val mlsMsg = MlsMessage.decodeTls(TlsReader(welcomeBytes)) require(mlsMsg.wireFormat == WireFormat.WELCOME) { "Expected Welcome message" } @@ -3854,7 +3621,15 @@ class MlsGroup private constructor( // must advertise. MLS cannot enforce it, so a joiner that skipped // this check would join a group it can never satisfy and have // every one of its commits refused by peers that do check. - requireAgentTextStreamRoles(groupContext.extensions, tree, myLeafIndex) + policy.validateJoin( + GroupView( + extensions = groupContext.extensions, + leafCount = tree.leafCount, + myLeafIndex = myLeafIndex, + identityAt = { leaf -> (tree.getLeaf(leaf)?.credential as? Credential.Basic)?.identity?.let { Hex.encode(it) } }, + capabilitiesAt = { tree.getLeaf(it)?.capabilities }, + ), + ) // Derive epoch secrets directly from memberSecret (RFC 9420 Section 8.3) // For Welcome, epoch_secret = ExpandWithLabel(member_secret, "epoch", GroupContext, Nh) @@ -3930,6 +3705,7 @@ class MlsGroup private constructor( signingPrivateKey = bundle.signaturePrivateKey, encryptionPrivateKey = bundle.encryptionPrivateKey, interimTranscriptHash = interimTranscriptHash, + policy = policy, ) groupSecrets.pathSecret?.let { pathSecret -> val ancestorIdx = joined.directPathIndexOfAncestorWith(groupInfo.signer) @@ -3963,7 +3739,8 @@ class MlsGroup private constructor( groupInfoBytes: ByteArray, identity: ByteArray, signingKey: ByteArray? = null, - capabilities: Capabilities = marmotLeafCapabilities(), + policy: MlsGroupPolicy = MlsGroupPolicy.Permissive, + capabilities: Capabilities = policy.defaultLeafCapabilities, leafExtensions: List = emptyList(), ): ExternalJoinResult { val groupInfo = GroupInfo.decodeTls(TlsReader(groupInfoBytes)) @@ -4073,7 +3850,7 @@ class MlsGroup private constructor( computeExternalSenderParentHashes(tree, myLeafIndex, preUpdateSiblingHashes) for (nodeIdx in BinaryTree.directPath(myLeafIndex, tree.leafCount)) { val existing = tree.getNode(nodeIdx) - if (existing is com.vitorpamplona.quartz.marmot.mls.tree.TreeNode.Parent) { + if (existing is com.vitorpamplona.quartz.mls.tree.TreeNode.Parent) { tree.setParent( nodeIdx, existing.parentNode.copy( @@ -4116,11 +3893,11 @@ class MlsGroup private constructor( val node = tree.getNode(resNode) ?: return@mapNotNull null val recipientPub = when (node) { - is com.vitorpamplona.quartz.marmot.mls.tree.TreeNode.Leaf -> { + is com.vitorpamplona.quartz.mls.tree.TreeNode.Leaf -> { node.leafNode.encryptionKey } - is com.vitorpamplona.quartz.marmot.mls.tree.TreeNode.Parent -> { + is com.vitorpamplona.quartz.mls.tree.TreeNode.Parent -> { node.parentNode.encryptionKey } } @@ -4193,6 +3970,7 @@ class MlsGroup private constructor( signingPrivateKey = sigKp.privateKey, encryptionPrivateKey = encKp.privateKey, interimTranscriptHash = interimTranscriptHash, + policy = policy, ) // Wrap the commit in a PublicMessage envelope so existing members @@ -4239,7 +4017,10 @@ class MlsGroup private constructor( * or senders we never decrypted) simply re-derive from generation 0 on * first use — safe, because those messages were already processed. */ - fun restore(state: MlsGroupState): MlsGroup { + fun restore( + state: MlsGroupState, + policy: MlsGroupPolicy = MlsGroupPolicy.Permissive, + ): MlsGroup { val tree = RatchetTree.decodeTls(TlsReader(state.treeBytes)) val secretTree = SecretTree(state.encryptionSecret, tree.leafCount) secretTree.importSenderStates(state.senderRatchetStates) @@ -4256,6 +4037,7 @@ class MlsGroup private constructor( interimTranscriptHash = state.interimTranscriptHash, pathPrivateKeys = state.pathPrivateKeys.toMutableMap(), pendingProposals = state.pendingProposals.toMutableList(), + policy = policy, ) } @@ -4271,7 +4053,7 @@ class MlsGroup private constructor( groupId: ByteArray? = null, leafIndex: Int? = null, parentHash: ByteArray? = null, - capabilities: Capabilities = marmotLeafCapabilities(), + capabilities: Capabilities, leafExtensions: List = emptyList(), lifetime: Lifetime? = null, ): LeafNode { @@ -4366,12 +4148,10 @@ class MlsGroup private constructor( * return value is the epoch this message must be outer-encrypted under. */ fun buildSelfRemoveProposalMessage(): Pair { - check(!isLocalAdmin()) { - "Admin must self-demote via GroupContextExtensions before SelfRemove (MIP-01)" - } + policy.authorizeSelfRemove(view()) val preCommitExporterSecret = - exporterSecret("marmot", "group-event".encodeToByteArray(), 32) + policy.commitExporter?.let { exporterSecret(it.label, it.context, it.length) } ?: ByteArray(0) val proposal = Proposal.SelfRemove() val proposalBytes = proposal.toTlsBytes() @@ -4585,7 +4365,14 @@ data class DecryptedMessage( val contentType: ContentType, val content: ByteArray, val epoch: Long, - /** The message's `authenticated_data` (RFC 9420 §6.3.2), verified by the AEAD. */ + /** + * MLS `authenticated_data` as the sender wrote it — authenticated by the + * AEAD, but readable by anyone who carried the message. + * + * Empty when the sender set none. A binding that puts meaning here (cordn + * binds the sender's account pubkey) must treat empty as a rejection rather + * than a default, or an attacker simply omits the field. + */ val authenticatedData: ByteArray = ByteArray(0), ) { override fun equals(other: Any?): Boolean { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/group/MlsGroupPolicy.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/group/MlsGroupPolicy.kt new file mode 100644 index 0000000000..33ea399729 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/group/MlsGroupPolicy.kt @@ -0,0 +1,180 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.mls.group + +import com.vitorpamplona.quartz.mls.messages.CommitResult +import com.vitorpamplona.quartz.mls.tree.Capabilities +import com.vitorpamplona.quartz.mls.tree.Extension + +/** + * The application's rules about who in a group may do what. + * + * RFC 9420 says who may *send* a proposal and never who may *commit* one: + * beyond the protocol's own validity checks, any member may commit anything. + * Real deployments need more than that — Marmot's MIP-03 names a set of admin + * accounts and allows everyone else only a self-Update or a SelfRemove — but + * that is the application's rule, not the protocol's, and an engine with one + * binding's rules compiled into it cannot host a second binding. + * + * Every hook defaults to permissive, and that direction is deliberate. A + * policy defaulting to closed would make the engine unusable without one and + * would tempt callers into a "policy that allows everything" anyway; defaulting + * to open puts each restriction in the binding that actually documents it. + * + * The cost of that choice is that a group built or restored without its policy + * silently drops the binding's rules. A policy is behaviour, not state, so it + * is **not** carried in [MlsGroupState] — whatever restores a group has to + * supply the same policy it was created with. For Marmot that is + * `MlsGroupManager`, which is the only thing that restores a group in order to + * commit with it. + */ +interface MlsGroupPolicy { + /** + * Rejects, by throwing, a commit the application does not allow. + * + * Called for both directions — before building a local commit and before + * applying an inbound one — with [committerLeafIndex] identifying whose + * commit it is. Returning normally means "allowed"; the engine's own RFC + * 9420 validation runs regardless and is not something a policy can waive. + */ + fun authorizeCommit( + group: GroupView, + proposals: List, + committerLeafIndex: Int, + ) = Unit + + /** + * Rejects, by throwing, a SelfRemove the local member is not allowed to + * issue. + * + * Separate from [authorizeCommit] because it gates a *proposal* at its + * sender rather than a commit: Marmot requires an admin to first + * self-demote through a GroupContextExtensions proposal, and catching that + * locally is the difference between a clear error here and a commit every + * peer silently refuses. + */ + fun authorizeSelfRemove(group: GroupView) = Unit + + /** + * Rejects, by throwing, a group this client should not finish joining. + * + * Runs once the Welcome has been processed far enough to see the group's + * extensions and tree, so a policy can enforce requirements MLS itself + * cannot carry — a component that demands leaf capabilities outside + * `required_capabilities`, for instance. Failing here beats joining a group + * whose every commit peers would reject. + */ + fun validateJoin(group: GroupView) = Unit + + /** + * Leaf capabilities a new leaf advertises when the caller names none. + * + * Empty by default: RFC 9420 §7.2 forbids advertising the DEFAULT + * extension and proposal types, so a group that requires nothing beyond + * them needs nothing here. + */ + val defaultLeafCapabilities: Capabilities get() = Capabilities() + + /** + * The epoch-0 `required_capabilities` extension when the caller names none. + * + * Null means the group carries no such extension at all, which is the RFC + * 9420 default — requirements only ever restrict which leaves may join, so + * a binding that needs one says so. + */ + val defaultRequiredCapabilities: Extension? get() = null + + /** + * How this binding derives the pre-commit exporter secret a [CommitResult] + * carries, or null if it seals nothing outside MLS. + * + * A binding that wraps MLS messages in its own encryption needs a key both + * the committer and the members still at epoch N can derive, and RFC 9420 + * gives it one through `MLS-Exporter` — but the label is the application's, + * and `"marmot"` was hardcoded here. Null yields the empty secret that + * [CommitResult] already defaults to. + */ + val commitExporter: MlsExporterLabel? get() = null + + /** + * Extension types this binding's own members are known to support, even + * when an older member's leaf does not advertise them. + * + * RFC 9420 §13.4 makes every GroupContext extension mandatory for every + * member, and the engine enforces that from leaf capabilities. A binding + * whose own group-metadata extension predates it advertising that type has + * groups in the wild that would fail the check; listing the type here is + * how such a binding keeps them working, and it is the binding's statement + * about its own members - not a licence to skip the rule for types nobody + * declared. + */ + val knownExtensionTypes: Set get() = emptySet() + + companion object { + /** RFC 9420 exactly as written: any member may commit anything valid. */ + val Permissive: MlsGroupPolicy = object : MlsGroupPolicy {} + } +} + +/** + * The read-only slice of a group that a policy decision may look at. + * + * A projection rather than the [MlsGroup] itself. A policy handed the group + * could commit, rotate keys, or mutate epoch state from inside the very check + * meant to gate those things; passing only what a decision needs makes that + * impossible to write by accident. The accessors are functions rather than + * materialised collections so that a policy which inspects one leaf does not + * pay for walking the whole tree. + */ +class GroupView( + /** + * GroupContext extensions as they stand *before* the commit under + * consideration applies. A commit that replaces them carries the + * replacement in its own GroupContextExtensions proposal, which the policy + * reads from the proposal list. + */ + val extensions: List, + /** Leaf count of the ratchet tree, counting blank leaves. */ + val leafCount: Int, + /** The local member's leaf index. */ + val myLeafIndex: Int, + private val identityAt: (Int) -> String?, + private val capabilitiesAt: (Int) -> Capabilities?, +) { + /** Lowercase hex of the BasicCredential identity at [leafIndex], or null if blank. */ + fun memberIdentityHex(leafIndex: Int): String? = identityAt(leafIndex) + + /** Capabilities advertised by the leaf at [leafIndex], or null if blank. */ + fun leafCapabilities(leafIndex: Int): Capabilities? = capabilitiesAt(leafIndex) +} + +/** + * The three inputs to `MLS-Exporter` (RFC 9420 §8.5) that identify one + * application's key derivation. + * + * Not a data class: [context] is a ByteArray, whose `equals` is identity, so + * generated equality would quietly be wrong. + */ +class MlsExporterLabel( + val label: String, + val context: ByteArray, + val length: Int, +) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroupState.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/group/MlsGroupState.kt similarity index 97% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroupState.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/group/MlsGroupState.kt index a27fdce2d3..4b4907cc2c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroupState.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/group/MlsGroupState.kt @@ -18,14 +18,14 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.group +package com.vitorpamplona.quartz.mls.group -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter -import com.vitorpamplona.quartz.marmot.mls.messages.GroupContext -import com.vitorpamplona.quartz.marmot.mls.messages.Proposal -import com.vitorpamplona.quartz.marmot.mls.schedule.EpochSecrets -import com.vitorpamplona.quartz.marmot.mls.schedule.SenderRatchetState +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.codec.TlsWriter +import com.vitorpamplona.quartz.mls.messages.GroupContext +import com.vitorpamplona.quartz.mls.messages.Proposal +import com.vitorpamplona.quartz.mls.schedule.EpochSecrets +import com.vitorpamplona.quartz.mls.schedule.SenderRatchetState import com.vitorpamplona.quartz.utils.sha256.sha256 /** @@ -47,7 +47,7 @@ import com.vitorpamplona.quartz.utils.sha256.sha256 * [senderRatchetStates] carries each sender's live SecretTree ratchet position * (RFC 9420 §9). Preserving it is what stops the restored local member from * re-emitting an already-used generation within the same epoch — see - * [com.vitorpamplona.quartz.marmot.mls.schedule.SecretTree.exportSenderStates]. + * [com.vitorpamplona.quartz.mls.schedule.SecretTree.exportSenderStates]. * It is optional (empty for STATE_VERSION 1 blobs) so older persisted state * still decodes. */ diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/messages/Commit.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/messages/Commit.kt similarity index 88% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/messages/Commit.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/messages/Commit.kt index 9931ec2362..54e4c6400a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/messages/Commit.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/messages/Commit.kt @@ -18,13 +18,13 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.messages +package com.vitorpamplona.quartz.mls.messages -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.codec.TlsSerializable -import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter -import com.vitorpamplona.quartz.marmot.mls.tree.LeafNode -import com.vitorpamplona.quartz.marmot.mls.tree.UpdatePathNode +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.codec.TlsSerializable +import com.vitorpamplona.quartz.mls.codec.TlsWriter +import com.vitorpamplona.quartz.mls.tree.LeafNode +import com.vitorpamplona.quartz.mls.tree.UpdatePathNode /** * MLS Commit (RFC 9420 Section 12.4). @@ -93,7 +93,7 @@ data class UpdatePath( * Result of creating a Commit: the MLS messages to distribute. * * [commitBytes] is the raw TLS-encoded [Commit] struct (RFC 9420 §12.4), useful - * for unit tests and the [com.vitorpamplona.quartz.marmot.mls.group.MlsGroup.processCommit] + * for unit tests and the [com.vitorpamplona.quartz.mls.group.MlsGroup.processCommit] * entry point. For on-the-wire distribution, callers MUST publish * [framedCommitBytes] (the MlsMessage(PublicMessage(FramedContent(commit))) envelope) * so that receivers can parse the sender's leaf index and confirmation tag. @@ -130,17 +130,17 @@ data class CommitResult( } /** - * Result of [com.vitorpamplona.quartz.marmot.mls.group.MlsGroup.externalJoin]. + * Result of [com.vitorpamplona.quartz.mls.group.MlsGroup.externalJoin]. * * [commitBytes] is the raw TLS-encoded [Commit] struct. For on-the-wire * distribution to existing group members, callers MUST publish [framedCommitBytes] * — a `MlsMessage(PublicMessage(FramedContent(commit)))` envelope with sender * `new_member_commit` (RFC 9420 §12.4.3) — so that receivers can parse the * confirmation_tag and process the commit via - * [com.vitorpamplona.quartz.marmot.mls.group.MlsGroup.processFramedCommit]. + * [com.vitorpamplona.quartz.mls.group.MlsGroup.processFramedCommit]. */ data class ExternalJoinResult( - val group: com.vitorpamplona.quartz.marmot.mls.group.MlsGroup, + val group: com.vitorpamplona.quartz.mls.group.MlsGroup, val commitBytes: ByteArray, val framedCommitBytes: ByteArray, ) { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/messages/KeyPackageBundleCodec.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/messages/KeyPackageBundleCodec.kt new file mode 100644 index 0000000000..91bee57cb0 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/messages/KeyPackageBundleCodec.kt @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.mls.messages + +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.codec.TlsWriter + +/** + * Persisting a [KeyPackageBundle] — the published KeyPackage plus the three + * private keys only its owner holds. + * + * A published KeyPackage is useless to its publisher without these: the Welcome + * that admits them is encrypted to the init key, and the leaf they land on is + * signed with the signature key. Lose the bundle and the invitation cannot be + * opened; the group has to re-add them from a fresh one. + * + * **Whatever stores the output must encrypt it at rest.** This is key material, + * and the format makes no attempt to protect it — that is the storage layer's + * job, and saying so here is the only warning it gets. + * + * Binding-agnostic on purpose. Marmot serialises bundles inside its own + * rotation snapshot, which also carries rotation state it alone has; cordn has + * no rotation state and no KeyPackage event kind at all (`spec/00.md` §4.2), so + * it needs the bundle by itself. Neither binding is the right home for a codec + * over a plain RFC 9420 structure, so it lives here with the structure. + */ +object KeyPackageBundleCodec { + /** + * Bumped only for a breaking layout change. + * + * [decode] refuses anything it does not know rather than guessing, and + * refusing is the right failure: a misread bundle yields key material that + * is silently wrong, which surfaces much later as a Welcome that will not + * open. + */ + const val VERSION = 1 + + fun encode(bundle: KeyPackageBundle): ByteArray { + val writer = TlsWriter() + writer.putUint16(VERSION) + writer.putOpaque4(bundle.keyPackage.toTlsBytes()) + writer.putOpaque2(bundle.initPrivateKey) + writer.putOpaque2(bundle.encryptionPrivateKey) + writer.putOpaque2(bundle.signaturePrivateKey) + return writer.toByteArray() + } + + fun decode(bytes: ByteArray): KeyPackageBundle { + val reader = TlsReader(bytes) + val version = reader.readUint16() + require(version == VERSION) { "unknown KeyPackageBundle layout version $version" } + return KeyPackageBundle( + keyPackage = MlsKeyPackage.decodeTls(TlsReader(reader.readOpaque4())), + initPrivateKey = reader.readOpaque2(), + encryptionPrivateKey = reader.readOpaque2(), + signaturePrivateKey = reader.readOpaque2(), + ) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/messages/MlsKeyPackage.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/messages/MlsKeyPackage.kt similarity index 91% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/messages/MlsKeyPackage.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/messages/MlsKeyPackage.kt index a2cd4a3422..ee76dd9751 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/messages/MlsKeyPackage.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/messages/MlsKeyPackage.kt @@ -18,16 +18,16 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.messages +package com.vitorpamplona.quartz.mls.messages -import com.vitorpamplona.quartz.marmot.appComponents.AppComponentIds -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.codec.TlsSerializable -import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter -import com.vitorpamplona.quartz.marmot.mls.components.AppDataDictionary -import com.vitorpamplona.quartz.marmot.mls.crypto.MlsCryptoProvider -import com.vitorpamplona.quartz.marmot.mls.tree.Extension -import com.vitorpamplona.quartz.marmot.mls.tree.LeafNode +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.codec.TlsSerializable +import com.vitorpamplona.quartz.mls.codec.TlsWriter +import com.vitorpamplona.quartz.mls.components.AppDataDictionary +import com.vitorpamplona.quartz.mls.components.ComponentsList +import com.vitorpamplona.quartz.mls.crypto.MlsCryptoProvider +import com.vitorpamplona.quartz.mls.tree.Extension +import com.vitorpamplona.quartz.mls.tree.LeafNode /** * MLS KeyPackage (RFC 9420 Section 10). @@ -93,7 +93,7 @@ data class MlsKeyPackage( */ fun isLastResort(): Boolean = extensions.any { it.extensionType == LAST_RESORT_EXTENSION_TYPE } || - AppDataDictionary.fromExtensionsOrEmpty(extensions).contains(AppComponentIds.LAST_RESORT_KEY_PACKAGE) + AppDataDictionary.fromExtensionsOrEmpty(extensions).contains(ComponentsList.LAST_RESORT_KEY_PACKAGE_ID) /** * Encode the TBS (to-be-signed) portion for signature verification. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/messages/Proposal.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/messages/Proposal.kt similarity index 97% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/messages/Proposal.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/messages/Proposal.kt index afd21f4161..e87c3a97ba 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/messages/Proposal.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/messages/Proposal.kt @@ -18,13 +18,13 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.messages +package com.vitorpamplona.quartz.mls.messages -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.codec.TlsSerializable -import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter -import com.vitorpamplona.quartz.marmot.mls.tree.Extension -import com.vitorpamplona.quartz.marmot.mls.tree.LeafNode +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.codec.TlsSerializable +import com.vitorpamplona.quartz.mls.codec.TlsWriter +import com.vitorpamplona.quartz.mls.tree.Extension +import com.vitorpamplona.quartz.mls.tree.LeafNode /** * MLS Proposal types (RFC 9420 Section 12). diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/messages/Welcome.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/messages/Welcome.kt similarity index 95% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/messages/Welcome.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/messages/Welcome.kt index a237669f95..bbaaf38aaf 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/messages/Welcome.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/messages/Welcome.kt @@ -18,14 +18,14 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.messages +package com.vitorpamplona.quartz.mls.messages -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.codec.TlsSerializable -import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter -import com.vitorpamplona.quartz.marmot.mls.crypto.HpkeCiphertext -import com.vitorpamplona.quartz.marmot.mls.crypto.MlsCryptoProvider -import com.vitorpamplona.quartz.marmot.mls.tree.Extension +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.codec.TlsSerializable +import com.vitorpamplona.quartz.mls.codec.TlsWriter +import com.vitorpamplona.quartz.mls.crypto.HpkeCiphertext +import com.vitorpamplona.quartz.mls.crypto.MlsCryptoProvider +import com.vitorpamplona.quartz.mls.tree.Extension /** * MLS Welcome message (RFC 9420 Section 12.4.3.1). diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/schedule/KeySchedule.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/schedule/KeySchedule.kt similarity index 98% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/schedule/KeySchedule.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/schedule/KeySchedule.kt index 38b0712c80..046a36a7c2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/schedule/KeySchedule.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/schedule/KeySchedule.kt @@ -18,9 +18,9 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.schedule +package com.vitorpamplona.quartz.mls.schedule -import com.vitorpamplona.quartz.marmot.mls.crypto.MlsCryptoProvider +import com.vitorpamplona.quartz.mls.crypto.MlsCryptoProvider /** * MLS Key Schedule (RFC 9420 Section 8). diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/schedule/SecretTree.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/schedule/SecretTree.kt similarity index 99% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/schedule/SecretTree.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/schedule/SecretTree.kt index 6e33b96d40..7ce790586a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/schedule/SecretTree.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/schedule/SecretTree.kt @@ -18,10 +18,10 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.schedule +package com.vitorpamplona.quartz.mls.schedule -import com.vitorpamplona.quartz.marmot.mls.crypto.MlsCryptoProvider -import com.vitorpamplona.quartz.marmot.mls.tree.BinaryTree +import com.vitorpamplona.quartz.mls.crypto.MlsCryptoProvider +import com.vitorpamplona.quartz.mls.tree.BinaryTree /** * MLS Secret Tree (RFC 9420 Section 9). diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/tree/BinaryTree.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/tree/BinaryTree.kt similarity index 99% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/tree/BinaryTree.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/tree/BinaryTree.kt index a95ce22fef..e8cf80a520 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/tree/BinaryTree.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/tree/BinaryTree.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.tree +package com.vitorpamplona.quartz.mls.tree /** * Left-balanced binary tree index arithmetic for MLS ratchet trees (RFC 9420 Section 7.1). diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/tree/LeafNode.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/tree/LeafNode.kt similarity index 98% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/tree/LeafNode.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/tree/LeafNode.kt index 6ae1597895..4acc2b9cb8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/tree/LeafNode.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/tree/LeafNode.kt @@ -18,11 +18,11 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.tree +package com.vitorpamplona.quartz.mls.tree -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.codec.TlsSerializable -import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.codec.TlsSerializable +import com.vitorpamplona.quartz.mls.codec.TlsWriter /** * MLS Credential (RFC 9420 Section 5.3). diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/tree/ParentNode.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/tree/ParentNode.kt similarity index 93% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/tree/ParentNode.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/tree/ParentNode.kt index 868f1dd9fc..336cfea3fd 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/tree/ParentNode.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/tree/ParentNode.kt @@ -18,11 +18,11 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.tree +package com.vitorpamplona.quartz.mls.tree -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.codec.TlsSerializable -import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.codec.TlsSerializable +import com.vitorpamplona.quartz.mls.codec.TlsWriter /** * MLS ParentNode (RFC 9420 Section 7.1). diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/tree/RatchetTree.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/tree/RatchetTree.kt similarity index 97% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/tree/RatchetTree.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/tree/RatchetTree.kt index 91994ca1cc..bb9606b5bc 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/tree/RatchetTree.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/tree/RatchetTree.kt @@ -18,12 +18,12 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.tree +package com.vitorpamplona.quartz.mls.tree -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.codec.TlsSerializable -import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter -import com.vitorpamplona.quartz.marmot.mls.crypto.MlsCryptoProvider +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.codec.TlsSerializable +import com.vitorpamplona.quartz.mls.codec.TlsWriter +import com.vitorpamplona.quartz.mls.crypto.MlsCryptoProvider /** * MLS Ratchet Tree (RFC 9420 Section 7). @@ -471,7 +471,7 @@ class RatchetTree( // reject the UpdatePath with `UpdatePathError(PathMismatch)`. val nodeSecret = MlsCryptoProvider.deriveSecret(currentSecret, "node") val kp = - com.vitorpamplona.quartz.marmot.mls.crypto.Hpke + com.vitorpamplona.quartz.mls.crypto.Hpke .deriveKeyPair(nodeSecret) results.add(PathSecretAndKey(currentSecret, kp.privateKey, kp.publicKey)) @@ -620,7 +620,7 @@ sealed class TreeNode : TlsSerializable { /** UpdatePath node: public key + encrypted path secrets for copath nodes */ data class UpdatePathNode( val encryptionKey: ByteArray, - val encryptedPathSecret: List, + val encryptedPathSecret: List, ) : TlsSerializable { override fun encodeTls(writer: TlsWriter) { writer.putOpaqueVarInt(encryptionKey) @@ -645,7 +645,7 @@ data class UpdatePathNode( encryptionKey = reader.readOpaqueVarInt(), encryptedPathSecret = reader.readVectorVarInt { - com.vitorpamplona.quartz.marmot.mls.crypto.HpkeCiphertext + com.vitorpamplona.quartz.mls.crypto.HpkeCiphertext .decodeTls(it) }, ) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/cache/projection/EventStoreProjection.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/cache/projection/EventStoreProjection.kt index 58c3ecc6ff..df861928f5 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/cache/projection/EventStoreProjection.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/cache/projection/EventStoreProjection.kt @@ -30,7 +30,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.store.ObservableEventStore import com.vitorpamplona.quartz.nip01Core.store.ObservableEventStore.StoreChange import com.vitorpamplona.quartz.nip01Core.store.owner -import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent import com.vitorpamplona.quartz.nip40Expiration.isExpirationBefore import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent import com.vitorpamplona.quartz.utils.SortedList @@ -156,7 +156,7 @@ class EventStoreProjection( // NIP-09 / NIP-62 side effects come first — a deletion event // that arrives at the same instant as a matching event still // removes its targets. - if (event is DeletionEvent) { + if (event is DeletionRequestEvent) { if (handleDeletion(event)) changed = true } if (event is RequestToVanishEvent && event.shouldVanishFrom(store.relay)) { @@ -260,7 +260,7 @@ class EventStoreProjection( return changed } - private fun handleDeletion(deletion: DeletionEvent): Boolean { + private fun handleDeletion(deletion: DeletionRequestEvent): Boolean { var changed = false // NIP-09 by id, only if the deletion's author owns the target. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientNegentropyDeletionSettleExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientNegentropyDeletionSettleExt.kt index 67e770f1de..48ec8f2474 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientNegentropyDeletionSettleExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientNegentropyDeletionSettleExt.kt @@ -28,7 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.store.IEventStore import com.vitorpamplona.quartz.nip01Core.store.deletionsCovering -import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent /** * Outcome of a [negentropySettleDeletions] run. @@ -129,7 +129,7 @@ suspend fun INostrClient.negentropySettleDeletions( for (chunk in diff.haveIds.chunked(batchSize)) { val ours = store.query(Filter(ids = chunk)) val relayDeletions = deletionsCovering(ours, relay) { f -> fetchAll(relay, f, idleTimeoutMs) } - for (del in relayDeletions.filterIsInstance()) { + for (del in relayDeletions.filterIsInstance()) { if (del.verify() && appliedDown.add(del.id)) { store.insert(del) resolved++ diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/NostrSigner.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/NostrSigner.kt index 70836a44b9..bf50e9cd50 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/NostrSigner.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/NostrSigner.kt @@ -23,8 +23,8 @@ package com.vitorpamplona.quartz.nip01Core.signers import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip04Dm.crypto.EncryptedInfo -import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import com.vitorpamplona.quartz.nip57Zaps.PrivateZapEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapRequestEvent abstract class NostrSigner( pubKey: HexKey, @@ -67,7 +67,7 @@ abstract class NostrSigner( fromPublicKey: HexKey, ): String - abstract suspend fun decryptZapEvent(event: LnZapRequestEvent): LnZapPrivateEvent + abstract suspend fun decryptZapEvent(event: ZapRequestEvent): PrivateZapEvent abstract suspend fun deriveKey(nonce: HexKey): HexKey diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/NostrSignerInternal.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/NostrSignerInternal.kt index b308afbd06..006fd3791c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/NostrSignerInternal.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/NostrSignerInternal.kt @@ -24,8 +24,8 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair -import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import com.vitorpamplona.quartz.nip57Zaps.PrivateZapEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapRequestEvent import kotlinx.coroutines.CancellationException class NostrSignerInternal( @@ -89,7 +89,7 @@ class NostrSignerInternal( // always ready override fun hasForegroundSupport() = true - override suspend fun decryptZapEvent(event: LnZapRequestEvent): LnZapPrivateEvent { + override suspend fun decryptZapEvent(event: ZapRequestEvent): PrivateZapEvent { if (!event.isPrivateZap()) throw SignerExceptions.NothingToDecrypt() return runWrapErrors { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/NostrSignerSync.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/NostrSignerSync.kt index 9ca2935bf5..a821b95976 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/NostrSignerSync.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/NostrSignerSync.kt @@ -30,9 +30,9 @@ import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip04Dm.crypto.EncryptedInfo import com.vitorpamplona.quartz.nip04Dm.crypto.Nip04 import com.vitorpamplona.quartz.nip44Encryption.Nip44 -import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import com.vitorpamplona.quartz.nip57Zaps.PrivateZapEvent import com.vitorpamplona.quartz.nip57Zaps.PrivateZapRequestBuilder +import com.vitorpamplona.quartz.nip57Zaps.ZapRequestEvent import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.Psbt import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.PsbtSigner @@ -62,7 +62,7 @@ class NostrSignerSync( kind: Int, tags: Array>, ): Boolean = - kind == LnZapRequestEvent.KIND && + kind == ZapRequestEvent.KIND && tags.any { t -> t.size > 1 && t[0] == "anon" && t[1].isBlank() } fun signNormal( @@ -129,7 +129,7 @@ class NostrSignerSync( ) } - fun decryptZapEvent(event: LnZapRequestEvent): LnZapPrivateEvent = PrivateZapRequestBuilder().decryptZapEvent(event, this) + fun decryptZapEvent(event: ZapRequestEvent): PrivateZapEvent = PrivateZapRequestBuilder().decryptZapEvent(event, this) /** * NIP-BC `sign_psbt`: sign the key-path P2TR inputs of [psbtHex] this key diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/EventStoreDeletionsExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/EventStoreDeletionsExt.kt index 0c5a8203dc..ba070507e1 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/EventStoreDeletionsExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/EventStoreDeletionsExt.kt @@ -27,7 +27,7 @@ import com.vitorpamplona.quartz.nip01Core.core.isAddressable import com.vitorpamplona.quartz.nip01Core.core.isReplaceable import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent /** The addressable/replaceable coordinate of [event] as a NIP-01 `a`-tag value. */ @@ -68,15 +68,15 @@ suspend fun deletionsCovering( val covering = LinkedHashMap() // 1. id-based NIP-09: a kind-5 `e`-tagging an event's id. - query(Filter(kinds = listOf(DeletionEvent.KIND), tags = mapOf("e" to events.map { it.id }))) + query(Filter(kinds = listOf(DeletionRequestEvent.KIND), tags = mapOf("e" to events.map { it.id }))) .forEach { covering[it.id] = it } // 2. address-based NIP-09: a kind-5 `a`-tagging an event's coordinate, cutoff-checked. val byAddress = events.filter { it.kind.isAddressable() || it.kind.isReplaceable() }.groupBy(::addressValue) if (byAddress.isNotEmpty()) { - query(Filter(kinds = listOf(DeletionEvent.KIND), tags = mapOf("a" to byAddress.keys.toList()))) + query(Filter(kinds = listOf(DeletionRequestEvent.KIND), tags = mapOf("a" to byAddress.keys.toList()))) .forEach { del -> - if (del !is DeletionEvent) return@forEach + if (del !is DeletionRequestEvent) return@forEach for (addr in del.deleteAddresses()) { val hit = byAddress[addr.toValue()] ?: continue if (hit.any { it.createdAt <= del.createdAt }) { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionRequestModule.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionRequestModule.kt index 448296a4ae..880e1f4630 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionRequestModule.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionRequestModule.kt @@ -26,7 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.isAddressable import com.vitorpamplona.quartz.nip01Core.core.isReplaceable -import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent class DeletionRequestModule( val hasher: (db: SQLiteConnection) -> TagNameValueHasher, @@ -83,7 +83,7 @@ class DeletionRequestModule( event: Event, db: SQLiteConnection, ): Int { - if (event !is DeletionEvent) return 0 + if (event !is DeletionRequestEvent) return 0 val idValues = event.deleteEventIds() val addresses = event.deleteAddresses() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt index a56fb390f4..9f267c3d6c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt @@ -38,7 +38,7 @@ import com.vitorpamplona.quartz.nip01Core.store.IEventStore import com.vitorpamplona.quartz.nip01Core.store.IdAndTime import com.vitorpamplona.quartz.nip01Core.store.RawEvent import com.vitorpamplona.quartz.nip01Core.store.RejectionReason -import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent import com.vitorpamplona.quartz.nip40Expiration.isExpired import com.vitorpamplona.quartz.nip50Search.strippingSearchExtensions import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent @@ -349,7 +349,7 @@ class SQLiteEventStore( invalidateAll = true return } - if (event is DeletionEvent && deletedRows > 0) { + if (event is DeletionRequestEvent && deletedRows > 0) { // Only a kind-5 that actually removed rows costs a rebuild. // The common case — a delete broadcast for events this relay // never stored — deletes nothing and records as a plain row, @@ -519,7 +519,7 @@ class SQLiteEventStore( // ROLLBACK shouldn't mask the original cause. runCatching { db.execSQL("ROLLBACK TRANSACTION TO SAVEPOINT $sp") } runCatching { db.execSQL("RELEASE SAVEPOINT $sp") } - classifyRowError(e) + classifyRowError(e, event, db) } } @@ -531,8 +531,25 @@ class SQLiteEventStore( * I/O error, schema drift) is the store failing to write an acceptable * event: `Failed`, so a rising count is loud instead of blending into * the duplicate tally. + * + * Message text is the fast path, not the contract: which exception a + * driver throws and what it puts in `getMessage()` is the driver's + * business. Android's `SQLiteConnection` wraps constraint failures in + * an `android.database.SQLException` carrying a **null** message, + * where the bundled JVM driver spells out + * `UNIQUE constraint failed: …`. Classifying off text alone therefore + * turned every duplicate into a `Failed` on Android — an `OK false` + * the client retries forever. So [db] is asked instead: this runs after + * the savepoint rollback, so the connection shows the pre-insert state + * and the two questions that separate a duplicate from a genuine write + * failure ("is this id already here?", "does a stored version already + * beat this one?") have exact answers, on every driver. */ - private fun classifyRowError(e: Throwable): IEventStore.InsertOutcome { + private fun classifyRowError( + e: Throwable, + event: Event, + db: SQLiteConnection, + ): IEventStore.InsertOutcome { val message = e.message ?: e::class.simpleName ?: RejectionReason.INSERT_FAILED // A second copy of an event the store already holds trips the unique index on // event_headers.id. That is not a refusal of the event but a statement that it @@ -542,25 +559,108 @@ class SQLiteEventStore( if (message.contains(DUPLICATE_ID_CONSTRAINT)) { return IEventStore.InsertOutcome.Rejected(RejectionReason.DUPLICATE) } - // The replaceable / addressable unique indexes fire only when the supersession - // trigger found nothing older to delete, i.e. the stored version already wins - // (STORE-W01/W02). Same shape as a duplicate: nothing to write, `OK true`. - if (message.contains(SUPERSEDED_CONSTRAINT)) { - return IEventStore.InsertOutcome.Rejected(RejectionReason.SUPERSEDED) - } - val refusal = + // Trigger RAISEs and the immutability guards name themselves, and leave no + // database-visible trace to ask about, so they are decided by text alone. + val namedRefusal = message.contains("blocked:") || message.contains("duplicate:") || message.contains(RejectionReason.PREFIX_REPLACED) || - message.contains("not allowed") || - message.contains("constraint", ignoreCase = true) - return if (refusal) { + message.contains("not allowed") + if (namedRefusal) return IEventStore.InsertOutcome.Rejected(message) + + // Ask the database the two questions the unique indexes answer, in the + // order that makes the answer driver-independent. The id question goes + // first because *which* index a re-offered replaceable event trips is up + // to SQLite: re-inserting a stored replaceable byte-for-byte violates + // both `event_headers.id` and `replaceable_idx`, and only the id lookup + // says the same thing on every driver ("already have this event" — which + // is also the truer sentence). It costs one point lookup on an already + // open connection, on the rejected path only. + if (isAlreadyStored(event.id, db)) { + return IEventStore.InsertOutcome.Rejected(RejectionReason.DUPLICATE) + } + // The replaceable / addressable unique indexes fire only when the supersession + // trigger found nothing older to delete, i.e. the stored version already wins + // (STORE-W01/W02). Same shape as a duplicate: nothing to write, `OK true`. + if (message.contains(SUPERSEDED_CONSTRAINT) || isSupersededByStored(event, db)) { + return IEventStore.InsertOutcome.Rejected(RejectionReason.SUPERSEDED) + } + + return if (message.contains("constraint", ignoreCase = true)) { IEventStore.InsertOutcome.Rejected(message) } else { IEventStore.InsertOutcome.Failed(message) } } + /** + * Whether [id] is already in `event_headers` — the unique index on + * `event_headers.id` restated as a question, for drivers that don't say + * which index they tripped. Any failure answers "no": the point is to + * recognize a duplicate, and a connection too broken to answer is a + * write failure, which is what the caller falls through to. + */ + private fun isAlreadyStored( + id: String, + db: SQLiteConnection, + ): Boolean = + runCatching { + db.prepare("SELECT 1 FROM event_headers WHERE id = ? LIMIT 1").use { stmt -> + stmt.bindText(1, id) + stmt.step() + } + }.getOrDefault(false) + + /** + * Whether a stored version already beats [event] at its replaceable / + * addressable coordinate (STORE-W01/W02) — the exact complement of + * [displacedBy]'s predicate, so a stored row that the supersession + * trigger *would* have deleted doesn't count. That precision matters + * here: this is the fallback for unrecognized failures, and a disk + * error while inserting a winning replaceable event must stay `Failed` + * rather than turn into a silent `OK true`. An equal id is the + * duplicate case and is answered before this one. + */ + private fun isSupersededByStored( + event: Event, + db: SQLiteConnection, + ): Boolean { + val addressable = event.kind.isAddressable() && event is AddressableEvent + val sql = + when { + event.kind.isReplaceable() -> + """ + SELECT 1 FROM event_headers + WHERE kind = ? AND pubkey = ? + AND (created_at > ? OR (created_at = ? AND id < ?)) + LIMIT 1 + """.trimIndent() + + addressable -> + """ + SELECT 1 FROM event_headers + WHERE kind = ? AND pubkey = ? AND d_tag = ? + AND kind >= 30000 AND kind < 40000 + AND (created_at > ? OR (created_at = ? AND id < ?)) + LIMIT 1 + """.trimIndent() + + else -> return false + } + return runCatching { + db.prepare(sql).use { stmt -> + var i = 1 + stmt.bindLong(i++, event.kind.toLong()) + stmt.bindText(i++, event.pubKey) + if (addressable) stmt.bindText(i++, (event as AddressableEvent).dTag()) + stmt.bindLong(i++, event.createdAt) + stmt.bindLong(i++, event.createdAt) + stmt.bindText(i, event.id) + stmt.step() + } + }.getOrDefault(false) + } + inner class Transaction internal constructor( val db: SQLiteConnection, private val delta: LiveIndexDelta?, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip04Dm/PrivateDMCache.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip04Dm/PrivateDMCache.kt index 84ad273dfc..18b9c6a075 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip04Dm/PrivateDMCache.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip04Dm/PrivateDMCache.kt @@ -23,14 +23,14 @@ package com.vitorpamplona.quartz.nip04Dm import androidx.collection.LruCache import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.caches.DecryptCache -import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip04Dm.messages.EncryptedDmEvent class PrivateDMCache( signer: NostrSigner, ) { private val decryptionCache = - object : LruCache(10000) { - override fun create(key: PrivateDmEvent): PrivateDMDecryptCache? { + object : LruCache(10000) { + override fun create(key: EncryptedDmEvent): PrivateDMDecryptCache? { val canDecrypt = key.isIncluded(signer.pubKey) return if (key.content.isNotBlank() && canDecrypt) { PrivateDMDecryptCache(signer) @@ -40,16 +40,16 @@ class PrivateDMCache( } } - fun cachedDM(event: PrivateDmEvent): String? = decryptionCache[event]?.cached() + fun cachedDM(event: EncryptedDmEvent): String? = decryptionCache[event]?.cached() - suspend fun decryptDM(event: PrivateDmEvent) = decryptionCache[event]?.decrypt(event) + suspend fun decryptDM(event: EncryptedDmEvent) = decryptionCache[event]?.decrypt(event) } class PrivateDMDecryptCache( signer: NostrSigner, -) : DecryptCache(signer) { +) : DecryptCache(signer) { override suspend fun decryptAndParse( - event: PrivateDmEvent, + event: EncryptedDmEvent, signer: NostrSigner, ) = event.decryptContent(signer) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip04Dm/messages/PrivateDmEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip04Dm/messages/EncryptedDmEvent.kt similarity index 92% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip04Dm/messages/PrivateDmEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip04Dm/messages/EncryptedDmEvent.kt index 9b774e3c49..fc5ec3dd8e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip04Dm/messages/PrivateDmEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip04Dm/messages/EncryptedDmEvent.kt @@ -42,7 +42,7 @@ import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.collections.immutable.persistentSetOf @Immutable -class PrivateDmEvent( +class EncryptedDmEvent( id: HexKey, pubKey: HexKey, createdAt: Long, @@ -124,10 +124,10 @@ class PrivateDmEvent( toUser: PTag, message: String, imetas: List? = null, - replyingTo: EventHintBundle? = null, + replyingTo: EventHintBundle? = null, createdAt: Long = TimeUtils.now(), signer: NostrSigner, - initializer: TagArrayBuilder.() -> Unit = {}, + initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate( kind = KIND, description = @@ -147,9 +147,15 @@ class PrivateDmEvent( to: PTag, message: String, imetas: List? = null, - replyingTo: EventHintBundle?, + replyingTo: EventHintBundle?, createdAt: Long = TimeUtils.now(), signer: NostrSigner, ) = signer.sign(build(to, message, imetas, replyingTo, createdAt, signer)) } } + +@Deprecated( + "Renamed to EncryptedDmEvent. NIP-04 names kind 4 the encrypted direct message.", + ReplaceWith("EncryptedDmEvent", "com.vitorpamplona.quartz.nip04Dm.messages.EncryptedDmEvent"), +) +typealias PrivateDmEvent = EncryptedDmEvent diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip04Dm/messages/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip04Dm/messages/TagArrayBuilderExt.kt index 06d7a5e0b7..8a9d4559ab 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip04Dm/messages/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip04Dm/messages/TagArrayBuilderExt.kt @@ -26,6 +26,6 @@ import com.vitorpamplona.quartz.nip01Core.tags.events.EventReference import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag import com.vitorpamplona.quartz.nip10Notes.tags.toMarkedETag -fun TagArrayBuilder.reply(tag: EventReference) = add(MarkedETag.assemble(tag.eventId, tag.relayHint, MarkedETag.MARKER.REPLY, tag.author)) +fun TagArrayBuilder.reply(tag: EventReference) = add(MarkedETag.assemble(tag.eventId, tag.relayHint, MarkedETag.MARKER.REPLY, tag.author)) -fun TagArrayBuilder.reply(tag: EventHintBundle) = add(tag.toMarkedETag(MarkedETag.MARKER.REPLY).toTagArray()) +fun TagArrayBuilder.reply(tag: EventHintBundle) = add(tag.toMarkedETag(MarkedETag.MARKER.REPLY).toTagArray()) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip09Deletions/DeletionIndex.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip09Deletions/DeletionIndex.kt index a26c716af2..93099829a4 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip09Deletions/DeletionIndex.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip09Deletions/DeletionIndex.kt @@ -45,10 +45,10 @@ class DeletionIndex { // stores a set of id OR atags (kind:pubkey:dtag) by pubkey with the created at of the deletion event. // Anything newer than the date should not be deleted. - private val deletedReferencesBefore = LargeCache() + private val deletedReferencesBefore = LargeCache() fun add( - event: DeletionEvent, + event: DeletionRequestEvent, wasVerified: Boolean, ): Boolean { var atLeastOne = false @@ -74,7 +74,7 @@ class DeletionIndex { private fun add( ref: String, byPubKey: HexKey, - deletionEvent: DeletionEvent, + deletionEvent: DeletionRequestEvent, wasVerified: Boolean, ): Boolean { val key = DeletionRequest(ref, byPubKey) @@ -89,7 +89,7 @@ class DeletionIndex { return false } - fun hasBeenDeletedBy(event: Event): DeletionEvent? { + fun hasBeenDeletedBy(event: Event): DeletionRequestEvent? { deletedReferencesBefore.get(DeletionRequest(event.id, event.pubKey))?.let { return it } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip09Deletions/DeletionEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip09Deletions/DeletionRequestEvent.kt similarity index 90% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip09Deletions/DeletionEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip09Deletions/DeletionRequestEvent.kt index ec2f1de70a..b3aad126d8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip09Deletions/DeletionEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip09Deletions/DeletionRequestEvent.kt @@ -44,7 +44,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.people.pTagIds import com.vitorpamplona.quartz.utils.TimeUtils @Immutable -class DeletionEvent( +class DeletionRequestEvent( id: HexKey, pubKey: HexKey, createdAt: Long, @@ -80,7 +80,7 @@ class DeletionEvent( fun build( deleteEvents: List, createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, + initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, "", createdAt) { deleteEvents.forEach { eTag(ETag(it.id)) @@ -98,7 +98,7 @@ class DeletionEvent( fun buildAddressOnly( deleteEvents: List, createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, + initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, "", createdAt) { deleteEvents.forEach { if (it is AddressableEvent) { @@ -115,7 +115,7 @@ class DeletionEvent( fun buildForVersionOnly( deleteEvents: List, createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, + initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, "", createdAt) { deleteEvents.forEach { pTag(PTag(it.pubKey)) @@ -128,3 +128,9 @@ class DeletionEvent( } } } + +@Deprecated( + "Renamed to DeletionRequestEvent. NIP-09 names kind 5 the event deletion request.", + ReplaceWith("DeletionRequestEvent", "com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent"), +) +typealias DeletionEvent = DeletionRequestEvent diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/BaseThreadedEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/BaseThreadedEvent.kt index b8379fb628..a1f57456d9 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/BaseThreadedEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/BaseThreadedEvent.kt @@ -27,7 +27,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.tags.aTag.taggedATags import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUsers import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag -import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent +import com.vitorpamplona.quartz.nip54Wiki.WikiArticleEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.utils.lastNotNullOfOrNull @@ -90,7 +90,7 @@ open class BaseThreadedEvent( val tagAddresses = taggedATags() .filter { aTag -> - aTag.kind != CommunityDefinitionEvent.KIND && (kind != WikiNoteEvent.KIND || aTag.kind != WikiNoteEvent.KIND) && (kind != NipTextEvent.KIND || aTag.kind != NipTextEvent.KIND) + aTag.kind != CommunityDefinitionEvent.KIND && (kind != WikiArticleEvent.KIND || aTag.kind != WikiArticleEvent.KIND) && (kind != NipTextEvent.KIND || aTag.kind != NipTextEvent.KIND) // removes forks from itself. }.map { it.toTag() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip13Pow/signer/PoWNostrSigner.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip13Pow/signer/PoWNostrSigner.kt index de180cdbb6..770c08923d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip13Pow/signer/PoWNostrSigner.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip13Pow/signer/PoWNostrSigner.kt @@ -26,8 +26,8 @@ import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip13Pow.miner.PoWMiner import com.vitorpamplona.quartz.nip13Pow.tags.PoWTag -import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import com.vitorpamplona.quartz.nip57Zaps.PrivateZapEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapRequestEvent /** * A [NostrSigner] decorator that mines a NIP-13 proof of work into the unsigned @@ -98,7 +98,7 @@ class PoWNostrSigner( fromPublicKey: HexKey, ): String = signer.nip44Decrypt(ciphertext, fromPublicKey) - override suspend fun decryptZapEvent(event: LnZapRequestEvent): LnZapPrivateEvent = signer.decryptZapEvent(event) + override suspend fun decryptZapEvent(event: ZapRequestEvent): PrivateZapEvent = signer.decryptZapEvent(event) override suspend fun deriveKey(nonce: HexKey): HexKey = signer.deriveKey(nonce) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/NIP17Factory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/NIP17Factory.kt index 5b8284f329..605ce5df89 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/NIP17Factory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/NIP17Factory.kt @@ -27,7 +27,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUserIds -import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent @@ -35,7 +35,7 @@ import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag import com.vitorpamplona.quartz.nip40Expiration.expiration import com.vitorpamplona.quartz.nip46RemoteSigner.signer.NostrSignerRemote -import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent +import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapTemplateConversion import com.vitorpamplona.quartz.utils.mapNotNullAsync @@ -88,7 +88,7 @@ class NIP17Factory { AddressedSeal( recipient = next, seal = - SealedRumorEvent.create( + SealEvent.create( event = event, encryptTo = next, expirationDelta = innerExpDelta, @@ -169,7 +169,7 @@ class NIP17Factory { val build: suspend () -> GiftWrapEvent = { GiftWrapEvent.create( event = - SealedRumorEvent.create( + SealEvent.create( event = event, encryptTo = next, expirationDelta = innerExpDelta, @@ -258,7 +258,7 @@ class NIP17Factory { * it would e-tag the private rumor id onto public relays. */ suspend fun createDeletionNIP17( - template: EventTemplate, + template: EventTemplate, to: List, signer: NostrSigner, ): Result { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/files/ChatMessageEncryptedFileHeaderEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/files/ChatMessageEncryptedFileHeaderEvent.kt index 902016f868..894571e7fa 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/files/ChatMessageEncryptedFileHeaderEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/files/ChatMessageEncryptedFileHeaderEvent.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.quartz.nip17Dm.files import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.experimental.audio.header.tags.WaveformTag import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle @@ -65,6 +66,14 @@ class ChatMessageEncryptedFileHeaderEvent( fun blurhash() = tags.firstNotNullOfOrNull(BlurhashTag::parse) + /** + * The amplitude ladder a voice recording carries, when the sender attached + * one. Read-only: nothing in this app writes it on a kind 15 yet, but the + * renderer draws real bars instead of a bare transport the moment a client + * that does shows up. + */ + fun waveform() = tags.firstNotNullOfOrNull(WaveformTag::parse)?.wave + fun thumbhash() = tags.firstNotNullOfOrNull(ThumbhashTag::parse) fun originalHash() = tags.firstNotNullOfOrNull(OriginalHashTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/settings/ChatMessageRelayListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/settings/DmRelayListEvent.kt similarity index 86% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/settings/ChatMessageRelayListEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/settings/DmRelayListEvent.kt index da8184c850..c66c51610b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/settings/ChatMessageRelayListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/settings/DmRelayListEvent.kt @@ -37,7 +37,7 @@ import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayListDiff import com.vitorpamplona.quartz.utils.TimeUtils @Immutable -class ChatMessageRelayListEvent( +class DmRelayListEvent( id: HexKey, pubKey: HexKey, createdAt: Long, @@ -47,7 +47,7 @@ class ChatMessageRelayListEvent( ) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig), DiffableEvent { override fun diffFrom(older: Event): RelayListDiff? { - if (older !is ChatMessageRelayListEvent || older.pubKey != pubKey) return null + if (older !is DmRelayListEvent || older.pubKey != pubKey) return null return RelayListDiff(ListDiff.of(older.relays(), relays(), { it }), ContentChange.NONE) } @@ -71,11 +71,11 @@ class ChatMessageRelayListEvent( .toTypedArray() suspend fun updateRelayList( - earlierVersion: ChatMessageRelayListEvent, + earlierVersion: DmRelayListEvent, relays: List, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - ): ChatMessageRelayListEvent { + ): DmRelayListEvent { val tags = earlierVersion.tags .filter(RelayTag::notMatch) @@ -89,12 +89,18 @@ class ChatMessageRelayListEvent( relays: List, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - ): ChatMessageRelayListEvent = signer.sign(createdAt, KIND, createTagArray(relays), "") + ): DmRelayListEvent = signer.sign(createdAt, KIND, createTagArray(relays), "") fun create( relays: List, signer: NostrSignerSync, createdAt: Long = TimeUtils.now(), - ): ChatMessageRelayListEvent = signer.sign(createdAt, KIND, createTagArray(relays), "") + ): DmRelayListEvent = signer.sign(createdAt, KIND, createTagArray(relays), "") } } + +@Deprecated( + "Renamed to DmRelayListEvent. NIP-17 kind 10050 is the relay list to receive DMs.", + ReplaceWith("DmRelayListEvent", "com.vitorpamplona.quartz.nip17Dm.settings.DmRelayListEvent"), +) +typealias ChatMessageRelayListEvent = DmRelayListEvent diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip23LongContent/LongTextNoteEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip23LongContent/LongFormContentEvent.kt similarity index 94% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip23LongContent/LongTextNoteEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip23LongContent/LongFormContentEvent.kt index 501b0ea929..b4bc6a5cc6 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip23LongContent/LongTextNoteEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip23LongContent/LongFormContentEvent.kt @@ -56,7 +56,7 @@ import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid @Immutable -class LongTextNoteEvent( +class LongFormContentEvent( id: HexKey, pubKey: HexKey, createdAt: Long, @@ -162,7 +162,7 @@ class LongTextNoteEvent( publishedAt: Long? = null, dTag: String = Uuid.random().toString(), createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, + initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, description, createdAt) { dTag(dTag) @@ -175,3 +175,9 @@ class LongTextNoteEvent( } } } + +@Deprecated( + "Renamed to LongFormContentEvent. NIP-23 names kind 30023 long-form content.", + ReplaceWith("LongFormContentEvent", "com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent"), +) +typealias LongTextNoteEvent = LongFormContentEvent diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip23LongContent/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip23LongContent/TagArrayBuilderExt.kt index be24865911..174d59c9d0 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip23LongContent/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip23LongContent/TagArrayBuilderExt.kt @@ -26,10 +26,10 @@ import com.vitorpamplona.quartz.nip23LongContent.tags.PublishedAtTag import com.vitorpamplona.quartz.nip23LongContent.tags.SummaryTag import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag -fun TagArrayBuilder.title(title: String) = addUnique(TitleTag.assemble(title)) +fun TagArrayBuilder.title(title: String) = addUnique(TitleTag.assemble(title)) -fun TagArrayBuilder.summary(summary: String) = addUnique(SummaryTag.assemble(summary)) +fun TagArrayBuilder.summary(summary: String) = addUnique(SummaryTag.assemble(summary)) -fun TagArrayBuilder.image(imageUrl: String) = addUnique(ImageTag.assemble(imageUrl)) +fun TagArrayBuilder.image(imageUrl: String) = addUnique(ImageTag.assemble(imageUrl)) -fun TagArrayBuilder.publishedAt(publishedAt: Long) = addUnique(PublishedAtTag.assemble(publishedAt)) +fun TagArrayBuilder.publishedAt(publishedAt: Long) = addUnique(PublishedAtTag.assemble(publishedAt)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip28PublicChat/list/ChannelListDiff.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip28PublicChat/list/PublicChatListDiff.kt similarity index 84% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip28PublicChat/list/ChannelListDiff.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip28PublicChat/list/PublicChatListDiff.kt index 69ab74f29c..3143cb4363 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip28PublicChat/list/ChannelListDiff.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip28PublicChat/list/PublicChatListDiff.kt @@ -28,9 +28,15 @@ import com.vitorpamplona.quartz.nip28PublicChat.list.tags.ChannelTag /** Changes to the joined public chats: public channels as parsed tags, private ones as a whole. */ @Immutable -class ChannelListDiff( +class PublicChatListDiff( val channels: ListDiff, val privateItems: ContentChange, ) : EventDiff { override fun removesData() = privateItems.publicRemovalsAreLoss(channels.hasRemovals()) || privateItems.isRemoval() } + +@Deprecated( + "Renamed to PublicChatListDiff. Follows the ChannelListEvent to PublicChatListEvent rename (NIP-51 public chats list).", + ReplaceWith("PublicChatListDiff", "com.vitorpamplona.quartz.nip28PublicChat.list.PublicChatListDiff"), +) +typealias ChannelListDiff = PublicChatListDiff diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip28PublicChat/list/ChannelListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip28PublicChat/list/PublicChatListEvent.kt similarity index 88% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip28PublicChat/list/ChannelListEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip28PublicChat/list/PublicChatListEvent.kt index 27f84bb63c..78dd9fc4eb 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip28PublicChat/list/ChannelListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip28PublicChat/list/PublicChatListEvent.kt @@ -42,7 +42,7 @@ import com.vitorpamplona.quartz.nip51Lists.removeParsing import com.vitorpamplona.quartz.utils.TimeUtils @Immutable -class ChannelListEvent( +class PublicChatListEvent( id: HexKey, pubKey: HexKey, createdAt: Long, @@ -50,11 +50,11 @@ class ChannelListEvent( content: String, sig: HexKey, ) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig), - DiffableEvent, + DiffableEvent, EventHintProvider { - override fun diffFrom(older: Event): ChannelListDiff? { - if (older !is ChannelListEvent || older.pubKey != pubKey || older.dTag() != dTag()) return null - return ChannelListDiff( + override fun diffFrom(older: Event): PublicChatListDiff? { + if (older !is PublicChatListEvent || older.pubKey != pubKey || older.dTag() != dTag()) return null + return PublicChatListDiff( ListDiff.of(older.tags.channels(), tags.channels(), { it.eventId }, { a, b -> a.relay == b.relay }), privateItemsChangeFrom(older), ) @@ -87,7 +87,7 @@ class ChannelListEvent( isPrivate: Boolean, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - ): ChannelListEvent = + ): PublicChatListEvent = if (isPrivate) { create( publicChannels = emptyList(), @@ -105,7 +105,7 @@ class ChannelListEvent( } suspend fun add( - earlierVersion: ChannelListEvent, + earlierVersion: PublicChatListEvent, channel: ChannelTag, isPrivate: Boolean, signer: NostrSigner, @@ -119,12 +119,12 @@ class ChannelListEvent( ) suspend fun add( - earlierVersion: ChannelListEvent, + earlierVersion: PublicChatListEvent, channels: List, isPrivate: Boolean, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - ): ChannelListEvent = + ): PublicChatListEvent = if (isPrivate) { val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() resign( @@ -143,11 +143,11 @@ class ChannelListEvent( } suspend fun remove( - earlierVersion: ChannelListEvent, + earlierVersion: PublicChatListEvent, channel: ChannelTag, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - ): ChannelListEvent { + ): PublicChatListEvent { val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() return resign( privateTags = privateTags.removeParsing(ChannelTag::parseId, channel.eventId), @@ -174,14 +174,14 @@ class ChannelListEvent( tags: TagArray, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - ): ChannelListEvent = signer.sign(createdAt, KIND, tags, content) + ): PublicChatListEvent = signer.sign(createdAt, KIND, tags, content) suspend fun create( publicChannels: List = emptyList(), privateChannels: List = emptyList(), signer: NostrSigner, createdAt: Long = TimeUtils.now(), - ): ChannelListEvent { + ): PublicChatListEvent { val template = build(publicChannels, privateChannels, signer, createdAt) return signer.sign(template) } @@ -191,7 +191,7 @@ class ChannelListEvent( privateChannels: List = emptyList(), signer: NostrSignerSync, createdAt: Long = TimeUtils.now(), - ): ChannelListEvent { + ): PublicChatListEvent { val privateTagArray = privateChannels.map { it.toTagArray() }.toTypedArray() val publicTagArray = publicChannels.map { it.toTagArray() }.toTypedArray() return signer.signNip51List(createdAt, KIND, publicTagArray, privateTagArray) @@ -202,8 +202,8 @@ class ChannelListEvent( privateChannels: List = emptyList(), signer: NostrSigner, createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, - ) = eventTemplate( + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate( kind = KIND, description = PrivateTagsInContent.encryptNip44(privateChannels.map { it.toTagArray() }.toTypedArray(), signer), createdAt = createdAt, @@ -214,3 +214,9 @@ class ChannelListEvent( } } } + +@Deprecated( + "Renamed to PublicChatListEvent. NIP-51 names kind 10005 the public chats list.", + ReplaceWith("PublicChatListEvent", "com.vitorpamplona.quartz.nip28PublicChat.list.PublicChatListEvent"), +) +typealias ChannelListEvent = PublicChatListEvent diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip28PublicChat/list/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip28PublicChat/list/TagArrayBuilderExt.kt index a26e2947f4..fd2acae60b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip28PublicChat/list/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip28PublicChat/list/TagArrayBuilderExt.kt @@ -26,9 +26,9 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip28PublicChat.list.tags.ChannelTag -fun TagArrayBuilder.followChat( +fun TagArrayBuilder.followChat( eventId: HexKey, relayUrl: NormalizedRelayUrl, ) = addUnique(ETag.assemble(eventId, relayUrl, null)) -fun TagArrayBuilder.channels(rooms: List) = addAll(rooms.map { it.toTagArray() }) +fun TagArrayBuilder.channels(rooms: List) = addAll(rooms.map { it.toTagArray() }) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupPinnedEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupPinnedEvent.kt index a15aa5f589..aa3a653e01 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupPinnedEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupPinnedEvent.kt @@ -31,7 +31,7 @@ import com.vitorpamplona.quartz.utils.TimeUtils /** * NIP-29 relay-signed list of a group's pinned messages (kind 39005). The relay - * regenerates it from the accepted [com.vitorpamplona.quartz.nip29RelayGroups.moderation.UpdatePinListEvent] + * regenerates it from the accepted [com.vitorpamplona.quartz.nip29RelayGroups.moderation.GroupUpdatePinListEvent] * (kind 9010) moderation actions, so this is the read side clients render — the * source of truth for which messages are pinned and in what display order. * diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/SupportedRolesEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupRolesEvent.kt similarity index 83% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/SupportedRolesEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupRolesEvent.kt index 5efe39736b..9673adce52 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/SupportedRolesEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupRolesEvent.kt @@ -30,7 +30,7 @@ import com.vitorpamplona.quartz.nip29RelayGroups.tags.RoleTag import com.vitorpamplona.quartz.utils.TimeUtils @Immutable -class SupportedRolesEvent( +class GroupRolesEvent( id: HexKey, pubKey: HexKey, createdAt: Long, @@ -49,7 +49,7 @@ class SupportedRolesEvent( groupId: String, roles: List, createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, + initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, "", createdAt) { dTag(groupId) addAll(RoleTag.assemble(roles)) @@ -57,3 +57,9 @@ class SupportedRolesEvent( } } } + +@Deprecated( + "Renamed to GroupRolesEvent. NIP-29 names kind 39003 the group roles. NIP-29 group events carry the Group prefix to keep them apart from NIP-43 relay membership events.", + ReplaceWith("GroupRolesEvent", "com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupRolesEvent"), +) +typealias SupportedRolesEvent = GroupRolesEvent diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/CreateInviteEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/GroupCreateInviteEvent.kt similarity index 82% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/CreateInviteEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/GroupCreateInviteEvent.kt index 6b5ce7f322..ad4fedd9f8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/CreateInviteEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/GroupCreateInviteEvent.kt @@ -28,7 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.utils.TimeUtils @Immutable -class CreateInviteEvent( +class GroupCreateInviteEvent( id: HexKey, pubKey: HexKey, createdAt: Long, @@ -47,7 +47,7 @@ class CreateInviteEvent( groupId: String, code: String, createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, + initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, "", createdAt) { groupId(groupId) inviteCode(code) @@ -55,3 +55,9 @@ class CreateInviteEvent( } } } + +@Deprecated( + "Renamed to GroupCreateInviteEvent. NIP-29 group events carry the Group prefix to keep them apart from NIP-43 relay membership events.", + ReplaceWith("GroupCreateInviteEvent", "com.vitorpamplona.quartz.nip29RelayGroups.moderation.GroupCreateInviteEvent"), +) +typealias CreateInviteEvent = GroupCreateInviteEvent diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/DeleteEventEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/GroupDeleteEventEvent.kt similarity index 84% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/DeleteEventEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/GroupDeleteEventEvent.kt index 25ebd16183..342c1cba71 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/DeleteEventEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/GroupDeleteEventEvent.kt @@ -28,7 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.utils.TimeUtils @Immutable -class DeleteEventEvent( +class GroupDeleteEventEvent( id: HexKey, pubKey: HexKey, createdAt: Long, @@ -50,7 +50,7 @@ class DeleteEventEvent( eventIds: List, previousEvents: List = emptyList(), createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, + initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, "", createdAt) { groupId(groupId) eventIds.forEach { add(arrayOf("e", it)) } @@ -59,3 +59,9 @@ class DeleteEventEvent( } } } + +@Deprecated( + "Renamed to GroupDeleteEventEvent. NIP-29 group events carry the Group prefix to keep them apart from NIP-43 relay membership events.", + ReplaceWith("GroupDeleteEventEvent", "com.vitorpamplona.quartz.nip29RelayGroups.moderation.GroupDeleteEventEvent"), +) +typealias DeleteEventEvent = GroupDeleteEventEvent diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/EditMetadataEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/GroupEditMetadataEvent.kt similarity index 92% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/EditMetadataEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/GroupEditMetadataEvent.kt index 76d0b93ac1..1944ace1a7 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/EditMetadataEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/GroupEditMetadataEvent.kt @@ -36,7 +36,7 @@ import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.utils.TimeUtils @Immutable -class EditMetadataEvent( +class GroupEditMetadataEvent( id: HexKey, pubKey: HexKey, createdAt: Long, @@ -97,7 +97,7 @@ class EditMetadataEvent( visibility: String? = null, archived: Boolean? = null, createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, + initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, "", createdAt) { groupId(groupId) name?.let { add(arrayOf("name", it)) } @@ -116,3 +116,9 @@ class EditMetadataEvent( } } } + +@Deprecated( + "Renamed to GroupEditMetadataEvent. NIP-29 group events carry the Group prefix to keep them apart from NIP-43 relay membership events.", + ReplaceWith("GroupEditMetadataEvent", "com.vitorpamplona.quartz.nip29RelayGroups.moderation.GroupEditMetadataEvent"), +) +typealias EditMetadataEvent = GroupEditMetadataEvent diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/PutUserEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/GroupPutUserEvent.kt similarity index 88% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/PutUserEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/GroupPutUserEvent.kt index d2f9382413..6faf482d43 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/PutUserEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/GroupPutUserEvent.kt @@ -28,7 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.utils.TimeUtils @Immutable -class PutUserEvent( +class GroupPutUserEvent( id: HexKey, pubKey: HexKey, createdAt: Long, @@ -62,7 +62,7 @@ class PutUserEvent( previousEvents: List = emptyList(), buzzRole: String? = null, createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, + initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, "", createdAt) { groupId(groupId) pubKeysWithRoles.forEach { (pubKey, roles) -> @@ -74,3 +74,9 @@ class PutUserEvent( } } } + +@Deprecated( + "Renamed to GroupPutUserEvent. NIP-29 group events carry the Group prefix to keep them apart from NIP-43 relay membership events.", + ReplaceWith("GroupPutUserEvent", "com.vitorpamplona.quartz.nip29RelayGroups.moderation.GroupPutUserEvent"), +) +typealias PutUserEvent = GroupPutUserEvent diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/RemoveUserEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/GroupRemoveUserEvent.kt similarity index 84% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/RemoveUserEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/GroupRemoveUserEvent.kt index 09d7ea35fb..9a690605ee 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/RemoveUserEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/GroupRemoveUserEvent.kt @@ -28,7 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.utils.TimeUtils @Immutable -class RemoveUserEvent( +class GroupRemoveUserEvent( id: HexKey, pubKey: HexKey, createdAt: Long, @@ -50,7 +50,7 @@ class RemoveUserEvent( pubKeys: List, previousEvents: List = emptyList(), createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, + initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, "", createdAt) { groupId(groupId) pubKeys.forEach { userPubKey(it) } @@ -59,3 +59,9 @@ class RemoveUserEvent( } } } + +@Deprecated( + "Renamed to GroupRemoveUserEvent. NIP-29 group events carry the Group prefix to keep them apart from NIP-43 relay membership events.", + ReplaceWith("GroupRemoveUserEvent", "com.vitorpamplona.quartz.nip29RelayGroups.moderation.GroupRemoveUserEvent"), +) +typealias RemoveUserEvent = GroupRemoveUserEvent diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/UpdatePinListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/GroupUpdatePinListEvent.kt similarity index 85% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/UpdatePinListEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/GroupUpdatePinListEvent.kt index 9a53d7e7cc..fe224d5d21 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/UpdatePinListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/GroupUpdatePinListEvent.kt @@ -35,7 +35,7 @@ import com.vitorpamplona.quartz.utils.TimeUtils * kind-39005 [com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupPinnedEvent]. */ @Immutable -class UpdatePinListEvent( +class GroupUpdatePinListEvent( id: HexKey, pubKey: HexKey, createdAt: Long, @@ -54,7 +54,7 @@ class UpdatePinListEvent( groupId: String, pinnedEventIds: List, createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, + initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, "", createdAt) { groupId(groupId) pinnedEventIds.forEach { add(arrayOf("e", it)) } @@ -62,3 +62,9 @@ class UpdatePinListEvent( } } } + +@Deprecated( + "Renamed to GroupUpdatePinListEvent. NIP-29 group events carry the Group prefix to keep them apart from NIP-43 relay membership events.", + ReplaceWith("GroupUpdatePinListEvent", "com.vitorpamplona.quartz.nip29RelayGroups.moderation.GroupUpdatePinListEvent"), +) +typealias UpdatePinListEvent = GroupUpdatePinListEvent diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/request/JoinRequestEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/request/GroupJoinRequestEvent.kt similarity index 84% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/request/JoinRequestEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/request/GroupJoinRequestEvent.kt index 2db9b25108..86953702f6 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/request/JoinRequestEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/request/GroupJoinRequestEvent.kt @@ -30,7 +30,7 @@ import com.vitorpamplona.quartz.nip29RelayGroups.moderation.inviteCode import com.vitorpamplona.quartz.utils.TimeUtils @Immutable -class JoinRequestEvent( +class GroupJoinRequestEvent( id: HexKey, pubKey: HexKey, createdAt: Long, @@ -50,7 +50,7 @@ class JoinRequestEvent( reason: String = "", inviteCode: String? = null, createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, + initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, reason, createdAt) { groupId(groupId) inviteCode?.let { inviteCode(it) } @@ -58,3 +58,9 @@ class JoinRequestEvent( } } } + +@Deprecated( + "Renamed to GroupJoinRequestEvent. NIP-29 group events carry the Group prefix to keep them apart from NIP-43 relay membership events.", + ReplaceWith("GroupJoinRequestEvent", "com.vitorpamplona.quartz.nip29RelayGroups.request.GroupJoinRequestEvent"), +) +typealias JoinRequestEvent = GroupJoinRequestEvent diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/request/LeaveRequestEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/request/GroupLeaveRequestEvent.kt similarity index 82% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/request/LeaveRequestEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/request/GroupLeaveRequestEvent.kt index 429750cc61..574c5cc38d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/request/LeaveRequestEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/request/GroupLeaveRequestEvent.kt @@ -29,7 +29,7 @@ import com.vitorpamplona.quartz.nip29RelayGroups.moderation.groupId import com.vitorpamplona.quartz.utils.TimeUtils @Immutable -class LeaveRequestEvent( +class GroupLeaveRequestEvent( id: HexKey, pubKey: HexKey, createdAt: Long, @@ -46,10 +46,16 @@ class LeaveRequestEvent( groupId: String, reason: String = "", createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, + initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, reason, createdAt) { groupId(groupId) initializer() } } } + +@Deprecated( + "Renamed to GroupLeaveRequestEvent. NIP-29 group events carry the Group prefix to keep them apart from NIP-43 relay membership events.", + ReplaceWith("GroupLeaveRequestEvent", "com.vitorpamplona.quartz.nip29RelayGroups.request.GroupLeaveRequestEvent"), +) +typealias LeaveRequestEvent = GroupLeaveRequestEvent diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip30CustomEmoji/selection/EmojiPackSelectionEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip30CustomEmoji/selection/EmojiListEvent.kt similarity index 85% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip30CustomEmoji/selection/EmojiPackSelectionEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip30CustomEmoji/selection/EmojiListEvent.kt index 774f834730..1d2a17c5b6 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip30CustomEmoji/selection/EmojiPackSelectionEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip30CustomEmoji/selection/EmojiListEvent.kt @@ -35,7 +35,7 @@ import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent import com.vitorpamplona.quartz.utils.TimeUtils @Immutable -class EmojiPackSelectionEvent( +class EmojiListEvent( id: HexKey, pubKey: HexKey, createdAt: Long, @@ -61,20 +61,20 @@ class EmojiPackSelectionEvent( fun createAddressTag(pubKey: HexKey) = Address.assemble(KIND, pubKey, FIXED_D_TAG) fun add( - currentSelection: EmojiPackSelectionEvent, + currentSelection: EmojiListEvent, packToAdd: EventHintBundle, createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, + initializer: TagArrayBuilder.() -> Unit = {}, ) = eventUpdate(currentSelection, createdAt) { pack(packToAdd.toATag()) initializer() } fun remove( - currentSelection: EmojiPackSelectionEvent, + currentSelection: EmojiListEvent, packToRemove: EmojiPackEvent, createdAt: Long = TimeUtils.now(), - updater: TagArrayBuilder.() -> Unit = {}, + updater: TagArrayBuilder.() -> Unit = {}, ) = eventUpdate(currentSelection, createdAt) { removePack(packToRemove.address()) updater() @@ -83,10 +83,16 @@ class EmojiPackSelectionEvent( fun build( listOfEmojiPacks: List>, createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, + initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, "", createdAt) { packs(listOfEmojiPacks.map { it.toATag() }) initializer() } } } + +@Deprecated( + "Renamed to EmojiListEvent. NIP-51 names kind 10030 the user emoji list.", + ReplaceWith("EmojiListEvent", "com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiListEvent"), +) +typealias EmojiPackSelectionEvent = EmojiListEvent diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip30CustomEmoji/selection/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip30CustomEmoji/selection/TagArrayBuilderExt.kt index c97a08a7fb..455d33dfef 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip30CustomEmoji/selection/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip30CustomEmoji/selection/TagArrayBuilderExt.kt @@ -27,8 +27,8 @@ import com.vitorpamplona.quartz.nip01Core.tags.aTag.aTag import com.vitorpamplona.quartz.nip01Core.tags.aTag.aTags import com.vitorpamplona.quartz.nip01Core.tags.aTag.removeAddress -fun TagArrayBuilder.pack(tag: ATag) = aTag(tag) +fun TagArrayBuilder.pack(tag: ATag) = aTag(tag) -fun TagArrayBuilder.packs(tags: List) = aTags(tags) +fun TagArrayBuilder.packs(tags: List) = aTags(tags) -fun TagArrayBuilder.removePack(address: Address) = removeAddress(address) +fun TagArrayBuilder.removePack(address: Address) = removeAddress(address) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip38UserStatus/StatusEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip38UserStatus/UserStatusEvent.kt similarity index 90% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip38UserStatus/StatusEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip38UserStatus/UserStatusEvent.kt index 1ddc25cbc9..db4faeae99 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip38UserStatus/StatusEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip38UserStatus/UserStatusEvent.kt @@ -34,7 +34,7 @@ import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.utils.TimeUtils @Immutable -class StatusEvent( +class UserStatusEvent( id: HexKey, pubKey: HexKey, createdAt: Long, @@ -70,7 +70,7 @@ class StatusEvent( emojiTags: List? = null, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - ): StatusEvent { + ): UserStatusEvent { val tags = mutableListOf>() tags.add(arrayOf("d", type)) @@ -85,23 +85,29 @@ class StatusEvent( } suspend fun update( - event: StatusEvent, + event: UserStatusEvent, newStatus: String, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - ): StatusEvent { + ): UserStatusEvent { val tags = event.tags return signer.sign(createdAt, KIND, tags, newStatus) } suspend fun clear( - event: StatusEvent, + event: UserStatusEvent, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - ): StatusEvent { + ): UserStatusEvent { val msg = "" val tags = event.tags.filter { it.size > 1 && it[0] == "d" } return signer.sign(createdAt, KIND, tags.toTypedArray(), msg) } } } + +@Deprecated( + "Renamed to UserStatusEvent. NIP-38 names kind 30315 the user status.", + ReplaceWith("UserStatusEvent", "com.vitorpamplona.quartz.nip38UserStatus.UserStatusEvent"), +) +typealias StatusEvent = UserStatusEvent diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/SharedKeyCache.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/SharedKeyCache.kt index a74614dda6..f15272c2c2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/SharedKeyCache.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/SharedKeyCache.kt @@ -56,7 +56,7 @@ class SharedKeyCache { * hex string). The precomputed [hash] is only a bucket selector — [equals] does * the authoritative full-content comparison, so hash collisions can never return * the wrong peer's secret. Callers must treat the passed arrays as immutable - * (the same value-type contract [com.vitorpamplona.quartz.marmot.mls.crypto.X25519KeyPair] + * (the same value-type contract [com.vitorpamplona.quartz.mls.crypto.X25519KeyPair] * relies on when used as a map key). */ private class CacheKey( diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemote.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemote.kt index 5ca77a0410..5a7820cc32 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemote.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemote.kt @@ -40,8 +40,8 @@ import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Encrypt import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestPing import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestSign import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import com.vitorpamplona.quartz.nip57Zaps.PrivateZapEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapRequestEvent import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineScope @@ -325,7 +325,7 @@ class NostrSignerRemote( throw convertExceptions("Could not get public key", result) } - override suspend fun decryptZapEvent(event: LnZapRequestEvent): LnZapPrivateEvent { + override suspend fun decryptZapEvent(event: ZapRequestEvent): PrivateZapEvent { TODO("Not yet implemented") } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47Client.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47Client.kt index 4ee11947c6..acee4cec7c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47Client.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47Client.kt @@ -27,10 +27,10 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal -import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent -import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcInfoEvent import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcNotificationEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcRequestEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcResponseEvent import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CancelHoldInvoiceMethod import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBalanceMethod import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBudgetMethod @@ -115,7 +115,7 @@ class Nip47Client( suspend fun payInvoice( bolt11: String, amount: Long? = null, - ): LnZapPaymentRequestEvent = + ): NwcRequestEvent = buildRequest( if (amount != null) { PayInvoiceMethod.create(bolt11, amount) @@ -132,17 +132,17 @@ class Nip47Client( pubkey: String, preimage: String? = null, tlvRecords: List? = null, - ): LnZapPaymentRequestEvent = buildRequest(PayKeysendMethod.create(amount, pubkey, preimage, tlvRecords)) + ): NwcRequestEvent = buildRequest(PayKeysendMethod.create(amount, pubkey, preimage, tlvRecords)) /** * Builds a get_balance request event. */ - suspend fun getBalance(): LnZapPaymentRequestEvent = buildRequest(GetBalanceMethod.create()) + suspend fun getBalance(): NwcRequestEvent = buildRequest(GetBalanceMethod.create()) /** * Builds a get_info request event. */ - suspend fun getInfo(): LnZapPaymentRequestEvent = buildRequest(GetInfoMethod.create()) + suspend fun getInfo(): NwcRequestEvent = buildRequest(GetInfoMethod.create()) /** * Builds a make_invoice request event. @@ -152,17 +152,17 @@ class Nip47Client( description: String? = null, descriptionHash: String? = null, expiry: Long? = null, - ): LnZapPaymentRequestEvent = buildRequest(MakeInvoiceMethod.create(amount, description, descriptionHash, expiry)) + ): NwcRequestEvent = buildRequest(MakeInvoiceMethod.create(amount, description, descriptionHash, expiry)) /** * Builds a lookup_invoice request event by payment hash. */ - suspend fun lookupInvoiceByHash(paymentHash: String): LnZapPaymentRequestEvent = buildRequest(LookupInvoiceMethod.createByHash(paymentHash)) + suspend fun lookupInvoiceByHash(paymentHash: String): NwcRequestEvent = buildRequest(LookupInvoiceMethod.createByHash(paymentHash)) /** * Builds a lookup_invoice request event by BOLT11 invoice. */ - suspend fun lookupInvoiceByInvoice(invoice: String): LnZapPaymentRequestEvent = buildRequest(LookupInvoiceMethod.createByInvoice(invoice)) + suspend fun lookupInvoiceByInvoice(invoice: String): NwcRequestEvent = buildRequest(LookupInvoiceMethod.createByInvoice(invoice)) /** * Builds a list_transactions request event. @@ -174,17 +174,17 @@ class Nip47Client( offset: Int? = null, unpaid: Boolean? = null, type: String? = null, - ): LnZapPaymentRequestEvent = buildRequest(ListTransactionsMethod.create(from, until, limit, offset, unpaid, type)) + ): NwcRequestEvent = buildRequest(ListTransactionsMethod.create(from, until, limit, offset, unpaid, type)) /** * Builds a get_budget request event. */ - suspend fun getBudget(): LnZapPaymentRequestEvent = buildRequest(GetBudgetMethod.create()) + suspend fun getBudget(): NwcRequestEvent = buildRequest(GetBudgetMethod.create()) /** * Builds a sign_message request event. */ - suspend fun signMessage(message: String): LnZapPaymentRequestEvent = buildRequest(SignMessageMethod.create(message)) + suspend fun signMessage(message: String): NwcRequestEvent = buildRequest(SignMessageMethod.create(message)) /** * Builds a make_hold_invoice request event. @@ -196,24 +196,24 @@ class Nip47Client( descriptionHash: String? = null, expiry: Long? = null, minCltvExpiryDelta: Int? = null, - ): LnZapPaymentRequestEvent = buildRequest(MakeHoldInvoiceMethod.create(amount, paymentHash, description, descriptionHash, expiry, minCltvExpiryDelta)) + ): NwcRequestEvent = buildRequest(MakeHoldInvoiceMethod.create(amount, paymentHash, description, descriptionHash, expiry, minCltvExpiryDelta)) /** * Builds a cancel_hold_invoice request event. */ - suspend fun cancelHoldInvoice(paymentHash: String): LnZapPaymentRequestEvent = buildRequest(CancelHoldInvoiceMethod.create(paymentHash)) + suspend fun cancelHoldInvoice(paymentHash: String): NwcRequestEvent = buildRequest(CancelHoldInvoiceMethod.create(paymentHash)) /** * Builds a settle_hold_invoice request event. */ - suspend fun settleHoldInvoice(preimage: String): LnZapPaymentRequestEvent = buildRequest(SettleHoldInvoiceMethod.create(preimage)) + suspend fun settleHoldInvoice(preimage: String): NwcRequestEvent = buildRequest(SettleHoldInvoiceMethod.create(preimage)) /** * Builds a request event from any [Request] object. * This is the low-level method used by all convenience methods above. */ - suspend fun buildRequest(request: Request): LnZapPaymentRequestEvent = - LnZapPaymentRequestEvent.createRequest( + suspend fun buildRequest(request: Request): NwcRequestEvent = + NwcRequestEvent.createRequest( request = request, walletServicePubkey = walletPubKeyHex, signer = signer, @@ -225,7 +225,7 @@ class Nip47Client( /** * Decrypts and parses a response event from the wallet. */ - suspend fun parseResponse(event: LnZapPaymentResponseEvent): Response = event.decrypt(signer) + suspend fun parseResponse(event: NwcResponseEvent): Response = event.decrypt(signer) /** * Decrypts and parses a notification event from the wallet. @@ -240,7 +240,7 @@ class Nip47Client( */ fun responseFilter(requestEventId: HexKey): Filter = Filter( - kinds = listOf(LnZapPaymentResponseEvent.KIND), + kinds = listOf(NwcResponseEvent.KIND), authors = listOf(walletPubKeyHex), tags = mapOf("e" to listOf(requestEventId)), ) @@ -251,7 +251,7 @@ class Nip47Client( */ fun allResponsesFilter(since: Long? = null): Filter = Filter( - kinds = listOf(LnZapPaymentResponseEvent.KIND), + kinds = listOf(NwcResponseEvent.KIND), authors = listOf(walletPubKeyHex), tags = mapOf("p" to listOf(signer.pubKey)), since = since, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47Server.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47Server.kt index 739351ca16..da7baebe4c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47Server.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47Server.kt @@ -23,10 +23,10 @@ package com.vitorpamplona.quartz.nip47WalletConnect import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent -import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcInfoEvent import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcNotificationEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcRequestEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcResponseEvent import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBalanceSuccessResponse import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBudgetSuccessResponse import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetInfoSuccessResponse @@ -105,7 +105,7 @@ class Nip47Server( /** * Decrypts and parses an incoming client request. */ - suspend fun parseRequest(event: LnZapPaymentRequestEvent): Request = event.decryptRequest(signer) + suspend fun parseRequest(event: NwcRequestEvent): Request = event.decryptRequest(signer) // --- Response builders --- @@ -114,9 +114,9 @@ class Nip47Server( */ suspend fun buildResponse( response: Response, - requestEvent: LnZapPaymentRequestEvent, - ): LnZapPaymentResponseEvent = - LnZapPaymentResponseEvent.createResponse( + requestEvent: NwcRequestEvent, + ): NwcResponseEvent = + NwcResponseEvent.createResponse( response = response, requestEvent = requestEvent, signer = signer, @@ -127,11 +127,11 @@ class Nip47Server( * Builds an error response for any method. */ suspend fun respondError( - requestEvent: LnZapPaymentRequestEvent, + requestEvent: NwcRequestEvent, code: NwcErrorCode, message: String, resultType: String? = null, - ): LnZapPaymentResponseEvent { + ): NwcResponseEvent { val method = resultType ?: requestEvent.decryptRequest(signer).method ?: NwcMethod.PAY_INVOICE return buildResponse(NwcErrorResponse(method, NwcError(code, message)), requestEvent) } @@ -140,10 +140,10 @@ class Nip47Server( * Builds a pay_invoice success response. */ suspend fun respondPayInvoice( - requestEvent: LnZapPaymentRequestEvent, + requestEvent: NwcRequestEvent, preimage: String? = null, feesPaid: Long? = null, - ): LnZapPaymentResponseEvent = + ): NwcResponseEvent = buildResponse( PayInvoiceSuccessResponse(PayInvoiceSuccessResponse.PayInvoiceResultParams(preimage, feesPaid)), requestEvent, @@ -153,10 +153,10 @@ class Nip47Server( * Builds a pay_keysend success response. */ suspend fun respondPayKeysend( - requestEvent: LnZapPaymentRequestEvent, + requestEvent: NwcRequestEvent, preimage: String? = null, feesPaid: Long? = null, - ): LnZapPaymentResponseEvent = + ): NwcResponseEvent = buildResponse( PayKeysendSuccessResponse(PayKeysendSuccessResponse.PayKeysendResult(preimage, feesPaid)), requestEvent, @@ -166,9 +166,9 @@ class Nip47Server( * Builds a get_balance success response. */ suspend fun respondGetBalance( - requestEvent: LnZapPaymentRequestEvent, + requestEvent: NwcRequestEvent, balance: Long, - ): LnZapPaymentResponseEvent = + ): NwcResponseEvent = buildResponse( GetBalanceSuccessResponse(GetBalanceSuccessResponse.GetBalanceResult(balance)), requestEvent, @@ -178,7 +178,7 @@ class Nip47Server( * Builds a get_info success response. */ suspend fun respondGetInfo( - requestEvent: LnZapPaymentRequestEvent, + requestEvent: NwcRequestEvent, alias: String? = null, color: String? = null, pubkey: String? = null, @@ -188,7 +188,7 @@ class Nip47Server( methods: List? = null, notifications: List? = null, lud16: String? = null, - ): LnZapPaymentResponseEvent = + ): NwcResponseEvent = buildResponse( GetInfoSuccessResponse( GetInfoSuccessResponse.GetInfoResult( @@ -211,26 +211,26 @@ class Nip47Server( * Builds a make_invoice success response. */ suspend fun respondMakeInvoice( - requestEvent: LnZapPaymentRequestEvent, + requestEvent: NwcRequestEvent, transaction: NwcTransaction, - ): LnZapPaymentResponseEvent = buildResponse(MakeInvoiceSuccessResponse(transaction), requestEvent) + ): NwcResponseEvent = buildResponse(MakeInvoiceSuccessResponse(transaction), requestEvent) /** * Builds a lookup_invoice success response. */ suspend fun respondLookupInvoice( - requestEvent: LnZapPaymentRequestEvent, + requestEvent: NwcRequestEvent, transaction: NwcTransaction, - ): LnZapPaymentResponseEvent = buildResponse(LookupInvoiceSuccessResponse(transaction), requestEvent) + ): NwcResponseEvent = buildResponse(LookupInvoiceSuccessResponse(transaction), requestEvent) /** * Builds a list_transactions success response. */ suspend fun respondListTransactions( - requestEvent: LnZapPaymentRequestEvent, + requestEvent: NwcRequestEvent, transactions: List, totalCount: Long? = null, - ): LnZapPaymentResponseEvent = + ): NwcResponseEvent = buildResponse( ListTransactionsSuccessResponse( ListTransactionsSuccessResponse.ListTransactionsResult(transactions, totalCount), @@ -242,12 +242,12 @@ class Nip47Server( * Builds a get_budget success response. */ suspend fun respondGetBudget( - requestEvent: LnZapPaymentRequestEvent, + requestEvent: NwcRequestEvent, usedBudget: Long? = null, totalBudget: Long? = null, renewsAt: Long? = null, renewalPeriod: String? = null, - ): LnZapPaymentResponseEvent = + ): NwcResponseEvent = buildResponse( GetBudgetSuccessResponse( GetBudgetSuccessResponse.GetBudgetResult(usedBudget, totalBudget, renewsAt, renewalPeriod), @@ -259,10 +259,10 @@ class Nip47Server( * Builds a sign_message success response. */ suspend fun respondSignMessage( - requestEvent: LnZapPaymentRequestEvent, + requestEvent: NwcRequestEvent, message: String, signature: String, - ): LnZapPaymentResponseEvent = + ): NwcResponseEvent = buildResponse( SignMessageSuccessResponse(SignMessageSuccessResponse.SignMessageResult(message, signature)), requestEvent, @@ -317,7 +317,7 @@ class Nip47Server( */ fun requestsFilter(since: Long? = null): Filter = Filter( - kinds = listOf(LnZapPaymentRequestEvent.KIND), + kinds = listOf(NwcRequestEvent.KIND), tags = mapOf("p" to listOf(signer.pubKey)), since = since, ) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/README.md b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/README.md index 0d372e1a49..7ee4df5c25 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/README.md +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/README.md @@ -92,8 +92,8 @@ nip47WalletConnect/ ├── NwcErrorCode.kt # Error codes enum + NwcError ├── NwcTransaction.kt # Transaction, state, budget, TLV models ├── NwcInfoEvent.kt # Kind 13194 — wallet capabilities -├── LnZapPaymentRequestEvent.kt # Kind 23194 — client → wallet request -├── LnZapPaymentResponseEvent.kt # Kind 23195 — wallet → client response +├── NwcRequestEvent.kt # Kind 23194 — client → wallet request +├── NwcResponseEvent.kt # Kind 23195 — wallet → client response ├── NwcNotificationEvent.kt # Kind 23197 — wallet → client notification ├── NostrWalletConnectRequestCache.kt # Request decryption cache ├── NostrWalletConnectResponseCache.kt # Response decryption cache @@ -107,8 +107,8 @@ nip47WalletConnect/ | Kind | Class | Direction | Purpose | |-------|------------------------------|-----------------|----------------------| | 13194 | `NwcInfoEvent` | Wallet → Relay | Service capabilities | -| 23194 | `LnZapPaymentRequestEvent` | Client → Wallet | NWC request | -| 23195 | `LnZapPaymentResponseEvent` | Wallet → Client | NWC response | +| 23194 | `NwcRequestEvent` | Client → Wallet | NWC request | +| 23195 | `NwcResponseEvent` | Wallet → Client | NWC response | | 23196 | `NwcNotificationEvent` | Wallet → Client | Notification (NIP-04, legacy) | | 23197 | `NwcNotificationEvent` | Wallet → Client | Notification (NIP-44) | @@ -161,7 +161,7 @@ val clientSigner = NostrSignerInternal( ```kotlin val balanceRequest = GetBalanceMethod.create() -val event = LnZapPaymentRequestEvent.createRequest( +val event = NwcRequestEvent.createRequest( request = balanceRequest, walletServicePubkey = nwcConfig.pubKeyHex, signer = clientSigner, @@ -172,7 +172,7 @@ val event = LnZapPaymentRequestEvent.createRequest( To use NIP-44 encryption instead of NIP-04: ```kotlin -val event = LnZapPaymentRequestEvent.createRequest( +val event = NwcRequestEvent.createRequest( request = GetInfoMethod.create(), walletServicePubkey = nwcConfig.pubKeyHex, signer = clientSigner, @@ -238,7 +238,7 @@ val request: Request = requestEvent.decryptRequest(walletSigner) val balanceResponse = GetBalanceSuccessResponse( GetBalanceSuccessResponse.GetBalanceResult(balance = 2100000L) ) -val responseEvent = LnZapPaymentResponseEvent.createResponse( +val responseEvent = NwcResponseEvent.createResponse( response = balanceResponse, requestEvent = requestEvent, signer = walletSigner, @@ -249,7 +249,7 @@ val errorResponse = NwcErrorResponse( resultType = NwcMethod.PAY_INVOICE, error = NwcError(NwcErrorCode.INSUFFICIENT_BALANCE, "Not enough funds"), ) -val errorEvent = LnZapPaymentResponseEvent.createResponse( +val errorEvent = NwcResponseEvent.createResponse( response = errorResponse, requestEvent = requestEvent, signer = walletSigner, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/cache/NostrWalletConnectRequestCache.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/cache/NostrWalletConnectRequestCache.kt index 59a160871f..b76e0320ac 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/cache/NostrWalletConnectRequestCache.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/cache/NostrWalletConnectRequestCache.kt @@ -23,15 +23,15 @@ package com.vitorpamplona.quartz.nip47WalletConnect.cache import androidx.collection.LruCache import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.caches.DecryptCache -import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcRequestEvent import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request class NostrWalletConnectRequestCache( signer: NostrSigner, ) { private val decryptionCache = - object : LruCache(50) { - override fun create(key: LnZapPaymentRequestEvent): NWCRequestDecryptCache? = + object : LruCache(50) { + override fun create(key: NwcRequestEvent): NWCRequestDecryptCache? = if (key.content.isNotBlank() && key.canDecrypt(signer)) { NWCRequestDecryptCache(signer) } else { @@ -39,16 +39,16 @@ class NostrWalletConnectRequestCache( } } - fun cachedRequest(event: LnZapPaymentRequestEvent): Request? = decryptionCache[event]?.cached() + fun cachedRequest(event: NwcRequestEvent): Request? = decryptionCache[event]?.cached() - suspend fun decryptRequest(event: LnZapPaymentRequestEvent) = decryptionCache[event]?.decrypt(event) + suspend fun decryptRequest(event: NwcRequestEvent) = decryptionCache[event]?.decrypt(event) } class NWCRequestDecryptCache( signer: NostrSigner, -) : DecryptCache(signer) { +) : DecryptCache(signer) { override suspend fun decryptAndParse( - event: LnZapPaymentRequestEvent, + event: NwcRequestEvent, signer: NostrSigner, ) = event.decryptRequest(signer) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/cache/NostrWalletConnectResponseCache.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/cache/NostrWalletConnectResponseCache.kt index c291c2c988..818b1041dc 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/cache/NostrWalletConnectResponseCache.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/cache/NostrWalletConnectResponseCache.kt @@ -23,15 +23,15 @@ package com.vitorpamplona.quartz.nip47WalletConnect.cache import androidx.collection.LruCache import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.caches.DecryptCache -import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcResponseEvent import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response class NostrWalletConnectResponseCache( signer: NostrSigner, ) { private val decryptionCache = - object : LruCache(50) { - override fun create(key: LnZapPaymentResponseEvent): NWCResponseDecryptCache? = + object : LruCache(50) { + override fun create(key: NwcResponseEvent): NWCResponseDecryptCache? = if (key.content.isNotBlank() && key.canDecrypt(signer)) { NWCResponseDecryptCache(signer) } else { @@ -39,16 +39,16 @@ class NostrWalletConnectResponseCache( } } - fun cachedResponse(event: LnZapPaymentResponseEvent): Response? = decryptionCache[event]?.cached() + fun cachedResponse(event: NwcResponseEvent): Response? = decryptionCache[event]?.cached() - suspend fun decryptResponse(event: LnZapPaymentResponseEvent) = decryptionCache[event]?.decrypt(event) + suspend fun decryptResponse(event: NwcResponseEvent) = decryptionCache[event]?.decrypt(event) } class NWCResponseDecryptCache( signer: NostrSigner, -) : DecryptCache(signer) { +) : DecryptCache(signer) { override suspend fun decryptAndParse( - event: LnZapPaymentResponseEvent, + event: NwcResponseEvent, signer: NostrSigner, ) = event.decrypt(signer) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/events/LnZapPaymentRequestEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/events/NwcRequestEvent.kt similarity index 91% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/events/LnZapPaymentRequestEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/events/NwcRequestEvent.kt index 010ebbef74..fc549558fc 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/events/LnZapPaymentRequestEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/events/NwcRequestEvent.kt @@ -31,7 +31,7 @@ import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request import com.vitorpamplona.quartz.utils.TimeUtils @Immutable -class LnZapPaymentRequestEvent( +class NwcRequestEvent( id: HexKey, pubKey: HexKey, createdAt: Long, @@ -64,7 +64,7 @@ class LnZapPaymentRequestEvent( signer: NostrSigner, createdAt: Long = TimeUtils.now(), useNip44: Boolean = false, - ): LnZapPaymentRequestEvent = + ): NwcRequestEvent = createRequest( PayInvoiceMethod.create(lnInvoice), walletServicePubkey, @@ -79,7 +79,7 @@ class LnZapPaymentRequestEvent( signer: NostrSigner, createdAt: Long = TimeUtils.now(), useNip44: Boolean = false, - ): LnZapPaymentRequestEvent { + ): NwcRequestEvent { val serializedRequest = OptimizedJsonMapper.toJson(request) val tags = @@ -100,3 +100,9 @@ class LnZapPaymentRequestEvent( } } } + +@Deprecated( + "Renamed to NwcRequestEvent. Kind 23194 is the generic NIP-47 wallet request, not only a zap payment.", + ReplaceWith("NwcRequestEvent", "com.vitorpamplona.quartz.nip47WalletConnect.events.NwcRequestEvent"), +) +typealias LnZapPaymentRequestEvent = NwcRequestEvent diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/events/LnZapPaymentResponseEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/events/NwcResponseEvent.kt similarity index 90% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/events/LnZapPaymentResponseEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/events/NwcResponseEvent.kt index 88b6519e95..7c8769191a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/events/LnZapPaymentResponseEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/events/NwcResponseEvent.kt @@ -30,7 +30,7 @@ import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response import com.vitorpamplona.quartz.utils.TimeUtils @Immutable -class LnZapPaymentResponseEvent( +class NwcResponseEvent( id: HexKey, pubKey: HexKey, createdAt: Long, @@ -69,11 +69,11 @@ class LnZapPaymentResponseEvent( */ suspend fun createResponse( response: Response, - requestEvent: LnZapPaymentRequestEvent, + requestEvent: NwcRequestEvent, signer: NostrSigner, useNip44: Boolean = false, createdAt: Long = TimeUtils.now(), - ): LnZapPaymentResponseEvent { + ): NwcResponseEvent { val serializedResponse = OptimizedJsonMapper.toJson(response) val clientPubkey = requestEvent.pubKey @@ -95,3 +95,9 @@ class LnZapPaymentResponseEvent( } } } + +@Deprecated( + "Renamed to NwcResponseEvent. Kind 23195 is the generic NIP-47 wallet response, not only a zap payment.", + ReplaceWith("NwcResponseEvent", "com.vitorpamplona.quartz.nip47WalletConnect.events.NwcResponseEvent"), +) +typealias LnZapPaymentResponseEvent = NwcResponseEvent diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/NwcTransactionMetadata.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/NwcTransactionMetadata.kt index d5fca147f8..4a8d5dbf98 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/NwcTransactionMetadata.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/NwcTransactionMetadata.kt @@ -179,7 +179,7 @@ class NwcTransactionMetadata( * * NIP-57 sets a zap invoice's `description_hash` to the sha256 of the raw * JSON the LNURL callback received in `nostr=`, and that is - * `LnZapRequestEvent.toJson()` — the exact string used here. A wallet can + * `ZapRequestEvent.toJson()` — the exact string used here. A wallet can * therefore bind this stored event to the invoice it labels, which is what * turns "the client says it paid X" into something the wallet checked. * diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/SearchFieldExtractor.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/SearchFieldExtractor.kt index 97fcb8b7cb..139530d089 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/SearchFieldExtractor.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/SearchFieldExtractor.kt @@ -56,11 +56,11 @@ import com.vitorpamplona.quartz.nip15Marketplace.marketplace.MarketplaceEvent import com.vitorpamplona.quartz.nip15Marketplace.product.ProductEvent import com.vitorpamplona.quartz.nip15Marketplace.stall.StallEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent -import com.vitorpamplona.quartz.nip29RelayGroups.moderation.EditMetadataEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.GroupEditMetadataEvent import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent import com.vitorpamplona.quartz.nip32Labeling.LabelEvent import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent @@ -71,24 +71,24 @@ import com.vitorpamplona.quartz.nip51Lists.appCurationSet.AppCurationSetEvent import com.vitorpamplona.quartz.nip51Lists.articleCurationSet.ArticleCurationSetEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent -import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkSet.BookmarkSetEvent +import com.vitorpamplona.quartz.nip51Lists.followSet.FollowSetEvent import com.vitorpamplona.quartz.nip51Lists.interestSet.InterestSetEvent -import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.LabeledBookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.mediaStarterPack.MediaStarterPackEvent -import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import com.vitorpamplona.quartz.nip51Lists.pictureCurationSet.PictureCurationSetEvent import com.vitorpamplona.quartz.nip51Lists.relaySets.RelaySetEvent import com.vitorpamplona.quartz.nip51Lists.releaseArtifactSet.ReleaseArtifactSetEvent +import com.vitorpamplona.quartz.nip51Lists.starterPack.StarterPackEvent import com.vitorpamplona.quartz.nip51Lists.videoCurationSet.VideoCurationSetEvent import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent -import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent +import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarCollectionEvent import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent -import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent +import com.vitorpamplona.quartz.nip54Wiki.WikiArticleEvent import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent import com.vitorpamplona.quartz.nip5aStaticWebsites.NamedSiteEvent import com.vitorpamplona.quartz.nip5aStaticWebsites.RootSiteEvent @@ -100,13 +100,13 @@ import com.vitorpamplona.quartz.nip69P2pOrderEvents.P2POrderEvent import com.vitorpamplona.quartz.nip71Video.AddressableVideoEvent import com.vitorpamplona.quartz.nip71Video.RegularVideoEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent -import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent +import com.vitorpamplona.quartz.nip75ZapGoals.ZapGoalEvent import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent -import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent +import com.vitorpamplona.quartz.nip85TrustedAssertions.users.UserAssertionEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent -import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent +import com.vitorpamplona.quartz.nip94FileMetadata.FileMetadataEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent @@ -158,11 +158,11 @@ object SearchFieldExtractor { } } - is LongTextNoteEvent -> { + is LongFormContentEvent -> { tiers(event, event.title(), event.summary(), event.content) } - is WikiNoteEvent -> { + is WikiArticleEvent -> { tiers(event, event.title(), event.summary(), event.content) } @@ -263,7 +263,7 @@ object SearchFieldExtractor { tiers(event, event.title(), null, event.content) } - is CalendarEvent -> { + is CalendarCollectionEvent -> { tiers(event, event.title(), null, event.content) } @@ -352,7 +352,7 @@ object SearchFieldExtractor { // kind 9002 edits the very metadata kind 39000 publishes, so it // splits the same way -- it was the only half of the pair falling // through. Its hashtags() is `t`, carried once by the funnel. - is EditMetadataEvent -> { + is GroupEditMetadataEvent -> { tiers(event, event.name(), event.about(), null) } @@ -360,7 +360,7 @@ object SearchFieldExtractor { tiers(event, event.title(), event.description(), null) } - is FollowListEvent -> { + is StarterPackEvent -> { tiers(event, event.title(), event.description(), null) } @@ -448,7 +448,7 @@ object SearchFieldExtractor { // concatenates the topics INTO the body while the funnel added // them as hashtags. Whether that role is tokenized or kept as // keywords is the backend's call, per IndexableFields. - is ContactCardEvent -> { + is UserAssertionEvent -> { tiers(event, event.petName(), event.summary(), null) } @@ -456,11 +456,11 @@ object SearchFieldExtractor { tiers(event, event.title(), null, null) } - is LabeledBookmarkListEvent -> { + is BookmarkSetEvent -> { tiers(event, event.titleOrName(), event.description(), null) } - is PeopleListEvent -> { + is FollowSetEvent -> { tiers(event, event.titleOrName(), event.description(), null) } @@ -472,7 +472,7 @@ object SearchFieldExtractor { tiers(event, event.title(), null, null) } - is GoalEvent -> { + is ZapGoalEvent -> { tiers(event, null, event.summary(), event.content) } @@ -480,7 +480,7 @@ object SearchFieldExtractor { tiers(event, emptyList(), listOf(event.comment(), event.context()), event.content) } - is FileHeaderEvent -> { + is FileMetadataEvent -> { tiers(event, null, event.summary(), event.content) } @@ -607,7 +607,7 @@ object SearchFieldExtractor { // kinds 1111/1311 -- body-only kinds whose indexableContent() // concatenates the `t` tags INTO the body. The funnel already // carries them in the hashtag role, so passing the body alone - // stops the same words being indexed twice (the ContactCard + // stops the same words being indexed twice (the UserAssertion // reasoning above, applied to the two chat/comment kinds). is CommentEvent -> { tiers(event, null, null, event.content) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/SearchableKinds.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/SearchableKinds.kt index 7c7eb0e954..357eb0c7e2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/SearchableKinds.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/SearchableKinds.kt @@ -71,7 +71,7 @@ object SearchableKinds { 54, // PodcastEpisodeEvent 818, // WikiMergeRequestEvent 1010, // TextNoteModificationEvent - 1063, // FileHeaderEvent + 1063, // FileMetadataEvent 1065, // FileStorageHeaderEvent 1068, // PollEvent 1111, // CommentEvent @@ -96,20 +96,20 @@ object SearchableKinds { 2004, // TorrentCommentEvent 2473, // BirdDetectionEvent 3302, // ConcordChatEditEvent - 5050, // NIP90TextGenerationRequestEvent - 5100, // NIP90ImageGenerationRequestEvent + 5050, // DvmTextGenerationRequestEvent + 5100, // DvmImageGenerationRequestEvent 5129, // NappletSnapshotEvent - 5250, // NIP90TextToSpeechRequestEvent - 5302, // NIP90ContentSearchRequestEvent - 5303, // NIP90PeopleSearchRequestEvent + 5250, // DvmTextToSpeechRequestEvent + 5302, // DvmContentSearchRequestEvent + 5303, // DvmPeopleSearchRequestEvent 6969, // ZapPollEvent 7516, // GeocacheFoundLogEvent 8333, // OnchainZapEvent - 9002, // EditMetadataEvent - 9041, // GoalEvent + 9002, // GroupEditMetadataEvent + 9041, // ZapGoalEvent 9321, // NutzapEvent - 9734, // LnZapRequestEvent - 9735, // LnZapEvent + 9734, // ZapRequestEvent + 9735, // ZapReceiptEvent 9736, // Bolt12ZapEvent 9737, // Bolt12ZapIntentEvent 9802, // HighlightEvent @@ -122,10 +122,10 @@ object SearchableKinds { 12473, // BirdexEvent 15128, // RootSiteEvent 15129, // RootNappletEvent - 30000, // PeopleListEvent + 30000, // FollowSetEvent 30001, // OldBookmarkListEvent 30002, // RelaySetEvent - 30003, // LabeledBookmarkListEvent + 30003, // BookmarkSetEvent 30004, // ArticleCurationSetEvent 30005, // VideoCurationSetEvent 30006, // PictureCurationSetEvent @@ -135,7 +135,7 @@ object SearchableKinds { 30018, // ProductEvent 30019, // MarketplaceEvent 30020, // AuctionEvent - 30023, // LongTextNoteEvent + 30023, // LongFormContentEvent 30030, // EmojiPackEvent 30040, // PublicationIndexEvent 30041, // PublicationContentEvent @@ -153,8 +153,8 @@ object SearchableKinds { 30311, // LiveActivitiesEvent 30312, // MeetingSpaceEvent 30313, // MeetingRoomEvent - 30315, // StatusEvent - 30382, // ContactCardEvent + 30315, // UserStatusEvent + 30382, // UserAssertionEvent 30392, // UserTrustedListEvent 30393, // EventTrustedListEvent 30394, // AddressableTrustedListEvent @@ -163,14 +163,14 @@ object SearchableKinds { 30617, // GitRepositoryEvent 30620, // WorkflowDefEvent 30817, // NipTextEvent - 30818, // WikiNoteEvent + 30818, // WikiArticleEvent 31337, // AudioTrackEvent 31871, // AttestationEvent 31872, // AttestationRequestEvent 31873, // AttestorRecommendationEvent 31922, // CalendarDateSlotEvent 31923, // CalendarTimeSlotEvent - 31924, // CalendarEvent + 31924, // CalendarCollectionEvent 31925, // CalendarRSVPEvent 31987, // RelayReviewEvent 31990, // AppDefinitionEvent @@ -179,8 +179,8 @@ object SearchableKinds { 33401, // ExerciseTemplateEvent 33863, // FundraiserEvent 34139, // MusicPlaylistEvent - 34235, // VideoHorizontalEvent - 34236, // VideoVerticalEvent + 34235, // AddressableNormalVideoEvent + 34236, // AddressableShortVideoEvent 34259, // EntityRatingEvent 34550, // CommunityDefinitionEvent 35128, // NamedSiteEvent @@ -192,7 +192,7 @@ object SearchableKinds { 38192, // Ps1SaveEvent 38383, // P2POrderEvent 39000, // GroupMetadataEvent - 39089, // FollowListEvent + 39089, // StarterPackEvent 39092, // MediaStarterPackEvent 39307, // TextTrackEvent 39701, // WebBookmarkEvent diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/labeledBookmarkList/LabeledBookmarkListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/bookmarkSet/BookmarkSetEvent.kt similarity index 94% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/labeledBookmarkList/LabeledBookmarkListEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/bookmarkSet/BookmarkSetEvent.kt index 2edbdca9e1..318f74f5c5 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/labeledBookmarkList/LabeledBookmarkListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/bookmarkSet/BookmarkSetEvent.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList +package com.vitorpamplona.quartz.nip51Lists.bookmarkSet import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Address @@ -49,7 +49,7 @@ import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid @Immutable -class LabeledBookmarkListEvent( +class BookmarkSetEvent( id: HexKey, pubKey: HexKey, createdAt: Long, @@ -113,7 +113,7 @@ class LabeledBookmarkListEvent( optionalListDescription: String? = null, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - ): LabeledBookmarkListEvent = + ): BookmarkSetEvent = if (isPrivate) { create( title = title, @@ -135,12 +135,12 @@ class LabeledBookmarkListEvent( } suspend fun addBookmark( - earlierVersion: LabeledBookmarkListEvent, + earlierVersion: BookmarkSetEvent, bookmarkIdTag: BookmarkIdTag, isPrivate: Boolean, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - ): LabeledBookmarkListEvent = + ): BookmarkSetEvent = if (isPrivate) { val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() resign( @@ -159,12 +159,12 @@ class LabeledBookmarkListEvent( } suspend fun moveBookmark( - earlierVersion: LabeledBookmarkListEvent, + earlierVersion: BookmarkSetEvent, bookmarkIdTag: BookmarkIdTag, isCurrentlyPrivate: Boolean, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - ): LabeledBookmarkListEvent = + ): BookmarkSetEvent = if (isCurrentlyPrivate) { val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() resign( @@ -184,12 +184,12 @@ class LabeledBookmarkListEvent( } suspend fun removeBookmark( - earlierVersion: LabeledBookmarkListEvent, + earlierVersion: BookmarkSetEvent, bookmarkIdTag: BookmarkIdTag, isPrivate: Boolean, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - ): LabeledBookmarkListEvent = + ): BookmarkSetEvent = if (isPrivate) { val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() resign( @@ -208,11 +208,11 @@ class LabeledBookmarkListEvent( } suspend fun modifyName( - earlierVersion: LabeledBookmarkListEvent, + earlierVersion: BookmarkSetEvent, newTitle: String, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - ): LabeledBookmarkListEvent { + ): BookmarkSetEvent { val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() val currentTitle = earlierVersion.tags.first { it[0] == NameTag.TAG_NAME || it[0] == TitleTag.TAG_NAME } val newTitleTag = @@ -231,11 +231,11 @@ class LabeledBookmarkListEvent( } suspend fun modifyDescription( - earlierVersion: LabeledBookmarkListEvent, + earlierVersion: BookmarkSetEvent, newDescription: String?, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - ): LabeledBookmarkListEvent? { + ): BookmarkSetEvent? { val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() val currentDescriptionTag = earlierVersion.tags.firstOrNull { it[0] == DescriptionTag.TAG_NAME } val currentDescription = currentDescriptionTag?.get(1) @@ -288,7 +288,7 @@ class LabeledBookmarkListEvent( tags: TagArray, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - ): LabeledBookmarkListEvent { + ): BookmarkSetEvent { val newTags = tags return signer.sign(createdAt, KIND, newTags, content) @@ -304,7 +304,7 @@ class LabeledBookmarkListEvent( dTag: String = Uuid.random().toString(), signer: NostrSigner, createdAt: Long = TimeUtils.now(), - ): LabeledBookmarkListEvent { + ): BookmarkSetEvent { val template = build(title, publicBookmarks, privateBookmarks, signer, dTag, createdAt) { if (description != null) description(description) @@ -321,7 +321,7 @@ class LabeledBookmarkListEvent( signer: NostrSigner, dTag: String = Uuid.random().toString(), createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, + initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate( kind = KIND, description = PrivateTagsInContent.encryptNip44(privateBookmarks.map { it.toTagArray() }.toTypedArray(), signer), diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/labeledBookmarkList/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/bookmarkSet/TagArrayBuilderExt.kt similarity index 71% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/labeledBookmarkList/TagArrayBuilderExt.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/bookmarkSet/TagArrayBuilderExt.kt index 109d81a516..568b662ff4 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/labeledBookmarkList/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/bookmarkSet/TagArrayBuilderExt.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList +package com.vitorpamplona.quartz.nip51Lists.bookmarkSet import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.BookmarkIdTag @@ -28,12 +28,12 @@ import com.vitorpamplona.quartz.nip51Lists.tags.NameTag import com.vitorpamplona.quartz.nip51Lists.tags.TitleTag @Deprecated("NIP-51 has deprecated name. Use title instead", ReplaceWith("title()")) -fun TagArrayBuilder.name(name: String) = addUnique(NameTag.assemble(name)) +fun TagArrayBuilder.name(name: String) = addUnique(NameTag.assemble(name)) -fun TagArrayBuilder.title(title: String) = addUnique(TitleTag.assemble(title)) +fun TagArrayBuilder.title(title: String) = addUnique(TitleTag.assemble(title)) -fun TagArrayBuilder.bookmarks(bookmarks: List) = addAll(bookmarks.map { it.toTagArray() }) +fun TagArrayBuilder.bookmarks(bookmarks: List) = addAll(bookmarks.map { it.toTagArray() }) -fun TagArrayBuilder.description(listDescription: String) = addUnique(DescriptionTag.assemble(listDescription)) +fun TagArrayBuilder.description(listDescription: String) = addUnique(DescriptionTag.assemble(listDescription)) -fun TagArrayBuilder.image(url: String) = addUnique(ImageTag.assemble(url)) +fun TagArrayBuilder.image(url: String) = addUnique(ImageTag.assemble(url)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/followList/Deprecated.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/followList/Deprecated.kt new file mode 100644 index 0000000000..f4ba91cd99 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/followList/Deprecated.kt @@ -0,0 +1,90 @@ +/* + * 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. + */ +@file:Suppress("DEPRECATION") + +package com.vitorpamplona.quartz.nip51Lists.followList + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag +import com.vitorpamplona.quartz.nip51Lists.starterPack.StarterPackEvent +import com.vitorpamplona.quartz.nip51Lists.starterPack.description as movedDescription +import com.vitorpamplona.quartz.nip51Lists.starterPack.followIdSet as movedFollowIdSet +import com.vitorpamplona.quartz.nip51Lists.starterPack.followIds as movedFollowIds +import com.vitorpamplona.quartz.nip51Lists.starterPack.follows as movedFollows +import com.vitorpamplona.quartz.nip51Lists.starterPack.image as movedImage +import com.vitorpamplona.quartz.nip51Lists.starterPack.people as movedPeople +import com.vitorpamplona.quartz.nip51Lists.starterPack.person as movedPerson +import com.vitorpamplona.quartz.nip51Lists.starterPack.personFirst as movedPersonFirst +import com.vitorpamplona.quartz.nip51Lists.starterPack.removePerson as movedRemovePerson +import com.vitorpamplona.quartz.nip51Lists.starterPack.title as movedTitle + +// This package was renamed to com.vitorpamplona.quartz.nip51Lists.starterPack (NIP-51 kind 39089 is a starter pack). +// Everything below forwards to it and will be removed in a future release. + +private const val MOVED = "Moved to com.vitorpamplona.quartz.nip51Lists.starterPack" + +@Deprecated( + "Renamed to StarterPackEvent and moved to com.vitorpamplona.quartz.nip51Lists.starterPack. NIP-51 kind 39089 is a starter pack.", + ReplaceWith("StarterPackEvent", "com.vitorpamplona.quartz.nip51Lists.starterPack.StarterPackEvent"), +) +typealias FollowListEvent = StarterPackEvent + +@Deprecated(MOVED, ReplaceWith("title(title)", "com.vitorpamplona.quartz.nip51Lists.starterPack.title")) +fun TagArrayBuilder.title(title: String) = movedTitle(title) + +@Deprecated(MOVED, ReplaceWith("description(desc)", "com.vitorpamplona.quartz.nip51Lists.starterPack.description")) +fun TagArrayBuilder.description(desc: String) = movedDescription(desc) + +@Deprecated(MOVED, ReplaceWith("image(imageUrl)", "com.vitorpamplona.quartz.nip51Lists.starterPack.image")) +fun TagArrayBuilder.image(imageUrl: String) = movedImage(imageUrl) + +@Deprecated(MOVED, ReplaceWith("people(peoples)", "com.vitorpamplona.quartz.nip51Lists.starterPack.people")) +fun TagArrayBuilder.people(peoples: List) = movedPeople(peoples) + +@Deprecated(MOVED, ReplaceWith("person(person)", "com.vitorpamplona.quartz.nip51Lists.starterPack.person")) +fun TagArrayBuilder.person(person: UserTag) = movedPerson(person) + +@Deprecated(MOVED, ReplaceWith("person(pubkey, relayHint)", "com.vitorpamplona.quartz.nip51Lists.starterPack.person")) +fun TagArrayBuilder.person( + pubkey: HexKey, + relayHint: NormalizedRelayUrl?, +) = movedPerson(pubkey, relayHint) + +@Deprecated(MOVED, ReplaceWith("personFirst(pubkey, relayHint)", "com.vitorpamplona.quartz.nip51Lists.starterPack.personFirst")) +fun TagArrayBuilder.personFirst( + pubkey: HexKey, + relayHint: NormalizedRelayUrl?, +) = movedPersonFirst(pubkey, relayHint) + +@Deprecated(MOVED, ReplaceWith("removePerson(pubkey)", "com.vitorpamplona.quartz.nip51Lists.starterPack.removePerson")) +fun TagArrayBuilder.removePerson(pubkey: HexKey) = movedRemovePerson(pubkey) + +@Deprecated(MOVED, ReplaceWith("follows()", "com.vitorpamplona.quartz.nip51Lists.starterPack.follows")) +fun TagArray.follows() = movedFollows() + +@Deprecated(MOVED, ReplaceWith("followIds()", "com.vitorpamplona.quartz.nip51Lists.starterPack.followIds")) +fun TagArray.followIds() = movedFollowIds() + +@Deprecated(MOVED, ReplaceWith("followIdSet()", "com.vitorpamplona.quartz.nip51Lists.starterPack.followIdSet")) +fun TagArray.followIdSet() = movedFollowIdSet() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/peopleList/PeopleListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/followSet/FollowSetEvent.kt similarity index 93% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/peopleList/PeopleListEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/followSet/FollowSetEvent.kt index 906f06587e..fe0353dd48 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/peopleList/PeopleListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/followSet/FollowSetEvent.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip51Lists.peopleList +package com.vitorpamplona.quartz.nip51Lists.followSet import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Address @@ -48,7 +48,7 @@ import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid @Immutable -class PeopleListEvent( +class FollowSetEvent( id: HexKey, pubKey: HexKey, createdAt: Long, @@ -121,7 +121,7 @@ class PeopleListEvent( signer: NostrSigner, dTag: String = Uuid.random().toString(), createdAt: Long = TimeUtils.now(), - ): PeopleListEvent = + ): FollowSetEvent = if (isPrivate) { create( title = title, @@ -143,12 +143,12 @@ class PeopleListEvent( } suspend fun add( - earlierVersion: PeopleListEvent, + earlierVersion: FollowSetEvent, person: UserTag, isPrivate: Boolean, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - ): PeopleListEvent = + ): FollowSetEvent = if (isPrivate) { val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() resign( @@ -167,12 +167,12 @@ class PeopleListEvent( } suspend fun addFirst( - earlierVersion: PeopleListEvent, + earlierVersion: FollowSetEvent, person: UserTag, isPrivate: Boolean, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - ): PeopleListEvent = + ): FollowSetEvent = if (isPrivate) { val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() resign( @@ -191,11 +191,11 @@ class PeopleListEvent( } suspend fun remove( - earlierVersion: PeopleListEvent, + earlierVersion: FollowSetEvent, person: MuteTag, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - ): PeopleListEvent { + ): FollowSetEvent { val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() return resign( @@ -207,11 +207,11 @@ class PeopleListEvent( } suspend fun removeAll( - earlierVersion: PeopleListEvent, + earlierVersion: FollowSetEvent, persons: List, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - ): PeopleListEvent { + ): FollowSetEvent { val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() val ids = persons.map { it.toTagIdOnly() } @@ -224,12 +224,12 @@ class PeopleListEvent( } suspend fun remove( - earlierVersion: PeopleListEvent, + earlierVersion: FollowSetEvent, person: UserTag, isPrivate: Boolean, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - ): PeopleListEvent { + ): FollowSetEvent { if (isPrivate) { val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() return resign( @@ -265,7 +265,7 @@ class PeopleListEvent( tags: Array>, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - ): PeopleListEvent { + ): FollowSetEvent { val newTags = tags return signer.sign(createdAt, KIND, newTags, content) @@ -279,7 +279,7 @@ class PeopleListEvent( signer: NostrSigner, dTag: String = Uuid.random().toString(), createdAt: Long = TimeUtils.now(), - ): PeopleListEvent { + ): FollowSetEvent { val template = build(title, publicMembers, privateMembers, signer, dTag, createdAt) return signer.sign(template) } @@ -292,8 +292,8 @@ class PeopleListEvent( signer: NostrSigner, dTag: String = Uuid.random().toString(), createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, - ) = eventTemplate( + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate( kind = KIND, description = PrivateTagsInContent.encryptNip44(privateMembers.map { it.toTagArray() }.toTypedArray(), signer), createdAt = createdAt, @@ -313,7 +313,7 @@ class PeopleListEvent( privateMembers: List = emptyList(), signer: NostrSigner, createdAt: Long = TimeUtils.now(), - ): PeopleListEvent { + ): FollowSetEvent { val newListTemplate = build( title = title, @@ -336,7 +336,7 @@ class PeopleListEvent( privateMembers: List = emptyList(), signer: NostrSigner, createdAt: Long = TimeUtils.now(), - ): PeopleListEvent { + ): FollowSetEvent { val cloneTemplate = build( title = title, @@ -358,7 +358,7 @@ class PeopleListEvent( isPrivate: Boolean, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - ): PeopleListEvent = + ): FollowSetEvent = create( title = title, person = UserTag(pubKey = pubKeyHex), @@ -368,13 +368,13 @@ class PeopleListEvent( ) suspend fun addUser( - earlierVersion: PeopleListEvent, + earlierVersion: FollowSetEvent, pubKeyHex: String, relayHint: NormalizedRelayUrl?, isPrivate: Boolean, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - ): PeopleListEvent = + ): FollowSetEvent = add( earlierVersion = earlierVersion, person = UserTag(pubKey = pubKeyHex, relayHint), @@ -384,13 +384,13 @@ class PeopleListEvent( ) suspend fun addUserFirst( - earlierVersion: PeopleListEvent, + earlierVersion: FollowSetEvent, pubKeyHex: String, relayHint: NormalizedRelayUrl?, isPrivate: Boolean, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - ): PeopleListEvent = + ): FollowSetEvent = addFirst( earlierVersion = earlierVersion, person = UserTag(pubKey = pubKeyHex, relayHint), @@ -400,12 +400,12 @@ class PeopleListEvent( ) suspend fun removeUser( - earlierVersion: PeopleListEvent, + earlierVersion: FollowSetEvent, pubKeyHex: String, isUserPrivate: Boolean, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - ): PeopleListEvent = + ): FollowSetEvent = remove( earlierVersion = earlierVersion, person = UserTag(pubKey = pubKeyHex), diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/peopleList/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/followSet/TagArrayBuilderExt.kt similarity index 74% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/peopleList/TagArrayBuilderExt.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/followSet/TagArrayBuilderExt.kt index 5129023f76..18e1ef4cba 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/peopleList/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/followSet/TagArrayBuilderExt.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip51Lists.peopleList +package com.vitorpamplona.quartz.nip51Lists.followSet import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag @@ -28,12 +28,12 @@ import com.vitorpamplona.quartz.nip51Lists.tags.NameTag import com.vitorpamplona.quartz.nip51Lists.tags.TitleTag @Deprecated("NIP-51 has deprecated name. Use title instead", ReplaceWith("title()")) -fun TagArrayBuilder.name(name: String) = addUnique(NameTag.assemble(name)) +fun TagArrayBuilder.name(name: String) = addUnique(NameTag.assemble(name)) -fun TagArrayBuilder.title(name: String) = addUnique(TitleTag.assemble(name)) +fun TagArrayBuilder.title(name: String) = addUnique(TitleTag.assemble(name)) -fun TagArrayBuilder.description(desc: String) = addUnique(DescriptionTag.assemble(desc)) +fun TagArrayBuilder.description(desc: String) = addUnique(DescriptionTag.assemble(desc)) -fun TagArrayBuilder.image(url: String) = addUnique(ImageTag.assemble(url)) +fun TagArrayBuilder.image(url: String) = addUnique(ImageTag.assemble(url)) -fun TagArrayBuilder.peoples(peoples: List) = addAll(peoples.map { it.toTagArray() }) +fun TagArrayBuilder.peoples(peoples: List) = addAll(peoples.map { it.toTagArray() }) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/peopleList/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/followSet/TagArrayExt.kt similarity index 96% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/peopleList/TagArrayExt.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/followSet/TagArrayExt.kt index bad2b2563c..d0cd484542 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/peopleList/TagArrayExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/followSet/TagArrayExt.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip51Lists.peopleList +package com.vitorpamplona.quartz.nip51Lists.followSet import com.vitorpamplona.quartz.nip01Core.core.TagArray import com.vitorpamplona.quartz.nip51Lists.muteList.tags.MuteTag diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/hashtagList/Deprecated.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/hashtagList/Deprecated.kt new file mode 100644 index 0000000000..417d0575b9 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/hashtagList/Deprecated.kt @@ -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. + */ +@file:Suppress("DEPRECATION") + +package com.vitorpamplona.quartz.nip51Lists.hashtagList + +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip51Lists.interestList.InterestListDiff +import com.vitorpamplona.quartz.nip51Lists.interestList.InterestListEvent +import com.vitorpamplona.quartz.nip51Lists.interestList.followHashTag as movedFollowHashTag +import com.vitorpamplona.quartz.nip51Lists.interestList.hashtagList as movedHashtagList +import com.vitorpamplona.quartz.nip51Lists.interestList.hashtagSet as movedHashtagSet +import com.vitorpamplona.quartz.nip51Lists.interestList.hashtags as movedHashtags + +// This package was renamed to com.vitorpamplona.quartz.nip51Lists.interestList (NIP-51 kind 10015 is the interests list). +// Everything below forwards to it and will be removed in a future release. + +private const val MOVED = "Moved to com.vitorpamplona.quartz.nip51Lists.interestList" + +@Deprecated( + "Renamed to InterestListEvent and moved to com.vitorpamplona.quartz.nip51Lists.interestList. NIP-51 kind 10015 is the interests list.", + ReplaceWith("InterestListEvent", "com.vitorpamplona.quartz.nip51Lists.interestList.InterestListEvent"), +) +typealias HashtagListEvent = InterestListEvent + +@Deprecated( + "Renamed to InterestListDiff and moved to com.vitorpamplona.quartz.nip51Lists.interestList. NIP-51 kind 10015 is the interests list.", + ReplaceWith("InterestListDiff", "com.vitorpamplona.quartz.nip51Lists.interestList.InterestListDiff"), +) +typealias HashtagListDiff = InterestListDiff + +@Deprecated(MOVED, ReplaceWith("followHashTag(hashtag)", "com.vitorpamplona.quartz.nip51Lists.interestList.followHashTag")) +fun TagArrayBuilder.followHashTag(hashtag: String) = movedFollowHashTag(hashtag) + +@Deprecated(MOVED, ReplaceWith("hashtags(hashtags)", "com.vitorpamplona.quartz.nip51Lists.interestList.hashtags")) +fun TagArrayBuilder.hashtags(hashtags: List) = movedHashtags(hashtags) + +@Deprecated(MOVED, ReplaceWith("hashtagList()", "com.vitorpamplona.quartz.nip51Lists.interestList.hashtagList")) +fun TagArray.hashtagList() = movedHashtagList() + +@Deprecated(MOVED, ReplaceWith("hashtagSet()", "com.vitorpamplona.quartz.nip51Lists.interestList.hashtagSet")) +fun TagArray.hashtagSet() = movedHashtagSet() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/hashtagList/HashtagListDiff.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/interestList/InterestListDiff.kt similarity index 95% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/hashtagList/HashtagListDiff.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/interestList/InterestListDiff.kt index 21faacdee8..34284ce4db 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/hashtagList/HashtagListDiff.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/interestList/InterestListDiff.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip51Lists.hashtagList +package com.vitorpamplona.quartz.nip51Lists.interestList import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.diff.ContentChange @@ -27,7 +27,7 @@ import com.vitorpamplona.quartz.nip01Core.diff.ListDiff /** Changes to the followed hashtags: public ones, and the private ones as a whole. */ @Immutable -class HashtagListDiff( +class InterestListDiff( val hashtags: ListDiff, val privateItems: ContentChange, ) : EventDiff { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/hashtagList/HashtagListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/interestList/InterestListEvent.kt similarity index 91% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/hashtagList/HashtagListEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/interestList/InterestListEvent.kt index 67af6cddc7..eaac7eaab7 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/hashtagList/HashtagListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/interestList/InterestListEvent.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip51Lists.hashtagList +package com.vitorpamplona.quartz.nip51Lists.interestList import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Address @@ -41,7 +41,7 @@ import com.vitorpamplona.quartz.nip51Lists.removeIgnoreCase import com.vitorpamplona.quartz.utils.TimeUtils @Immutable -class HashtagListEvent( +class InterestListEvent( id: HexKey, pubKey: HexKey, createdAt: Long, @@ -49,10 +49,10 @@ class HashtagListEvent( content: String, sig: HexKey, ) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig), - DiffableEvent { - override fun diffFrom(older: Event): HashtagListDiff? { - if (older !is HashtagListEvent || older.pubKey != pubKey || older.dTag() != dTag()) return null - return HashtagListDiff( + DiffableEvent { + override fun diffFrom(older: Event): InterestListDiff? { + if (older !is InterestListEvent || older.pubKey != pubKey || older.dTag() != dTag()) return null + return InterestListDiff( ListDiff.of(older.publicHashtags(), publicHashtags(), { it.lowercase() }), privateItemsChangeFrom(older), ) @@ -83,7 +83,7 @@ class HashtagListEvent( isPrivate: Boolean, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - ): HashtagListEvent = + ): InterestListEvent = if (isPrivate) { create( publicHashtags = emptyList(), @@ -101,7 +101,7 @@ class HashtagListEvent( } suspend fun add( - earlierVersion: HashtagListEvent, + earlierVersion: InterestListEvent, hashtag: String, isPrivate: Boolean, signer: NostrSigner, @@ -115,12 +115,12 @@ class HashtagListEvent( ) suspend fun add( - earlierVersion: HashtagListEvent, + earlierVersion: InterestListEvent, hashtags: List, isPrivate: Boolean, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - ): HashtagListEvent { + ): InterestListEvent { val hashtags = hashtags.map { HashtagTag.assemble(it) } return if (isPrivate) { val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() @@ -141,11 +141,11 @@ class HashtagListEvent( } suspend fun remove( - earlierVersion: HashtagListEvent, + earlierVersion: InterestListEvent, hashtag: String, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - ): HashtagListEvent { + ): InterestListEvent { val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() return resign( privateTags = privateTags.removeIgnoreCase(HashtagTag.assemble(hashtag)), @@ -172,7 +172,7 @@ class HashtagListEvent( tags: TagArray, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - ): HashtagListEvent { + ): InterestListEvent { val newTags = tags return signer.sign(createdAt, KIND, newTags, content) @@ -183,7 +183,7 @@ class HashtagListEvent( privateHashtags: List = emptyList(), signer: NostrSigner, createdAt: Long = TimeUtils.now(), - ): HashtagListEvent { + ): InterestListEvent { val template = build(publicHashtags, privateHashtags, signer, createdAt) return signer.sign(template) } @@ -193,7 +193,7 @@ class HashtagListEvent( privateHashtags: List = emptyList(), signer: NostrSignerSync, createdAt: Long = TimeUtils.now(), - ): HashtagListEvent { + ): InterestListEvent { val privateTagArray = publicHashtags.map { HashtagTag.assemble(it) }.toTypedArray() val publicTagArray = privateHashtags.map { HashtagTag.assemble(it) }.toTypedArray() return signer.signNip51List(createdAt, KIND, publicTagArray, privateTagArray) @@ -204,8 +204,8 @@ class HashtagListEvent( privateHashtags: List = emptyList(), signer: NostrSigner, createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, - ) = eventTemplate( + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate( kind = KIND, description = PrivateTagsInContent.encryptNip44(privateHashtags.map { HashtagTag.assemble(it) }.toTypedArray(), signer), createdAt = createdAt, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/hashtagList/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/interestList/TagArrayBuilderExt.kt similarity index 81% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/hashtagList/TagArrayBuilderExt.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/interestList/TagArrayBuilderExt.kt index 5d271de593..4de4cce0d9 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/hashtagList/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/interestList/TagArrayBuilderExt.kt @@ -18,11 +18,11 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip51Lists.hashtagList +package com.vitorpamplona.quartz.nip51Lists.interestList import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.tags.hashtags.HashtagTag -fun TagArrayBuilder.followHashTag(hashtag: String) = add(HashtagTag.assemble(hashtag)) +fun TagArrayBuilder.followHashTag(hashtag: String) = add(HashtagTag.assemble(hashtag)) -fun TagArrayBuilder.hashtags(hashtags: List) = addAll(hashtags.map { HashtagTag.assemble(it) }) +fun TagArrayBuilder.hashtags(hashtags: List) = addAll(hashtags.map { HashtagTag.assemble(it) }) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/hashtagList/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/interestList/TagArrayExt.kt similarity index 96% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/hashtagList/TagArrayExt.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/interestList/TagArrayExt.kt index ec7d75dd35..9819d231a8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/hashtagList/TagArrayExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/interestList/TagArrayExt.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip51Lists.hashtagList +package com.vitorpamplona.quartz.nip51Lists.interestList import com.vitorpamplona.quartz.nip01Core.core.TagArray import com.vitorpamplona.quartz.nip01Core.tags.hashtags.HashtagTag diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/labeledBookmarkList/Deprecated.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/labeledBookmarkList/Deprecated.kt new file mode 100644 index 0000000000..e420c86082 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/labeledBookmarkList/Deprecated.kt @@ -0,0 +1,58 @@ +/* + * 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. + */ +@file:Suppress("DEPRECATION") + +package com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList + +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.BookmarkIdTag +import com.vitorpamplona.quartz.nip51Lists.bookmarkSet.BookmarkSetEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkSet.bookmarks as movedBookmarks +import com.vitorpamplona.quartz.nip51Lists.bookmarkSet.description as movedDescription +import com.vitorpamplona.quartz.nip51Lists.bookmarkSet.image as movedImage +import com.vitorpamplona.quartz.nip51Lists.bookmarkSet.name as movedName +import com.vitorpamplona.quartz.nip51Lists.bookmarkSet.title as movedTitle + +// This package was renamed to com.vitorpamplona.quartz.nip51Lists.bookmarkSet (NIP-51 kind 30003 is a bookmark set). +// Everything below forwards to it and will be removed in a future release. + +private const val MOVED = "Moved to com.vitorpamplona.quartz.nip51Lists.bookmarkSet" + +@Deprecated( + "Renamed to BookmarkSetEvent and moved to com.vitorpamplona.quartz.nip51Lists.bookmarkSet. NIP-51 kind 30003 is a bookmark set.", + ReplaceWith("BookmarkSetEvent", "com.vitorpamplona.quartz.nip51Lists.bookmarkSet.BookmarkSetEvent"), +) +typealias LabeledBookmarkListEvent = BookmarkSetEvent + +@Deprecated(MOVED, ReplaceWith("name(name)", "com.vitorpamplona.quartz.nip51Lists.bookmarkSet.name")) +fun TagArrayBuilder.name(name: String) = movedName(name) + +@Deprecated(MOVED, ReplaceWith("title(title)", "com.vitorpamplona.quartz.nip51Lists.bookmarkSet.title")) +fun TagArrayBuilder.title(title: String) = movedTitle(title) + +@Deprecated(MOVED, ReplaceWith("bookmarks(bookmarks)", "com.vitorpamplona.quartz.nip51Lists.bookmarkSet.bookmarks")) +fun TagArrayBuilder.bookmarks(bookmarks: List) = movedBookmarks(bookmarks) + +@Deprecated(MOVED, ReplaceWith("description(listDescription)", "com.vitorpamplona.quartz.nip51Lists.bookmarkSet.description")) +fun TagArrayBuilder.description(listDescription: String) = movedDescription(listDescription) + +@Deprecated(MOVED, ReplaceWith("image(url)", "com.vitorpamplona.quartz.nip51Lists.bookmarkSet.image")) +fun TagArrayBuilder.image(url: String) = movedImage(url) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/mediaStarterPack/MediaStarterPackEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/mediaStarterPack/MediaStarterPackEvent.kt index 9cec42f0c3..5d862bfc41 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/mediaStarterPack/MediaStarterPackEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/mediaStarterPack/MediaStarterPackEvent.kt @@ -31,11 +31,11 @@ import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag import com.vitorpamplona.quartz.nip50Search.IndexableFieldVisitor import com.vitorpamplona.quartz.nip50Search.SearchableEvent -import com.vitorpamplona.quartz.nip51Lists.followList.followIdSet -import com.vitorpamplona.quartz.nip51Lists.followList.followIds -import com.vitorpamplona.quartz.nip51Lists.followList.follows import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag import com.vitorpamplona.quartz.nip51Lists.remove +import com.vitorpamplona.quartz.nip51Lists.starterPack.followIdSet +import com.vitorpamplona.quartz.nip51Lists.starterPack.followIds +import com.vitorpamplona.quartz.nip51Lists.starterPack.follows import com.vitorpamplona.quartz.nip51Lists.tags.DescriptionTag import com.vitorpamplona.quartz.nip51Lists.tags.ImageTag import com.vitorpamplona.quartz.nip51Lists.tags.TitleTag diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/peopleList/Deprecated.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/peopleList/Deprecated.kt new file mode 100644 index 0000000000..0e973677cc --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/peopleList/Deprecated.kt @@ -0,0 +1,83 @@ +/* + * 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. + */ +@file:Suppress("DEPRECATION") + +package com.vitorpamplona.quartz.nip51Lists.peopleList + +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip51Lists.followSet.FollowSetEvent +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag +import com.vitorpamplona.quartz.nip51Lists.followSet.description as movedDescription +import com.vitorpamplona.quartz.nip51Lists.followSet.image as movedImage +import com.vitorpamplona.quartz.nip51Lists.followSet.name as movedName +import com.vitorpamplona.quartz.nip51Lists.followSet.peoples as movedPeoples +import com.vitorpamplona.quartz.nip51Lists.followSet.title as movedTitle +import com.vitorpamplona.quartz.nip51Lists.followSet.userIdSet as movedUserIdSet +import com.vitorpamplona.quartz.nip51Lists.followSet.userIds as movedUserIds +import com.vitorpamplona.quartz.nip51Lists.followSet.users as movedUsers +import com.vitorpamplona.quartz.nip51Lists.followSet.usersAndWords as movedUsersAndWords +import com.vitorpamplona.quartz.nip51Lists.followSet.wordSet as movedWordSet +import com.vitorpamplona.quartz.nip51Lists.followSet.words as movedWords + +// This package was renamed to com.vitorpamplona.quartz.nip51Lists.followSet (NIP-51 kind 30000 is a follow set). +// Everything below forwards to it and will be removed in a future release. + +private const val MOVED = "Moved to com.vitorpamplona.quartz.nip51Lists.followSet" + +@Deprecated( + "Renamed to FollowSetEvent and moved to com.vitorpamplona.quartz.nip51Lists.followSet. NIP-51 kind 30000 is a follow set.", + ReplaceWith("FollowSetEvent", "com.vitorpamplona.quartz.nip51Lists.followSet.FollowSetEvent"), +) +typealias PeopleListEvent = FollowSetEvent + +@Deprecated(MOVED, ReplaceWith("name(name)", "com.vitorpamplona.quartz.nip51Lists.followSet.name")) +fun TagArrayBuilder.name(name: String) = movedName(name) + +@Deprecated(MOVED, ReplaceWith("title(name)", "com.vitorpamplona.quartz.nip51Lists.followSet.title")) +fun TagArrayBuilder.title(name: String) = movedTitle(name) + +@Deprecated(MOVED, ReplaceWith("description(desc)", "com.vitorpamplona.quartz.nip51Lists.followSet.description")) +fun TagArrayBuilder.description(desc: String) = movedDescription(desc) + +@Deprecated(MOVED, ReplaceWith("image(url)", "com.vitorpamplona.quartz.nip51Lists.followSet.image")) +fun TagArrayBuilder.image(url: String) = movedImage(url) + +@Deprecated(MOVED, ReplaceWith("peoples(peoples)", "com.vitorpamplona.quartz.nip51Lists.followSet.peoples")) +fun TagArrayBuilder.peoples(peoples: List) = movedPeoples(peoples) + +@Deprecated(MOVED, ReplaceWith("usersAndWords()", "com.vitorpamplona.quartz.nip51Lists.followSet.usersAndWords")) +fun TagArray.usersAndWords() = movedUsersAndWords() + +@Deprecated(MOVED, ReplaceWith("users()", "com.vitorpamplona.quartz.nip51Lists.followSet.users")) +fun TagArray.users() = movedUsers() + +@Deprecated(MOVED, ReplaceWith("userIds()", "com.vitorpamplona.quartz.nip51Lists.followSet.userIds")) +fun TagArray.userIds() = movedUserIds() + +@Deprecated(MOVED, ReplaceWith("userIdSet()", "com.vitorpamplona.quartz.nip51Lists.followSet.userIdSet")) +fun TagArray.userIdSet() = movedUserIdSet() + +@Deprecated(MOVED, ReplaceWith("words()", "com.vitorpamplona.quartz.nip51Lists.followSet.words")) +fun TagArray.words() = movedWords() + +@Deprecated(MOVED, ReplaceWith("wordSet()", "com.vitorpamplona.quartz.nip51Lists.followSet.wordSet")) +fun TagArray.wordSet() = movedWordSet() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/RelayFeedsListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/FavoriteRelayListEvent.kt similarity index 88% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/RelayFeedsListEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/FavoriteRelayListEvent.kt index a758db06d1..29179f5d54 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/RelayFeedsListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/FavoriteRelayListEvent.kt @@ -44,7 +44,7 @@ import com.vitorpamplona.quartz.nip51Lists.splitRelayListUpdate import com.vitorpamplona.quartz.utils.TimeUtils @Immutable -class RelayFeedsListEvent( +class FavoriteRelayListEvent( id: HexKey, pubKey: HexKey, createdAt: Long, @@ -54,7 +54,7 @@ class RelayFeedsListEvent( ) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig), DiffableEvent { override fun diffFrom(older: Event): RelayListDiff? { - if (older !is RelayFeedsListEvent || older.pubKey != pubKey || older.dTag() != dTag()) return null + if (older !is FavoriteRelayListEvent || older.pubKey != pubKey || older.dTag() != dTag()) return null return RelayListDiff(ListDiff.of(older.publicRelays(), publicRelays(), { it }), privateItemsChangeFrom(older)) } @@ -74,11 +74,11 @@ class RelayFeedsListEvent( fun createAddressTag(pubKey: HexKey): String = Address.Companion.assemble(KIND, pubKey, "") suspend fun updateRelayList( - earlierVersion: RelayFeedsListEvent, + earlierVersion: FavoriteRelayListEvent, relays: List, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - ): RelayFeedsListEvent { + ): FavoriteRelayListEvent { val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() // Keeps public relays public and private relays private: rewriting them all as @@ -95,7 +95,7 @@ class RelayFeedsListEvent( relays: List, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - ): RelayFeedsListEvent { + ): FavoriteRelayListEvent { val privateTagArray = relays.map { RelayTag.assemble(it) }.toTypedArray() return signer.signNip51List(createdAt, KIND, emptyArray(), privateTagArray) } @@ -104,7 +104,7 @@ class RelayFeedsListEvent( relays: List, signer: NostrSignerSync, createdAt: Long = TimeUtils.now(), - ): RelayFeedsListEvent { + ): FavoriteRelayListEvent { val privateTagArray = relays.map { RelayTag.assemble(it) }.toTypedArray() return signer.signNip51List(createdAt, KIND, emptyArray(), privateTagArray) } @@ -114,8 +114,8 @@ class RelayFeedsListEvent( privateRelays: List = emptyList(), signer: NostrSigner, createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, - ) = eventTemplate( + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate( kind = KIND, description = PrivateTagsInContent.encryptNip44(privateRelays.map { RelayTag.assemble(it) }.toTypedArray(), signer), createdAt = createdAt, @@ -126,3 +126,9 @@ class RelayFeedsListEvent( } } } + +@Deprecated( + "Renamed to FavoriteRelayListEvent. NIP-51 names kind 10012 the favorite relays list.", + ReplaceWith("FavoriteRelayListEvent", "com.vitorpamplona.quartz.nip51Lists.relayLists.FavoriteRelayListEvent"), +) +typealias RelayFeedsListEvent = FavoriteRelayListEvent diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/tags/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/tags/TagArrayBuilderExt.kt index ecbc663eea..c2dfb98574 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/tags/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/tags/TagArrayBuilderExt.kt @@ -27,9 +27,9 @@ import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BroadcastRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.FavoriteRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent -import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relaySets.RelaySetEvent import com.vitorpamplona.quartz.nip51Lists.tags.NameTag @@ -52,4 +52,4 @@ fun TagArrayBuilder.privateRelays(relays: List.relaySet(relays: List) = addAll(relays.map { RelayTag.assemble(it) }) -fun TagArrayBuilder.relayFeeds(relays: List) = addAll(relays.map { RelayTag.assemble(it) }) +fun TagArrayBuilder.relayFeeds(relays: List) = addAll(relays.map { RelayTag.assemble(it) }) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/releaseArtifactSet/ReleaseArtifactSetEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/releaseArtifactSet/ReleaseArtifactSetEvent.kt index 99bb13f527..8926ed25cb 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/releaseArtifactSet/ReleaseArtifactSetEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/releaseArtifactSet/ReleaseArtifactSetEvent.kt @@ -21,11 +21,19 @@ package com.vitorpamplona.quartz.nip51Lists.releaseArtifactSet import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.asset.SoftwareAssetEvent +import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.appId +import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.assets +import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.channel +import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.isNip82SoftwareRelease +import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.version import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArray import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate @@ -42,6 +50,15 @@ import com.vitorpamplona.quartz.utils.TimeUtils import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid +/** + * Kind 30063: the artifacts of one software release. + * + * Two specs publish this kind and both are parsed here: + * - NIP-51 "release artifact set": a `title`, a `description` and `e`/`a` items. + * - NIP-82 "software release": `d = @`, `i` (app id), `version`, + * `c` (channel), `e` tags to its kind 3063 assets, and the release notes as content. + * Use `isNip82SoftwareRelease()` to tell them apart. + */ @Immutable class ReleaseArtifactSetEvent( id: HexKey, @@ -50,19 +67,25 @@ class ReleaseArtifactSetEvent( tags: Array>, content: String, sig: HexKey, -) : com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), EventHintProvider, AddressHintProvider, SearchableEvent { - override fun indexableContent() = listOfNotNull(title(), description()).joinToString("\n") + override fun indexableContent() = listOfNotNull(title(), description(), searchableReleaseNotes()).joinToString("\n") // The read path: the same fields indexableContent() joins, handed over without // building the joined string a scan would throw away. override fun forEachIndexableField(visitor: IndexableFieldVisitor) { if (!visitor.visit(title())) return - visitor.visit(description()) + if (!visitor.visit(description())) return + visitor.visit(searchableReleaseNotes()) } + // Only NIP-82 releases carry release notes in `content`. A NIP-51 set may keep + // encrypted private items there, which must never reach the index. The blank + // check runs first so empty sets skip the two tag scans on every search. + private fun searchableReleaseNotes() = if (content.isNotBlank() && isNip82SoftwareRelease()) content else null + override fun eventHints() = tags.mapNotNull(EventBookmark::parseAsHint) override fun linkedEventIds() = tags.mapNotNull(EventBookmark::parseId) @@ -77,9 +100,48 @@ class ReleaseArtifactSetEvent( fun items(): List = tags.mapNotNull(BookmarkIdTag::parse) + /** NIP-82: the application identifier (`i` tag). */ + fun appId() = tags.appId() + + /** NIP-82: the release version. */ + fun version() = tags.version() + + /** NIP-82: the release channel (`c` tag). */ + fun channel() = tags.channel() + + /** NIP-82: the kind 3063 assets of this release. */ + fun assets() = tags.assets() + + /** NIP-82: the release notes. */ + fun releaseNotes() = content + companion object { const val KIND = 30063 + /** NIP-82 requires `d = @`. */ + fun buildSoftwareReleaseDTag( + appId: String, + version: String, + ) = "$appId@$version" + + /** Builds a NIP-82 software release. */ + fun buildSoftwareRelease( + appId: String, + version: String, + channel: String, + assets: List>, + releaseNotes: String = "", + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, releaseNotes, createdAt) { + dTag(buildSoftwareReleaseDTag(appId, version)) + appId(appId) + version(version) + channel(channel) + assets(assets) + initializer() + } + fun createAddress( pubKey: HexKey, dTag: String, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/followList/FollowListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/starterPack/StarterPackEvent.kt similarity index 93% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/followList/FollowListEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/starterPack/StarterPackEvent.kt index edcc791377..81fc1f760f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/followList/FollowListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/starterPack/StarterPackEvent.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip51Lists.followList +package com.vitorpamplona.quartz.nip51Lists.starterPack import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Address @@ -42,7 +42,7 @@ import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid @Immutable -class FollowListEvent( +class StarterPackEvent( id: HexKey, pubKey: HexKey, createdAt: Long, @@ -99,7 +99,7 @@ class FollowListEvent( signer: NostrSigner, dTag: String = Uuid.random().toString(), createdAt: Long = TimeUtils.now(), - ): FollowListEvent = + ): StarterPackEvent = create( name = name, people = listOf(person), @@ -109,11 +109,11 @@ class FollowListEvent( ) suspend fun addUsers( - earlierVersion: FollowListEvent, + earlierVersion: StarterPackEvent, people: List, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - ): FollowListEvent = + ): StarterPackEvent = resign( content = earlierVersion.content, tags = earlierVersion.tags.plus(people.map { it.toTagArray() }), @@ -122,18 +122,18 @@ class FollowListEvent( ) suspend fun add( - earlierVersion: FollowListEvent, + earlierVersion: StarterPackEvent, person: UserTag, signer: NostrSigner, createdAt: Long = TimeUtils.now(), ) = addUsers(earlierVersion, listOf(person), signer, createdAt) suspend fun remove( - earlierVersion: FollowListEvent, + earlierVersion: StarterPackEvent, person: UserTag, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - ): FollowListEvent = + ): StarterPackEvent = resign( content = earlierVersion.content, tags = earlierVersion.tags.remove(person.toTagIdOnly()), @@ -146,7 +146,7 @@ class FollowListEvent( tags: Array>, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - ): FollowListEvent { + ): StarterPackEvent { val newTags = tags return signer.sign(createdAt, KIND, newTags, content) @@ -159,7 +159,7 @@ class FollowListEvent( signer: NostrSigner, dTag: String = Uuid.random().toString(), createdAt: Long = TimeUtils.now(), - ): FollowListEvent { + ): StarterPackEvent { val template = build(name, people, dTag, createdAt) return signer.sign(template) } @@ -170,7 +170,7 @@ class FollowListEvent( people: List = emptyList(), dTag: String = Uuid.random().toString(), createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, + initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate( kind = KIND, description = "", diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/followList/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/starterPack/TagArrayBuilderExt.kt similarity index 69% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/followList/TagArrayBuilderExt.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/starterPack/TagArrayBuilderExt.kt index 60eac2631c..4fcace3cf1 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/followList/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/starterPack/TagArrayBuilderExt.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip51Lists.followList +package com.vitorpamplona.quartz.nip51Lists.starterPack import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder @@ -28,24 +28,24 @@ import com.vitorpamplona.quartz.nip51Lists.tags.DescriptionTag import com.vitorpamplona.quartz.nip51Lists.tags.ImageTag import com.vitorpamplona.quartz.nip51Lists.tags.TitleTag -fun TagArrayBuilder.title(title: String) = addUnique(TitleTag.assemble(title)) +fun TagArrayBuilder.title(title: String) = addUnique(TitleTag.assemble(title)) -fun TagArrayBuilder.description(desc: String) = addUnique(DescriptionTag.assemble(desc)) +fun TagArrayBuilder.description(desc: String) = addUnique(DescriptionTag.assemble(desc)) -fun TagArrayBuilder.image(imageUrl: String) = addUnique(ImageTag.assemble(imageUrl)) +fun TagArrayBuilder.image(imageUrl: String) = addUnique(ImageTag.assemble(imageUrl)) -fun TagArrayBuilder.people(peoples: List) = addAll(peoples.map { it.toTagArray() }) +fun TagArrayBuilder.people(peoples: List) = addAll(peoples.map { it.toTagArray() }) -fun TagArrayBuilder.person(person: UserTag) = add(person.toTagArray()) +fun TagArrayBuilder.person(person: UserTag) = add(person.toTagArray()) -fun TagArrayBuilder.person( +fun TagArrayBuilder.person( pubkey: HexKey, relayHint: NormalizedRelayUrl?, ) = add(UserTag.assemble(pubkey, relayHint)) -fun TagArrayBuilder.personFirst( +fun TagArrayBuilder.personFirst( pubkey: HexKey, relayHint: NormalizedRelayUrl?, ) = addFirst(UserTag.assemble(pubkey, relayHint)) -fun TagArrayBuilder.removePerson(pubkey: HexKey) = remove(UserTag.TAG_NAME, pubkey) +fun TagArrayBuilder.removePerson(pubkey: HexKey) = remove(UserTag.TAG_NAME, pubkey) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/followList/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/starterPack/TagArrayExt.kt similarity index 96% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/followList/TagArrayExt.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/starterPack/TagArrayExt.kt index e8f8a00bea..69303a2097 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/followList/TagArrayExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/starterPack/TagArrayExt.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip51Lists.followList +package com.vitorpamplona.quartz.nip51Lists.starterPack import com.vitorpamplona.quartz.nip01Core.core.TagArray import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/calendar/CalendarEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/calendar/CalendarCollectionEvent.kt similarity index 89% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/calendar/CalendarEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/calendar/CalendarCollectionEvent.kt index 5bbebfda64..f9347efae3 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/calendar/CalendarEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/calendar/CalendarCollectionEvent.kt @@ -37,7 +37,7 @@ import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid @Immutable -class CalendarEvent( +class CalendarCollectionEvent( id: HexKey, pubKey: HexKey, createdAt: Long, @@ -76,7 +76,7 @@ class CalendarEvent( content: String = "", dTag: String = Uuid.random().toString(), createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, + initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, content, createdAt) { dTag(dTag) title(title) @@ -84,3 +84,9 @@ class CalendarEvent( } } } + +@Deprecated( + "Renamed to CalendarCollectionEvent. NIP-52 kind 31924 is a calendar (a collection of calendar events), not a calendar event.", + ReplaceWith("CalendarCollectionEvent", "com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarCollectionEvent"), +) +typealias CalendarEvent = CalendarCollectionEvent diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/calendar/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/calendar/TagArrayBuilderExt.kt index 5e0a174929..2376bb6f97 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/calendar/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/calendar/TagArrayBuilderExt.kt @@ -23,6 +23,6 @@ package com.vitorpamplona.quartz.nip52Calendar.calendar import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag -// CalendarEvent builder extensions +// CalendarCollectionEvent builder extensions -fun TagArrayBuilder.title(title: String) = addUnique(TitleTag.assemble(title)) +fun TagArrayBuilder.title(title: String) = addUnique(TitleTag.assemble(title)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip54Wiki/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip54Wiki/TagArrayBuilderExt.kt index fc7bcb353a..eb73b29190 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip54Wiki/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip54Wiki/TagArrayBuilderExt.kt @@ -26,10 +26,10 @@ import com.vitorpamplona.quartz.nip23LongContent.tags.PublishedAtTag import com.vitorpamplona.quartz.nip23LongContent.tags.SummaryTag import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag -fun TagArrayBuilder.title(title: String) = addUnique(TitleTag.assemble(title)) +fun TagArrayBuilder.title(title: String) = addUnique(TitleTag.assemble(title)) -fun TagArrayBuilder.summary(summary: String) = addUnique(SummaryTag.assemble(summary)) +fun TagArrayBuilder.summary(summary: String) = addUnique(SummaryTag.assemble(summary)) -fun TagArrayBuilder.image(imageUrl: String) = addUnique(ImageTag.assemble(imageUrl)) +fun TagArrayBuilder.image(imageUrl: String) = addUnique(ImageTag.assemble(imageUrl)) -fun TagArrayBuilder.publishedAt(publishedAt: Long) = addUnique(PublishedAtTag.assemble(publishedAt)) +fun TagArrayBuilder.publishedAt(publishedAt: Long) = addUnique(PublishedAtTag.assemble(publishedAt)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip54Wiki/WikiNoteEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip54Wiki/WikiArticleEvent.kt similarity index 95% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip54Wiki/WikiNoteEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip54Wiki/WikiArticleEvent.kt index 42cae11b8a..ce75d16efb 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip54Wiki/WikiNoteEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip54Wiki/WikiArticleEvent.kt @@ -62,7 +62,7 @@ import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid @Immutable -class WikiNoteEvent( +class WikiArticleEvent( id: HexKey, pubKey: HexKey, createdAt: Long, @@ -177,8 +177,8 @@ class WikiNoteEvent( publishedAt: Long? = null, dTag: String = Uuid.random().toString(), createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, - ): EventTemplate = + initializer: TagArrayBuilder.() -> Unit = {}, + ): EventTemplate = eventTemplate(KIND, description, createdAt) { dTag(dTag) @@ -191,3 +191,9 @@ class WikiNoteEvent( } } } + +@Deprecated( + "Renamed to WikiArticleEvent. NIP-54 names kind 30818 the wiki article.", + ReplaceWith("WikiArticleEvent", "com.vitorpamplona.quartz.nip54Wiki.WikiArticleEvent"), +) +typealias WikiNoteEvent = WikiArticleEvent diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/IPrivateZapsDecryptionCache.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/IPrivateZapsDecryptionCache.kt index 2b717721b2..3c36ab0fa2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/IPrivateZapsDecryptionCache.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/IPrivateZapsDecryptionCache.kt @@ -25,7 +25,7 @@ package com.vitorpamplona.quartz.nip57Zaps * Used by Note.kt for checking private zap status. */ interface IPrivateZapsDecryptionCache { - fun cachedPrivateZap(event: LnZapRequestEvent): LnZapPrivateEvent? + fun cachedPrivateZap(event: ZapRequestEvent): PrivateZapEvent? - suspend fun decryptPrivateZap(event: LnZapRequestEvent): LnZapPrivateEvent? + suspend fun decryptPrivateZap(event: ZapRequestEvent): PrivateZapEvent? } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/PrivateZapCache.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/PrivateZapCache.kt index 129d64a346..29319eb223 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/PrivateZapCache.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/PrivateZapCache.kt @@ -28,8 +28,8 @@ class PrivateZapCache( signer: NostrSigner, ) : IPrivateZapsDecryptionCache { private val decryptionCache = - object : LruCache(1000) { - override fun create(key: LnZapRequestEvent): PrivateZapDecryptCache? { + object : LruCache(1000) { + override fun create(key: ZapRequestEvent): PrivateZapDecryptCache? { val zappedAuthor = key.zappedAuthor().firstOrNull() return if (key.isPrivateZap() && zappedAuthor != null) { PrivateZapDecryptCache(signer) @@ -39,20 +39,20 @@ class PrivateZapCache( } } - fun delete(event: LnZapRequestEvent) { + fun delete(event: ZapRequestEvent) { decryptionCache.remove(event) } - override fun cachedPrivateZap(event: LnZapRequestEvent): LnZapPrivateEvent? = decryptionCache[event]?.cached() + override fun cachedPrivateZap(event: ZapRequestEvent): PrivateZapEvent? = decryptionCache[event]?.cached() - override suspend fun decryptPrivateZap(event: LnZapRequestEvent) = decryptionCache[event]?.decrypt(event) + override suspend fun decryptPrivateZap(event: ZapRequestEvent) = decryptionCache[event]?.decrypt(event) } class PrivateZapDecryptCache( signer: NostrSigner, -) : DecryptCache(signer) { +) : DecryptCache(signer) { override suspend fun decryptAndParse( - event: LnZapRequestEvent, + event: ZapRequestEvent, signer: NostrSigner, - ): LnZapPrivateEvent = signer.decryptZapEvent(event) + ): PrivateZapEvent = signer.decryptZapEvent(event) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/LnZapPrivateEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/PrivateZapEvent.kt similarity index 87% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/LnZapPrivateEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/PrivateZapEvent.kt index 09c88dd2a3..163f2415a1 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/LnZapPrivateEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/PrivateZapEvent.kt @@ -34,7 +34,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.utils.TimeUtils @Immutable -class LnZapPrivateEvent( +class PrivateZapEvent( id: HexKey, pubKey: HexKey, createdAt: Long, @@ -66,13 +66,19 @@ class LnZapPrivateEvent( tags: Array> = emptyArray(), content: String = "", createdAt: Long = TimeUtils.now(), - ): LnZapPrivateEvent = signer.sign(createdAt, KIND, tags, content) + ): PrivateZapEvent = signer.sign(createdAt, KIND, tags, content) fun create( signer: NostrSignerSync, tags: Array> = emptyArray(), content: String = "", createdAt: Long = TimeUtils.now(), - ): LnZapPrivateEvent = signer.sign(createdAt, KIND, tags, content) + ): PrivateZapEvent = signer.sign(createdAt, KIND, tags, content) } } + +@Deprecated( + "Renamed to PrivateZapEvent. Follows the NIP-57 zap naming (ZapRequestEvent, ZapReceiptEvent).", + ReplaceWith("PrivateZapEvent", "com.vitorpamplona.quartz.nip57Zaps.PrivateZapEvent"), +) +typealias LnZapPrivateEvent = PrivateZapEvent diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/PrivateZapRequestBuilder.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/PrivateZapRequestBuilder.kt index 2b91ea6934..a26cc784bd 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/PrivateZapRequestBuilder.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/PrivateZapRequestBuilder.kt @@ -56,7 +56,7 @@ class PrivateZapRequestBuilder { val fullTagsNoAnon = tags.filter { t -> t.getOrNull(0) != "anon" }.toTypedArray() - val privateEvent = LnZapPrivateEvent.create(signer, fullTagsNoAnon, content) + val privateEvent = PrivateZapEvent.create(signer, fullTagsNoAnon, content) val noteJson = privateEvent.toJson() val encryptedContent = @@ -73,9 +73,9 @@ class PrivateZapRequestBuilder { } fun decryptZapEvent( - event: LnZapRequestEvent, + event: ZapRequestEvent, signer: NostrSignerSync, - ): LnZapPrivateEvent { + ): PrivateZapEvent { if (signer.keyPair.privKey == null) throw SignerExceptions.ReadOnlyException() val recipientPK = event.zappedAuthor().firstOrNull() @@ -129,12 +129,12 @@ class PrivateZapRequestBuilder { encNote: String, privateKey: ByteArray, pubKey: HexKey, - ): LnZapPrivateEvent = + ): PrivateZapEvent = try { val note = PrivateZapEncryption.decryptPrivateZapMessage(encNote, privateKey, pubKey.hexToByteArray()) val decryptedEvent = fromJson(note) if (decryptedEvent.kind == 9733) { - decryptedEvent as LnZapPrivateEvent + decryptedEvent as PrivateZapEvent } else { throw IllegalStateException("The decrypted event is not a private zap.") } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/LnZapEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/ZapReceiptEvent.kt similarity index 87% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/LnZapEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/ZapReceiptEvent.kt index 5bbf5d889e..c8df2c63a9 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/LnZapEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/ZapReceiptEvent.kt @@ -36,7 +36,7 @@ import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.utils.Log @Immutable -class LnZapEvent( +class ZapReceiptEvent( id: HexKey, pubKey: HexKey, createdAt: Long, @@ -44,7 +44,7 @@ class LnZapEvent( content: String, sig: HexKey, ) : Event(id, pubKey, createdAt, KIND, tags, content, sig), - LnZapEventInterface, + ZapReceiptEventInterface, EventHintProvider, AddressHintProvider, PubKeyHintProvider, @@ -75,23 +75,23 @@ class LnZapEvent( // This event is also kept in LocalCache (same object) @kotlinx.serialization.Transient @kotlin.jvm.Transient - val zapRequest: LnZapRequestEvent? + val zapRequest: ZapRequestEvent? // Keeps this as a field because it's a heavier function used everywhere. val amount by lazy { try { lnInvoice()?.let { LnInvoiceUtil.getAmountInSats(it) } } catch (e: Exception) { - Log.e("LnZapEvent", "Failed to Parse LnInvoice ${lnInvoice()}", e) + Log.e("ZapReceiptEvent", "Failed to Parse LnInvoice ${lnInvoice()}", e) null } } - override fun containedPost(): LnZapRequestEvent? = + override fun containedPost(): ZapRequestEvent? = try { - description()?.ifBlank { null }?.let { fromJson(it) } as? LnZapRequestEvent + description()?.ifBlank { null }?.let { fromJson(it) } as? ZapRequestEvent } catch (e: Exception) { - Log.w("LnZapEvent", "Failed to Parse Contained Post ${description()} in event $id", e) + Log.w("ZapReceiptEvent", "Failed to Parse Contained Post ${description()} in event $id", e) null } @@ -111,7 +111,7 @@ class LnZapEvent( ?.get(1) ?.toInt() } catch (e: Exception) { - Log.e("LnZapEvent", "ZappedPollOption failed to parse", e) + Log.e("ZapReceiptEvent", "ZappedPollOption failed to parse", e) null } @@ -135,3 +135,9 @@ class LnZapEvent( NONZAP, } } + +@Deprecated( + "Renamed to ZapReceiptEvent. NIP-57 names kind 9735 the zap receipt.", + ReplaceWith("ZapReceiptEvent", "com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent"), +) +typealias LnZapEvent = ZapReceiptEvent diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/LnZapEventInterface.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/ZapReceiptEventInterface.kt similarity index 82% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/LnZapEventInterface.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/ZapReceiptEventInterface.kt index d5fc8c0d96..5a188986a1 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/LnZapEventInterface.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/ZapReceiptEventInterface.kt @@ -25,7 +25,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.utils.BigDecimal @Immutable -interface LnZapEventInterface { +interface ZapReceiptEventInterface { fun zappedPost(): List fun zappedPollOption(): Int? @@ -38,3 +38,9 @@ interface LnZapEventInterface { fun containedPost(): Event? } + +@Deprecated( + "Renamed to ZapReceiptEventInterface. Follows the LnZapEvent to ZapReceiptEvent rename (NIP-57 zap receipt).", + ReplaceWith("ZapReceiptEventInterface", "com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEventInterface"), +) +typealias LnZapEventInterface = ZapReceiptEventInterface diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/LnZapRequestEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/ZapRequestEvent.kt similarity index 91% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/LnZapRequestEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/ZapRequestEvent.kt index 7347f6bf62..7896537350 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/LnZapRequestEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/ZapRequestEvent.kt @@ -43,7 +43,7 @@ import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.utils.TimeUtils @Immutable -class LnZapRequestEvent( +class ZapRequestEvent( id: HexKey, pubKey: HexKey, createdAt: Long, @@ -113,12 +113,12 @@ class LnZapRequestEvent( signer: NostrSigner, pollOption: Int?, message: String, - zapType: LnZapEvent.ZapType, + zapType: ZapReceiptEvent.ZapType, toUserPubHex: String?, amountMillisats: Long? = null, lnurl: String? = null, createdAt: Long = TimeUtils.now(), - ): LnZapRequestEvent { + ): ZapRequestEvent { var tags = listOf( arrayOf("e", zappedEvent.id), @@ -146,21 +146,21 @@ class LnZapRequestEvent( } return when (zapType) { - LnZapEvent.ZapType.PUBLIC -> { + ZapReceiptEvent.ZapType.PUBLIC -> { signer.sign(createdAt, KIND, tags.toTypedArray(), message) } - LnZapEvent.ZapType.ANONYMOUS -> { + ZapReceiptEvent.ZapType.ANONYMOUS -> { tags = tags + listOf(arrayOf("anon")) NostrSignerInternal(KeyPair()).sign(createdAt, KIND, tags.toTypedArray(), message) } - LnZapEvent.ZapType.PRIVATE -> { + ZapReceiptEvent.ZapType.PRIVATE -> { tags = tags + listOf(arrayOf("anon", "")) signer.sign(createdAt, KIND, tags.toTypedArray(), message) } - LnZapEvent.ZapType.NONZAP -> { + ZapReceiptEvent.ZapType.NONZAP -> { throw IllegalArgumentException("Invalid zap type") } } @@ -171,11 +171,11 @@ class LnZapRequestEvent( relays: Set, signer: NostrSigner, message: String, - zapType: LnZapEvent.ZapType, + zapType: ZapReceiptEvent.ZapType, amountMillisats: Long? = null, lnurl: String? = null, createdAt: Long = TimeUtils.now(), - ): LnZapRequestEvent { + ): ZapRequestEvent { var tags = arrayOf( arrayOf("p", userHex), @@ -189,11 +189,11 @@ class LnZapRequestEvent( } return when (zapType) { - LnZapEvent.ZapType.PUBLIC -> { + ZapReceiptEvent.ZapType.PUBLIC -> { signer.sign(createdAt, KIND, tags, message) } - LnZapEvent.ZapType.ANONYMOUS -> { + ZapReceiptEvent.ZapType.ANONYMOUS -> { // Valueless `anon` tag: a blank-valued one (`["anon", ""]`) is the // marker for an *unsigned private* zap and would make the throwaway // signer encrypt the message instead of keeping it public. @@ -201,15 +201,21 @@ class LnZapRequestEvent( NostrSignerInternal(KeyPair()).sign(createdAt, KIND, tags, message) } - LnZapEvent.ZapType.PRIVATE -> { + ZapReceiptEvent.ZapType.PRIVATE -> { tags += arrayOf(arrayOf("anon", "")) signer.sign(createdAt, KIND, tags, message) } - LnZapEvent.ZapType.NONZAP -> { + ZapReceiptEvent.ZapType.NONZAP -> { throw IllegalArgumentException("Invalid zap type") } } } } } + +@Deprecated( + "Renamed to ZapRequestEvent. NIP-57 names kind 9734 the zap request.", + ReplaceWith("ZapRequestEvent", "com.vitorpamplona.quartz.nip57Zaps.ZapRequestEvent"), +) +typealias LnZapRequestEvent = ZapRequestEvent diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/validate/LnurlEndpointResolver.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/validate/LnurlEndpointResolver.kt index 5b1841ada5..cb9cab5e9c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/validate/LnurlEndpointResolver.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/validate/LnurlEndpointResolver.kt @@ -24,7 +24,7 @@ package com.vitorpamplona.quartz.nip57Zaps.validate * Resolves an LNURL-pay URL to its [LnurlEndpointInfo]. Implementations should * consult [LnurlEndpointCache] first and only fall through to a network fetch * on a miss. Lives in quartz so that pure-logic verifiers (e.g. - * [LnZapReceiptValidator]) can be wired against an abstract interface without + * [ZapReceiptValidator]) can be wired against an abstract interface without * depending on OkHttp. */ fun interface LnurlEndpointResolver { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/validate/LnZapReceiptValidator.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/validate/ZapReceiptValidator.kt similarity index 91% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/validate/LnZapReceiptValidator.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/validate/ZapReceiptValidator.kt index c6206814f5..b7f812289d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/validate/LnZapReceiptValidator.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/validate/ZapReceiptValidator.kt @@ -22,8 +22,8 @@ package com.vitorpamplona.quartz.nip57Zaps.validate import com.vitorpamplona.quartz.lightning.LnInvoiceUtil import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapRequestEvent import com.vitorpamplona.quartz.utils.BigDecimal /** @@ -45,7 +45,7 @@ import com.vitorpamplona.quartz.utils.BigDecimal * check on the receipt's signer pubkey is non-negotiable when an * `expectedNostrPubkey` is provided. */ -object LnZapReceiptValidator { +object ZapReceiptValidator { sealed class Result { data object Valid : Result() @@ -84,7 +84,7 @@ object LnZapReceiptValidator { * When true, a missing `amount` tag is treated as a failure. */ fun validate( - receipt: LnZapEvent, + receipt: ZapReceiptEvent, expectedNostrPubkey: HexKey?, expectedLnurl: String?, strictAmount: Boolean = false, @@ -141,15 +141,21 @@ object LnZapReceiptValidator { return Result.Valid } - private fun LnZapRequestEvent.amountMillisats(): Long? = + private fun ZapRequestEvent.amountMillisats(): Long? = tags .firstOrNull { it.size > 1 && it[0] == "amount" } ?.get(1) ?.toLongOrNull() - private fun LnZapRequestEvent.lnurl(): String? = + private fun ZapRequestEvent.lnurl(): String? = tags .firstOrNull { it.size > 1 && it[0] == "lnurl" } ?.get(1) ?.takeIf { it.isNotBlank() } } + +@Deprecated( + "Renamed to ZapReceiptValidator. Validates NIP-57 zap receipts (ZapReceiptEvent).", + ReplaceWith("ZapReceiptValidator", "com.vitorpamplona.quartz.nip57Zaps.validate.ZapReceiptValidator"), +) +typealias LnZapReceiptValidator = ZapReceiptValidator diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/HasInnerEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/HasInnerEvent.kt index 45b0b0af55..1e948258c7 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/HasInnerEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/HasInnerEvent.kt @@ -24,7 +24,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey /** * Marker for NIP-59 wrappers that carry an inner-event id once decrypted — - * GiftWrapEvent (and its subclasses) and SealedRumorEvent. Lets callers walk + * GiftWrapEvent (and its subclasses) and SealEvent. Lets callers walk * to the wrapped event without branching on the concrete wrapper type. */ interface HasInnerEvent { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/rumors/Rumor.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/rumors/Rumor.kt index c1ca788297..6d3d79d0b8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/rumors/Rumor.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/rumors/Rumor.kt @@ -27,7 +27,7 @@ import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper import com.vitorpamplona.quartz.nip01Core.core.OptimizedSerializable import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher import com.vitorpamplona.quartz.nip59Giftwrap.rumors.kotlinSerialization.RumorKSerializer -import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent +import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealEvent import com.vitorpamplona.quartz.utils.EventFactory import kotlinx.serialization.Serializable @@ -41,7 +41,7 @@ class Rumor( val tags: Array>?, val content: String?, ) : OptimizedSerializable { - fun mergeWith(event: SealedRumorEvent): Event { + fun mergeWith(event: SealEvent): Event { val newPubKey = event.pubKey // forces to be the pubkey of the seal to make sure impersonators don't impersonate val newCreatedAt = if (createdAt != null && createdAt > 1000) createdAt else event.createdAt val newKind = kind ?: -1 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/seals/SealedRumorEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/seals/SealEvent.kt similarity index 93% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/seals/SealedRumorEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/seals/SealEvent.kt index d37a771984..badc0cbc7d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/seals/SealedRumorEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/seals/SealEvent.kt @@ -32,7 +32,7 @@ import com.vitorpamplona.quartz.utils.TimeUtils import kotlin.concurrent.Volatile @Immutable -class SealedRumorEvent( +class SealEvent( id: HexKey, pubKey: HexKey, createdAt: Long, @@ -48,9 +48,9 @@ class SealedRumorEvent( @Volatile override var innerEventId: HexKey? = null - fun copyNoContent(): SealedRumorEvent { + fun copyNoContent(): SealEvent { val copy = - SealedRumorEvent( + SealEvent( id, pubKey, createdAt, @@ -98,7 +98,7 @@ class SealedRumorEvent( signer: NostrSigner, expirationDelta: Long? = null, createdAt: Long = TimeUtils.now(), - ): SealedRumorEvent { + ): SealEvent { val rumor = Rumor.create(event) return create(rumor, encryptTo, signer, expirationDelta, createdAt) } @@ -109,7 +109,7 @@ class SealedRumorEvent( signer: NostrSigner, expirationDelta: Long? = null, createdAt: Long = TimeUtils.randomWithTwoDays(), - ): SealedRumorEvent { + ): SealEvent { val msg = Rumor.toJson(rumor) val tags = @@ -128,3 +128,9 @@ class SealedRumorEvent( } } } + +@Deprecated( + "Renamed to SealEvent. NIP-59 names kind 13 the seal.", + ReplaceWith("SealEvent", "com.vitorpamplona.quartz.nip59Giftwrap.seals.SealEvent"), +) +typealias SealedRumorEvent = SealEvent diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/history/CashuSpendingHistoryEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/history/CashuSpendingHistoryEvent.kt index adcd455875..66cf469bf6 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/history/CashuSpendingHistoryEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/history/CashuSpendingHistoryEvent.kt @@ -25,10 +25,15 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArray import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent +import com.vitorpamplona.quartz.nip61Nutzaps.redemption.redeemedNutzaps import com.vitorpamplona.quartz.utils.TimeUtils /** @@ -43,6 +48,10 @@ import com.vitorpamplona.quartz.utils.TimeUtils * * The "e" tags with "redeemed" marker SHOULD be left unencrypted in the tags array. * All other "e" tags should be encrypted in the content. + * + * NIP-61 nutzap redemptions are this same kind: a history entry whose public tags + * carry the redeemed nutzap (`["e", , , "redeemed"]`) and the + * sender's `p` tag. See `buildNutzapRedemption` in `nip61Nutzaps.redemption`. */ @Immutable class CashuSpendingHistoryEvent( @@ -52,7 +61,22 @@ class CashuSpendingHistoryEvent( tags: Array>, content: String, sig: HexKey, -) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { +) : Event(id, pubKey, createdAt, KIND, tags, content, sig), + EventHintProvider, + PubKeyHintProvider { + override fun eventHints() = tags.mapNotNull(ETag::parseAsHint) + + override fun linkedEventIds() = tags.mapNotNull(ETag::parseId) + + override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) + + override fun linkedPubKeys() = tags.mapNotNull(PTag::parseKey) + + /** + * NIP-61: the nutzaps this entry redeemed, from the public `e` tags marked `redeemed`. + */ + fun redeemedNutzaps() = tags.redeemedNutzaps() + /** * Decrypts the content to get the private spending history tags. */ diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/history/TokenReference.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/history/TokenReference.kt index fe16e631f2..ca73026eca 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/history/TokenReference.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/history/TokenReference.kt @@ -43,9 +43,11 @@ data class TokenReference( const val MARKER_REDEEMED = "redeemed" fun parseFromTag(tag: Array): TokenReference? { - if (tag.size < 4 || tag[0] != "e") return null + // Same id rule as ETag.parse, so redeemedReferences() and the NIP-61 + // redeemedNutzaps() agree on which tags are valid. + if (tag.size < 4 || tag[0] != "e" || tag[1].length != 64) return null val marker = tag[3] - if (marker !in listOf(MARKER_CREATED, MARKER_DESTROYED, MARKER_REDEEMED)) return null + if (marker != MARKER_CREATED && marker != MARKER_DESTROYED && marker != MARKER_REDEEMED) return null return TokenReference( eventId = tag[1], relay = tag.getOrNull(2)?.ifEmpty { null }, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/SecretFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/SecretFactory.kt index 51d662f82f..2da95e82fc 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/SecretFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/SecretFactory.kt @@ -44,6 +44,18 @@ data class DerivedSecret( override fun hashCode(): Int = 31 * secretHex.hashCode() + blindingFactor.contentHashCode() } +/** + * Where a batch of deterministic secrets starts. + * + * [firstCounter] is null when the secrets will be random — either because the + * factory is [RandomSecretFactory], or because a [DeterministicSecretFactory] + * had no seed available when it reserved. + */ +data class SecretReservation( + val keysetId: String, + val firstCounter: Long?, +) + /** * Strategy for producing the (secret, r) pairs that go into BDHKE blind * messages. Two impls today: @@ -61,21 +73,48 @@ data class DerivedSecret( */ interface SecretFactory { /** - * Mint [count] (secret, r) pairs for use on the specified keyset. + * Reserve whatever durable state the next [count] secrets need, and return + * where they start. * - * Batched on purpose: the deterministic implementation reserves a - * contiguous counter range via a single atomic critical section on - * `AccountSettings.reserveCashuCounters`. Calling one-at-a-time - * inside `splitAmounts(amount).map { ... }` would take the lock N - * times per mint — wasteful for both contention and disk writes. + * Suspends because that is the durability boundary: a NUT-13 counter MUST + * reach disk before any secret derived from it reaches a mint, or a crash + * mid-mint replays the counter on the next launch and the mint answers + * `outputs already signed`. + * + * Separate from [derive] so that derivation stays pure and synchronous. + * Reserving is the only part that touches storage, and only a + * deterministic factory does so at all. */ - fun nextSecrets( + suspend fun reserve( keysetId: String, count: Int, + ): SecretReservation + + /** + * Derive [count] (secret, r) pairs from an already-reserved position. + * + * Pure: no storage, no suspension, same output for the same reservation. + */ + fun derive( + reservation: SecretReservation, + count: Int, ): List + /** + * Reserve and derive in one step. + * + * Batched on purpose: the deterministic implementation reserves a + * contiguous counter range in a single atomic write. Calling one-at-a-time + * inside `splitAmounts(amount).map { ... }` would take the lock N times per + * mint — wasteful for both contention and disk writes. + */ + suspend fun nextSecrets( + keysetId: String, + count: Int, + ): List = derive(reserve(keysetId, count), count) + /** Convenience for ops that need a single output. */ - fun nextSecret(keysetId: String): DerivedSecret = nextSecrets(keysetId, 1).first() + suspend fun nextSecret(keysetId: String): DerivedSecret = nextSecrets(keysetId, 1).first() } /** @@ -85,9 +124,15 @@ interface SecretFactory { * pre-dates the NUT-13 wiring). */ object RandomSecretFactory : SecretFactory { - override fun nextSecrets( + /** Nothing to reserve: random secrets keep no durable state. */ + override suspend fun reserve( keysetId: String, count: Int, + ): SecretReservation = SecretReservation(keysetId, null) + + override fun derive( + reservation: SecretReservation, + count: Int, ): List { require(count > 0) { "Must request at least one secret" } return List(count) { @@ -126,29 +171,51 @@ class DeterministicSecretFactory( private val seedProvider: () -> ByteArray?, /** * Atomically reserves [count] consecutive counters for a keyset and - * returns the FIRST one — the factory then derives at indices + * returns the FIRST one — derivation then runs at indices * `[returned .. returned+count)`. Persisting in one shot avoids the - * lock-N-times-per-mint waste of the old per-secret API. + * lock-N-times-per-mint waste of a per-secret API. * + * Suspends: it must reach disk before the secrets are used. * `AccountSettings.reserveCashuCounters(keysetId, count)` is the * canonical implementation. */ - private val reserveCounters: (keysetId: String, count: Int) -> Long, + private val reserveCounters: suspend (keysetId: String, count: Int) -> Long, private val fallback: SecretFactory = RandomSecretFactory, ) : SecretFactory { - override fun nextSecrets( + /** + * With no seed yet — the wallet has not decrypted its kind:17375 — this + * reserves nothing and reports a random batch, so counters are not burned + * for secrets that will not be derived from them. + */ + override suspend fun reserve( keysetId: String, count: Int, + ): SecretReservation { + require(count > 0) { "Must request at least one secret" } + seedProvider() ?: return SecretReservation(keysetId, null) + return SecretReservation(keysetId, reserveCounters(keysetId, count)) + } + + override fun derive( + reservation: SecretReservation, + count: Int, ): List { require(count > 0) { "Must request at least one secret" } - val seed = seedProvider() ?: return fallback.nextSecrets(keysetId, count) - val first = reserveCounters(keysetId, count) + val first = reservation.firstCounter ?: return fallback.derive(reservation, count) + + // Re-read rather than capturing the seed in the reservation, which + // would carry it through a public data class. If the seed vanished + // between reserving and deriving, this batch falls back to random and + // the reserved counters go unused — harmless, because counters only + // ever move forward and an unused one is never replayed. + val seed = seedProvider() ?: return fallback.derive(SecretReservation(reservation.keysetId, null), count) + return List(count) { offset -> val counter = first + offset // CashuDeterministic.secretBytes returns the raw 32 bytes; // the hex form is what BDHKE/proof storage actually use. - val secretHex = CashuDeterministic.secretBytes(seed, keysetId, counter).toHexKey() - val r = CashuDeterministic.blindingFactor(seed, keysetId, counter) + val secretHex = CashuDeterministic.secretBytes(seed, reservation.keysetId, counter).toHexKey() + val r = CashuDeterministic.blindingFactor(seed, reservation.keysetId, counter) DerivedSecret(secretHex, r) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip61Nutzaps/redemption/NutzapRedemptionEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip61Nutzaps/redemption/NutzapRedemptionEvent.kt index 8cfc908eef..0b2897a1cf 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip61Nutzaps/redemption/NutzapRedemptionEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip61Nutzaps/redemption/NutzapRedemptionEvent.kt @@ -20,52 +20,53 @@ */ package com.vitorpamplona.quartz.nip61Nutzaps.redemption -import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle -import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider -import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate -import com.vitorpamplona.quartz.nip01Core.tags.events.ETag -import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip60Cashu.history.CashuSpendingHistoryEvent import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent import com.vitorpamplona.quartz.utils.TimeUtils -@Immutable -class NutzapRedemptionEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, -) : Event(id, pubKey, createdAt, KIND, tags, content, sig), - EventHintProvider, - PubKeyHintProvider { - override fun eventHints() = tags.mapNotNull(ETag::parseAsHint) - - override fun linkedEventIds() = tags.mapNotNull(ETag::parseId) - - override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) - - override fun linkedPubKeys() = tags.mapNotNull(PTag::parseKey) - - fun redeemedNutzaps() = tags.redeemedNutzaps() - - companion object { - const val KIND = 7376 - - fun build( - nutzap: EventHintBundle, - encryptedContent: String, - createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, - ) = eventTemplate(KIND, encryptedContent, createdAt) { - redeemedNutzap(nutzap) - notifySender(nutzap) - initializer() - } - } +/** + * Builds a NIP-61 nutzap redemption: a NIP-60 spending history event (kind 7376) + * that publicly marks [nutzap] as redeemed and notifies its sender. + * + * [encryptedContent] is the NIP-44 encrypted history payload (direction, amount, ...). + */ +fun CashuSpendingHistoryEvent.Companion.buildNutzapRedemption( + nutzap: EventHintBundle, + encryptedContent: String, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, +) = eventTemplate(KIND, encryptedContent, createdAt) { + redeemedNutzap(nutzap) + notifySender(nutzap) + initializer() } + +/** The old `NutzapRedemptionEvent.build`. */ +@Deprecated( + "Use CashuSpendingHistoryEvent.buildNutzapRedemption.", + ReplaceWith( + "CashuSpendingHistoryEvent.buildNutzapRedemption(nutzap, encryptedContent, createdAt, initializer)", + "com.vitorpamplona.quartz.nip60Cashu.history.CashuSpendingHistoryEvent", + "com.vitorpamplona.quartz.nip61Nutzaps.redemption.buildNutzapRedemption", + ), +) +fun CashuSpendingHistoryEvent.Companion.build( + nutzap: EventHintBundle, + encryptedContent: String, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, +) = buildNutzapRedemption(nutzap, encryptedContent, createdAt, initializer) + +/** + * NIP-61 redemptions are NIP-60 spending history events (kind 7376). This used to be a + * second class for the same kind, which `EventFactory` could never instantiate because + * [CashuSpendingHistoryEvent] matched first. + */ +@Deprecated( + "Kind 7376 is the NIP-60 spending history event. Use CashuSpendingHistoryEvent and CashuSpendingHistoryEvent.buildNutzapRedemption.", + ReplaceWith("CashuSpendingHistoryEvent", "com.vitorpamplona.quartz.nip60Cashu.history.CashuSpendingHistoryEvent"), +) +typealias NutzapRedemptionEvent = CashuSpendingHistoryEvent diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip61Nutzaps/redemption/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip61Nutzaps/redemption/TagArrayBuilderExt.kt index 26ffc67ab9..cfd334df4e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip61Nutzaps/redemption/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip61Nutzaps/redemption/TagArrayBuilderExt.kt @@ -23,10 +23,11 @@ package com.vitorpamplona.quartz.nip61Nutzaps.redemption import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip60Cashu.history.CashuSpendingHistoryEvent import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent import com.vitorpamplona.quartz.utils.arrayOfNotNull -fun TagArrayBuilder.redeemedNutzap(nutzap: EventHintBundle) = +fun TagArrayBuilder.redeemedNutzap(nutzap: EventHintBundle) = add( arrayOfNotNull( "e", @@ -36,4 +37,4 @@ fun TagArrayBuilder.redeemedNutzap(nutzap: EventHintBundl ), ) -fun TagArrayBuilder.notifySender(nutzap: EventHintBundle) = add(PTag.assemble(nutzap.event.pubKey, nutzap.authorHomeRelay)) +fun TagArrayBuilder.notifySender(nutzap: EventHintBundle) = add(PTag.assemble(nutzap.event.pubKey, nutzap.authorHomeRelay)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip61Nutzaps/token/TokenEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip61Nutzaps/token/TokenEvent.kt index 0970ccde4a..e48f186e62 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip61Nutzaps/token/TokenEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip61Nutzaps/token/TokenEvent.kt @@ -20,31 +20,31 @@ */ package com.vitorpamplona.quartz.nip61Nutzaps.token -import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip60Cashu.token.CashuTokenEvent import com.vitorpamplona.quartz.utils.TimeUtils -@Immutable -class TokenEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, -) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { - companion object { - const val KIND = 7375 +/** + * NIP-61 does not define its own kind 7375: redeemed nutzap proofs are stored as + * ordinary NIP-60 wallet token events. This used to be a second class for the same + * kind, which `EventFactory` could never instantiate because [CashuTokenEvent] matched first. + */ +@Deprecated( + "Kind 7375 is the NIP-60 Cashu wallet token event. Use CashuTokenEvent.", + ReplaceWith("CashuTokenEvent", "com.vitorpamplona.quartz.nip60Cashu.token.CashuTokenEvent"), +) +typealias TokenEvent = CashuTokenEvent - fun build( - encryptedContent: String, - createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, - ) = eventTemplate(KIND, encryptedContent, createdAt) { - initializer() - } - } +/** The old `TokenEvent.build` took content that was already encrypted. */ +@Deprecated( + "Use CashuTokenEvent.build(tokenContent, signer), which encrypts the token content.", + ReplaceWith("CashuTokenEvent.build(tokenContent, signer)", "com.vitorpamplona.quartz.nip60Cashu.token.CashuTokenEvent"), +) +fun CashuTokenEvent.Companion.build( + encryptedContent: String, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, +) = eventTemplate(KIND, encryptedContent, createdAt) { + initializer() } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/VideoHorizontalEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/AddressableNormalVideoEvent.kt similarity index 82% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/VideoHorizontalEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/AddressableNormalVideoEvent.kt index 67abb889ff..9f979d38f0 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/VideoHorizontalEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/AddressableNormalVideoEvent.kt @@ -32,7 +32,7 @@ import kotlin.uuid.Uuid @Immutable @OptIn(ExperimentalUuidApi::class) -class VideoHorizontalEvent( +class AddressableNormalVideoEvent( id: HexKey, pubKey: HexKey, createdAt: Long, @@ -49,7 +49,7 @@ class VideoHorizontalEvent( description: String, dTag: String = Uuid.random().toString(), createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, + initializer: TagArrayBuilder.() -> Unit = {}, ) = build(description, dTag, createdAt) { videoIMeta(video) initializer() @@ -60,7 +60,7 @@ class VideoHorizontalEvent( description: String, dTag: String = Uuid.random().toString(), createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, + initializer: TagArrayBuilder.() -> Unit = {}, ) = build(description, dTag, createdAt) { videoIMetas(video) initializer() @@ -70,10 +70,16 @@ class VideoHorizontalEvent( description: String, dTag: String = Uuid.random().toString(), createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, + initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, description, createdAt) { dTag(dTag) initializer() } } } + +@Deprecated( + "Renamed to AddressableNormalVideoEvent. NIP-71 names kind 34235 the addressable normal video.", + ReplaceWith("AddressableNormalVideoEvent", "com.vitorpamplona.quartz.nip71Video.AddressableNormalVideoEvent"), +) +typealias VideoHorizontalEvent = AddressableNormalVideoEvent diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/VideoVerticalEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/AddressableShortVideoEvent.kt similarity index 83% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/VideoVerticalEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/AddressableShortVideoEvent.kt index 880c8bf885..0c0347707d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/VideoVerticalEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/AddressableShortVideoEvent.kt @@ -32,7 +32,7 @@ import kotlin.uuid.Uuid @Immutable @OptIn(ExperimentalUuidApi::class) -class VideoVerticalEvent( +class AddressableShortVideoEvent( id: HexKey, pubKey: HexKey, createdAt: Long, @@ -49,7 +49,7 @@ class VideoVerticalEvent( description: String, dTag: String = Uuid.random().toString(), createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, + initializer: TagArrayBuilder.() -> Unit = {}, ) = build(description, dTag, createdAt) { videoIMeta(video) initializer() @@ -59,10 +59,16 @@ class VideoVerticalEvent( description: String, dTag: String = Uuid.random().toString(), createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, + initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, description, createdAt) { dTag(dTag) initializer() } } } + +@Deprecated( + "Renamed to AddressableShortVideoEvent. NIP-71 names kind 34236 the addressable short video.", + ReplaceWith("AddressableShortVideoEvent", "com.vitorpamplona.quartz.nip71Video.AddressableShortVideoEvent"), +) +typealias VideoVerticalEvent = AddressableShortVideoEvent diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip75ZapGoals/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip75ZapGoals/TagArrayBuilderExt.kt index 117e00a207..3bde9fbdd1 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip75ZapGoals/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip75ZapGoals/TagArrayBuilderExt.kt @@ -30,16 +30,16 @@ import com.vitorpamplona.quartz.nip75ZapGoals.tags.AmountTag import com.vitorpamplona.quartz.nip75ZapGoals.tags.ClosedAtTag import com.vitorpamplona.quartz.nip75ZapGoals.tags.RelayListTag -fun TagArrayBuilder.amount(amountInMillisats: Long) = addUnique(AmountTag.assemble(amountInMillisats)) +fun TagArrayBuilder.amount(amountInMillisats: Long) = addUnique(AmountTag.assemble(amountInMillisats)) -fun TagArrayBuilder.relays(urls: List) = add(RelayListTag.assemble(urls)) +fun TagArrayBuilder.relays(urls: List) = add(RelayListTag.assemble(urls)) -fun TagArrayBuilder.summary(summary: String) = addUnique(SummaryTag.assemble(summary)) +fun TagArrayBuilder.summary(summary: String) = addUnique(SummaryTag.assemble(summary)) -fun TagArrayBuilder.image(imageUrl: String) = addUnique(ImageTag.assemble(imageUrl)) +fun TagArrayBuilder.image(imageUrl: String) = addUnique(ImageTag.assemble(imageUrl)) -fun TagArrayBuilder.closedAt(closedAt: Long) = addUnique(ClosedAtTag.assemble(closedAt)) +fun TagArrayBuilder.closedAt(closedAt: Long) = addUnique(ClosedAtTag.assemble(closedAt)) -fun TagArrayBuilder.linked(tag: ETag) = addUnique(tag.toTagArray()) +fun TagArrayBuilder.linked(tag: ETag) = addUnique(tag.toTagArray()) -fun TagArrayBuilder.linked(tag: ATag) = addUnique(tag.toATagArray()) +fun TagArrayBuilder.linked(tag: ATag) = addUnique(tag.toATagArray()) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip75ZapGoals/GoalEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip75ZapGoals/ZapGoalEvent.kt similarity index 94% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip75ZapGoals/GoalEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip75ZapGoals/ZapGoalEvent.kt index da7b923c80..66f3007031 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip75ZapGoals/GoalEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip75ZapGoals/ZapGoalEvent.kt @@ -48,7 +48,7 @@ import com.vitorpamplona.quartz.nip75ZapGoals.tags.RelayListTag import com.vitorpamplona.quartz.utils.TimeUtils @Immutable -class GoalEvent( +class ZapGoalEvent( id: HexKey, pubKey: HexKey, createdAt: Long, @@ -106,7 +106,7 @@ class GoalEvent( websiteUrl: String? = null, linkedEvent: EventHintBundle? = null, createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, + initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, description, createdAt) { amount(amount) relays(relays) @@ -124,3 +124,9 @@ class GoalEvent( } } } + +@Deprecated( + "Renamed to ZapGoalEvent. NIP-75 names kind 9041 the zap goal.", + ReplaceWith("ZapGoalEvent", "com.vitorpamplona.quartz.nip75ZapGoals.ZapGoalEvent"), +) +typealias GoalEvent = ZapGoalEvent diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/users/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/users/TagArrayBuilderExt.kt index 161912e25d..1cdb5412b7 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/users/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/users/TagArrayBuilderExt.kt @@ -42,42 +42,42 @@ import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ZapAvgAmountDa import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ZapCountReceivedTag import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ZapCountSentTag -fun TagArrayBuilder.rank(rank: Int) = addUnique(RankTag.assemble(rank)) +fun TagArrayBuilder.rank(rank: Int) = addUnique(RankTag.assemble(rank)) -fun TagArrayBuilder.followers(count: Int) = addUnique(FollowerCountTag.assemble(count)) +fun TagArrayBuilder.followers(count: Int) = addUnique(FollowerCountTag.assemble(count)) -fun TagArrayBuilder.hops(hops: Int) = addUnique(HopsTag.assemble(hops)) +fun TagArrayBuilder.hops(hops: Int) = addUnique(HopsTag.assemble(hops)) -fun TagArrayBuilder.firstCreatedAt(timestamp: Long) = addUnique(FirstCreatedAtTag.assemble(timestamp)) +fun TagArrayBuilder.firstCreatedAt(timestamp: Long) = addUnique(FirstCreatedAtTag.assemble(timestamp)) -fun TagArrayBuilder.postCount(count: Int) = addUnique(PostCountTag.assemble(count)) +fun TagArrayBuilder.postCount(count: Int) = addUnique(PostCountTag.assemble(count)) -fun TagArrayBuilder.replyCount(count: Int) = addUnique(ReplyCountTag.assemble(count)) +fun TagArrayBuilder.replyCount(count: Int) = addUnique(ReplyCountTag.assemble(count)) -fun TagArrayBuilder.reactionsCount(count: Int) = addUnique(ReactionsCountTag.assemble(count)) +fun TagArrayBuilder.reactionsCount(count: Int) = addUnique(ReactionsCountTag.assemble(count)) -fun TagArrayBuilder.zapAmountReceived(sats: Long) = addUnique(ZapAmountReceivedTag.assemble(sats)) +fun TagArrayBuilder.zapAmountReceived(sats: Long) = addUnique(ZapAmountReceivedTag.assemble(sats)) -fun TagArrayBuilder.zapAmountSent(sats: Long) = addUnique(ZapAmountSentTag.assemble(sats)) +fun TagArrayBuilder.zapAmountSent(sats: Long) = addUnique(ZapAmountSentTag.assemble(sats)) -fun TagArrayBuilder.zapCountReceived(count: Int) = addUnique(ZapCountReceivedTag.assemble(count)) +fun TagArrayBuilder.zapCountReceived(count: Int) = addUnique(ZapCountReceivedTag.assemble(count)) -fun TagArrayBuilder.zapCountSent(count: Int) = addUnique(ZapCountSentTag.assemble(count)) +fun TagArrayBuilder.zapCountSent(count: Int) = addUnique(ZapCountSentTag.assemble(count)) -fun TagArrayBuilder.zapAvgAmountDayReceived(sats: Long) = addUnique(ZapAvgAmountDayReceivedTag.assemble(sats)) +fun TagArrayBuilder.zapAvgAmountDayReceived(sats: Long) = addUnique(ZapAvgAmountDayReceivedTag.assemble(sats)) -fun TagArrayBuilder.zapAvgAmountDaySent(sats: Long) = addUnique(ZapAvgAmountDaySentTag.assemble(sats)) +fun TagArrayBuilder.zapAvgAmountDaySent(sats: Long) = addUnique(ZapAvgAmountDaySentTag.assemble(sats)) -fun TagArrayBuilder.reportsCountReceived(count: Int) = addUnique(ReportsCountReceivedTag.assemble(count)) +fun TagArrayBuilder.reportsCountReceived(count: Int) = addUnique(ReportsCountReceivedTag.assemble(count)) -fun TagArrayBuilder.reportsCountSent(count: Int) = addUnique(ReportsCountSentTag.assemble(count)) +fun TagArrayBuilder.reportsCountSent(count: Int) = addUnique(ReportsCountSentTag.assemble(count)) -fun TagArrayBuilder.topic(topic: String) = add(TopicTag.assemble(topic)) +fun TagArrayBuilder.topic(topic: String) = add(TopicTag.assemble(topic)) -fun TagArrayBuilder.activeHoursStart(hour: Int) = addUnique(ActiveHoursStartTag.assemble(hour)) +fun TagArrayBuilder.activeHoursStart(hour: Int) = addUnique(ActiveHoursStartTag.assemble(hour)) -fun TagArrayBuilder.activeHoursEnd(hour: Int) = addUnique(ActiveHoursEndTag.assemble(hour)) +fun TagArrayBuilder.activeHoursEnd(hour: Int) = addUnique(ActiveHoursEndTag.assemble(hour)) -fun TagArrayBuilder.petName(name: String) = addUnique(PetNameTag.assemble(name)) +fun TagArrayBuilder.petName(name: String) = addUnique(PetNameTag.assemble(name)) -fun TagArrayBuilder.summary(summary: String) = addUnique(SummaryTag.assemble(summary)) +fun TagArrayBuilder.summary(summary: String) = addUnique(SummaryTag.assemble(summary)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/users/ContactCardEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/users/UserAssertionEvent.kt similarity index 89% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/users/ContactCardEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/users/UserAssertionEvent.kt index 91e7d58355..055df00154 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/users/ContactCardEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/users/UserAssertionEvent.kt @@ -43,7 +43,7 @@ import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.SummaryTag import com.vitorpamplona.quartz.utils.TimeUtils @Immutable -class ContactCardEvent( +class UserAssertionEvent( id: HexKey, pubKey: HexKey, createdAt: Long, @@ -127,9 +127,9 @@ class ContactCardEvent( emojis: List = emptyList(), signer: NostrSigner, createdAt: Long = TimeUtils.now(), - publicInitializer: TagArrayBuilder.() -> Unit = {}, - privateInitializer: TagArrayBuilder.() -> Unit = {}, - ): ContactCardEvent = signer.sign(build(targetUser, petName, summary, emojis, signer, createdAt, publicInitializer, privateInitializer)) + publicInitializer: TagArrayBuilder.() -> Unit = {}, + privateInitializer: TagArrayBuilder.() -> Unit = {}, + ): UserAssertionEvent = signer.sign(build(targetUser, petName, summary, emojis, signer, createdAt, publicInitializer, privateInitializer)) /** * Unsigned template for a new card about [targetUser]. The petname, summary @@ -143,9 +143,9 @@ class ContactCardEvent( emojis: List = emptyList(), signer: NostrSigner, createdAt: Long = TimeUtils.now(), - publicInitializer: TagArrayBuilder.() -> Unit = {}, - privateInitializer: TagArrayBuilder.() -> Unit = {}, - ): EventTemplate { + publicInitializer: TagArrayBuilder.() -> Unit = {}, + privateInitializer: TagArrayBuilder.() -> Unit = {}, + ): EventTemplate { val privateTags = tagArray { petName?.let { petName(it) } @@ -174,13 +174,13 @@ class ContactCardEvent( * [signer] only decrypts/encrypts here; the caller signs the template. */ suspend fun updatePetNameAndSummary( - earlierVersion: ContactCardEvent, + earlierVersion: UserAssertionEvent, petName: String? = null, summary: String? = null, emojis: List = emptyList(), signer: NostrSigner, createdAt: Long = TimeUtils.now(), - ): EventTemplate { + ): EventTemplate { val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() @@ -211,3 +211,9 @@ class ContactCardEvent( } } } + +@Deprecated( + "Renamed to UserAssertionEvent. NIP-85 names kind 30382 the user trusted assertion.", + ReplaceWith("UserAssertionEvent", "com.vitorpamplona.quartz.nip85TrustedAssertions.users.UserAssertionEvent"), +) +typealias ContactCardEvent = UserAssertionEvent diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/NostrSignerWithClientTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/NostrSignerWithClientTag.kt index 7cd234f925..3e9cd2ee4b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/NostrSignerWithClientTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/NostrSignerWithClientTag.kt @@ -24,8 +24,8 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import com.vitorpamplona.quartz.nip57Zaps.PrivateZapEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapRequestEvent /** * A [NostrSigner] decorator that automatically appends a NIP-89 client tag @@ -94,7 +94,7 @@ class NostrSignerWithClientTag( fromPublicKey: HexKey, ): String = inner.nip44Decrypt(ciphertext, fromPublicKey) - override suspend fun decryptZapEvent(event: LnZapRequestEvent): LnZapPrivateEvent = inner.decryptZapEvent(event) + override suspend fun decryptZapEvent(event: ZapRequestEvent): PrivateZapEvent = inner.decryptZapEvent(event) override suspend fun deriveKey(nonce: HexKey): HexKey = inner.deriveKey(nonce) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/contentDiscoveryRequest/NIP90ContentDiscoveryRequestEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/contentDiscoveryRequest/DvmContentDiscoveryRequestEvent.kt similarity index 85% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/contentDiscoveryRequest/NIP90ContentDiscoveryRequestEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/contentDiscoveryRequest/DvmContentDiscoveryRequestEvent.kt index d6df3c9589..d7b0825f50 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/contentDiscoveryRequest/NIP90ContentDiscoveryRequestEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/contentDiscoveryRequest/DvmContentDiscoveryRequestEvent.kt @@ -34,7 +34,7 @@ import com.vitorpamplona.quartz.utils.TimeUtils @Stable @Immutable -class NIP90ContentDiscoveryRequestEvent( +class DvmContentDiscoveryRequestEvent( id: HexKey, pubKey: HexKey, createdAt: Long, @@ -62,7 +62,7 @@ class NIP90ContentDiscoveryRequestEvent( forUser: HexKey, relays: Set, createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, + initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, "", createdAt) { dvmPubKey(dvmPublicKey) relays(relays) @@ -72,3 +72,9 @@ class NIP90ContentDiscoveryRequestEvent( } } } + +@Deprecated( + "Renamed to DvmContentDiscoveryRequestEvent. NIP-90 Data Vending Machine events use the Dvm prefix.", + ReplaceWith("DvmContentDiscoveryRequestEvent", "com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryRequest.DvmContentDiscoveryRequestEvent"), +) +typealias NIP90ContentDiscoveryRequestEvent = DvmContentDiscoveryRequestEvent diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/contentDiscoveryRequest/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/contentDiscoveryRequest/TagArrayBuilderExt.kt index b9ca5e65de..0af5322d05 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/contentDiscoveryRequest/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/contentDiscoveryRequest/TagArrayBuilderExt.kt @@ -26,11 +26,11 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryRequest.tags.ParamTag import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryRequest.tags.RelaysTag -fun TagArrayBuilder.dvmPubKey(pubKey: HexKey) = addUnique(arrayOf("p", pubKey)) +fun TagArrayBuilder.dvmPubKey(pubKey: HexKey) = addUnique(arrayOf("p", pubKey)) -fun TagArrayBuilder.relays(relays: Set) = addUnique(RelaysTag.assemble(relays)) +fun TagArrayBuilder.relays(relays: Set) = addUnique(RelaysTag.assemble(relays)) -fun TagArrayBuilder.param( +fun TagArrayBuilder.param( key: String, value: String, ) = add(ParamTag.assemble(key, value)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/contentDiscoveryResponse/NIP90ContentDiscoveryResponseEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/contentDiscoveryResponse/DvmContentDiscoveryResponseEvent.kt similarity index 82% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/contentDiscoveryResponse/NIP90ContentDiscoveryResponseEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/contentDiscoveryResponse/DvmContentDiscoveryResponseEvent.kt index 105a1d1fde..8b2cd681f7 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/contentDiscoveryResponse/NIP90ContentDiscoveryResponseEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/contentDiscoveryResponse/DvmContentDiscoveryResponseEvent.kt @@ -30,7 +30,7 @@ import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils @Immutable -class NIP90ContentDiscoveryResponseEvent( +class DvmContentDiscoveryResponseEvent( id: HexKey, pubKey: HexKey, createdAt: Long, @@ -61,7 +61,7 @@ class NIP90ContentDiscoveryResponseEvent( } } } catch (e: Throwable) { - Log.w("NIP90ContentDiscoveryResponseEvent") { "Error parsing the JSON ${e.message}" } + Log.w("DvmContentDiscoveryResponseEvent") { "Error parsing the JSON ${e.message}" } } return events ?: listOf() @@ -72,9 +72,15 @@ class NIP90ContentDiscoveryResponseEvent( fun build( createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, + initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, "", createdAt) { initializer() } } } + +@Deprecated( + "Renamed to DvmContentDiscoveryResponseEvent. NIP-90 Data Vending Machine events use the Dvm prefix.", + ReplaceWith("DvmContentDiscoveryResponseEvent", "com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.DvmContentDiscoveryResponseEvent"), +) +typealias NIP90ContentDiscoveryResponseEvent = DvmContentDiscoveryResponseEvent diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/contentSearch/NIP90ContentSearchRequestEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/contentSearch/DvmContentSearchRequestEvent.kt similarity index 89% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/contentSearch/NIP90ContentSearchRequestEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/contentSearch/DvmContentSearchRequestEvent.kt index ca04176d38..910922bd11 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/contentSearch/NIP90ContentSearchRequestEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/contentSearch/DvmContentSearchRequestEvent.kt @@ -36,7 +36,7 @@ import com.vitorpamplona.quartz.nip90Dvms.tags.param import com.vitorpamplona.quartz.utils.TimeUtils @Immutable -class NIP90ContentSearchRequestEvent( +class DvmContentSearchRequestEvent( id: HexKey, pubKey: HexKey, createdAt: Long, @@ -76,7 +76,7 @@ class NIP90ContentSearchRequestEvent( until: Long? = null, maxResults: Int? = null, createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, + initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, "", createdAt) { inputText(searchQuery) users?.let { param("users", it) } @@ -87,3 +87,9 @@ class NIP90ContentSearchRequestEvent( } } } + +@Deprecated( + "Renamed to DvmContentSearchRequestEvent. NIP-90 Data Vending Machine events use the Dvm prefix.", + ReplaceWith("DvmContentSearchRequestEvent", "com.vitorpamplona.quartz.nip90Dvms.contentSearch.DvmContentSearchRequestEvent"), +) +typealias NIP90ContentSearchRequestEvent = DvmContentSearchRequestEvent diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/contentSearch/NIP90ContentSearchResponseEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/contentSearch/DvmContentSearchResponseEvent.kt similarity index 83% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/contentSearch/NIP90ContentSearchResponseEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/contentSearch/DvmContentSearchResponseEvent.kt index 894a70dc90..013fa3abc7 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/contentSearch/NIP90ContentSearchResponseEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/contentSearch/DvmContentSearchResponseEvent.kt @@ -30,7 +30,7 @@ import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils @Immutable -class NIP90ContentSearchResponseEvent( +class DvmContentSearchResponseEvent( id: HexKey, pubKey: HexKey, createdAt: Long, @@ -61,7 +61,7 @@ class NIP90ContentSearchResponseEvent( } } } catch (e: Throwable) { - Log.w("NIP90ContentSearchResponseEvent") { "Error parsing the JSON ${e.message}" } + Log.w("DvmContentSearchResponseEvent") { "Error parsing the JSON ${e.message}" } } return events ?: listOf() @@ -72,9 +72,15 @@ class NIP90ContentSearchResponseEvent( fun build( createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, + initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, "", createdAt) { initializer() } } } + +@Deprecated( + "Renamed to DvmContentSearchResponseEvent. NIP-90 Data Vending Machine events use the Dvm prefix.", + ReplaceWith("DvmContentSearchResponseEvent", "com.vitorpamplona.quartz.nip90Dvms.contentSearch.DvmContentSearchResponseEvent"), +) +typealias NIP90ContentSearchResponseEvent = DvmContentSearchResponseEvent diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/eventCount/NIP90EventCountRequestEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/eventCount/DvmEventCountRequestEvent.kt similarity index 86% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/eventCount/NIP90EventCountRequestEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/eventCount/DvmEventCountRequestEvent.kt index 504672ea10..408c2be134 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/eventCount/NIP90EventCountRequestEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/eventCount/DvmEventCountRequestEvent.kt @@ -33,7 +33,7 @@ import com.vitorpamplona.quartz.nip90Dvms.tags.param import com.vitorpamplona.quartz.utils.TimeUtils @Immutable -class NIP90EventCountRequestEvent( +class DvmEventCountRequestEvent( id: HexKey, pubKey: HexKey, createdAt: Long, @@ -58,7 +58,7 @@ class NIP90EventCountRequestEvent( groups: List = emptyList(), filterJson: String = "", createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, + initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, filterJson, createdAt) { inputs.forEach { inputText(it) } relays.forEach { param("relay", it) } @@ -67,3 +67,9 @@ class NIP90EventCountRequestEvent( } } } + +@Deprecated( + "Renamed to DvmEventCountRequestEvent. NIP-90 Data Vending Machine events use the Dvm prefix.", + ReplaceWith("DvmEventCountRequestEvent", "com.vitorpamplona.quartz.nip90Dvms.eventCount.DvmEventCountRequestEvent"), +) +typealias NIP90EventCountRequestEvent = DvmEventCountRequestEvent diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/eventCount/NIP90EventCountResponseEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/eventCount/DvmEventCountResponseEvent.kt similarity index 82% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/eventCount/NIP90EventCountResponseEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/eventCount/DvmEventCountResponseEvent.kt index 84f0d173d6..10e0e62444 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/eventCount/NIP90EventCountResponseEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/eventCount/DvmEventCountResponseEvent.kt @@ -28,7 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.utils.TimeUtils @Immutable -class NIP90EventCountResponseEvent( +class DvmEventCountResponseEvent( id: HexKey, pubKey: HexKey, createdAt: Long, @@ -43,9 +43,15 @@ class NIP90EventCountResponseEvent( fun build( createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, + initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, "", createdAt) { initializer() } } } + +@Deprecated( + "Renamed to DvmEventCountResponseEvent. NIP-90 Data Vending Machine events use the Dvm prefix.", + ReplaceWith("DvmEventCountResponseEvent", "com.vitorpamplona.quartz.nip90Dvms.eventCount.DvmEventCountResponseEvent"), +) +typealias NIP90EventCountResponseEvent = DvmEventCountResponseEvent diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/eventPowDelegation/NIP90EventPowDelegationRequestEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/eventPowDelegation/DvmEventPowDelegationRequestEvent.kt similarity index 83% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/eventPowDelegation/NIP90EventPowDelegationRequestEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/eventPowDelegation/DvmEventPowDelegationRequestEvent.kt index 1f0d7570ae..ee577cb305 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/eventPowDelegation/NIP90EventPowDelegationRequestEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/eventPowDelegation/DvmEventPowDelegationRequestEvent.kt @@ -33,7 +33,7 @@ import com.vitorpamplona.quartz.nip90Dvms.tags.param import com.vitorpamplona.quartz.utils.TimeUtils @Immutable -class NIP90EventPowDelegationRequestEvent( +class DvmEventPowDelegationRequestEvent( id: HexKey, pubKey: HexKey, createdAt: Long, @@ -54,7 +54,7 @@ class NIP90EventPowDelegationRequestEvent( eventJson: String, pow: Int, createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, + initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, "", createdAt) { inputText(eventJson) param("pow", pow.toString()) @@ -62,3 +62,9 @@ class NIP90EventPowDelegationRequestEvent( } } } + +@Deprecated( + "Renamed to DvmEventPowDelegationRequestEvent. NIP-90 Data Vending Machine events use the Dvm prefix.", + ReplaceWith("DvmEventPowDelegationRequestEvent", "com.vitorpamplona.quartz.nip90Dvms.eventPowDelegation.DvmEventPowDelegationRequestEvent"), +) +typealias NIP90EventPowDelegationRequestEvent = DvmEventPowDelegationRequestEvent diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/eventPowDelegation/NIP90EventPowDelegationResponseEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/eventPowDelegation/DvmEventPowDelegationResponseEvent.kt similarity index 79% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/eventPowDelegation/NIP90EventPowDelegationResponseEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/eventPowDelegation/DvmEventPowDelegationResponseEvent.kt index ab46d67d28..0174d11e57 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/eventPowDelegation/NIP90EventPowDelegationResponseEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/eventPowDelegation/DvmEventPowDelegationResponseEvent.kt @@ -28,7 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.utils.TimeUtils @Immutable -class NIP90EventPowDelegationResponseEvent( +class DvmEventPowDelegationResponseEvent( id: HexKey, pubKey: HexKey, createdAt: Long, @@ -41,9 +41,15 @@ class NIP90EventPowDelegationResponseEvent( fun build( createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, + initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, "", createdAt) { initializer() } } } + +@Deprecated( + "Renamed to DvmEventPowDelegationResponseEvent. NIP-90 Data Vending Machine events use the Dvm prefix.", + ReplaceWith("DvmEventPowDelegationResponseEvent", "com.vitorpamplona.quartz.nip90Dvms.eventPowDelegation.DvmEventPowDelegationResponseEvent"), +) +typealias NIP90EventPowDelegationResponseEvent = DvmEventPowDelegationResponseEvent diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/eventPublishSchedule/NIP90EventPublishScheduleRequestEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/eventPublishSchedule/DvmEventPublishScheduleRequestEvent.kt similarity index 84% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/eventPublishSchedule/NIP90EventPublishScheduleRequestEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/eventPublishSchedule/DvmEventPublishScheduleRequestEvent.kt index a6eab69fa8..5160350b86 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/eventPublishSchedule/NIP90EventPublishScheduleRequestEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/eventPublishSchedule/DvmEventPublishScheduleRequestEvent.kt @@ -33,7 +33,7 @@ import com.vitorpamplona.quartz.nip90Dvms.tags.param import com.vitorpamplona.quartz.utils.TimeUtils @Immutable -class NIP90EventPublishScheduleRequestEvent( +class DvmEventPublishScheduleRequestEvent( id: HexKey, pubKey: HexKey, createdAt: Long, @@ -54,7 +54,7 @@ class NIP90EventPublishScheduleRequestEvent( eventJson: String, relays: List = emptyList(), createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, + initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, "", createdAt) { inputText(eventJson) if (relays.isNotEmpty()) { @@ -64,3 +64,9 @@ class NIP90EventPublishScheduleRequestEvent( } } } + +@Deprecated( + "Renamed to DvmEventPublishScheduleRequestEvent. NIP-90 Data Vending Machine events use the Dvm prefix.", + ReplaceWith("DvmEventPublishScheduleRequestEvent", "com.vitorpamplona.quartz.nip90Dvms.eventPublishSchedule.DvmEventPublishScheduleRequestEvent"), +) +typealias NIP90EventPublishScheduleRequestEvent = DvmEventPublishScheduleRequestEvent diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/eventPublishSchedule/NIP90EventPublishScheduleResponseEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/eventPublishSchedule/DvmEventPublishScheduleResponseEvent.kt similarity index 79% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/eventPublishSchedule/NIP90EventPublishScheduleResponseEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/eventPublishSchedule/DvmEventPublishScheduleResponseEvent.kt index 1dfa2e5980..8a5fde2e6e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/eventPublishSchedule/NIP90EventPublishScheduleResponseEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/eventPublishSchedule/DvmEventPublishScheduleResponseEvent.kt @@ -28,7 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.utils.TimeUtils @Immutable -class NIP90EventPublishScheduleResponseEvent( +class DvmEventPublishScheduleResponseEvent( id: HexKey, pubKey: HexKey, createdAt: Long, @@ -43,9 +43,15 @@ class NIP90EventPublishScheduleResponseEvent( fun build( createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, + initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, "", createdAt) { initializer() } } } + +@Deprecated( + "Renamed to DvmEventPublishScheduleResponseEvent. NIP-90 Data Vending Machine events use the Dvm prefix.", + ReplaceWith("DvmEventPublishScheduleResponseEvent", "com.vitorpamplona.quartz.nip90Dvms.eventPublishSchedule.DvmEventPublishScheduleResponseEvent"), +) +typealias NIP90EventPublishScheduleResponseEvent = DvmEventPublishScheduleResponseEvent diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/eventTimestamping/NIP90EventTimestampingRequestEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/eventTimestamping/DvmEventTimestampingRequestEvent.kt similarity index 83% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/eventTimestamping/NIP90EventTimestampingRequestEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/eventTimestamping/DvmEventTimestampingRequestEvent.kt index 4280ea87ca..454e3574a2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/eventTimestamping/NIP90EventTimestampingRequestEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/eventTimestamping/DvmEventTimestampingRequestEvent.kt @@ -32,7 +32,7 @@ import com.vitorpamplona.quartz.nip90Dvms.tags.inputs import com.vitorpamplona.quartz.utils.TimeUtils @Immutable -class NIP90EventTimestampingRequestEvent( +class DvmEventTimestampingRequestEvent( id: HexKey, pubKey: HexKey, createdAt: Long, @@ -50,10 +50,16 @@ class NIP90EventTimestampingRequestEvent( fun build( eventId: HexKey, createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, + initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, "", createdAt) { inputEvent(eventId) initializer() } } } + +@Deprecated( + "Renamed to DvmEventTimestampingRequestEvent. NIP-90 Data Vending Machine events use the Dvm prefix.", + ReplaceWith("DvmEventTimestampingRequestEvent", "com.vitorpamplona.quartz.nip90Dvms.eventTimestamping.DvmEventTimestampingRequestEvent"), +) +typealias NIP90EventTimestampingRequestEvent = DvmEventTimestampingRequestEvent diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/eventTimestamping/NIP90EventTimestampingResponseEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/eventTimestamping/DvmEventTimestampingResponseEvent.kt similarity index 80% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/eventTimestamping/NIP90EventTimestampingResponseEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/eventTimestamping/DvmEventTimestampingResponseEvent.kt index 604079190e..d4a1125fea 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/eventTimestamping/NIP90EventTimestampingResponseEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/eventTimestamping/DvmEventTimestampingResponseEvent.kt @@ -28,7 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.utils.TimeUtils @Immutable -class NIP90EventTimestampingResponseEvent( +class DvmEventTimestampingResponseEvent( id: HexKey, pubKey: HexKey, createdAt: Long, @@ -43,9 +43,15 @@ class NIP90EventTimestampingResponseEvent( fun build( createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, + initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, "", createdAt) { initializer() } } } + +@Deprecated( + "Renamed to DvmEventTimestampingResponseEvent. NIP-90 Data Vending Machine events use the Dvm prefix.", + ReplaceWith("DvmEventTimestampingResponseEvent", "com.vitorpamplona.quartz.nip90Dvms.eventTimestamping.DvmEventTimestampingResponseEvent"), +) +typealias NIP90EventTimestampingResponseEvent = DvmEventTimestampingResponseEvent diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/imageGeneration/NIP90ImageGenerationRequestEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/imageGeneration/DvmImageGenerationRequestEvent.kt similarity index 89% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/imageGeneration/NIP90ImageGenerationRequestEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/imageGeneration/DvmImageGenerationRequestEvent.kt index 08dc291512..7ffb56dad8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/imageGeneration/NIP90ImageGenerationRequestEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/imageGeneration/DvmImageGenerationRequestEvent.kt @@ -37,7 +37,7 @@ import com.vitorpamplona.quartz.nip90Dvms.tags.param import com.vitorpamplona.quartz.utils.TimeUtils @Immutable -class NIP90ImageGenerationRequestEvent( +class DvmImageGenerationRequestEvent( id: HexKey, pubKey: HexKey, createdAt: Long, @@ -85,7 +85,7 @@ class NIP90ImageGenerationRequestEvent( size: String? = null, negativePrompt: String? = null, createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, + initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, "", createdAt) { sourceImageUrl?.let { inputUrl(it) } inputText(prompt) @@ -98,3 +98,9 @@ class NIP90ImageGenerationRequestEvent( } } } + +@Deprecated( + "Renamed to DvmImageGenerationRequestEvent. NIP-90 Data Vending Machine events use the Dvm prefix.", + ReplaceWith("DvmImageGenerationRequestEvent", "com.vitorpamplona.quartz.nip90Dvms.imageGeneration.DvmImageGenerationRequestEvent"), +) +typealias NIP90ImageGenerationRequestEvent = DvmImageGenerationRequestEvent diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/imageGeneration/NIP90ImageGenerationResponseEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/imageGeneration/DvmImageGenerationResponseEvent.kt similarity index 80% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/imageGeneration/NIP90ImageGenerationResponseEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/imageGeneration/DvmImageGenerationResponseEvent.kt index ef07dd5fee..7978dc4aab 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/imageGeneration/NIP90ImageGenerationResponseEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/imageGeneration/DvmImageGenerationResponseEvent.kt @@ -28,7 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.utils.TimeUtils @Immutable -class NIP90ImageGenerationResponseEvent( +class DvmImageGenerationResponseEvent( id: HexKey, pubKey: HexKey, createdAt: Long, @@ -41,9 +41,15 @@ class NIP90ImageGenerationResponseEvent( fun build( createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, + initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, "", createdAt) { initializer() } } } + +@Deprecated( + "Renamed to DvmImageGenerationResponseEvent. NIP-90 Data Vending Machine events use the Dvm prefix.", + ReplaceWith("DvmImageGenerationResponseEvent", "com.vitorpamplona.quartz.nip90Dvms.imageGeneration.DvmImageGenerationResponseEvent"), +) +typealias NIP90ImageGenerationResponseEvent = DvmImageGenerationResponseEvent diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/imageToVideo/NIP90ImageToVideoRequestEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/imageToVideo/DvmImageToVideoRequestEvent.kt similarity index 84% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/imageToVideo/NIP90ImageToVideoRequestEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/imageToVideo/DvmImageToVideoRequestEvent.kt index 98557b94d5..a4d875fbaa 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/imageToVideo/NIP90ImageToVideoRequestEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/imageToVideo/DvmImageToVideoRequestEvent.kt @@ -32,7 +32,7 @@ import com.vitorpamplona.quartz.nip90Dvms.tags.inputs import com.vitorpamplona.quartz.utils.TimeUtils @Immutable -class NIP90ImageToVideoRequestEvent( +class DvmImageToVideoRequestEvent( id: HexKey, pubKey: HexKey, createdAt: Long, @@ -50,10 +50,16 @@ class NIP90ImageToVideoRequestEvent( fun build( imageUrl: String, createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, + initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, "", createdAt) { inputUrl(imageUrl) initializer() } } } + +@Deprecated( + "Renamed to DvmImageToVideoRequestEvent. NIP-90 Data Vending Machine events use the Dvm prefix.", + ReplaceWith("DvmImageToVideoRequestEvent", "com.vitorpamplona.quartz.nip90Dvms.imageToVideo.DvmImageToVideoRequestEvent"), +) +typealias NIP90ImageToVideoRequestEvent = DvmImageToVideoRequestEvent diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/imageToVideo/NIP90ImageToVideoResponseEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/imageToVideo/DvmImageToVideoResponseEvent.kt similarity index 81% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/imageToVideo/NIP90ImageToVideoResponseEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/imageToVideo/DvmImageToVideoResponseEvent.kt index 9e3c21edfa..2fe0247505 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/imageToVideo/NIP90ImageToVideoResponseEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/imageToVideo/DvmImageToVideoResponseEvent.kt @@ -28,7 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.utils.TimeUtils @Immutable -class NIP90ImageToVideoResponseEvent( +class DvmImageToVideoResponseEvent( id: HexKey, pubKey: HexKey, createdAt: Long, @@ -41,9 +41,15 @@ class NIP90ImageToVideoResponseEvent( fun build( createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, + initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, "", createdAt) { initializer() } } } + +@Deprecated( + "Renamed to DvmImageToVideoResponseEvent. NIP-90 Data Vending Machine events use the Dvm prefix.", + ReplaceWith("DvmImageToVideoResponseEvent", "com.vitorpamplona.quartz.nip90Dvms.imageToVideo.DvmImageToVideoResponseEvent"), +) +typealias NIP90ImageToVideoResponseEvent = DvmImageToVideoResponseEvent diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/malwareScanning/NIP90MalwareScanRequestEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/malwareScanning/DvmMalwareScanRequestEvent.kt similarity index 84% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/malwareScanning/NIP90MalwareScanRequestEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/malwareScanning/DvmMalwareScanRequestEvent.kt index af90743b08..9486fb274c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/malwareScanning/NIP90MalwareScanRequestEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/malwareScanning/DvmMalwareScanRequestEvent.kt @@ -32,7 +32,7 @@ import com.vitorpamplona.quartz.nip90Dvms.tags.inputs import com.vitorpamplona.quartz.utils.TimeUtils @Immutable -class NIP90MalwareScanRequestEvent( +class DvmMalwareScanRequestEvent( id: HexKey, pubKey: HexKey, createdAt: Long, @@ -50,10 +50,16 @@ class NIP90MalwareScanRequestEvent( fun build( fileUrl: String, createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, + initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, "", createdAt) { inputUrl(fileUrl) initializer() } } } + +@Deprecated( + "Renamed to DvmMalwareScanRequestEvent. NIP-90 Data Vending Machine events use the Dvm prefix.", + ReplaceWith("DvmMalwareScanRequestEvent", "com.vitorpamplona.quartz.nip90Dvms.malwareScanning.DvmMalwareScanRequestEvent"), +) +typealias NIP90MalwareScanRequestEvent = DvmMalwareScanRequestEvent diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/malwareScanning/NIP90MalwareScanResponseEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/malwareScanning/DvmMalwareScanResponseEvent.kt similarity index 81% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/malwareScanning/NIP90MalwareScanResponseEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/malwareScanning/DvmMalwareScanResponseEvent.kt index 37aae8e6df..d6cdace38d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/malwareScanning/NIP90MalwareScanResponseEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/malwareScanning/DvmMalwareScanResponseEvent.kt @@ -28,7 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.utils.TimeUtils @Immutable -class NIP90MalwareScanResponseEvent( +class DvmMalwareScanResponseEvent( id: HexKey, pubKey: HexKey, createdAt: Long, @@ -43,9 +43,15 @@ class NIP90MalwareScanResponseEvent( fun build( createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, + initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, "", createdAt) { initializer() } } } + +@Deprecated( + "Renamed to DvmMalwareScanResponseEvent. NIP-90 Data Vending Machine events use the Dvm prefix.", + ReplaceWith("DvmMalwareScanResponseEvent", "com.vitorpamplona.quartz.nip90Dvms.malwareScanning.DvmMalwareScanResponseEvent"), +) +typealias NIP90MalwareScanResponseEvent = DvmMalwareScanResponseEvent diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/opReturn/NIP90OpReturnRequestEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/opReturn/DvmOpReturnRequestEvent.kt similarity index 85% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/opReturn/NIP90OpReturnRequestEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/opReturn/DvmOpReturnRequestEvent.kt index 2cf9def354..478ab68e93 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/opReturn/NIP90OpReturnRequestEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/opReturn/DvmOpReturnRequestEvent.kt @@ -32,7 +32,7 @@ import com.vitorpamplona.quartz.nip90Dvms.tags.inputs import com.vitorpamplona.quartz.utils.TimeUtils @Immutable -class NIP90OpReturnRequestEvent( +class DvmOpReturnRequestEvent( id: HexKey, pubKey: HexKey, createdAt: Long, @@ -51,10 +51,16 @@ class NIP90OpReturnRequestEvent( fun build( text: String, createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, + initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, "", createdAt) { inputText(text) initializer() } } } + +@Deprecated( + "Renamed to DvmOpReturnRequestEvent. NIP-90 Data Vending Machine events use the Dvm prefix.", + ReplaceWith("DvmOpReturnRequestEvent", "com.vitorpamplona.quartz.nip90Dvms.opReturn.DvmOpReturnRequestEvent"), +) +typealias NIP90OpReturnRequestEvent = DvmOpReturnRequestEvent diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/opReturn/NIP90OpReturnResponseEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/opReturn/DvmOpReturnResponseEvent.kt similarity index 82% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/opReturn/NIP90OpReturnResponseEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/opReturn/DvmOpReturnResponseEvent.kt index 9e9b8e986c..32007e843e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/opReturn/NIP90OpReturnResponseEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/opReturn/DvmOpReturnResponseEvent.kt @@ -28,7 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.utils.TimeUtils @Immutable -class NIP90OpReturnResponseEvent( +class DvmOpReturnResponseEvent( id: HexKey, pubKey: HexKey, createdAt: Long, @@ -43,9 +43,15 @@ class NIP90OpReturnResponseEvent( fun build( createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, + initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, "", createdAt) { initializer() } } } + +@Deprecated( + "Renamed to DvmOpReturnResponseEvent. NIP-90 Data Vending Machine events use the Dvm prefix.", + ReplaceWith("DvmOpReturnResponseEvent", "com.vitorpamplona.quartz.nip90Dvms.opReturn.DvmOpReturnResponseEvent"), +) +typealias NIP90OpReturnResponseEvent = DvmOpReturnResponseEvent diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/peopleSearch/NIP90PeopleSearchRequestEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/peopleSearch/DvmPeopleSearchRequestEvent.kt similarity index 87% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/peopleSearch/NIP90PeopleSearchRequestEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/peopleSearch/DvmPeopleSearchRequestEvent.kt index d542a5ccc9..a44279270c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/peopleSearch/NIP90PeopleSearchRequestEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/peopleSearch/DvmPeopleSearchRequestEvent.kt @@ -36,7 +36,7 @@ import com.vitorpamplona.quartz.nip90Dvms.tags.param import com.vitorpamplona.quartz.utils.TimeUtils @Immutable -class NIP90PeopleSearchRequestEvent( +class DvmPeopleSearchRequestEvent( id: HexKey, pubKey: HexKey, createdAt: Long, @@ -67,7 +67,7 @@ class NIP90PeopleSearchRequestEvent( searchQuery: String, maxResults: Int? = null, createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, + initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, "", createdAt) { inputText(searchQuery) maxResults?.let { param("max_results", it.toString()) } @@ -75,3 +75,9 @@ class NIP90PeopleSearchRequestEvent( } } } + +@Deprecated( + "Renamed to DvmPeopleSearchRequestEvent. NIP-90 Data Vending Machine events use the Dvm prefix.", + ReplaceWith("DvmPeopleSearchRequestEvent", "com.vitorpamplona.quartz.nip90Dvms.peopleSearch.DvmPeopleSearchRequestEvent"), +) +typealias NIP90PeopleSearchRequestEvent = DvmPeopleSearchRequestEvent diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/peopleSearch/NIP90PeopleSearchResponseEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/peopleSearch/DvmPeopleSearchResponseEvent.kt similarity index 83% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/peopleSearch/NIP90PeopleSearchResponseEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/peopleSearch/DvmPeopleSearchResponseEvent.kt index 6156985d53..05c7c163ac 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/peopleSearch/NIP90PeopleSearchResponseEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/peopleSearch/DvmPeopleSearchResponseEvent.kt @@ -30,7 +30,7 @@ import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils @Immutable -class NIP90PeopleSearchResponseEvent( +class DvmPeopleSearchResponseEvent( id: HexKey, pubKey: HexKey, createdAt: Long, @@ -61,7 +61,7 @@ class NIP90PeopleSearchResponseEvent( } } } catch (e: Throwable) { - Log.w("NIP90PeopleSearchResponseEvent") { "Error parsing the JSON ${e.message}" } + Log.w("DvmPeopleSearchResponseEvent") { "Error parsing the JSON ${e.message}" } } return people ?: listOf() @@ -72,9 +72,15 @@ class NIP90PeopleSearchResponseEvent( fun build( createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, + initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, "", createdAt) { initializer() } } } + +@Deprecated( + "Renamed to DvmPeopleSearchResponseEvent. NIP-90 Data Vending Machine events use the Dvm prefix.", + ReplaceWith("DvmPeopleSearchResponseEvent", "com.vitorpamplona.quartz.nip90Dvms.peopleSearch.DvmPeopleSearchResponseEvent"), +) +typealias NIP90PeopleSearchResponseEvent = DvmPeopleSearchResponseEvent diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/status/NIP90StatusEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/status/DvmStatusEvent.kt similarity index 85% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/status/NIP90StatusEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/status/DvmStatusEvent.kt index c01eb4cc38..e14e41468c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/status/NIP90StatusEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/status/DvmStatusEvent.kt @@ -28,7 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.utils.TimeUtils @Immutable -class NIP90StatusEvent( +class DvmStatusEvent( id: HexKey, pubKey: HexKey, createdAt: Long, @@ -45,9 +45,15 @@ class NIP90StatusEvent( fun build( createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, + initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, "", createdAt) { initializer() } } } + +@Deprecated( + "Renamed to DvmStatusEvent. NIP-90 Data Vending Machine events use the Dvm prefix.", + ReplaceWith("DvmStatusEvent", "com.vitorpamplona.quartz.nip90Dvms.status.DvmStatusEvent"), +) +typealias NIP90StatusEvent = DvmStatusEvent diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/status/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/status/TagArrayBuilderExt.kt index ff12777c74..bc919bff57 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/status/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/status/TagArrayBuilderExt.kt @@ -24,14 +24,14 @@ import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip90Dvms.status.tags.AmountTag import com.vitorpamplona.quartz.nip90Dvms.status.tags.StatusTag -fun TagArrayBuilder.status( +fun TagArrayBuilder.status( code: String, description: String, ) = addUnique(StatusTag.assemble(code, description)) -fun TagArrayBuilder.amount(amount: Long) = addUnique(AmountTag.assemble(amount)) +fun TagArrayBuilder.amount(amount: Long) = addUnique(AmountTag.assemble(amount)) -fun TagArrayBuilder.amount( +fun TagArrayBuilder.amount( amount: Long, lnInvoice: String, ) = addUnique(AmountTag.assemble(amount, lnInvoice)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/summarization/NIP90SummarizationRequestEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/summarization/DvmSummarizationRequestEvent.kt similarity index 85% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/summarization/NIP90SummarizationRequestEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/summarization/DvmSummarizationRequestEvent.kt index 3e7cf4a0db..aaf23220b0 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/summarization/NIP90SummarizationRequestEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/summarization/DvmSummarizationRequestEvent.kt @@ -35,7 +35,7 @@ import com.vitorpamplona.quartz.nip90Dvms.tags.param import com.vitorpamplona.quartz.utils.TimeUtils @Immutable -class NIP90SummarizationRequestEvent( +class DvmSummarizationRequestEvent( id: HexKey, pubKey: HexKey, createdAt: Long, @@ -57,7 +57,7 @@ class NIP90SummarizationRequestEvent( length: String? = null, outputMimeType: String? = null, createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, + initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, "", createdAt) { eventIds.forEach { inputEvent(it) } length?.let { param("length", it) } @@ -66,3 +66,9 @@ class NIP90SummarizationRequestEvent( } } } + +@Deprecated( + "Renamed to DvmSummarizationRequestEvent. NIP-90 Data Vending Machine events use the Dvm prefix.", + ReplaceWith("DvmSummarizationRequestEvent", "com.vitorpamplona.quartz.nip90Dvms.summarization.DvmSummarizationRequestEvent"), +) +typealias NIP90SummarizationRequestEvent = DvmSummarizationRequestEvent diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/summarization/NIP90SummarizationResponseEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/summarization/DvmSummarizationResponseEvent.kt similarity index 80% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/summarization/NIP90SummarizationResponseEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/summarization/DvmSummarizationResponseEvent.kt index d4b97ca533..9b04b3ffa0 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/summarization/NIP90SummarizationResponseEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/summarization/DvmSummarizationResponseEvent.kt @@ -28,7 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.utils.TimeUtils @Immutable -class NIP90SummarizationResponseEvent( +class DvmSummarizationResponseEvent( id: HexKey, pubKey: HexKey, createdAt: Long, @@ -41,9 +41,15 @@ class NIP90SummarizationResponseEvent( fun build( createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, + initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, "", createdAt) { initializer() } } } + +@Deprecated( + "Renamed to DvmSummarizationResponseEvent. NIP-90 Data Vending Machine events use the Dvm prefix.", + ReplaceWith("DvmSummarizationResponseEvent", "com.vitorpamplona.quartz.nip90Dvms.summarization.DvmSummarizationResponseEvent"), +) +typealias NIP90SummarizationResponseEvent = DvmSummarizationResponseEvent diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/textExtraction/NIP90TextExtractionRequestEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/textExtraction/DvmTextExtractionRequestEvent.kt similarity index 87% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/textExtraction/NIP90TextExtractionRequestEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/textExtraction/DvmTextExtractionRequestEvent.kt index c61c29b542..8ea2ad8cca 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/textExtraction/NIP90TextExtractionRequestEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/textExtraction/DvmTextExtractionRequestEvent.kt @@ -36,7 +36,7 @@ import com.vitorpamplona.quartz.nip90Dvms.tags.param import com.vitorpamplona.quartz.utils.TimeUtils @Immutable -class NIP90TextExtractionRequestEvent( +class DvmTextExtractionRequestEvent( id: HexKey, pubKey: HexKey, createdAt: Long, @@ -64,7 +64,7 @@ class NIP90TextExtractionRequestEvent( rangeEnd: String? = null, alignment: String? = null, createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, + initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, "", createdAt) { inputUrl(inputUrl) outputMimeType?.let { output(it) } @@ -76,3 +76,9 @@ class NIP90TextExtractionRequestEvent( } } } + +@Deprecated( + "Renamed to DvmTextExtractionRequestEvent. NIP-90 Data Vending Machine events use the Dvm prefix.", + ReplaceWith("DvmTextExtractionRequestEvent", "com.vitorpamplona.quartz.nip90Dvms.textExtraction.DvmTextExtractionRequestEvent"), +) +typealias NIP90TextExtractionRequestEvent = DvmTextExtractionRequestEvent diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/textExtraction/NIP90TextExtractionResponseEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/textExtraction/DvmTextExtractionResponseEvent.kt similarity index 80% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/textExtraction/NIP90TextExtractionResponseEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/textExtraction/DvmTextExtractionResponseEvent.kt index ae808faa45..814f311d3e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/textExtraction/NIP90TextExtractionResponseEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/textExtraction/DvmTextExtractionResponseEvent.kt @@ -28,7 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.utils.TimeUtils @Immutable -class NIP90TextExtractionResponseEvent( +class DvmTextExtractionResponseEvent( id: HexKey, pubKey: HexKey, createdAt: Long, @@ -41,9 +41,15 @@ class NIP90TextExtractionResponseEvent( fun build( createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, + initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, "", createdAt) { initializer() } } } + +@Deprecated( + "Renamed to DvmTextExtractionResponseEvent. NIP-90 Data Vending Machine events use the Dvm prefix.", + ReplaceWith("DvmTextExtractionResponseEvent", "com.vitorpamplona.quartz.nip90Dvms.textExtraction.DvmTextExtractionResponseEvent"), +) +typealias NIP90TextExtractionResponseEvent = DvmTextExtractionResponseEvent diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/textGeneration/NIP90TextGenerationRequestEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/textGeneration/DvmTextGenerationRequestEvent.kt similarity index 90% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/textGeneration/NIP90TextGenerationRequestEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/textGeneration/DvmTextGenerationRequestEvent.kt index 6554dd73ab..2e0f57fdde 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/textGeneration/NIP90TextGenerationRequestEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/textGeneration/DvmTextGenerationRequestEvent.kt @@ -37,7 +37,7 @@ import com.vitorpamplona.quartz.nip90Dvms.tags.param import com.vitorpamplona.quartz.utils.TimeUtils @Immutable -class NIP90TextGenerationRequestEvent( +class DvmTextGenerationRequestEvent( id: HexKey, pubKey: HexKey, createdAt: Long, @@ -88,7 +88,7 @@ class NIP90TextGenerationRequestEvent( frequencyPenalty: Double? = null, outputMimeType: String? = null, createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, + initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, "", createdAt) { inputPrompt(prompt) model?.let { param("model", it) } @@ -102,3 +102,9 @@ class NIP90TextGenerationRequestEvent( } } } + +@Deprecated( + "Renamed to DvmTextGenerationRequestEvent. NIP-90 Data Vending Machine events use the Dvm prefix.", + ReplaceWith("DvmTextGenerationRequestEvent", "com.vitorpamplona.quartz.nip90Dvms.textGeneration.DvmTextGenerationRequestEvent"), +) +typealias NIP90TextGenerationRequestEvent = DvmTextGenerationRequestEvent diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/textGeneration/NIP90TextGenerationResponseEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/textGeneration/DvmTextGenerationResponseEvent.kt similarity index 80% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/textGeneration/NIP90TextGenerationResponseEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/textGeneration/DvmTextGenerationResponseEvent.kt index ded0df9d9b..2619816759 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/textGeneration/NIP90TextGenerationResponseEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/textGeneration/DvmTextGenerationResponseEvent.kt @@ -28,7 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.utils.TimeUtils @Immutable -class NIP90TextGenerationResponseEvent( +class DvmTextGenerationResponseEvent( id: HexKey, pubKey: HexKey, createdAt: Long, @@ -41,9 +41,15 @@ class NIP90TextGenerationResponseEvent( fun build( createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, + initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, "", createdAt) { initializer() } } } + +@Deprecated( + "Renamed to DvmTextGenerationResponseEvent. NIP-90 Data Vending Machine events use the Dvm prefix.", + ReplaceWith("DvmTextGenerationResponseEvent", "com.vitorpamplona.quartz.nip90Dvms.textGeneration.DvmTextGenerationResponseEvent"), +) +typealias NIP90TextGenerationResponseEvent = DvmTextGenerationResponseEvent diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/textToSpeech/NIP90TextToSpeechRequestEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/textToSpeech/DvmTextToSpeechRequestEvent.kt similarity index 87% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/textToSpeech/NIP90TextToSpeechRequestEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/textToSpeech/DvmTextToSpeechRequestEvent.kt index 251fe7b510..41427dbb9e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/textToSpeech/NIP90TextToSpeechRequestEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/textToSpeech/DvmTextToSpeechRequestEvent.kt @@ -36,7 +36,7 @@ import com.vitorpamplona.quartz.nip90Dvms.tags.param import com.vitorpamplona.quartz.utils.TimeUtils @Immutable -class NIP90TextToSpeechRequestEvent( +class DvmTextToSpeechRequestEvent( id: HexKey, pubKey: HexKey, createdAt: Long, @@ -67,7 +67,7 @@ class NIP90TextToSpeechRequestEvent( text: String, language: String? = null, createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, + initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, "", createdAt) { inputText(text) language?.let { param("language", it) } @@ -75,3 +75,9 @@ class NIP90TextToSpeechRequestEvent( } } } + +@Deprecated( + "Renamed to DvmTextToSpeechRequestEvent. NIP-90 Data Vending Machine events use the Dvm prefix.", + ReplaceWith("DvmTextToSpeechRequestEvent", "com.vitorpamplona.quartz.nip90Dvms.textToSpeech.DvmTextToSpeechRequestEvent"), +) +typealias NIP90TextToSpeechRequestEvent = DvmTextToSpeechRequestEvent diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/textToSpeech/NIP90TextToSpeechResponseEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/textToSpeech/DvmTextToSpeechResponseEvent.kt similarity index 81% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/textToSpeech/NIP90TextToSpeechResponseEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/textToSpeech/DvmTextToSpeechResponseEvent.kt index 01cbf1e0ca..2a76d0f54a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/textToSpeech/NIP90TextToSpeechResponseEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/textToSpeech/DvmTextToSpeechResponseEvent.kt @@ -28,7 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.utils.TimeUtils @Immutable -class NIP90TextToSpeechResponseEvent( +class DvmTextToSpeechResponseEvent( id: HexKey, pubKey: HexKey, createdAt: Long, @@ -41,9 +41,15 @@ class NIP90TextToSpeechResponseEvent( fun build( createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, + initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, "", createdAt) { initializer() } } } + +@Deprecated( + "Renamed to DvmTextToSpeechResponseEvent. NIP-90 Data Vending Machine events use the Dvm prefix.", + ReplaceWith("DvmTextToSpeechResponseEvent", "com.vitorpamplona.quartz.nip90Dvms.textToSpeech.DvmTextToSpeechResponseEvent"), +) +typealias NIP90TextToSpeechResponseEvent = DvmTextToSpeechResponseEvent diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/translation/NIP90TranslationRequestEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/translation/DvmTranslationRequestEvent.kt similarity index 85% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/translation/NIP90TranslationRequestEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/translation/DvmTranslationRequestEvent.kt index aed285ea54..560842a3cd 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/translation/NIP90TranslationRequestEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/translation/DvmTranslationRequestEvent.kt @@ -33,7 +33,7 @@ import com.vitorpamplona.quartz.nip90Dvms.tags.param import com.vitorpamplona.quartz.utils.TimeUtils @Immutable -class NIP90TranslationRequestEvent( +class DvmTranslationRequestEvent( id: HexKey, pubKey: HexKey, createdAt: Long, @@ -52,7 +52,7 @@ class NIP90TranslationRequestEvent( eventIds: List, language: String, createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, + initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, "", createdAt) { eventIds.forEach { inputEvent(it) } param("language", language) @@ -60,3 +60,9 @@ class NIP90TranslationRequestEvent( } } } + +@Deprecated( + "Renamed to DvmTranslationRequestEvent. NIP-90 Data Vending Machine events use the Dvm prefix.", + ReplaceWith("DvmTranslationRequestEvent", "com.vitorpamplona.quartz.nip90Dvms.translation.DvmTranslationRequestEvent"), +) +typealias NIP90TranslationRequestEvent = DvmTranslationRequestEvent diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/translation/NIP90TranslationResponseEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/translation/DvmTranslationResponseEvent.kt similarity index 81% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/translation/NIP90TranslationResponseEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/translation/DvmTranslationResponseEvent.kt index b43ef64283..a051d8a242 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/translation/NIP90TranslationResponseEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/translation/DvmTranslationResponseEvent.kt @@ -28,7 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.utils.TimeUtils @Immutable -class NIP90TranslationResponseEvent( +class DvmTranslationResponseEvent( id: HexKey, pubKey: HexKey, createdAt: Long, @@ -41,9 +41,15 @@ class NIP90TranslationResponseEvent( fun build( createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, + initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, "", createdAt) { initializer() } } } + +@Deprecated( + "Renamed to DvmTranslationResponseEvent. NIP-90 Data Vending Machine events use the Dvm prefix.", + ReplaceWith("DvmTranslationResponseEvent", "com.vitorpamplona.quartz.nip90Dvms.translation.DvmTranslationResponseEvent"), +) +typealias NIP90TranslationResponseEvent = DvmTranslationResponseEvent diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/userDiscoveryRequest/NIP90UserDiscoveryRequestEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/userDiscoveryRequest/DvmUserDiscoveryRequestEvent.kt similarity index 83% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/userDiscoveryRequest/NIP90UserDiscoveryRequestEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/userDiscoveryRequest/DvmUserDiscoveryRequestEvent.kt index be937e06fe..a3482cd7e7 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/userDiscoveryRequest/NIP90UserDiscoveryRequestEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/userDiscoveryRequest/DvmUserDiscoveryRequestEvent.kt @@ -31,7 +31,7 @@ import com.vitorpamplona.quartz.nip90Dvms.tags.inputs import com.vitorpamplona.quartz.utils.TimeUtils @Immutable -class NIP90UserDiscoveryRequestEvent( +class DvmUserDiscoveryRequestEvent( id: HexKey, pubKey: HexKey, createdAt: Long, @@ -50,9 +50,15 @@ class NIP90UserDiscoveryRequestEvent( fun build( createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, + initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, "", createdAt) { initializer() } } } + +@Deprecated( + "Renamed to DvmUserDiscoveryRequestEvent. NIP-90 Data Vending Machine events use the Dvm prefix.", + ReplaceWith("DvmUserDiscoveryRequestEvent", "com.vitorpamplona.quartz.nip90Dvms.userDiscoveryRequest.DvmUserDiscoveryRequestEvent"), +) +typealias NIP90UserDiscoveryRequestEvent = DvmUserDiscoveryRequestEvent diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/userDiscoveryResponse/NIP90UserDiscoveryResponseEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/userDiscoveryResponse/DvmUserDiscoveryResponseEvent.kt similarity index 82% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/userDiscoveryResponse/NIP90UserDiscoveryResponseEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/userDiscoveryResponse/DvmUserDiscoveryResponseEvent.kt index ba220f40bd..28d3d42e05 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/userDiscoveryResponse/NIP90UserDiscoveryResponseEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/userDiscoveryResponse/DvmUserDiscoveryResponseEvent.kt @@ -30,7 +30,7 @@ import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils @Immutable -class NIP90UserDiscoveryResponseEvent( +class DvmUserDiscoveryResponseEvent( id: HexKey, pubKey: HexKey, createdAt: Long, @@ -61,7 +61,7 @@ class NIP90UserDiscoveryResponseEvent( } } } catch (e: Throwable) { - Log.w("NIP90UserDiscoveryResponseEvent") { "Error parsing the JSON ${e.message}" } + Log.w("DvmUserDiscoveryResponseEvent") { "Error parsing the JSON ${e.message}" } } return people ?: listOf() @@ -72,9 +72,15 @@ class NIP90UserDiscoveryResponseEvent( fun build( createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, + initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, "", createdAt) { initializer() } } } + +@Deprecated( + "Renamed to DvmUserDiscoveryResponseEvent. NIP-90 Data Vending Machine events use the Dvm prefix.", + ReplaceWith("DvmUserDiscoveryResponseEvent", "com.vitorpamplona.quartz.nip90Dvms.userDiscoveryResponse.DvmUserDiscoveryResponseEvent"), +) +typealias NIP90UserDiscoveryResponseEvent = DvmUserDiscoveryResponseEvent diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/videoConversion/NIP90VideoConversionRequestEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/videoConversion/DvmVideoConversionRequestEvent.kt similarity index 86% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/videoConversion/NIP90VideoConversionRequestEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/videoConversion/DvmVideoConversionRequestEvent.kt index ffc3adc0c5..d438273af9 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/videoConversion/NIP90VideoConversionRequestEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/videoConversion/DvmVideoConversionRequestEvent.kt @@ -35,7 +35,7 @@ import com.vitorpamplona.quartz.nip90Dvms.tags.param import com.vitorpamplona.quartz.utils.TimeUtils @Immutable -class NIP90VideoConversionRequestEvent( +class DvmVideoConversionRequestEvent( id: HexKey, pubKey: HexKey, createdAt: Long, @@ -60,7 +60,7 @@ class NIP90VideoConversionRequestEvent( rangeStart: String? = null, rangeEnd: String? = null, createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, + initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, "", createdAt) { inputUrl(videoUrl) outputMimeType?.let { output(it) } @@ -71,3 +71,9 @@ class NIP90VideoConversionRequestEvent( } } } + +@Deprecated( + "Renamed to DvmVideoConversionRequestEvent. NIP-90 Data Vending Machine events use the Dvm prefix.", + ReplaceWith("DvmVideoConversionRequestEvent", "com.vitorpamplona.quartz.nip90Dvms.videoConversion.DvmVideoConversionRequestEvent"), +) +typealias NIP90VideoConversionRequestEvent = DvmVideoConversionRequestEvent diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/videoConversion/NIP90VideoConversionResponseEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/videoConversion/DvmVideoConversionResponseEvent.kt similarity index 80% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/videoConversion/NIP90VideoConversionResponseEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/videoConversion/DvmVideoConversionResponseEvent.kt index ebd7a9031c..923217e476 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/videoConversion/NIP90VideoConversionResponseEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/videoConversion/DvmVideoConversionResponseEvent.kt @@ -28,7 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.utils.TimeUtils @Immutable -class NIP90VideoConversionResponseEvent( +class DvmVideoConversionResponseEvent( id: HexKey, pubKey: HexKey, createdAt: Long, @@ -41,9 +41,15 @@ class NIP90VideoConversionResponseEvent( fun build( createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, + initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, "", createdAt) { initializer() } } } + +@Deprecated( + "Renamed to DvmVideoConversionResponseEvent. NIP-90 Data Vending Machine events use the Dvm prefix.", + ReplaceWith("DvmVideoConversionResponseEvent", "com.vitorpamplona.quartz.nip90Dvms.videoConversion.DvmVideoConversionResponseEvent"), +) +typealias NIP90VideoConversionResponseEvent = DvmVideoConversionResponseEvent diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/videoTranslation/NIP90VideoTranslationRequestEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/videoTranslation/DvmVideoTranslationRequestEvent.kt similarity index 87% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/videoTranslation/NIP90VideoTranslationRequestEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/videoTranslation/DvmVideoTranslationRequestEvent.kt index 859be0382c..7df77e7a7f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/videoTranslation/NIP90VideoTranslationRequestEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/videoTranslation/DvmVideoTranslationRequestEvent.kt @@ -36,7 +36,7 @@ import com.vitorpamplona.quartz.nip90Dvms.tags.param import com.vitorpamplona.quartz.utils.TimeUtils @Immutable -class NIP90VideoTranslationRequestEvent( +class DvmVideoTranslationRequestEvent( id: HexKey, pubKey: HexKey, createdAt: Long, @@ -67,7 +67,7 @@ class NIP90VideoTranslationRequestEvent( rangeEnd: String? = null, outputFormat: String? = null, createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, + initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, "", createdAt) { inputUrl(videoUrl) param("language", language) @@ -80,3 +80,9 @@ class NIP90VideoTranslationRequestEvent( } } } + +@Deprecated( + "Renamed to DvmVideoTranslationRequestEvent. NIP-90 Data Vending Machine events use the Dvm prefix.", + ReplaceWith("DvmVideoTranslationRequestEvent", "com.vitorpamplona.quartz.nip90Dvms.videoTranslation.DvmVideoTranslationRequestEvent"), +) +typealias NIP90VideoTranslationRequestEvent = DvmVideoTranslationRequestEvent diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/videoTranslation/NIP90VideoTranslationResponseEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/videoTranslation/DvmVideoTranslationResponseEvent.kt similarity index 80% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/videoTranslation/NIP90VideoTranslationResponseEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/videoTranslation/DvmVideoTranslationResponseEvent.kt index cea768fdd5..a1ee14aa54 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/videoTranslation/NIP90VideoTranslationResponseEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/videoTranslation/DvmVideoTranslationResponseEvent.kt @@ -28,7 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.utils.TimeUtils @Immutable -class NIP90VideoTranslationResponseEvent( +class DvmVideoTranslationResponseEvent( id: HexKey, pubKey: HexKey, createdAt: Long, @@ -41,9 +41,15 @@ class NIP90VideoTranslationResponseEvent( fun build( createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, + initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, "", createdAt) { initializer() } } } + +@Deprecated( + "Renamed to DvmVideoTranslationResponseEvent. NIP-90 Data Vending Machine events use the Dvm prefix.", + ReplaceWith("DvmVideoTranslationResponseEvent", "com.vitorpamplona.quartz.nip90Dvms.videoTranslation.DvmVideoTranslationResponseEvent"), +) +typealias NIP90VideoTranslationResponseEvent = DvmVideoTranslationResponseEvent diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip94FileMetadata/FileHeaderEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip94FileMetadata/FileMetadataEvent.kt similarity index 93% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip94FileMetadata/FileHeaderEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip94FileMetadata/FileMetadataEvent.kt index 6ac3a389cf..d4e9cfd79d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip94FileMetadata/FileHeaderEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip94FileMetadata/FileMetadataEvent.kt @@ -46,7 +46,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.tags.UrlTag import com.vitorpamplona.quartz.utils.TimeUtils @Immutable -class FileHeaderEvent( +class FileMetadataEvent( id: HexKey, pubKey: HexKey, createdAt: Long, @@ -105,7 +105,7 @@ class FileHeaderEvent( url: String, caption: String?, createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, + initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, caption ?: "", createdAt) { url(url) // NIP-94 accessibility description of the file (kept; the deprecated @@ -127,7 +127,7 @@ class FileHeaderEvent( magnetUri: String? = null, torrentInfoHash: String? = null, createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, + initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, caption ?: "", createdAt) { url(url) // NIP-94 accessibility description of the file (kept; the deprecated @@ -148,3 +148,9 @@ class FileHeaderEvent( } } } + +@Deprecated( + "Renamed to FileMetadataEvent. NIP-94 names kind 1063 file metadata.", + ReplaceWith("FileMetadataEvent", "com.vitorpamplona.quartz.nip94FileMetadata.FileMetadataEvent"), +) +typealias FileHeaderEvent = FileMetadataEvent diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip94FileMetadata/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip94FileMetadata/TagArrayBuilderExt.kt index e000583d6e..d89c862235 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip94FileMetadata/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip94FileMetadata/TagArrayBuilderExt.kt @@ -38,32 +38,32 @@ import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbhashTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.TorrentInfoHash import com.vitorpamplona.quartz.nip94FileMetadata.tags.UrlTag -fun TagArrayBuilder.url(url: String) = add(UrlTag.assemble(url)) +fun TagArrayBuilder.url(url: String) = add(UrlTag.assemble(url)) -fun TagArrayBuilder.mimeType(mimeType: String) = add(MimeTypeTag.assemble(mimeType)) +fun TagArrayBuilder.mimeType(mimeType: String) = add(MimeTypeTag.assemble(mimeType)) -fun TagArrayBuilder.hash(hash: HexKey) = add(HashSha256Tag.assemble(hash)) +fun TagArrayBuilder.hash(hash: HexKey) = add(HashSha256Tag.assemble(hash)) -fun TagArrayBuilder.fileSize(size: Int) = add(SizeTag.assemble(size)) +fun TagArrayBuilder.fileSize(size: Int) = add(SizeTag.assemble(size)) -fun TagArrayBuilder.dimension(dim: DimensionTag) = add(DimensionTag.assemble(dim)) +fun TagArrayBuilder.dimension(dim: DimensionTag) = add(DimensionTag.assemble(dim)) -fun TagArrayBuilder.blurhash(blurhash: String) = add(BlurhashTag.assemble(blurhash)) +fun TagArrayBuilder.blurhash(blurhash: String) = add(BlurhashTag.assemble(blurhash)) -fun TagArrayBuilder.thumbhash(thumbhash: String) = add(ThumbhashTag.assemble(thumbhash)) +fun TagArrayBuilder.thumbhash(thumbhash: String) = add(ThumbhashTag.assemble(thumbhash)) -fun TagArrayBuilder.originalHash(hash: HexKey) = add(OriginalHashTag.assemble(hash)) +fun TagArrayBuilder.originalHash(hash: HexKey) = add(OriginalHashTag.assemble(hash)) -fun TagArrayBuilder.torrentInfohash(hash: String) = add(TorrentInfoHash.assemble(hash)) +fun TagArrayBuilder.torrentInfohash(hash: String) = add(TorrentInfoHash.assemble(hash)) -fun TagArrayBuilder.magnet(magnetUri: String) = add(MagnetTag.assemble(magnetUri)) +fun TagArrayBuilder.magnet(magnetUri: String) = add(MagnetTag.assemble(magnetUri)) -fun TagArrayBuilder.image(imageUrl: HexKey) = add(ImageTag.assemble(imageUrl)) +fun TagArrayBuilder.image(imageUrl: HexKey) = add(ImageTag.assemble(imageUrl)) -fun TagArrayBuilder.thumb(trumbUrl: HexKey) = add(ThumbTag.assemble(trumbUrl)) +fun TagArrayBuilder.thumb(trumbUrl: HexKey) = add(ThumbTag.assemble(trumbUrl)) -fun TagArrayBuilder.summary(summary: HexKey) = add(SummaryTag.assemble(summary)) +fun TagArrayBuilder.summary(summary: HexKey) = add(SummaryTag.assemble(summary)) -fun TagArrayBuilder.fallback(fallbackUrl: HexKey) = add(FallbackTag.assemble(fallbackUrl)) +fun TagArrayBuilder.fallback(fallbackUrl: HexKey) = add(FallbackTag.assemble(fallbackUrl)) -fun TagArrayBuilder.service(service: HexKey) = add(ServiceTag.assemble(service)) +fun TagArrayBuilder.service(service: HexKey) = add(ServiceTag.assemble(service)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index 76257ca74d..06f5682286 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -103,6 +103,8 @@ import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent import com.vitorpamplona.quartz.concord.cord04Roles.control.ControlEditionEvent import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEvent import com.vitorpamplona.quartz.concord.cord05Invites.bundle.ConcordInviteBundleEvent +import com.vitorpamplona.quartz.contextvm.cep06Announcements.CvmServerAnnouncementEvent +import com.vitorpamplona.quartz.contextvm.cep06Announcements.CvmToolsListEvent import com.vitorpamplona.quartz.cyberspace.CyberspaceBagEvent import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoAvatarEvent import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoObjectEvent @@ -179,8 +181,8 @@ import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent -import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent -import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip04Dm.messages.EncryptedDmEvent +import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip15Marketplace.auction.AuctionEvent import com.vitorpamplona.quartz.nip15Marketplace.bid.BidEvent @@ -190,38 +192,38 @@ import com.vitorpamplona.quartz.nip15Marketplace.product.ProductEvent import com.vitorpamplona.quartz.nip15Marketplace.stall.StallEvent import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent -import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.nip17Dm.settings.DmRelayListEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent import com.vitorpamplona.quartz.nip25Reactions.ExternalReactionEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelHideMessageEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMuteUserEvent -import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent +import com.vitorpamplona.quartz.nip28PublicChat.list.PublicChatListEvent import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent 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.GroupParticipantsEvent import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupPinnedEvent -import com.vitorpamplona.quartz.nip29RelayGroups.metadata.SupportedRolesEvent +import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupRolesEvent import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateGroupEvent -import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateInviteEvent -import com.vitorpamplona.quartz.nip29RelayGroups.moderation.DeleteEventEvent import com.vitorpamplona.quartz.nip29RelayGroups.moderation.DeleteGroupEvent -import com.vitorpamplona.quartz.nip29RelayGroups.moderation.EditMetadataEvent -import com.vitorpamplona.quartz.nip29RelayGroups.moderation.PutUserEvent -import com.vitorpamplona.quartz.nip29RelayGroups.moderation.RemoveUserEvent -import com.vitorpamplona.quartz.nip29RelayGroups.moderation.UpdatePinListEvent -import com.vitorpamplona.quartz.nip29RelayGroups.request.JoinRequestEvent -import com.vitorpamplona.quartz.nip29RelayGroups.request.LeaveRequestEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.GroupCreateInviteEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.GroupDeleteEventEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.GroupEditMetadataEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.GroupPutUserEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.GroupRemoveUserEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.GroupUpdatePinListEvent +import com.vitorpamplona.quartz.nip29RelayGroups.request.GroupJoinRequestEvent +import com.vitorpamplona.quartz.nip29RelayGroups.request.GroupLeaveRequestEvent import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent -import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEvent +import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiListEvent import com.vitorpamplona.quartz.nip32Labeling.LabelEvent import com.vitorpamplona.quartz.nip34Git.grasp.UserGraspListEvent import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent @@ -239,7 +241,7 @@ import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayListEvent -import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent +import com.vitorpamplona.quartz.nip38UserStatus.UserStatusEvent import com.vitorpamplona.quartz.nip39ExtIdentities.ExternalIdentitiesEvent import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent import com.vitorpamplona.quartz.nip43RelayMembers.addMember.RelayAddMemberEvent @@ -249,45 +251,45 @@ import com.vitorpamplona.quartz.nip43RelayMembers.leaveRequest.RelayLeaveRequest import com.vitorpamplona.quartz.nip43RelayMembers.list.RelayMembershipListEvent import com.vitorpamplona.quartz.nip43RelayMembers.removeMember.RelayRemoveMemberEvent import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent -import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent -import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcInfoEvent import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcNotificationEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcRequestEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcResponseEvent import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent import com.vitorpamplona.quartz.nip51Lists.PinListEvent import com.vitorpamplona.quartz.nip51Lists.appCurationSet.AppCurationSetEvent import com.vitorpamplona.quartz.nip51Lists.articleCurationSet.ArticleCurationSetEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkSet.BookmarkSetEvent import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListEvent -import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import com.vitorpamplona.quartz.nip51Lists.followSet.FollowSetEvent import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent import com.vitorpamplona.quartz.nip51Lists.gitAuthorList.GitAuthorListEvent import com.vitorpamplona.quartz.nip51Lists.gitRepositoryList.GitRepositoryListEvent import com.vitorpamplona.quartz.nip51Lists.goodWikiAuthorList.GoodWikiAuthorListEvent import com.vitorpamplona.quartz.nip51Lists.goodWikiRelayList.GoodWikiRelayListEvent -import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent +import com.vitorpamplona.quartz.nip51Lists.interestList.InterestListEvent import com.vitorpamplona.quartz.nip51Lists.interestSet.InterestSetEvent import com.vitorpamplona.quartz.nip51Lists.kindMuteSet.KindMuteSetEvent -import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.LabeledBookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.mediaFollowList.MediaFollowListEvent import com.vitorpamplona.quartz.nip51Lists.mediaStarterPack.MediaStarterPackEvent import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent -import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import com.vitorpamplona.quartz.nip51Lists.pictureCurationSet.PictureCurationSetEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BroadcastRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.FavoriteRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent -import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relaySets.RelaySetEvent import com.vitorpamplona.quartz.nip51Lists.releaseArtifactSet.ReleaseArtifactSetEvent import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.SimpleGroupListEvent +import com.vitorpamplona.quartz.nip51Lists.starterPack.StarterPackEvent import com.vitorpamplona.quartz.nip51Lists.videoCurationSet.VideoCurationSetEvent import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent -import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent +import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarCollectionEvent import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent @@ -297,19 +299,19 @@ import com.vitorpamplona.quartz.nip53LiveActivities.nestsServers.NestsServersEve import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent import com.vitorpamplona.quartz.nip53LiveActivities.raid.LiveActivitiesRaidEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent +import com.vitorpamplona.quartz.nip54Wiki.WikiArticleEvent import com.vitorpamplona.quartz.nip54Wiki.WikiMergeAcceptanceEvent import com.vitorpamplona.quartz.nip54Wiki.WikiMergeRequestEvent -import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip54Wiki.WikiRedirectEvent import com.vitorpamplona.quartz.nip56Reports.ReportEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import com.vitorpamplona.quartz.nip57Zaps.PrivateZapEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapRequestEvent import com.vitorpamplona.quartz.nip58Badges.accepted.AcceptedBadgeSetEvent import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent import com.vitorpamplona.quartz.nip58Badges.profile.ProfileBadgesEvent -import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent +import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.EphemeralGiftWrapEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip5aStaticWebsites.NamedSiteEvent @@ -323,8 +325,6 @@ import com.vitorpamplona.quartz.nip60Cashu.token.CashuTokenEvent import com.vitorpamplona.quartz.nip60Cashu.wallet.CashuWalletEvent import com.vitorpamplona.quartz.nip61Nutzaps.info.NutzapInfoEvent import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent -import com.vitorpamplona.quartz.nip61Nutzaps.redemption.NutzapRedemptionEvent -import com.vitorpamplona.quartz.nip61Nutzaps.token.TokenEvent import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent import com.vitorpamplona.quartz.nip64Chess.challenge.accept.LiveChessGameAcceptEvent import com.vitorpamplona.quartz.nip64Chess.challenge.offer.LiveChessGameChallengeEvent @@ -338,16 +338,16 @@ import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.RelayDiscoveryEvent import com.vitorpamplona.quartz.nip66RelayMonitor.monitor.RelayMonitorEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent import com.vitorpamplona.quartz.nip69P2pOrderEvents.P2POrderEvent -import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent +import com.vitorpamplona.quartz.nip71Video.AddressableNormalVideoEvent +import com.vitorpamplona.quartz.nip71Video.AddressableShortVideoEvent import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent import com.vitorpamplona.quartz.nip71Video.VideoShortEvent -import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent import com.vitorpamplona.quartz.nip71Video.textTrack.TextTrackEvent import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent import com.vitorpamplona.quartz.nip72ModCommunities.rules.CommunityRulesEvent -import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent +import com.vitorpamplona.quartz.nip75ZapGoals.ZapGoalEvent import com.vitorpamplona.quartz.nip78AppData.AppDataEvent import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent @@ -356,7 +356,7 @@ import com.vitorpamplona.quartz.nip85TrustedAssertions.addressables.AddressableA import com.vitorpamplona.quartz.nip85TrustedAssertions.events.EventAssertionEvent import com.vitorpamplona.quartz.nip85TrustedAssertions.externalIds.ExternalIdAssertionEvent import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent -import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent +import com.vitorpamplona.quartz.nip85TrustedAssertions.users.UserAssertionEvent import com.vitorpamplona.quartz.nip87Ecash.cashu.CashuMintEvent import com.vitorpamplona.quartz.nip87Ecash.fedimint.FedimintEvent import com.vitorpamplona.quartz.nip87Ecash.recommendation.MintRecommendationEvent @@ -364,47 +364,47 @@ import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.AppRecommendationEvent -import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryRequest.NIP90ContentDiscoveryRequestEvent -import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent -import com.vitorpamplona.quartz.nip90Dvms.contentSearch.NIP90ContentSearchRequestEvent -import com.vitorpamplona.quartz.nip90Dvms.contentSearch.NIP90ContentSearchResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryRequest.DvmContentDiscoveryRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.DvmContentDiscoveryResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.contentSearch.DvmContentSearchRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.contentSearch.DvmContentSearchResponseEvent import com.vitorpamplona.quartz.nip90Dvms.dvmHeartbeat.DvmHeartbeatEvent -import com.vitorpamplona.quartz.nip90Dvms.eventCount.NIP90EventCountRequestEvent -import com.vitorpamplona.quartz.nip90Dvms.eventCount.NIP90EventCountResponseEvent -import com.vitorpamplona.quartz.nip90Dvms.eventPowDelegation.NIP90EventPowDelegationRequestEvent -import com.vitorpamplona.quartz.nip90Dvms.eventPowDelegation.NIP90EventPowDelegationResponseEvent -import com.vitorpamplona.quartz.nip90Dvms.eventPublishSchedule.NIP90EventPublishScheduleRequestEvent -import com.vitorpamplona.quartz.nip90Dvms.eventPublishSchedule.NIP90EventPublishScheduleResponseEvent -import com.vitorpamplona.quartz.nip90Dvms.eventTimestamping.NIP90EventTimestampingRequestEvent -import com.vitorpamplona.quartz.nip90Dvms.eventTimestamping.NIP90EventTimestampingResponseEvent -import com.vitorpamplona.quartz.nip90Dvms.imageGeneration.NIP90ImageGenerationRequestEvent -import com.vitorpamplona.quartz.nip90Dvms.imageGeneration.NIP90ImageGenerationResponseEvent -import com.vitorpamplona.quartz.nip90Dvms.imageToVideo.NIP90ImageToVideoRequestEvent -import com.vitorpamplona.quartz.nip90Dvms.imageToVideo.NIP90ImageToVideoResponseEvent -import com.vitorpamplona.quartz.nip90Dvms.malwareScanning.NIP90MalwareScanRequestEvent -import com.vitorpamplona.quartz.nip90Dvms.malwareScanning.NIP90MalwareScanResponseEvent -import com.vitorpamplona.quartz.nip90Dvms.opReturn.NIP90OpReturnRequestEvent -import com.vitorpamplona.quartz.nip90Dvms.opReturn.NIP90OpReturnResponseEvent -import com.vitorpamplona.quartz.nip90Dvms.peopleSearch.NIP90PeopleSearchRequestEvent -import com.vitorpamplona.quartz.nip90Dvms.peopleSearch.NIP90PeopleSearchResponseEvent -import com.vitorpamplona.quartz.nip90Dvms.status.NIP90StatusEvent -import com.vitorpamplona.quartz.nip90Dvms.summarization.NIP90SummarizationRequestEvent -import com.vitorpamplona.quartz.nip90Dvms.summarization.NIP90SummarizationResponseEvent -import com.vitorpamplona.quartz.nip90Dvms.textExtraction.NIP90TextExtractionRequestEvent -import com.vitorpamplona.quartz.nip90Dvms.textExtraction.NIP90TextExtractionResponseEvent -import com.vitorpamplona.quartz.nip90Dvms.textGeneration.NIP90TextGenerationRequestEvent -import com.vitorpamplona.quartz.nip90Dvms.textGeneration.NIP90TextGenerationResponseEvent -import com.vitorpamplona.quartz.nip90Dvms.textToSpeech.NIP90TextToSpeechRequestEvent -import com.vitorpamplona.quartz.nip90Dvms.textToSpeech.NIP90TextToSpeechResponseEvent -import com.vitorpamplona.quartz.nip90Dvms.translation.NIP90TranslationRequestEvent -import com.vitorpamplona.quartz.nip90Dvms.translation.NIP90TranslationResponseEvent -import com.vitorpamplona.quartz.nip90Dvms.userDiscoveryRequest.NIP90UserDiscoveryRequestEvent -import com.vitorpamplona.quartz.nip90Dvms.userDiscoveryResponse.NIP90UserDiscoveryResponseEvent -import com.vitorpamplona.quartz.nip90Dvms.videoConversion.NIP90VideoConversionRequestEvent -import com.vitorpamplona.quartz.nip90Dvms.videoConversion.NIP90VideoConversionResponseEvent -import com.vitorpamplona.quartz.nip90Dvms.videoTranslation.NIP90VideoTranslationRequestEvent -import com.vitorpamplona.quartz.nip90Dvms.videoTranslation.NIP90VideoTranslationResponseEvent -import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent +import com.vitorpamplona.quartz.nip90Dvms.eventCount.DvmEventCountRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.eventCount.DvmEventCountResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.eventPowDelegation.DvmEventPowDelegationRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.eventPowDelegation.DvmEventPowDelegationResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.eventPublishSchedule.DvmEventPublishScheduleRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.eventPublishSchedule.DvmEventPublishScheduleResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.eventTimestamping.DvmEventTimestampingRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.eventTimestamping.DvmEventTimestampingResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.imageGeneration.DvmImageGenerationRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.imageGeneration.DvmImageGenerationResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.imageToVideo.DvmImageToVideoRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.imageToVideo.DvmImageToVideoResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.malwareScanning.DvmMalwareScanRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.malwareScanning.DvmMalwareScanResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.opReturn.DvmOpReturnRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.opReturn.DvmOpReturnResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.peopleSearch.DvmPeopleSearchRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.peopleSearch.DvmPeopleSearchResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.status.DvmStatusEvent +import com.vitorpamplona.quartz.nip90Dvms.summarization.DvmSummarizationRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.summarization.DvmSummarizationResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.textExtraction.DvmTextExtractionRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.textExtraction.DvmTextExtractionResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.textGeneration.DvmTextGenerationRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.textGeneration.DvmTextGenerationResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.textToSpeech.DvmTextToSpeechRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.textToSpeech.DvmTextToSpeechResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.translation.DvmTranslationRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.translation.DvmTranslationResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.userDiscoveryRequest.DvmUserDiscoveryRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.userDiscoveryResponse.DvmUserDiscoveryResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.videoConversion.DvmVideoConversionRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.videoConversion.DvmVideoConversionResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.videoTranslation.DvmVideoTranslationRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.videoTranslation.DvmVideoTranslationResponseEvent +import com.vitorpamplona.quartz.nip94FileMetadata.FileMetadataEvent import com.vitorpamplona.quartz.nip96FileStorage.config.FileServersEvent import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent @@ -465,6 +465,8 @@ class EventFactory { AcceptedBadgeSetEvent.KIND -> AcceptedBadgeSetEvent(id, pubKey, createdAt, tags, content, sig) ConcordChatEditEvent.KIND -> ConcordChatEditEvent(id, pubKey, createdAt, tags, content, sig) AdvertisedRelayListEvent.KIND -> AdvertisedRelayListEvent(id, pubKey, createdAt, tags, content, sig) + CvmServerAnnouncementEvent.KIND -> CvmServerAnnouncementEvent(id, pubKey, createdAt, tags, content, sig) + CvmToolsListEvent.KIND -> CvmToolsListEvent(id, pubKey, createdAt, tags, content, sig) AgentTurnMetricEvent.KIND -> AgentTurnMetricEvent(id, pubKey, createdAt, tags, content, sig) EngramEvent.KIND -> EngramEvent(id, pubKey, createdAt, tags, content, sig) AgentProfileEvent.KIND -> AgentProfileEvent(id, pubKey, createdAt, tags, content, sig) @@ -566,7 +568,7 @@ class EventFactory { BookmarkListEvent.KIND -> BookmarkListEvent(id, pubKey, createdAt, tags, content, sig) OldBookmarkListEvent.KIND -> OldBookmarkListEvent(id, pubKey, createdAt, tags, content, sig) CalendarDateSlotEvent.KIND -> CalendarDateSlotEvent(id, pubKey, createdAt, tags, content, sig) - CalendarEvent.KIND -> CalendarEvent(id, pubKey, createdAt, tags, content, sig) + CalendarCollectionEvent.KIND -> CalendarCollectionEvent(id, pubKey, createdAt, tags, content, sig) CalendarTimeSlotEvent.KIND -> CalendarTimeSlotEvent(id, pubKey, createdAt, tags, content, sig) CalendarRSVPEvent.KIND -> CalendarRSVPEvent(id, pubKey, createdAt, tags, content, sig) CallAnswerEvent.KIND -> CallAnswerEvent(id, pubKey, createdAt, tags, content, sig) @@ -581,19 +583,19 @@ class EventFactory { CashuSpendingHistoryEvent.KIND -> CashuSpendingHistoryEvent(id, pubKey, createdAt, tags, content, sig) CashuWalletEvent.KIND -> CashuWalletEvent(id, pubKey, createdAt, tags, content, sig) ChatEvent.KIND -> ChatEvent(id, pubKey, createdAt, tags, content, sig) - PutUserEvent.KIND -> PutUserEvent(id, pubKey, createdAt, tags, content, sig) - RemoveUserEvent.KIND -> RemoveUserEvent(id, pubKey, createdAt, tags, content, sig) - EditMetadataEvent.KIND -> EditMetadataEvent(id, pubKey, createdAt, tags, content, sig) - DeleteEventEvent.KIND -> DeleteEventEvent(id, pubKey, createdAt, tags, content, sig) + GroupPutUserEvent.KIND -> GroupPutUserEvent(id, pubKey, createdAt, tags, content, sig) + GroupRemoveUserEvent.KIND -> GroupRemoveUserEvent(id, pubKey, createdAt, tags, content, sig) + GroupEditMetadataEvent.KIND -> GroupEditMetadataEvent(id, pubKey, createdAt, tags, content, sig) + GroupDeleteEventEvent.KIND -> GroupDeleteEventEvent(id, pubKey, createdAt, tags, content, sig) CreateGroupEvent.KIND -> CreateGroupEvent(id, pubKey, createdAt, tags, content, sig) DeleteGroupEvent.KIND -> DeleteGroupEvent(id, pubKey, createdAt, tags, content, sig) - CreateInviteEvent.KIND -> CreateInviteEvent(id, pubKey, createdAt, tags, content, sig) - JoinRequestEvent.KIND -> JoinRequestEvent(id, pubKey, createdAt, tags, content, sig) - LeaveRequestEvent.KIND -> LeaveRequestEvent(id, pubKey, createdAt, tags, content, sig) + GroupCreateInviteEvent.KIND -> GroupCreateInviteEvent(id, pubKey, createdAt, tags, content, sig) + GroupJoinRequestEvent.KIND -> GroupJoinRequestEvent(id, pubKey, createdAt, tags, content, sig) + GroupLeaveRequestEvent.KIND -> GroupLeaveRequestEvent(id, pubKey, createdAt, tags, content, sig) GroupMetadataEvent.KIND -> GroupMetadataEvent(id, pubKey, createdAt, tags, content, sig) GroupAdminsEvent.KIND -> GroupAdminsEvent(id, pubKey, createdAt, tags, content, sig) GroupMembersEvent.KIND -> GroupMembersEvent(id, pubKey, createdAt, tags, content, sig) - SupportedRolesEvent.KIND -> SupportedRolesEvent(id, pubKey, createdAt, tags, content, sig) + GroupRolesEvent.KIND -> GroupRolesEvent(id, pubKey, createdAt, tags, content, sig) GroupParticipantsEvent.KIND -> GroupParticipantsEvent(id, pubKey, createdAt, tags, content, sig) // kind:39005 is shared by two relay-signed, addressable events that never coexist on // one relay: NIP-29's group pin list (relay29 family) and Buzz's NIP-CW thread @@ -614,10 +616,10 @@ class EventFactory { } else { GroupPinnedEvent(id, pubKey, createdAt, tags, content, sig) } - UpdatePinListEvent.KIND -> UpdatePinListEvent(id, pubKey, createdAt, tags, content, sig) + GroupUpdatePinListEvent.KIND -> GroupUpdatePinListEvent(id, pubKey, createdAt, tags, content, sig) ChessGameEvent.KIND -> ChessGameEvent(id, pubKey, createdAt, tags, content, sig) CodeSnippetEvent.KIND -> CodeSnippetEvent(id, pubKey, createdAt, tags, content, sig) - RelayFeedsListEvent.KIND -> RelayFeedsListEvent(id, pubKey, createdAt, tags, content, sig) + FavoriteRelayListEvent.KIND -> FavoriteRelayListEvent(id, pubKey, createdAt, tags, content, sig) JesterEvent.KIND -> JesterEvent(id, pubKey, createdAt, tags, content, sig) LiveChessGameChallengeEvent.KIND -> LiveChessGameChallengeEvent(id, pubKey, createdAt, tags, content, sig) LiveChessGameAcceptEvent.KIND -> LiveChessGameAcceptEvent(id, pubKey, createdAt, tags, content, sig) @@ -626,13 +628,13 @@ class EventFactory { LiveChessDrawOfferEvent.KIND -> LiveChessDrawOfferEvent(id, pubKey, createdAt, tags, content, sig) ChannelCreateEvent.KIND -> ChannelCreateEvent(id, pubKey, createdAt, tags, content, sig) ChannelHideMessageEvent.KIND -> ChannelHideMessageEvent(id, pubKey, createdAt, tags, content, sig) - ChannelListEvent.KIND -> ChannelListEvent(id, pubKey, createdAt, tags, content, sig) + PublicChatListEvent.KIND -> PublicChatListEvent(id, pubKey, createdAt, tags, content, sig) ChannelMessageEvent.KIND -> ChannelMessageEvent(id, pubKey, createdAt, tags, content, sig) ChannelMetadataEvent.KIND -> ChannelMetadataEvent(id, pubKey, createdAt, tags, content, sig) ChannelMuteUserEvent.KIND -> ChannelMuteUserEvent(id, pubKey, createdAt, tags, content, sig) ChatMessageEncryptedFileHeaderEvent.KIND -> ChatMessageEncryptedFileHeaderEvent(id.ifBlank { EventHasher.hashId(pubKey, createdAt, kind, tags, content) }, pubKey, createdAt, tags, content, sig) ChatMessageEvent.KIND -> ChatMessageEvent(id.ifBlank { EventHasher.hashId(pubKey, createdAt, kind, tags, content) }, pubKey, createdAt, tags, content, sig) - ChatMessageRelayListEvent.KIND -> ChatMessageRelayListEvent(id, pubKey, createdAt, tags, content, sig) + DmRelayListEvent.KIND -> DmRelayListEvent(id, pubKey, createdAt, tags, content, sig) ClassifiedsEvent.KIND -> ClassifiedsEvent(id, pubKey, createdAt, tags, content, sig) CommentEvent.KIND -> CommentEvent(id, pubKey, createdAt, tags, content, sig) CommunityDefinitionEvent.KIND -> CommunityDefinitionEvent(id, pubKey, createdAt, tags, content, sig) @@ -640,21 +642,21 @@ class EventFactory { CommunityPostApprovalEvent.KIND -> CommunityPostApprovalEvent(id, pubKey, createdAt, tags, content, sig) CommunityRulesEvent.KIND -> CommunityRulesEvent(id, pubKey, createdAt, tags, content, sig) ContactListEvent.KIND -> ContactListEvent(id, pubKey, createdAt, tags, content, sig) - DeletionEvent.KIND -> DeletionEvent(id, pubKey, createdAt, tags, content, sig) + DeletionRequestEvent.KIND -> DeletionRequestEvent(id, pubKey, createdAt, tags, content, sig) DraftWrapEvent.KIND -> DraftWrapEvent(id, pubKey, createdAt, tags, content, sig) EmojiPackEvent.KIND -> EmojiPackEvent(id, pubKey, createdAt, tags, content, sig) - EmojiPackSelectionEvent.KIND -> EmojiPackSelectionEvent(id, pubKey, createdAt, tags, content, sig) + EmojiListEvent.KIND -> EmojiListEvent(id, pubKey, createdAt, tags, content, sig) EphemeralChatEvent.KIND -> EphemeralChatEvent(id, pubKey, createdAt, tags, content, sig) EphemeralChatListEvent.KIND -> EphemeralChatListEvent(id, pubKey, createdAt, tags, content, sig) ExternalIdentitiesEvent.KIND -> ExternalIdentitiesEvent(id, pubKey, createdAt, tags, content, sig) FedimintEvent.KIND -> FedimintEvent(id, pubKey, createdAt, tags, content, sig) - FileHeaderEvent.KIND -> FileHeaderEvent(id, pubKey, createdAt, tags, content, sig) + FileMetadataEvent.KIND -> FileMetadataEvent(id, pubKey, createdAt, tags, content, sig) ProfileGalleryEntryEvent.KIND -> ProfileGalleryEntryEvent(id, pubKey, createdAt, tags, content, sig) FileServersEvent.KIND -> FileServersEvent(id, pubKey, createdAt, tags, content, sig) FileStorageEvent.KIND -> FileStorageEvent(id, pubKey, createdAt, tags, content, sig) FileStorageHeaderEvent.KIND -> FileStorageHeaderEvent(id, pubKey, createdAt, tags, content, sig) FhirResourceEvent.KIND -> FhirResourceEvent(id, pubKey, createdAt, tags, content, sig) - FollowListEvent.KIND -> FollowListEvent(id, pubKey, createdAt, tags, content, sig) + StarterPackEvent.KIND -> StarterPackEvent(id, pubKey, createdAt, tags, content, sig) FundraiserEvent.KIND -> FundraiserEvent(id, pubKey, createdAt, tags, content, sig) GenericRepostEvent.KIND -> GenericRepostEvent(id, pubKey, createdAt, tags, content, sig) CyberspaceBagEvent.KIND -> CyberspaceBagEvent(id, pubKey, createdAt, tags, content, sig) @@ -697,9 +699,9 @@ class EventFactory { UserGraspListEvent.KIND -> UserGraspListEvent(id, pubKey, createdAt, tags, content, sig) GoodWikiAuthorListEvent.KIND -> GoodWikiAuthorListEvent(id, pubKey, createdAt, tags, content, sig) GoodWikiRelayListEvent.KIND -> GoodWikiRelayListEvent(id, pubKey, createdAt, tags, content, sig) - GoalEvent.KIND -> GoalEvent(id, pubKey, createdAt, tags, content, sig) + ZapGoalEvent.KIND -> ZapGoalEvent(id, pubKey, createdAt, tags, content, sig) FavoriteAlgoFeedsListEvent.KIND -> FavoriteAlgoFeedsListEvent(id, pubKey, createdAt, tags, content, sig) - HashtagListEvent.KIND -> HashtagListEvent(id, pubKey, createdAt, tags, content, sig) + InterestListEvent.KIND -> InterestListEvent(id, pubKey, createdAt, tags, content, sig) HighlightEvent.KIND -> HighlightEvent(id, pubKey, createdAt, tags, content, sig) HTTPAuthorizationEvent.KIND -> HTTPAuthorizationEvent(id, pubKey, createdAt, tags, content, sig) IndexerRelayListEvent.KIND -> IndexerRelayListEvent(id, pubKey, createdAt, tags, content, sig) @@ -709,23 +711,23 @@ class EventFactory { InteractiveStoryReadingStateEvent.KIND -> InteractiveStoryReadingStateEvent(id, pubKey, createdAt, tags, content, sig) LabelEvent.KIND -> LabelEvent(id, pubKey, createdAt, tags, content, sig) KindMuteSetEvent.KIND -> KindMuteSetEvent(id, pubKey, createdAt, tags, content, sig) - LabeledBookmarkListEvent.KIND -> LabeledBookmarkListEvent(id, pubKey, createdAt, tags, content, sig) + BookmarkSetEvent.KIND -> BookmarkSetEvent(id, pubKey, createdAt, tags, content, sig) LiveActivitiesChatMessageEvent.KIND -> LiveActivitiesChatMessageEvent(id, pubKey, createdAt, tags, content, sig) LiveActivitiesClipEvent.KIND -> LiveActivitiesClipEvent(id, pubKey, createdAt, tags, content, sig) LiveActivitiesEvent.KIND -> LiveActivitiesEvent(id, pubKey, createdAt, tags, content, sig) LiveActivitiesRaidEvent.KIND -> LiveActivitiesRaidEvent(id, pubKey, createdAt, tags, content, sig) - LnZapEvent.KIND -> LnZapEvent(id, pubKey, createdAt, tags, content, sig) - LnZapPaymentRequestEvent.KIND -> LnZapPaymentRequestEvent(id, pubKey, createdAt, tags, content, sig) - LnZapPaymentResponseEvent.KIND -> LnZapPaymentResponseEvent(id, pubKey, createdAt, tags, content, sig) + ZapReceiptEvent.KIND -> ZapReceiptEvent(id, pubKey, createdAt, tags, content, sig) + NwcRequestEvent.KIND -> NwcRequestEvent(id, pubKey, createdAt, tags, content, sig) + NwcResponseEvent.KIND -> NwcResponseEvent(id, pubKey, createdAt, tags, content, sig) OfferEvent.KIND -> OfferEvent(id, pubKey, createdAt, tags, content, sig) DebitEvent.KIND -> DebitEvent(id, pubKey, createdAt, tags, content, sig) ManageEvent.KIND -> ManageEvent(id, pubKey, createdAt, tags, content, sig) NwcInfoEvent.KIND -> NwcInfoEvent(id, pubKey, createdAt, tags, content, sig) NwcNotificationEvent.KIND -> NwcNotificationEvent(id, pubKey, createdAt, tags, content, sig) NwcNotificationEvent.LEGACY_KIND -> NwcNotificationEvent(id, pubKey, createdAt, tags, content, sig) - LnZapPrivateEvent.KIND -> LnZapPrivateEvent(id, pubKey, createdAt, tags, content, sig) - LnZapRequestEvent.KIND -> LnZapRequestEvent(id, pubKey, createdAt, tags, content, sig) - LongTextNoteEvent.KIND -> LongTextNoteEvent(id, pubKey, createdAt, tags, content, sig) + PrivateZapEvent.KIND -> PrivateZapEvent(id, pubKey, createdAt, tags, content, sig) + ZapRequestEvent.KIND -> ZapRequestEvent(id, pubKey, createdAt, tags, content, sig) + LongFormContentEvent.KIND -> LongFormContentEvent(id, pubKey, createdAt, tags, content, sig) MarketplaceEvent.KIND -> MarketplaceEvent(id, pubKey, createdAt, tags, content, sig) MeetingRoomEvent.KIND -> MeetingRoomEvent(id, pubKey, createdAt, tags, content, sig) MeetingRoomPresenceEvent.KIND -> MeetingRoomPresenceEvent(id, pubKey, createdAt, tags, content, sig) @@ -754,55 +756,54 @@ class EventFactory { NipTextEvent.KIND -> NipTextEvent(id, pubKey, createdAt, tags, content, sig) NutzapEvent.KIND -> NutzapEvent(id, pubKey, createdAt, tags, content, sig) NutzapInfoEvent.KIND -> NutzapInfoEvent(id, pubKey, createdAt, tags, content, sig) - NutzapRedemptionEvent.KIND -> NutzapRedemptionEvent(id, pubKey, createdAt, tags, content, sig) NostrConnectEvent.KIND -> NostrConnectEvent(id, pubKey, createdAt, tags, content, sig) - NIP90StatusEvent.KIND -> NIP90StatusEvent(id, pubKey, createdAt, tags, content, sig) + DvmStatusEvent.KIND -> DvmStatusEvent(id, pubKey, createdAt, tags, content, sig) DvmHeartbeatEvent.KIND -> DvmHeartbeatEvent(id, pubKey, createdAt, tags, content, sig) - NIP90TextExtractionRequestEvent.KIND -> NIP90TextExtractionRequestEvent(id, pubKey, createdAt, tags, content, sig) - NIP90TextExtractionResponseEvent.KIND -> NIP90TextExtractionResponseEvent(id, pubKey, createdAt, tags, content, sig) - NIP90SummarizationRequestEvent.KIND -> NIP90SummarizationRequestEvent(id, pubKey, createdAt, tags, content, sig) - NIP90SummarizationResponseEvent.KIND -> NIP90SummarizationResponseEvent(id, pubKey, createdAt, tags, content, sig) - NIP90TranslationRequestEvent.KIND -> NIP90TranslationRequestEvent(id, pubKey, createdAt, tags, content, sig) - NIP90TranslationResponseEvent.KIND -> NIP90TranslationResponseEvent(id, pubKey, createdAt, tags, content, sig) - NIP90TextGenerationRequestEvent.KIND -> NIP90TextGenerationRequestEvent(id, pubKey, createdAt, tags, content, sig) - NIP90TextGenerationResponseEvent.KIND -> NIP90TextGenerationResponseEvent(id, pubKey, createdAt, tags, content, sig) - NIP90ImageGenerationRequestEvent.KIND -> NIP90ImageGenerationRequestEvent(id, pubKey, createdAt, tags, content, sig) - NIP90ImageGenerationResponseEvent.KIND -> NIP90ImageGenerationResponseEvent(id, pubKey, createdAt, tags, content, sig) - NIP90VideoConversionRequestEvent.KIND -> NIP90VideoConversionRequestEvent(id, pubKey, createdAt, tags, content, sig) - NIP90VideoConversionResponseEvent.KIND -> NIP90VideoConversionResponseEvent(id, pubKey, createdAt, tags, content, sig) - NIP90VideoTranslationRequestEvent.KIND -> NIP90VideoTranslationRequestEvent(id, pubKey, createdAt, tags, content, sig) - NIP90VideoTranslationResponseEvent.KIND -> NIP90VideoTranslationResponseEvent(id, pubKey, createdAt, tags, content, sig) - NIP90ImageToVideoRequestEvent.KIND -> NIP90ImageToVideoRequestEvent(id, pubKey, createdAt, tags, content, sig) - NIP90ImageToVideoResponseEvent.KIND -> NIP90ImageToVideoResponseEvent(id, pubKey, createdAt, tags, content, sig) - NIP90TextToSpeechRequestEvent.KIND -> NIP90TextToSpeechRequestEvent(id, pubKey, createdAt, tags, content, sig) - NIP90TextToSpeechResponseEvent.KIND -> NIP90TextToSpeechResponseEvent(id, pubKey, createdAt, tags, content, sig) - NIP90ContentDiscoveryRequestEvent.KIND -> NIP90ContentDiscoveryRequestEvent(id, pubKey, createdAt, tags, content, sig) - NIP90ContentDiscoveryResponseEvent.KIND -> NIP90ContentDiscoveryResponseEvent(id, pubKey, createdAt, tags, content, sig) - NIP90UserDiscoveryRequestEvent.KIND -> NIP90UserDiscoveryRequestEvent(id, pubKey, createdAt, tags, content, sig) - NIP90UserDiscoveryResponseEvent.KIND -> NIP90UserDiscoveryResponseEvent(id, pubKey, createdAt, tags, content, sig) - NIP90ContentSearchRequestEvent.KIND -> NIP90ContentSearchRequestEvent(id, pubKey, createdAt, tags, content, sig) - NIP90ContentSearchResponseEvent.KIND -> NIP90ContentSearchResponseEvent(id, pubKey, createdAt, tags, content, sig) - NIP90PeopleSearchRequestEvent.KIND -> NIP90PeopleSearchRequestEvent(id, pubKey, createdAt, tags, content, sig) - NIP90PeopleSearchResponseEvent.KIND -> NIP90PeopleSearchResponseEvent(id, pubKey, createdAt, tags, content, sig) - NIP90EventCountRequestEvent.KIND -> NIP90EventCountRequestEvent(id, pubKey, createdAt, tags, content, sig) - NIP90EventCountResponseEvent.KIND -> NIP90EventCountResponseEvent(id, pubKey, createdAt, tags, content, sig) - NIP90MalwareScanRequestEvent.KIND -> NIP90MalwareScanRequestEvent(id, pubKey, createdAt, tags, content, sig) - NIP90MalwareScanResponseEvent.KIND -> NIP90MalwareScanResponseEvent(id, pubKey, createdAt, tags, content, sig) - NIP90EventTimestampingRequestEvent.KIND -> NIP90EventTimestampingRequestEvent(id, pubKey, createdAt, tags, content, sig) - NIP90EventTimestampingResponseEvent.KIND -> NIP90EventTimestampingResponseEvent(id, pubKey, createdAt, tags, content, sig) - NIP90OpReturnRequestEvent.KIND -> NIP90OpReturnRequestEvent(id, pubKey, createdAt, tags, content, sig) - NIP90OpReturnResponseEvent.KIND -> NIP90OpReturnResponseEvent(id, pubKey, createdAt, tags, content, sig) - NIP90EventPublishScheduleRequestEvent.KIND -> NIP90EventPublishScheduleRequestEvent(id, pubKey, createdAt, tags, content, sig) - NIP90EventPublishScheduleResponseEvent.KIND -> NIP90EventPublishScheduleResponseEvent(id, pubKey, createdAt, tags, content, sig) - NIP90EventPowDelegationRequestEvent.KIND -> NIP90EventPowDelegationRequestEvent(id, pubKey, createdAt, tags, content, sig) - NIP90EventPowDelegationResponseEvent.KIND -> NIP90EventPowDelegationResponseEvent(id, pubKey, createdAt, tags, content, sig) + DvmTextExtractionRequestEvent.KIND -> DvmTextExtractionRequestEvent(id, pubKey, createdAt, tags, content, sig) + DvmTextExtractionResponseEvent.KIND -> DvmTextExtractionResponseEvent(id, pubKey, createdAt, tags, content, sig) + DvmSummarizationRequestEvent.KIND -> DvmSummarizationRequestEvent(id, pubKey, createdAt, tags, content, sig) + DvmSummarizationResponseEvent.KIND -> DvmSummarizationResponseEvent(id, pubKey, createdAt, tags, content, sig) + DvmTranslationRequestEvent.KIND -> DvmTranslationRequestEvent(id, pubKey, createdAt, tags, content, sig) + DvmTranslationResponseEvent.KIND -> DvmTranslationResponseEvent(id, pubKey, createdAt, tags, content, sig) + DvmTextGenerationRequestEvent.KIND -> DvmTextGenerationRequestEvent(id, pubKey, createdAt, tags, content, sig) + DvmTextGenerationResponseEvent.KIND -> DvmTextGenerationResponseEvent(id, pubKey, createdAt, tags, content, sig) + DvmImageGenerationRequestEvent.KIND -> DvmImageGenerationRequestEvent(id, pubKey, createdAt, tags, content, sig) + DvmImageGenerationResponseEvent.KIND -> DvmImageGenerationResponseEvent(id, pubKey, createdAt, tags, content, sig) + DvmVideoConversionRequestEvent.KIND -> DvmVideoConversionRequestEvent(id, pubKey, createdAt, tags, content, sig) + DvmVideoConversionResponseEvent.KIND -> DvmVideoConversionResponseEvent(id, pubKey, createdAt, tags, content, sig) + DvmVideoTranslationRequestEvent.KIND -> DvmVideoTranslationRequestEvent(id, pubKey, createdAt, tags, content, sig) + DvmVideoTranslationResponseEvent.KIND -> DvmVideoTranslationResponseEvent(id, pubKey, createdAt, tags, content, sig) + DvmImageToVideoRequestEvent.KIND -> DvmImageToVideoRequestEvent(id, pubKey, createdAt, tags, content, sig) + DvmImageToVideoResponseEvent.KIND -> DvmImageToVideoResponseEvent(id, pubKey, createdAt, tags, content, sig) + DvmTextToSpeechRequestEvent.KIND -> DvmTextToSpeechRequestEvent(id, pubKey, createdAt, tags, content, sig) + DvmTextToSpeechResponseEvent.KIND -> DvmTextToSpeechResponseEvent(id, pubKey, createdAt, tags, content, sig) + DvmContentDiscoveryRequestEvent.KIND -> DvmContentDiscoveryRequestEvent(id, pubKey, createdAt, tags, content, sig) + DvmContentDiscoveryResponseEvent.KIND -> DvmContentDiscoveryResponseEvent(id, pubKey, createdAt, tags, content, sig) + DvmUserDiscoveryRequestEvent.KIND -> DvmUserDiscoveryRequestEvent(id, pubKey, createdAt, tags, content, sig) + DvmUserDiscoveryResponseEvent.KIND -> DvmUserDiscoveryResponseEvent(id, pubKey, createdAt, tags, content, sig) + DvmContentSearchRequestEvent.KIND -> DvmContentSearchRequestEvent(id, pubKey, createdAt, tags, content, sig) + DvmContentSearchResponseEvent.KIND -> DvmContentSearchResponseEvent(id, pubKey, createdAt, tags, content, sig) + DvmPeopleSearchRequestEvent.KIND -> DvmPeopleSearchRequestEvent(id, pubKey, createdAt, tags, content, sig) + DvmPeopleSearchResponseEvent.KIND -> DvmPeopleSearchResponseEvent(id, pubKey, createdAt, tags, content, sig) + DvmEventCountRequestEvent.KIND -> DvmEventCountRequestEvent(id, pubKey, createdAt, tags, content, sig) + DvmEventCountResponseEvent.KIND -> DvmEventCountResponseEvent(id, pubKey, createdAt, tags, content, sig) + DvmMalwareScanRequestEvent.KIND -> DvmMalwareScanRequestEvent(id, pubKey, createdAt, tags, content, sig) + DvmMalwareScanResponseEvent.KIND -> DvmMalwareScanResponseEvent(id, pubKey, createdAt, tags, content, sig) + DvmEventTimestampingRequestEvent.KIND -> DvmEventTimestampingRequestEvent(id, pubKey, createdAt, tags, content, sig) + DvmEventTimestampingResponseEvent.KIND -> DvmEventTimestampingResponseEvent(id, pubKey, createdAt, tags, content, sig) + DvmOpReturnRequestEvent.KIND -> DvmOpReturnRequestEvent(id, pubKey, createdAt, tags, content, sig) + DvmOpReturnResponseEvent.KIND -> DvmOpReturnResponseEvent(id, pubKey, createdAt, tags, content, sig) + DvmEventPublishScheduleRequestEvent.KIND -> DvmEventPublishScheduleRequestEvent(id, pubKey, createdAt, tags, content, sig) + DvmEventPublishScheduleResponseEvent.KIND -> DvmEventPublishScheduleResponseEvent(id, pubKey, createdAt, tags, content, sig) + DvmEventPowDelegationRequestEvent.KIND -> DvmEventPowDelegationRequestEvent(id, pubKey, createdAt, tags, content, sig) + DvmEventPowDelegationResponseEvent.KIND -> DvmEventPowDelegationResponseEvent(id, pubKey, createdAt, tags, content, sig) OnchainZapEvent.KIND -> OnchainZapEvent(id, pubKey, createdAt, tags, content, sig) Bolt12ZapEvent.KIND -> Bolt12ZapEvent(id, pubKey, createdAt, tags, content, sig) Bolt12ZapIntentEvent.KIND -> Bolt12ZapIntentEvent(id, pubKey, createdAt, tags, content, sig) Bolt12OfferListEvent.KIND -> Bolt12OfferListEvent(id, pubKey, createdAt, tags, content, sig) OtsEvent.KIND -> OtsEvent(id, pubKey, createdAt, tags, content, sig) PaymentTargetsEvent.KIND -> PaymentTargetsEvent(id, pubKey, createdAt, tags, content, sig) - PeopleListEvent.KIND -> PeopleListEvent(id, pubKey, createdAt, tags, content, sig) + FollowSetEvent.KIND -> FollowSetEvent(id, pubKey, createdAt, tags, content, sig) PictureCurationSetEvent.KIND -> PictureCurationSetEvent(id, pubKey, createdAt, tags, content, sig) P2POrderEvent.KIND -> P2POrderEvent(id, pubKey, createdAt, tags, content, sig) PictureEvent.KIND -> PictureEvent(id, pubKey, createdAt, tags, content, sig) @@ -819,7 +820,7 @@ class EventFactory { Podcasting20EpisodeEvent.KIND -> Podcasting20EpisodeEvent(id, pubKey, createdAt, tags, content, sig) Podcasting20TrailerEvent.KIND -> Podcasting20TrailerEvent(id, pubKey, createdAt, tags, content, sig) ProductEvent.KIND -> ProductEvent(id, pubKey, createdAt, tags, content, sig) - PrivateDmEvent.KIND -> PrivateDmEvent(id, pubKey, createdAt, tags, content, sig) + EncryptedDmEvent.KIND -> EncryptedDmEvent(id, pubKey, createdAt, tags, content, sig) PrivateOutboxRelayListEvent.KIND -> PrivateOutboxRelayListEvent(id, pubKey, createdAt, tags, content, sig) ProxyRelayListEvent.KIND -> ProxyRelayListEvent(id, pubKey, createdAt, tags, content, sig) PublicMessageEvent.KIND -> PublicMessageEvent(id, pubKey, createdAt, tags, content, sig) @@ -838,7 +839,7 @@ class EventFactory { PublicationIndexEvent.KIND -> PublicationIndexEvent(id, pubKey, createdAt, tags, content, sig) PublicationContentEvent.KIND -> PublicationContentEvent(id, pubKey, createdAt, tags, content, sig) RelayReviewEvent.KIND -> RelayReviewEvent(id, pubKey, createdAt, tags, content, sig) - ContactCardEvent.KIND -> ContactCardEvent(id, pubKey, createdAt, tags, content, sig) + UserAssertionEvent.KIND -> UserAssertionEvent(id, pubKey, createdAt, tags, content, sig) EventAssertionEvent.KIND -> EventAssertionEvent(id, pubKey, createdAt, tags, content, sig) AddressableAssertionEvent.KIND -> AddressableAssertionEvent(id, pubKey, createdAt, tags, content, sig) ExternalIdAssertionEvent.KIND -> ExternalIdAssertionEvent(id, pubKey, createdAt, tags, content, sig) @@ -871,24 +872,23 @@ class EventFactory { ControlEditionEvent.KIND -> ControlEditionEvent(id, pubKey, createdAt, tags, content, sig) ConcordInviteListEvent.KIND -> ConcordInviteListEvent(id, pubKey, createdAt, tags, content, sig) ConcordInviteBundleEvent.KIND -> ConcordInviteBundleEvent(id, pubKey, createdAt, tags, content, sig) - SealedRumorEvent.KIND -> SealedRumorEvent(id, pubKey, createdAt, tags, content, sig) + SealEvent.KIND -> SealEvent(id, pubKey, createdAt, tags, content, sig) SearchRelayListEvent.KIND -> SearchRelayListEvent(id, pubKey, createdAt, tags, content, sig) SimpleGroupListEvent.KIND -> SimpleGroupListEvent(id, pubKey, createdAt, tags, content, sig) SoftwareApplicationEvent.KIND -> SoftwareApplicationEvent(id, pubKey, createdAt, tags, content, sig) SoftwareAssetEvent.KIND -> SoftwareAssetEvent(id, pubKey, createdAt, tags, content, sig) StallEvent.KIND -> StallEvent(id, pubKey, createdAt, tags, content, sig) - StatusEvent.KIND -> StatusEvent(id, pubKey, createdAt, tags, content, sig) + UserStatusEvent.KIND -> UserStatusEvent(id, pubKey, createdAt, tags, content, sig) TextNoteEvent.KIND -> TextNoteEvent(id, pubKey, createdAt, tags, content, sig) ThreadEvent.KIND -> ThreadEvent(id, pubKey, createdAt, tags, content, sig) TextNoteModificationEvent.KIND -> TextNoteModificationEvent(id, pubKey, createdAt, tags, content, sig) - TokenEvent.KIND -> TokenEvent(id, pubKey, createdAt, tags, content, sig) TorrentEvent.KIND -> TorrentEvent(id, pubKey, createdAt, tags, content, sig) TorrentCommentEvent.KIND -> TorrentCommentEvent(id, pubKey, createdAt, tags, content, sig) TrustedRelayListEvent.KIND -> TrustedRelayListEvent(id, pubKey, createdAt, tags, content, sig) TrustProviderListEvent.KIND -> TrustProviderListEvent(id, pubKey, createdAt, tags, content, sig) VideoCurationSetEvent.KIND -> VideoCurationSetEvent(id, pubKey, createdAt, tags, content, sig) - VideoHorizontalEvent.KIND -> VideoHorizontalEvent(id, pubKey, createdAt, tags, content, sig) - VideoVerticalEvent.KIND -> VideoVerticalEvent(id, pubKey, createdAt, tags, content, sig) + AddressableNormalVideoEvent.KIND -> AddressableNormalVideoEvent(id, pubKey, createdAt, tags, content, sig) + AddressableShortVideoEvent.KIND -> AddressableShortVideoEvent(id, pubKey, createdAt, tags, content, sig) TextTrackEvent.KIND -> TextTrackEvent(id, pubKey, createdAt, tags, content, sig) VideoCollaborationEvent.KIND -> VideoCollaborationEvent(id, pubKey, createdAt, tags, content, sig) VideoNormalEvent.KIND -> VideoNormalEvent(id, pubKey, createdAt, tags, content, sig) @@ -897,7 +897,7 @@ class EventFactory { VoiceReplyEvent.KIND -> VoiceReplyEvent(id, pubKey, createdAt, tags, content, sig) WakeUpEvent.KIND -> WakeUpEvent(id, pubKey, createdAt, tags, content, sig) WebBookmarkEvent.KIND -> WebBookmarkEvent(id, pubKey, createdAt, tags, content, sig) - WikiNoteEvent.KIND -> WikiNoteEvent(id, pubKey, createdAt, tags, content, sig) + WikiArticleEvent.KIND -> WikiArticleEvent(id, pubKey, createdAt, tags, content, sig) WorkoutRecordEvent.KIND -> WorkoutRecordEvent(id, pubKey, createdAt, tags, content, sig) ExerciseTemplateEvent.KIND -> ExerciseTemplateEvent(id, pubKey, createdAt, tags, content, sig) else -> factories[kind]?.build(id, pubKey, createdAt, tags, content, sig) ?: Event(id, pubKey, createdAt, kind, tags, content, sig) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/jcs/JsonCanonicalization.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/jcs/JsonCanonicalization.kt new file mode 100644 index 0000000000..d1f920ffa1 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/jcs/JsonCanonicalization.kt @@ -0,0 +1,224 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.utils.jcs + +/** + * RFC 8785 JSON Canonicalization Scheme (JCS). + * + * Produces the one serialization of a JSON value that every conformant + * implementation agrees on, so a hash over the result is portable. Used wherever + * a protocol hashes structured data rather than bytes it was handed — ContextVM + * needs it twice (CEP-8's canonical invocation identity and CEP-15's schema + * hash), and it is generic enough to belong here rather than in that module. + * + * The rules: + * - no insignificant whitespace + * - object members sorted by key, compared as UTF-16 code units + * - strings escaped minimally, with non-ASCII left literal (output is UTF-8) + * - numbers serialized exactly as ECMAScript `Number.prototype.toString()` + * + * The number rule is the subtle one and [canonicalNumber] implements it in full. + */ +object JsonCanonicalization { + /** + * Serializes [value] canonically. + * + * [value] is a plain JSON tree: `Map`, `List`, [String], + * [Boolean], a number, or null. Using plain types rather than a JSON library's + * node types keeps this usable from any module and any serializer. + */ + fun canonicalize(value: Any?): String = StringBuilder().also { write(value, it) }.toString() + + private fun write( + value: Any?, + out: StringBuilder, + ) { + when (value) { + null -> out.append("null") + is Boolean -> out.append(if (value) "true" else "false") + is String -> writeString(value, out) + is Map<*, *> -> writeObject(value, out) + is List<*> -> writeArray(value, out) + is Double -> out.append(canonicalNumber(value)) + is Float -> out.append(canonicalNumber(value.toDouble())) + is Int -> out.append(canonicalNumber(value.toDouble())) + is Long -> out.append(canonicalNumber(value.toDouble())) + is Short -> out.append(canonicalNumber(value.toDouble())) + is Byte -> out.append(canonicalNumber(value.toDouble())) + else -> throw IllegalArgumentException("cannot canonicalize ${value::class.simpleName}") + } + } + + private fun writeObject( + value: Map<*, *>, + out: StringBuilder, + ) { + out.append('{') + value.entries + .map { (key, entry) -> + (key as? String ?: throw IllegalArgumentException("object keys must be strings")) to entry + } + // RFC 8785 sorts by UTF-16 code unit, which is exactly what Kotlin's + // natural String ordering does. Do not swap this for a locale-aware + // or codepoint-aware comparison. + .sortedBy { it.first } + .forEachIndexed { index, (key, entry) -> + if (index > 0) out.append(',') + writeString(key, out) + out.append(':') + write(entry, out) + } + out.append('}') + } + + private fun writeArray( + value: List<*>, + out: StringBuilder, + ) { + out.append('[') + value.forEachIndexed { index, entry -> + if (index > 0) out.append(',') + write(entry, out) + } + out.append(']') + } + + private fun writeString( + value: String, + out: StringBuilder, + ) { + out.append('"') + value.forEach { char -> + when (char) { + '"' -> out.append("\\\"") + '\\' -> out.append("\\\\") + '\b' -> out.append("\\b") + '\u000C' -> out.append("\\f") + '\n' -> out.append("\\n") + '\r' -> out.append("\\r") + '\t' -> out.append("\\t") + else -> + if (char < '\u0020') { + // Only C0 controls without a short escape use \u, and the + // hex digits are lowercase. + out.append("\\u").append(char.code.toString(16).padStart(4, '0')) + } else { + // Everything else stays literal, non-ASCII included: the + // canonical form is UTF-8, not \u-escaped ASCII. + out.append(char) + } + } + } + out.append('"') + } + + /** + * Serializes [value] as ECMAScript `Number.prototype.toString()` does, which + * is what RFC 8785 requires and is *not* what any JVM/Kotlin `toString()` + * produces (`1.0E30` where ECMAScript says `1e+30`). + * + * The digits come from the platform's [Double.toString], but they are then + * shortened explicitly until the shortest form that still round-trips is + * found. That extra step is not optional: JVM prints [Double.MIN_VALUE] as + * `4.9E-324` while ECMAScript requires `5e-324`, so trusting the platform to + * already be shortest produces a different hash from every other conformant + * implementation. Shortening here makes the result platform-independent. + */ + fun canonicalNumber(value: Double): String { + if (value.isNaN() || value.isInfinite()) { + throw IllegalArgumentException("JCS cannot represent $value") + } + // ECMAScript prints both zeroes as "0"; JCS inherits that, so -0.0 and + // 0.0 canonicalize identically. + if (value == 0.0) return "0" + if (value < 0) return "-" + canonicalNumber(-value) + + val raw = value.toString() + val exponentSplit = raw.indexOfFirst { it == 'e' || it == 'E' } + val mantissa = if (exponentSplit >= 0) raw.substring(0, exponentSplit) else raw + val exponent = if (exponentSplit >= 0) raw.substring(exponentSplit + 1).toInt() else 0 + + val pointIndex = mantissa.indexOf('.') + val digits = if (pointIndex >= 0) mantissa.removeRange(pointIndex, pointIndex + 1) else mantissa + val fractionLength = if (pointIndex >= 0) mantissa.length - pointIndex - 1 else 0 + + // `s` is the shortest digit string, `n` its decimal exponent, such that + // value = s * 10^(n - k) with k = s.length. This is ECMAScript's (s, n, k). + val trailingZeros = digits.length - digits.trimEnd('0').length + val initial = digits.trim('0').ifEmpty { "0" } + val (significant, n) = + shorten(value, initial, initial.length + exponent - fractionLength + trailingZeros) + val k = significant.length + + return when { + // Integral, short enough to print plainly. + n in k..21 -> significant + "0".repeat(n - k) + // Has a fractional part but no exponent needed. + n in 1..21 -> significant.substring(0, n) + "." + significant.substring(n) + // Small enough for a leading "0." but not for an exponent. + n in -5..0 -> "0." + "0".repeat(-n) + significant + // Exponential form. + k == 1 -> significant + "e" + exponentSuffix(n - 1) + else -> significant.substring(0, 1) + "." + significant.substring(1) + "e" + exponentSuffix(n - 1) + } + } + + /** + * Finds the shortest digit string that still parses back to [value]. + * + * ECMAScript defines the digits as the fewest that round-trip, so a platform + * that prints more (JVM does, for some subnormals) has to be corrected here + * or the canonical form diverges. + */ + private fun shorten( + value: Double, + digits: String, + exponent: Int, + ): Pair { + for (length in 1 until digits.length) { + val (candidate, candidateExponent) = roundTo(digits, exponent, length) + val rebuilt = "${candidate}e${candidateExponent - candidate.length}".toDouble() + if (rebuilt == value) return candidate to candidateExponent + } + return digits to exponent + } + + /** Rounds [digits] to [length] significant digits, half-up, carrying into [exponent]. */ + private fun roundTo( + digits: String, + exponent: Int, + length: Int, + ): Pair { + val kept = digits.substring(0, length) + if (digits[length] < '5') return kept to exponent + + val incremented = (kept.toLong() + 1).toString() + // "99" + 1 becomes "100": one digit longer, so drop the last and shift + // the exponent rather than growing the significand. + return if (incremented.length > length) { + incremented.substring(0, length) to exponent + 1 + } else { + incremented.padStart(length, '0') to exponent + } + } + + private fun exponentSuffix(exponent: Int) = if (exponent >= 0) "+$exponent" else "-${-exponent}" +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/moderation/ModerationUnbanEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/moderation/ModerationUnbanEventTest.kt index 0f53a118c3..ec1a31dea2 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/moderation/ModerationUnbanEventTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/moderation/ModerationUnbanEventTest.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.quartz.buzz.moderation -import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent +import com.vitorpamplona.quartz.nip75ZapGoals.ZapGoalEvent import kotlin.test.Test import kotlin.test.assertEquals @@ -40,7 +40,7 @@ class ModerationUnbanEventTest { @Test fun kindCollidesWithNip75GoalEvent() { // Documents the collision: this kind must NOT be registered in EventFactory, - // where 9041 already belongs to the NIP-75 GoalEvent. - assertEquals(GoalEvent.KIND, ModerationUnbanEvent.KIND) + // where 9041 already belongs to the NIP-75 ZapGoalEvent. + assertEquals(ZapGoalEvent.KIND, ModerationUnbanEvent.KIND) } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/contextvm/cep04Encryption/CvmGiftWrapPolicyTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/contextvm/cep04Encryption/CvmGiftWrapPolicyTest.kt new file mode 100644 index 0000000000..f50414ad24 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/contextvm/cep04Encryption/CvmGiftWrapPolicyTest.kt @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.contextvm.cep04Encryption + +import com.vitorpamplona.quartz.contextvm.core.CvmKinds +import com.vitorpamplona.quartz.contextvm.core.CvmTags +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** `CVM-4-*` and `CVM-19-*`: encryption policy and wrap-kind negotiation. */ +class CvmGiftWrapPolicyTest { + @Test + fun `CVM-4-01 REQUIRED refuses to downgrade for a peer that cannot encrypt`() { + // The whole reason this client does not use the permissive default: + // a coordinator that never advertises encryption would otherwise get + // plaintext JSON-RPC on public relays. + val crypto = CvmGiftWrap(encryptionMode = EncryptionMode.REQUIRED) + assertTrue(crypto.shouldEncrypt(peerSupportsEncryption = true)) + assertFailsWith { crypto.shouldEncrypt(peerSupportsEncryption = false) } + } + + @Test + fun `CVM-4-02 OPTIONAL follows the peer and DISABLED never encrypts`() { + val optional = CvmGiftWrap(encryptionMode = EncryptionMode.OPTIONAL) + assertTrue(optional.shouldEncrypt(peerSupportsEncryption = true)) + assertFalse(optional.shouldEncrypt(peerSupportsEncryption = false)) + + val disabled = CvmGiftWrap(encryptionMode = EncryptionMode.DISABLED) + assertFalse(disabled.shouldEncrypt(peerSupportsEncryption = true)) + } + + @Test + fun `CVM-19-01 prefers the ephemeral wrap when the peer supports it`() { + val crypto = CvmGiftWrap(giftWrapMode = GiftWrapMode.EPHEMERAL) + assertEquals(CvmKinds.EPHEMERAL_GIFT_WRAP, crypto.negotiatedWrapKind(peerSupportsEphemeral = true)) + } + + @Test + fun `CVM-19-02 falls back to the persistent wrap rather than refusing`() { + // Preferring 21059 must never mean refusing to talk to a 1059-only + // server: CEP-19 is additive, not a requirement. + val crypto = CvmGiftWrap(giftWrapMode = GiftWrapMode.EPHEMERAL) + assertEquals(CvmKinds.GIFT_WRAP, crypto.negotiatedWrapKind(peerSupportsEphemeral = false)) + } + + @Test + fun `CVM-19-03 a persistent-mode client stays on 1059 regardless`() { + val crypto = CvmGiftWrap(giftWrapMode = GiftWrapMode.PERSISTENT) + assertEquals(CvmKinds.GIFT_WRAP, crypto.negotiatedWrapKind(peerSupportsEphemeral = true)) + } + + @Test + fun `CVM-4-03 advertises the capability flags matching its own configuration`() { + val ephemeral = CvmGiftWrap().capabilityTags().map { it[0] } + assertTrue(ephemeral.contains(CvmTags.SUPPORT_ENCRYPTION)) + assertTrue(ephemeral.contains(CvmTags.SUPPORT_ENCRYPTION_EPHEMERAL)) + + val persistent = CvmGiftWrap(giftWrapMode = GiftWrapMode.PERSISTENT).capabilityTags().map { it[0] } + assertTrue(persistent.contains(CvmTags.SUPPORT_ENCRYPTION)) + assertFalse( + persistent.contains(CvmTags.SUPPORT_ENCRYPTION_EPHEMERAL), + "a persistent-mode client must not claim ephemeral support", + ) + + assertTrue(CvmGiftWrap(encryptionMode = EncryptionMode.DISABLED).capabilityTags().isEmpty()) + } + + @Test + fun `CVM-4-04 reads the peer's advertised encryption flags`() { + val crypto = CvmGiftWrap() + val tags = arrayOf(CvmTags.flag(CvmTags.SUPPORT_ENCRYPTION)) + assertTrue(crypto.peerSupportsEncryption(tags)) + assertFalse(crypto.peerSupportsEphemeral(tags)) + + val both = tags + arrayOf(CvmTags.flag(CvmTags.SUPPORT_ENCRYPTION_EPHEMERAL)) + assertTrue(crypto.peerSupportsEphemeral(both)) + } + + @Test + fun `CVM-19-04 both wrap kinds are recognised on the way in`() { + // A client subscribes to both because CEP-19's fallback means either may + // arrive regardless of which it sends. + assertTrue(CvmKinds.isGiftWrap(CvmKinds.GIFT_WRAP)) + assertTrue(CvmKinds.isGiftWrap(CvmKinds.EPHEMERAL_GIFT_WRAP)) + assertFalse(CvmKinds.isGiftWrap(CvmKinds.MESSAGE)) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/contextvm/cep08Payments/PaymentTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/contextvm/cep08Payments/PaymentTest.kt new file mode 100644 index 0000000000..2225f18361 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/contextvm/cep08Payments/PaymentTest.kt @@ -0,0 +1,371 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.contextvm.cep08Payments + +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcCodec +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcError +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcId +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcNotification +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcRequest +import com.vitorpamplona.quartz.nip01Core.core.Tag +import kotlinx.serialization.json.JsonObject +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** `CVM-8-*` and `CVM-21-*`: pricing, payment lifecycles and canonical identity. */ +class PaymentTest { + private val lightning = Pmi.LIGHTNING_BOLT11 + private val cashuDirect = Pmi("bitcoin-cashu-v4-direct") + + private fun params(json: String): JsonObject = (JsonRpcCodec.decode("""{"jsonrpc":"2.0","id":1,"method":"m","params":$json}""") as JsonRpcRequest).params!! + + private fun request( + method: String, + json: String, + ) = JsonRpcRequest(JsonRpcId.Num(1), method, params(json)) + + // --- CEP-21 PMI --- + + @Test + fun `CVM-21-01 accepts the W3C PMI format and rejects anything else`() { + assertEquals("bitcoin-lightning-bolt11", lightning.value) + assertNull(Pmi.parseOrNull("Bitcoin-Lightning")) + assertNull(Pmi.parseOrNull("bitcoin_lightning")) + assertFailsWith { Pmi("UPPER") } + } + + @Test + fun `CVM-21-02 detects the -direct bearer settlement suffix`() { + assertTrue(cashuDirect.supportsDirectPayment) + assertFalse(lightning.supportsDirectPayment) + } + + // --- cap tag --- + + @Test + fun `CVM-8-01 parses a fixed price`() { + val tag = CapTag.parse(arrayOf("cap", "tool:get_weather", "100", "sats"))!! + assertEquals(CapabilityKind.TOOL, tag.kind) + assertEquals("get_weather", tag.name) + assertEquals(Price.Fixed(100), tag.price) + assertEquals("sats", tag.unit) + } + + @Test + fun `CVM-8-02 parses an inclusive range price`() { + val tag = CapTag.parse(arrayOf("cap", "prompt:summarize", "100-1000", "sats"))!! + assertEquals(CapabilityKind.PROMPT, tag.kind) + assertEquals(Price.Range(100, 1000), tag.price) + assertTrue(tag.price.includes(500)) + assertFalse(tag.price.includes(1001)) + } + + @Test + fun `CVM-8-03 round-trips a cap tag`() { + val tag = CapTag(CapabilityKind.RESOURCE, "file://x", Price.Range(1, 2), "usd") + assertEquals(tag, CapTag.parse(tag.toTag())) + } + + @Test + fun `CVM-8-04 rejects a cap tag without a typed capability prefix`() { + assertNull(CapTag.parse(arrayOf("cap", "get_weather", "100", "sats"))) + } + + // --- canonical invocation identity --- + + @Test + fun `CVM-8-10 excludes _meta so a regenerated progressToken still matches`() { + // MCP regenerates progressToken on every callTool. Without the exclusion + // a retry could never match a paid authorization. + val first = + request( + "tools/call", + """{"name":"get_weather","arguments":{"location":"NY"},"_meta":{"progressToken":"a"}}""", + ) + val retry = + request( + "tools/call", + """{"name":"get_weather","arguments":{"location":"NY"},"_meta":{"progressToken":"b"}}""", + ) + assertEquals(CanonicalInvocation.identityOf(first), CanonicalInvocation.identityOf(retry)) + } + + @Test + fun `CVM-8-11 is unaffected by the JSON-RPC id`() { + val a = JsonRpcRequest(JsonRpcId.Num(1), "tools/call", params("""{"name":"x"}""")) + val b = JsonRpcRequest(JsonRpcId.Text("other"), "tools/call", params("""{"name":"x"}""")) + assertEquals(CanonicalInvocation.identityOf(a), CanonicalInvocation.identityOf(b)) + } + + @Test + fun `CVM-8-12 is unaffected by params member order`() { + val a = request("tools/call", """{"name":"x","arguments":{"a":1,"b":2}}""") + val b = request("tools/call", """{"arguments":{"b":2,"a":1},"name":"x"}""") + assertEquals(CanonicalInvocation.identityOf(a), CanonicalInvocation.identityOf(b)) + } + + @Test + fun `CVM-8-13 changes when the semantic arguments change`() { + val ny = request("tools/call", """{"name":"get_weather","arguments":{"location":"NY"}}""") + val sf = request("tools/call", """{"name":"get_weather","arguments":{"location":"SF"}}""") + assertNotEquals(CanonicalInvocation.identityOf(ny), CanonicalInvocation.identityOf(sf)) + } + + @Test + fun `CVM-8-14 changes when the method changes`() { + assertNotEquals( + CanonicalInvocation.identityOf(request("tools/call", """{"name":"x"}""")), + CanonicalInvocation.identityOf(request("prompts/get", """{"name":"x"}""")), + ) + } + + @Test + fun `CVM-8-15 binds the authorization to the requesting client`() { + val call = request("tools/call", """{"name":"x"}""") + assertNotEquals( + CanonicalInvocation.authorizationKey("aa".repeat(32), call), + CanonicalInvocation.authorizationKey("bb".repeat(32), call), + ) + } + + @Test + fun `CVM-8-16 semanticParams strips only _meta and leaves the rest intact`() { + // The exclusion is for identity only: the handler still needs the full + // params at execution time, so this must not mutate the original. + val original = params("""{"name":"x","arguments":{"a":1},"_meta":{"progressToken":"t"}}""") + val semantic = CanonicalInvocation.semanticParams(original) + + assertFalse(semantic.containsKey("_meta")) + assertTrue(semantic.containsKey("name")) + assertTrue(semantic.containsKey("arguments")) + assertTrue(original.containsKey("_meta"), "the source params must not be mutated") + } + + // --- lifecycle negotiation --- + + @Test + fun `CVM-8-20 an absent payment_interaction tag means transparent`() { + val session = PaymentSession() + session.observeServerTags(emptyArray()) + assertEquals(PaymentInteraction.TRANSPARENT, session.effectiveMode) + assertFalse(session.negotiationFailed) + } + + @Test + fun `CVM-8-21 explicit gating is in force once the server echoes it`() { + val session = PaymentSession(requested = PaymentInteraction.EXPLICIT_GATING) + session.observeServerTags(arrayOf(PaymentTags.paymentInteraction(PaymentInteraction.EXPLICIT_GATING))) + assertEquals(PaymentInteraction.EXPLICIT_GATING, session.effectiveMode) + assertFalse(session.negotiationFailed) + } + + @Test + fun `CVM-8-22 a server that ignores the request is a failed negotiation rather than a downgrade`() { + val session = PaymentSession(requested = PaymentInteraction.EXPLICIT_GATING) + session.observeServerTags(emptyArray()) + assertTrue(session.negotiationFailed, "silent fallback must be visible to the caller") + } + + @Test + fun `CVM-8-23 a client needing visible payments will not auto-pay after a failed negotiation`() { + // The client half of the no-silent-fallback rule: a handler that simply + // pays whatever it is asked would violate it. + val session = + PaymentSession( + requested = PaymentInteraction.EXPLICIT_GATING, + requiresVisiblePayments = true, + supportedPmis = listOf(lightning), + ) + session.observeServerTags(emptyArray()) + + val demand = PaymentRequest(amount = 100.0, pmi = lightning, payRequest = "lnbc...") + assertFalse(session.mayAutoPay(demand)) + } + + @Test + fun `CVM-8-24 the same client does auto-pay once explicit gating is accepted`() { + val session = + PaymentSession( + requested = PaymentInteraction.EXPLICIT_GATING, + requiresVisiblePayments = true, + supportedPmis = listOf(lightning), + ) + session.observeServerTags(arrayOf(PaymentTags.paymentInteraction(PaymentInteraction.EXPLICIT_GATING))) + assertTrue(session.mayAutoPay(PaymentRequest(100.0, lightning, "lnbc..."))) + } + + @Test + fun `CVM-8-25 never auto-pays a PMI it cannot settle`() { + val session = PaymentSession(supportedPmis = listOf(lightning)) + session.observeServerTags(emptyArray()) + assertFalse(session.mayAutoPay(PaymentRequest(100.0, cashuDirect, "cashuB..."))) + } + + @Test + fun `CVM-8-26 a later payment_interaction tag upserts the session mode`() { + val session = PaymentSession(requested = PaymentInteraction.EXPLICIT_GATING) + session.observeServerTags(emptyArray()) + assertEquals(PaymentInteraction.TRANSPARENT, session.effectiveMode) + + session.observeLaterServerTags(arrayOf(PaymentTags.paymentInteraction(PaymentInteraction.EXPLICIT_GATING))) + assertEquals(PaymentInteraction.EXPLICIT_GATING, session.effectiveMode) + } + + @Test + fun `CVM-8-27 an absent tag on a later message inherits the current mode`() { + val session = PaymentSession() + session.observeServerTags(arrayOf(PaymentTags.paymentInteraction(PaymentInteraction.EXPLICIT_GATING))) + session.observeLaterServerTags(emptyArray()) + assertEquals(PaymentInteraction.EXPLICIT_GATING, session.effectiveMode) + } + + @Test + fun `CVM-8-28 advertises the requested mode and its PMIs on the first message`() { + val session = + PaymentSession( + requested = PaymentInteraction.EXPLICIT_GATING, + supportedPmis = listOf(lightning, cashuDirect), + ) + val tags: List = session.negotiationTags() + assertContentEquals(arrayOf("payment_interaction", "explicit_gating"), tags[0]) + assertContentEquals(arrayOf("pmi", lightning.value), tags[1]) + assertContentEquals(arrayOf("pmi", cashuDirect.value), tags[2]) + } + + @Test + fun `CVM-8-29 omits the tag when requesting the default mode`() { + assertTrue(PaymentSession().negotiationTags().isEmpty()) + } + + @Test + fun `CVM-8-30 intersects PMIs preserving the server's preference order`() { + val session = PaymentSession(supportedPmis = listOf(cashuDirect, lightning)) + assertEquals(listOf(lightning, cashuDirect), session.intersectPmis(listOf(lightning, cashuDirect))) + assertEquals(listOf(lightning), session.intersectPmis(listOf(Pmi("unknown-rail"), lightning))) + } + + // --- messages --- + + @Test + fun `CVM-8-40 parses a payment_required notification`() { + val notification = + JsonRpcNotification( + "notifications/payment_required", + params( + """{"amount":100,"pay_req":"lnbc...","pmi":"bitcoin-lightning-bolt11", + "description":"tool run","ttl":600,"_meta":{"note":"x"}}""", + ), + ) + val parsed = PaymentMessages.paymentRequired(notification)!! + assertEquals(100.0, parsed.amount) + assertEquals(lightning, parsed.pmi) + assertEquals("lnbc...", parsed.payRequest) + assertEquals(600L, parsed.ttl) + assertEquals("tool run", parsed.description) + } + + @Test + fun `CVM-8-41 parses accepted and rejected notifications`() { + val accepted = + PaymentMessages.paymentAccepted( + JsonRpcNotification( + "notifications/payment_accepted", + params("""{"amount":100,"pmi":"bitcoin-lightning-bolt11"}"""), + ), + )!! + assertEquals(100.0, accepted.amount) + + val rejected = + PaymentMessages.paymentRejected( + JsonRpcNotification( + "notifications/payment_rejected", + params("""{"pmi":"bitcoin-cashu-v4-direct","message":"Insufficient","amount":150}"""), + ), + )!! + assertEquals(cashuDirect, rejected.pmi) + assertEquals(150.0, rejected.amount) + } + + @Test + fun `CVM-8-42 reads payment options off a -32042 error`() { + val error = + JsonRpcError( + JsonRpcError.PAYMENT_REQUIRED, + "Payment Required", + params( + """{"instructions":"pay then retry","payment_options":[ + {"amount":100,"pmi":"bitcoin-lightning-bolt11","pay_req":"lnbc..."}]}""", + ), + ) + val options = PaymentMessages.paymentOptions(error)!! + assertEquals(1, options.size) + assertEquals(lightning, options[0].pmi) + assertEquals("pay then retry", PaymentMessages.instructions(error)) + } + + @Test + fun `CVM-8-43 reads retry_after off a -32043 error`() { + val error = + JsonRpcError( + JsonRpcError.PAYMENT_PENDING, + "Payment Pending", + params("""{"instructions":"retry later","retry_after":5}"""), + ) + assertEquals(5L, PaymentMessages.retryAfter(error)) + assertNull(PaymentMessages.paymentOptions(error), "a pending error carries no options") + } + + @Test + fun `CVM-8-44 ignores a non-payment error`() { + val error = JsonRpcError(JsonRpcError.INVALID_PARAMS, "Unsupported payment_interaction") + assertNull(PaymentMessages.paymentOptions(error)) + assertNull(PaymentMessages.retryAfter(error)) + } + + @Test + fun `CVM-8-45 selects the first offered option it can settle`() { + val session = PaymentSession(supportedPmis = listOf(lightning)) + val options = + listOf( + PaymentRequest(100.0, cashuDirect, "cashuB..."), + PaymentRequest(100.0, lightning, "lnbc..."), + ) + assertEquals(lightning, session.selectPayable(options)?.pmi) + assertNull(session.selectPayable(listOf(PaymentRequest(1.0, cashuDirect, "x")))) + } + + @Test + fun `CVM-8-46 parses direct_payment offers in request order and the change tag`() { + val tags = + arrayOf( + PaymentTags.directPayment(cashuDirect, "token-a"), + PaymentTags.directPayment(lightning, "token-b"), + ) + assertEquals(listOf(cashuDirect to "token-a", lightning to "token-b"), PaymentTags.parseDirectPayments(tags)) + assertEquals(cashuDirect to "rest", PaymentTags.parseChange(arrayOf(PaymentTags.change(cashuDirect, "rest")))) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/contextvm/cep15CommonSchemas/CommonToolSchemaTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/contextvm/cep15CommonSchemas/CommonToolSchemaTest.kt new file mode 100644 index 0000000000..a11035c061 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/contextvm/cep15CommonSchemas/CommonToolSchemaTest.kt @@ -0,0 +1,209 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.contextvm.cep15CommonSchemas + +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcCodec +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** `CVM-15-*`: common tool schemas. */ +class CommonToolSchemaTest { + private fun obj(json: String) = + JsonRpcCodec + .decode("""{"jsonrpc":"2.0","id":1,"method":"m","params":$json}""") + .let { (it as com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcRequest).params!! } + + private val plainInput = + obj( + """{"type":"object","properties":{"text":{"type":"string"}, + "target_language":{"type":"string"}},"required":["text","target_language"]}""", + ) + + private val documentedInput = + obj( + """{"type":"object","title":"Translate input","description":"args", + "properties":{"text":{"type":"string","description":"Text to translate","examples":["hi"]}, + "target_language":{"type":"string","description":"ISO 639-1","default":"en"}}, + "required":["text","target_language"],"x-vendor-note":"internal"}""", + ) + + @Test + fun `CVM-15-01 the same interface documented differently yields the same hash`() { + // This is the entire point of the CEP: providers compete on docs and + // quality while remaining interchangeable. + assertEquals( + CommonToolSchema.hash("translate_text", plainInput), + CommonToolSchema.hash("translate_text", documentedInput), + ) + } + + @Test + fun `CVM-15-02 strips annotation keywords at every nesting level`() { + val normalized = CommonToolSchema.normalize(documentedInput) as JsonObject + assertFalse(normalized.containsKey("title")) + assertFalse(normalized.containsKey("description")) + + val properties = normalized["properties"] as JsonObject + val text = properties["text"] as JsonObject + assertFalse(text.containsKey("description"), "nested description must be stripped too") + assertFalse(text.containsKey("examples")) + + val target = properties["target_language"] as JsonObject + assertFalse(target.containsKey("default")) + } + + @Test + fun `CVM-15-03 strips vendor extensions by prefix`() { + val normalized = CommonToolSchema.normalize(documentedInput) as JsonObject + assertFalse(normalized.keys.any { it.startsWith("x-") }) + } + + @Test + fun `CVM-15-04 keeps structural keywords`() { + val normalized = CommonToolSchema.normalize(documentedInput) as JsonObject + assertEquals("object", (normalized["type"] as JsonPrimitive).content) + assertTrue(normalized.containsKey("properties")) + assertTrue(normalized.containsKey("required")) + } + + @Test + fun `CVM-15-05 normalizes inside arrays`() { + val schema = + obj( + """{"anyOf":[{"type":"string","description":"a"},{"type":"number","title":"b"}]}""", + ) + val normalized = CommonToolSchema.normalize(schema) as JsonObject + val branches = normalized["anyOf"]!! + assertEquals( + """{"anyOf":[{"type":"string"},{"type":"number"}]}""", + normalized.toString(), + ) + assertEquals(2, (branches as kotlinx.serialization.json.JsonArray).size) + } + + @Test + fun `CVM-15-06 the tool name is part of the hash`() { + assertNotEquals( + CommonToolSchema.hash("translate_text", plainInput), + CommonToolSchema.hash("translate_prose", plainInput), + ) + } + + @Test + fun `CVM-15-07 adding an outputSchema changes the hash`() { + val output = obj("""{"type":"object","properties":{"translated_text":{"type":"string"}}}""") + assertNotEquals( + CommonToolSchema.hash("translate_text", plainInput), + CommonToolSchema.hash("translate_text", plainInput, output), + ) + } + + @Test + fun `CVM-15-08 member order in the source schema does not change the hash`() { + // JCS sorts keys, so a server emitting members in a different order + // still lands on the same identity. + val reordered = + obj( + """{"required":["text","target_language"],"properties":{ + "target_language":{"type":"string"},"text":{"type":"string"}},"type":"object"}""", + ) + assertEquals( + CommonToolSchema.hash("translate_text", plainInput), + CommonToolSchema.hash("translate_text", reordered), + ) + } + + @Test + fun `CVM-15-09 verify recomputes rather than trusting the advertised hash`() { + val correct = CommonToolSchema.hash("translate_text", plainInput) + val tool = + buildJsonObject { + put("name", JsonPrimitive("translate_text")) + put("inputSchema", plainInput) + put("_meta", CommonToolSchema.metaFor(correct)) + } + assertTrue(CommonToolSchema.verify(tool)) + assertEquals(correct, CommonToolSchema.hashOf(tool)) + } + + @Test + fun `CVM-15-10 verify rejects a tool advertising someone else's hash`() { + val tool = + buildJsonObject { + put("name", JsonPrimitive("translate_text")) + put("inputSchema", plainInput) + put("_meta", CommonToolSchema.metaFor("00".repeat(32))) + } + assertFalse(CommonToolSchema.verify(tool), "a mismatched hash must not be accepted") + } + + @Test + fun `CVM-15-11 a bespoke tool advertises no hash and does not verify`() { + val tool = + buildJsonObject { + put("name", JsonPrimitive("bespoke")) + put("inputSchema", plainInput) + } + assertNull(CommonToolSchema.advertisedHash(tool)) + assertFalse(CommonToolSchema.verify(tool)) + } + + @Test + fun `CVM-15-12 builds and parses the NIP-73 discovery tags`() { + val hash = CommonToolSchema.hash("translate_text", plainInput) + assertContentEquals( + arrayOf("i", hash, "translate_text"), + CommonToolSchema.externalIdTag(hash, "translate_text"), + ) + assertContentEquals( + arrayOf("k", "io.contextvm/common-schema"), + CommonToolSchema.externalKindTag(), + ) + + val parsed = + CommonToolSchema.parseExternalIds( + arrayOf( + CommonToolSchema.externalIdTag(hash, "translate_text"), + CommonToolSchema.externalKindTag(), + arrayOf("p", "irrelevant"), + ), + ) + assertEquals(listOf(hash to "translate_text"), parsed) + } + + @Test + fun `CVM-15-13 the hash is stable across runs`() { + // Pins the wire value so a refactor of normalization or JCS that changes + // identity shows up as a failure here rather than as silent divergence + // from every other implementation. + val hash = CommonToolSchema.hash("echo", obj("""{"type":"object"}""")) + assertEquals(64, hash.length, "sha256 hex") + assertEquals(hash, CommonToolSchema.hash("echo", obj("""{"type":"object"}"""))) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/contextvm/cep22OversizedTransfer/OversizedTransferTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/contextvm/cep22OversizedTransfer/OversizedTransferTest.kt new file mode 100644 index 0000000000..dfb9dc02a2 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/contextvm/cep22OversizedTransfer/OversizedTransferTest.kt @@ -0,0 +1,347 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.contextvm.cep22OversizedTransfer + +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcCodec +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcId +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcSuccess +import com.vitorpamplona.quartz.contextvm.transfer.ProgressEnvelope +import com.vitorpamplona.quartz.contextvm.transfer.ProgressToken +import com.vitorpamplona.quartz.contextvm.transfer.TransferFrameException +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertTrue + +/** + * `CVM-22-*`: bounded oversized payload transfer. + * + * The CEP is written almost entirely as failure conditions, so most of this is + * negative. A suite of happy paths would prove nothing. + */ +class OversizedTransferTest { + private val token = ProgressToken.Text("req-123") + + private val payload = + JsonRpcCodec.encode( + JsonRpcSuccess( + JsonRpcId.Num(1), + buildJsonObject { put("text", JsonPrimitive("a".repeat(200))) }, + ), + ) + + private fun sender(chunkChars: Int = 64) = OversizedTransferSender(chunkChars) + + private fun receiver( + limits: OversizedLimits = OversizedLimits(), + requireAccept: Boolean = false, + ) = OversizedTransferReceiver(token, limits, requireAccept) + + // --- happy paths --- + + @Test + fun `CVM-22-01 round-trips a fragmented message through the frames`() { + val frames = sender().frame(token, payload) + val rx = receiver() + + var completed: OversizedProgressResult.Completed? = null + frames.forEach { frame -> + val result = rx.accept(frame) + if (result is OversizedProgressResult.Completed) completed = result + } + + assertEquals(payload, completed?.raw) + assertEquals(JsonRpcCodec.decode(payload), completed?.message) + } + + @Test + fun `CVM-22-02 assembles out-of-order chunks by progress rather than arrival order`() { + // Relays may reorder. `progress` is the canonical assembly index. + val frames = sender().frame(token, payload) + val start = frames.first() + val end = frames.last() + val chunks = frames.drop(1).dropLast(1).reversed() + + val rx = receiver() + rx.accept(start) + chunks.forEach { rx.accept(it) } + val result = rx.accept(end) + + assertIs(result) + assertEquals(payload, result.raw) + } + + @Test + fun `CVM-22-03 surfaces nothing before validation succeeds`() { + val frames = sender().frame(token, payload) + val rx = receiver() + + frames.dropLast(1).forEach { + assertTrue( + rx.accept(it) is OversizedProgressResult.Continue, + "no payload may be surfaced before end validates", + ) + } + } + + @Test + fun `CVM-22-04 a bootstrap transfer asks the receiver to send accept`() { + val frames = sender().frame(token, payload) + val rx = receiver(requireAccept = true) + assertIs(rx.accept(frames.first())) + } + + @Test + fun `CVM-22-05 abort is terminal`() { + val rx = receiver() + rx.accept(sender().frame(token, payload).first()) + + val aborted = rx.accept(OversizedFrame.abort(token, 99.0, "peer gave up")) + assertIs(aborted) + assertEquals("peer gave up", aborted.reason) + assertTrue(rx.isTerminal) + + assertFailsWith { + rx.accept(OversizedFrame.chunk(token, 100.0, "x")) + } + } + + @Test + fun `CVM-22-06 the sender never splits a surrogate pair`() { + // Each emoji is a surrogate pair. Splitting one would still produce two + // valid JSON strings, so only the reassembled bytes catch it. + val emoji = "🚀".repeat(40) + val text = JsonRpcCodec.encode(JsonRpcSuccess(JsonRpcId.Num(1), JsonPrimitive(emoji))) + + val frames = sender(chunkChars = 9).frame(token, text) + frames.filterIsInstance().forEach { chunk -> + assertTrue( + chunk.data.isEmpty() || !chunk.data.last().isHighSurrogate(), + "a fragment must not end on an unpaired high surrogate", + ) + } + + val rx = receiver() + val result = frames.map { rx.accept(it) }.last() + assertIs(result) + assertEquals(text, result.raw) + } + + // --- rejections --- + + @Test + fun `CVM-22-10 rejects a digest mismatch`() { + val frames = sender().frame(token, payload).toMutableList() + val start = frames.first() as OversizedFrame.Start + frames[0] = + OversizedFrame.start( + token, + start.progress, + OversizedFrame.DIGEST_PREFIX_SHA256 + "00".repeat(32), + start.totalBytes, + start.totalChunks, + ) + + val rx = receiver() + assertFailsWith { frames.forEach { rx.accept(it) } } + } + + @Test + fun `CVM-22-11 rejects a totalBytes mismatch`() { + val frames = sender().frame(token, payload).toMutableList() + val start = frames.first() as OversizedFrame.Start + frames[0] = + OversizedFrame.start(token, start.progress, start.digest, start.totalBytes + 1, start.totalChunks) + + val rx = receiver() + assertFailsWith { frames.forEach { rx.accept(it) } } + } + + @Test + fun `CVM-22-12 rejects a totalChunks mismatch`() { + val frames = sender().frame(token, payload).toMutableList() + val start = frames.first() as OversizedFrame.Start + frames[0] = + OversizedFrame.start(token, start.progress, start.digest, start.totalBytes, start.totalChunks + 1) + + val rx = receiver() + assertFailsWith { frames.forEach { rx.accept(it) } } + } + + @Test + fun `CVM-22-13 rejects a chunk before accept in a bootstrap transfer`() { + val frames = sender().frame(token, payload) + val rx = receiver(requireAccept = true) + rx.accept(frames.first()) + assertFailsWith { rx.accept(frames[1]) } + } + + @Test + fun `CVM-22-14 rejects non-monotonic progress`() { + val rx = receiver() + rx.accept(OversizedFrame.start(token, 5.0, digestOf(""), 0, 0)) + assertFailsWith { + rx.accept(OversizedFrame.chunk(token, 4.0, "x")) + } + } + + @Test + fun `CVM-22-15 rejects end with unresolved gaps`() { + val frames = sender().frame(token, payload) + val rx = receiver() + rx.accept(frames.first()) + // Deliver every chunk but the last, then end. + frames.drop(1).dropLast(2).forEach { rx.accept(it) } + assertFailsWith { rx.accept(frames.last()) } + } + + @Test + fun `CVM-22-16 rejects an unknown completionMode at parse time`() { + val envelope = + ProgressEnvelope( + token = token, + progress = 1.0, + cvm = + buildJsonObject { + put(ProgressEnvelope.TYPE, JsonPrimitive(ProgressEnvelope.TYPE_OVERSIZED)) + put(ProgressEnvelope.FRAME_TYPE, JsonPrimitive(OversizedFrame.START)) + put(OversizedFrame.COMPLETION_MODE, JsonPrimitive("stream-someday")) + put(OversizedFrame.DIGEST, JsonPrimitive(digestOf(""))) + put(OversizedFrame.TOTAL_BYTES, JsonPrimitive(0)) + put(OversizedFrame.TOTAL_CHUNKS, JsonPrimitive(0)) + }, + ) + assertFailsWith { OversizedFrame.parseOrNull(envelope) } + } + + @Test + fun `CVM-22-17 rejects declared totals over local policy at start`() { + val rx = receiver(limits = OversizedLimits(maxTotalBytes = 10, maxTotalChunks = 2)) + assertFailsWith { + rx.accept(OversizedFrame.start(token, 1.0, digestOf(""), 1_000_000, 1)) + } + } + + @Test + fun `CVM-22-17b refuses at start a transfer larger than the chunk buffer`() { + // Every chunk is held until `end`, so maxPendingChunks is the ceiling + // on a whole transfer, not a reordering window. It used to be checked + // only per chunk, which admitted anything under maxTotalChunks and + // then failed it at chunk 257 — after the sender had pushed megabytes + // for a refusal that was certain from the start frame. + val rx = receiver(limits = OversizedLimits(maxTotalChunks = 4_096, maxPendingChunks = 2)) + + val refused = + assertFailsWith { + rx.accept(OversizedFrame.start(token, 1.0, digestOf(""), 100, 3)) + } + assertTrue( + refused.message!!.contains("buffer limit"), + "the reason should name the buffer, not the declared total: ${refused.message}", + ) + } + + @Test + fun `CVM-22-17c admits a transfer that exactly fills the buffer`() { + // The boundary in the allowed direction, so the check is an upper + // bound rather than an off-by-one that rejects a legal transfer. + val rx = receiver(limits = OversizedLimits(maxTotalChunks = 4_096, maxPendingChunks = 2)) + rx.accept(OversizedFrame.start(token, 1.0, digestOf(""), 100, 2)) + } + + @Test + fun `CVM-22-18 rejects a chunk arriving before start`() { + val rx = receiver() + assertFailsWith { + rx.accept(OversizedFrame.chunk(token, 1.0, "x")) + } + } + + @Test + fun `CVM-22-19 rejects a second start on the same transfer`() { + val rx = receiver() + rx.accept(OversizedFrame.start(token, 1.0, digestOf(""), 0, 0)) + assertFailsWith { + rx.accept(OversizedFrame.start(token, 2.0, digestOf(""), 0, 0)) + } + } + + @Test + fun `CVM-22-20 rejects a frame belonging to another transfer`() { + val rx = receiver() + assertFailsWith { + rx.accept(OversizedFrame.start(ProgressToken.Text("other"), 1.0, digestOf(""), 0, 0)) + } + } + + @Test + fun `CVM-22-21 rejects an unsupported digest algorithm`() { + val rx = receiver() + assertFailsWith { + rx.accept(OversizedFrame.start(token, 1.0, "md5:" + "00".repeat(16), 0, 0)) + } + } + + @Test + fun `CVM-22-22 rejects a duplicate chunk at the same progress`() { + val rx = receiver() + rx.accept(OversizedFrame.start(token, 1.0, digestOf("ab"), 2, 2)) + rx.accept(OversizedFrame.chunk(token, 2.0, "a")) + assertFailsWith { + // Same progress value, so this cannot be a distinct fragment. + rx.accept(OversizedFrame.chunk(token, 2.0, "b")) + } + } + + @Test + fun `CVM-22-23 the envelope round-trips through a progress notification`() { + val frame = sender().frame(token, payload).first() + val notification = frame.envelope.toNotification() + val decoded = JsonRpcCodec.decode(JsonRpcCodec.encode(notification)) + + val envelope = ProgressEnvelope.parseOrNull(decoded as com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcNotification) + assertEquals(frame, OversizedFrame.parseOrNull(envelope!!)) + } + + @Test + fun `CVM-22-24 ignores an envelope belonging to another transfer profile`() { + val envelope = + ProgressEnvelope( + token = token, + progress = 1.0, + cvm = + buildJsonObject { + put(ProgressEnvelope.TYPE, JsonPrimitive(ProgressEnvelope.TYPE_OPEN_STREAM)) + put(ProgressEnvelope.FRAME_TYPE, JsonPrimitive("start")) + }, + ) + assertEquals(null, OversizedFrame.parseOrNull(envelope)) + } + + private fun digestOf(text: String) = + OversizedFrame.DIGEST_PREFIX_SHA256 + + com.vitorpamplona.quartz.utils.sha256 + .sha256(text.encodeToByteArray()) + .let { bytes -> bytes.joinToString("") { b -> (b.toInt() and 0xFF).toString(16).padStart(2, '0') } } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/contextvm/cep35Discovery/DiscoveryTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/contextvm/cep35Discovery/DiscoveryTest.kt new file mode 100644 index 0000000000..3b0eae6ca9 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/contextvm/cep35Discovery/DiscoveryTest.kt @@ -0,0 +1,226 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.contextvm.cep35Discovery + +import com.vitorpamplona.quartz.contextvm.cep06Announcements.DiscoverySurface +import com.vitorpamplona.quartz.contextvm.cep06Announcements.ServerAnnouncement +import com.vitorpamplona.quartz.contextvm.cep17RelayList.ServerRelay +import com.vitorpamplona.quartz.contextvm.cep24Reviews.ServerReview +import com.vitorpamplona.quartz.contextvm.core.CvmKinds +import com.vitorpamplona.quartz.contextvm.core.CvmTags +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.Kind +import com.vitorpamplona.quartz.nip01Core.core.Tag +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class DiscoveryTest { + private val serverPubKey = "a".repeat(64) + + private fun event( + kind: Kind, + tags: Array, + content: String = "", + createdAt: Long = 1_700_000_000L, + pubKey: String = serverPubKey, + ) = Event( + id = "c".repeat(64), + pubKey = pubKey, + createdAt = createdAt, + kind = kind, + tags = tags, + content = content, + sig = "e".repeat(128), + ) + + // --- CEP-6 / CEP-35 discovery surface --- + + @Test + fun `CVM-6-01 parses the announcement discovery tags`() { + val surface = + DiscoverySurface.parse( + arrayOf( + arrayOf("name", "Example Server"), + arrayOf("about", "Public MCP provider"), + arrayOf("picture", "https://example.com/a.png"), + arrayOf("website", "https://example.com"), + CvmTags.flag(CvmTags.SUPPORT_ENCRYPTION), + CvmTags.flag(CvmTags.SUPPORT_OPEN_STREAM), + ), + ) + assertEquals("Example Server", surface.name) + assertEquals("https://example.com", surface.website) + assertTrue(surface.supportsEncryption) + assertTrue(surface.supportsOpenStream) + assertFalse(surface.supportsOversizedTransfer) + } + + @Test + fun `CVM-35-01 preserves unknown discovery tags and excludes routing`() { + val surface = + DiscoverySurface.parse( + arrayOf( + arrayOf("p", serverPubKey), + arrayOf("e", "b".repeat(64)), + arrayOf("some_future_capability", "v2"), + CvmTags.flag(CvmTags.SUPPORT_ENCRYPTION), + ), + ) + assertEquals(1, surface.unknownTags.size, "only the unrecognised tag is retained") + assertContentEquals(arrayOf("some_future_capability", "v2"), surface.rawTag("some_future_capability")) + assertNull(surface.rawTag("p"), "routing tags are not discovery") + } + + @Test + fun `CVM-35-02 the first peer message establishes the baseline`() { + val session = SessionDiscovery() + assertFalse(session.hasLearned) + + session.observe(arrayOf(CvmTags.flag(CvmTags.SUPPORT_ENCRYPTION), arrayOf("name", "First"))) + assertTrue(session.hasLearned) + assertEquals("First", session.peer?.name) + assertTrue(session.peer!!.supportsEncryption) + } + + @Test + fun `CVM-35-03 a later message is interpreted locally without mutating the baseline`() { + val session = SessionDiscovery() + session.observe(arrayOf(arrayOf("name", "First"), CvmTags.flag(CvmTags.SUPPORT_ENCRYPTION))) + + val local = session.observe(arrayOf(arrayOf("cap", "tool:x", "100", "sats"))) + assertEquals(1, local.unknownTags.size, "the later tags are returned for local use") + assertEquals("First", session.peer?.name, "the baseline is unchanged") + assertTrue(session.peer!!.supportsEncryption) + } + + @Test + fun `CVM-35-04 a feature CEP can replace the baseline explicitly`() { + val session = SessionDiscovery() + session.observe(arrayOf(arrayOf("name", "First"))) + session.replaceBaseline(arrayOf(arrayOf("name", "Renamed"))) + assertEquals("Renamed", session.peer?.name) + } + + // --- CEP-6 announcements --- + + @Test + fun `CVM-6-02 parses each announcement kind and rejects others`() { + CvmKinds.ANNOUNCEMENTS.forEach { kind -> + assertNotNull(ServerAnnouncement.parseOrNull(event(kind, emptyArray(), """{"tools":[]}"""))) + } + assertNull(ServerAnnouncement.parseOrNull(event(1, emptyArray()))) + } + + @Test + fun `CVM-6-03 keeps the newest announcement per kind`() { + // Replaceable kinds: a stale event from a lagging relay must not + // overwrite a newer one already held. + val older = event(CvmKinds.SERVER_ANNOUNCEMENT, arrayOf(arrayOf("name", "old")), createdAt = 100) + val newer = event(CvmKinds.SERVER_ANNOUNCEMENT, arrayOf(arrayOf("name", "new")), createdAt = 200) + + val latest = ServerAnnouncement.latestPerKind(listOf(newer, older)) + assertEquals("new", latest[CvmKinds.SERVER_ANNOUNCEMENT]?.discovery?.name) + } + + @Test + fun `CVM-6-04 leaves the announcement content as text for the caller to decode`() { + val announcement = + ServerAnnouncement.parseOrNull( + event(CvmKinds.TOOLS_LIST, emptyArray(), """{"tools":[{"name":"x"}]}"""), + )!! + assertEquals("""{"tools":[{"name":"x"}]}""", announcement.content) + } + + // --- CEP-17 relay list --- + + @Test + fun `CVM-17-01 treats an unmarked relay as both read and write`() { + val relays = ServerRelay.parseAll(event(CvmKinds.RELAY_LIST, arrayOf(arrayOf("r", "wss://a")))) + assertEquals(listOf(ServerRelay("wss://a", read = true, write = true)), relays) + } + + @Test + fun `CVM-17-02 honours read and write markers when present`() { + val relays = + ServerRelay.parseAll( + event( + CvmKinds.RELAY_LIST, + arrayOf(arrayOf("r", "wss://r", "read"), arrayOf("r", "wss://w", "write")), + ), + ) + assertEquals(ServerRelay("wss://r", read = true, write = false), relays[0]) + assertEquals(ServerRelay("wss://w", read = false, write = true), relays[1]) + } + + @Test + fun `CVM-17-03 only a bidirectional relay can carry a full exchange`() { + // Kind 25910 is ephemeral, so a response missed on a write-only relay is + // simply gone -- both halves must share a relay. + val relays = + listOf( + ServerRelay("wss://both"), + ServerRelay("wss://read", read = true, write = false), + ) + assertEquals(listOf(ServerRelay("wss://both")), ServerRelay.operational(relays)) + } + + @Test + fun `CVM-17-04 ignores a blank relay url and a non-relay-list event`() { + assertTrue(ServerRelay.parseAll(event(CvmKinds.RELAY_LIST, arrayOf(arrayOf("r", "")))).isEmpty()) + assertTrue(ServerRelay.parseAll(event(1, arrayOf(arrayOf("r", "wss://a")))).isEmpty()) + } + + // --- CEP-24 reviews --- + + @Test + fun `CVM-24-01 a top-level review tags the announcement as both root and parent`() { + val tags = ServerReview.topLevelTags(serverPubKey, relayHint = "wss://r") + val names = tags.map { it[0] } + assertTrue(names.containsAll(listOf("A", "K", "P", "a", "k", "p"))) + assertEquals("11316:$serverPubKey:", tags.first { it[0] == "A" }[1]) + assertEquals("11316", tags.first { it[0] == "k" }[1]) + } + + @Test + fun `CVM-24-02 a reply keeps the root uppercase but moves the parent to the comment`() { + val parent = "b".repeat(64) + val author = "d".repeat(64) + val tags = ServerReview.replyTags(serverPubKey, parent, author) + + // Root stays on the announcement... + assertEquals("11316:$serverPubKey:", tags.first { it[0] == "A" }[1]) + assertEquals("11316", tags.first { it[0] == "K" }[1]) + // ...while the lowercase parent is the comment being answered. + assertEquals(parent, tags.first { it[0] == "e" }[1]) + assertEquals("1111", tags.first { it[0] == "k" }[1]) + assertEquals(author, tags.first { it[0] == "p" }[1]) + } + + @Test + fun `CVM-24-03 builds the addressable coordinate`() { + assertEquals("11316:$serverPubKey:", ServerReview.coordinate(serverPubKey)) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/contextvm/cep41OpenStreams/OpenStreamTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/contextvm/cep41OpenStreams/OpenStreamTest.kt new file mode 100644 index 0000000000..3726f310ea --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/contextvm/cep41OpenStreams/OpenStreamTest.kt @@ -0,0 +1,320 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.contextvm.cep41OpenStreams + +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcCodec +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcNotification +import com.vitorpamplona.quartz.contextvm.transfer.ProgressEnvelope +import com.vitorpamplona.quartz.contextvm.transfer.ProgressToken +import com.vitorpamplona.quartz.contextvm.transfer.TransferFrameException +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue + +/** `CVM-41-*`: open-ended streams. */ +class OpenStreamTest { + private val token = ProgressToken.Text("req-123") + + private fun receiver( + requireAccept: Boolean = false, + policy: OpenStreamPolicy = OpenStreamPolicy(), + clock: () -> Long = { 0L }, + ) = OpenStreamReceiver(token, policy, requireAccept, clock) + + // --- happy paths --- + + @Test + fun `CVM-41-01 delivers contiguous chunks incrementally`() { + val rx = receiver() + rx.accept(OpenStreamFrame.start(token, 1.0)) + + val first = rx.accept(OpenStreamFrame.chunk(token, 2.0, 0, "Hello")) + assertIs(first) + assertEquals(listOf("Hello"), first.fragments) + + val second = rx.accept(OpenStreamFrame.chunk(token, 3.0, 1, " world")) + assertIs(second) + assertEquals(listOf(" world"), second.fragments) + } + + @Test + fun `CVM-41-02 buffers a gap and releases the run once it closes`() { + val rx = receiver() + rx.accept(OpenStreamFrame.start(token, 1.0)) + + // index 1 arrives first; a gap is not an error while the stream is live. + assertIs(rx.accept(OpenStreamFrame.chunk(token, 2.0, 1, "b"))) + assertIs(rx.accept(OpenStreamFrame.chunk(token, 3.0, 2, "c"))) + + val released = rx.accept(OpenStreamFrame.chunk(token, 4.0, 0, "a")) + assertIs(released) + assertEquals(listOf("a", "b", "c"), released.fragments) + } + + @Test + fun `CVM-41-03 a zero-chunk stream is valid`() { + // close straight after start, lastChunkIndex omitted. + val rx = receiver() + rx.accept(OpenStreamFrame.start(token, 1.0)) + val closed = rx.accept(OpenStreamFrame.close(token, 2.0)) + assertIs(closed) + assertEquals(null, closed.lastChunkIndex) + } + + @Test + fun `CVM-41-04 close with a satisfied lastChunkIndex succeeds`() { + val rx = receiver() + rx.accept(OpenStreamFrame.start(token, 1.0)) + rx.accept(OpenStreamFrame.chunk(token, 2.0, 0, "a")) + rx.accept(OpenStreamFrame.chunk(token, 3.0, 1, "b")) + + val closed = rx.accept(OpenStreamFrame.close(token, 4.0, lastChunkIndex = 1)) + assertIs(closed) + assertEquals(1L, closed.lastChunkIndex) + } + + @Test + fun `CVM-41-05 close without lastChunkIndex succeeds on an open-ended feed`() { + val rx = receiver() + rx.accept(OpenStreamFrame.start(token, 1.0)) + rx.accept(OpenStreamFrame.chunk(token, 2.0, 0, "tick")) + assertIs(rx.accept(OpenStreamFrame.close(token, 3.0))) + } + + @Test + fun `CVM-41-06 a ping must be answered with a pong carrying the same nonce`() { + val rx = receiver() + rx.accept(OpenStreamFrame.start(token, 1.0)) + val event = rx.accept(OpenStreamFrame.ping(token, 2.0, "n-1")) + assertIs(event) + assertEquals("n-1", event.nonce) + } + + @Test + fun `CVM-41-07 pong progress has no ordering relationship to the ping`() { + // Pongs are matched by nonce only; each peer numbers its own sequence, + // so a pong may legitimately carry a lower progress than the ping. + val rx = receiver() + rx.accept(OpenStreamFrame.start(token, 10.0)) + rx.markProbeSent("n-1") + assertIs(rx.accept(OpenStreamFrame.pong(token, 1.0, "n-1"))) + assertFalse(rx.probeExpired(atMs = 1_000_000)) + } + + @Test + fun `CVM-41-08 keepalive fails the stream when a probe goes unanswered`() { + var clock = 0L + val rx = receiver(policy = OpenStreamPolicy(idleTimeoutMs = 30_000, probeTimeoutMs = 30_000)) { clock } + rx.accept(OpenStreamFrame.start(token, 1.0)) + + clock = 30_000 + assertTrue(rx.needsProbe(clock), "idle timeout elapsed, the peer must be probed") + + rx.markProbeSent("n-1", clock) + clock = 59_000 + assertFalse(rx.probeExpired(clock), "still inside the probe window") + + clock = 60_000 + assertTrue(rx.probeExpired(clock), "probe window elapsed with no matching pong") + } + + @Test + fun `CVM-41-09 a bootstrap stream asks the receiver to send accept`() { + val rx = receiver(requireAccept = true) + assertIs(rx.accept(OpenStreamFrame.start(token, 1.0))) + } + + @Test + fun `CVM-41-10 abort is terminal from either peer`() { + val rx = receiver() + rx.accept(OpenStreamFrame.start(token, 1.0)) + val aborted = rx.accept(OpenStreamFrame.abort(token, 2.0, "resource exhaustion")) + assertIs(aborted) + assertEquals("resource exhaustion", aborted.reason) + assertTrue(rx.isTerminal) + } + + // --- rejections --- + + @Test + fun `CVM-41-20 rejects a second start on a live stream`() { + val rx = receiver() + rx.accept(OpenStreamFrame.start(token, 1.0)) + assertFailsWith { rx.accept(OpenStreamFrame.start(token, 2.0)) } + } + + @Test + fun `CVM-41-21 rejects frames after close`() { + val rx = receiver() + rx.accept(OpenStreamFrame.start(token, 1.0)) + rx.accept(OpenStreamFrame.close(token, 2.0)) + assertFailsWith { rx.accept(OpenStreamFrame.chunk(token, 3.0, 0, "late")) } + } + + @Test + fun `CVM-41-22 rejects close with a lastChunkIndex that is not satisfied`() { + val rx = receiver() + rx.accept(OpenStreamFrame.start(token, 1.0)) + rx.accept(OpenStreamFrame.chunk(token, 2.0, 0, "a")) + // Declares index 5 as the bound while only 0 arrived. + assertFailsWith { + rx.accept(OpenStreamFrame.close(token, 3.0, lastChunkIndex = 5)) + } + } + + @Test + fun `CVM-41-23 rejects close with a bound while a gap remains unresolved`() { + val rx = receiver() + rx.accept(OpenStreamFrame.start(token, 1.0)) + rx.accept(OpenStreamFrame.chunk(token, 2.0, 0, "a")) + rx.accept(OpenStreamFrame.chunk(token, 3.0, 2, "c")) // index 1 missing + assertFailsWith { + rx.accept(OpenStreamFrame.close(token, 4.0, lastChunkIndex = 2)) + } + } + + @Test + fun `CVM-41-24 rejects lastChunkIndex on a stream that carried no chunks`() { + val rx = receiver() + rx.accept(OpenStreamFrame.start(token, 1.0)) + assertFailsWith { + rx.accept(OpenStreamFrame.close(token, 2.0, lastChunkIndex = 0)) + } + } + + @Test + fun `CVM-41-25 rejects a duplicate chunkIndex`() { + val rx = receiver() + rx.accept(OpenStreamFrame.start(token, 1.0)) + rx.accept(OpenStreamFrame.chunk(token, 2.0, 1, "b")) + assertFailsWith { rx.accept(OpenStreamFrame.chunk(token, 3.0, 1, "b again")) } + } + + @Test + fun `CVM-41-26 rejects a chunkIndex that was already delivered`() { + val rx = receiver() + rx.accept(OpenStreamFrame.start(token, 1.0)) + rx.accept(OpenStreamFrame.chunk(token, 2.0, 0, "a")) + assertFailsWith { rx.accept(OpenStreamFrame.chunk(token, 3.0, 0, "a again")) } + } + + @Test + fun `CVM-41-27 rejects a chunk before start`() { + val rx = receiver() + assertFailsWith { rx.accept(OpenStreamFrame.chunk(token, 1.0, 0, "a")) } + } + + @Test + fun `CVM-41-28 rejects a chunk before accept in a bootstrap stream`() { + val rx = receiver(requireAccept = true) + rx.accept(OpenStreamFrame.start(token, 1.0)) + assertFailsWith { rx.accept(OpenStreamFrame.chunk(token, 2.0, 0, "a")) } + } + + @Test + fun `CVM-41-29 rejects a repeated progress value from the peer`() { + val rx = receiver() + rx.accept(OpenStreamFrame.start(token, 1.0)) + rx.accept(OpenStreamFrame.chunk(token, 2.0, 0, "a")) + assertFailsWith { rx.accept(OpenStreamFrame.chunk(token, 2.0, 1, "b")) } + } + + @Test + fun `CVM-41-30 rejects a frame belonging to another stream`() { + val rx = receiver() + assertFailsWith { + rx.accept(OpenStreamFrame.start(ProgressToken.Text("other"), 1.0)) + } + } + + @Test + fun `CVM-41-31 rejects an oversized nonce at parse time`() { + val envelope = + ProgressEnvelope( + token = token, + progress = 1.0, + cvm = + buildJsonObject { + put(ProgressEnvelope.TYPE, JsonPrimitive(ProgressEnvelope.TYPE_OPEN_STREAM)) + put(ProgressEnvelope.FRAME_TYPE, JsonPrimitive(OpenStreamFrame.PING)) + put(OpenStreamFrame.NONCE, JsonPrimitive("x".repeat(65))) + }, + ) + assertFailsWith { OpenStreamFrame.parseOrNull(envelope) } + } + + @Test + fun `CVM-41-32 rejects a chunk missing its chunkIndex`() { + // progress is not a chunk counter, so a chunk without chunkIndex cannot + // be positioned at all. + val envelope = + ProgressEnvelope( + token = token, + progress = 1.0, + cvm = + buildJsonObject { + put(ProgressEnvelope.TYPE, JsonPrimitive(ProgressEnvelope.TYPE_OPEN_STREAM)) + put(ProgressEnvelope.FRAME_TYPE, JsonPrimitive(OpenStreamFrame.CHUNK)) + put(OpenStreamFrame.DATA, JsonPrimitive("a")) + }, + ) + assertFailsWith { OpenStreamFrame.parseOrNull(envelope) } + } + + @Test + fun `CVM-41-33 ignores a pong for a nonce we never sent`() { + // Not fatal: failing here would let a third party disrupt the stream by + // replaying a stale pong. It simply is not liveness evidence. + val rx = receiver() + rx.accept(OpenStreamFrame.start(token, 1.0)) + rx.markProbeSent("real") + assertIs(rx.accept(OpenStreamFrame.pong(token, 2.0, "forged"))) + assertTrue(rx.probeExpired(atMs = 1_000_000), "the real probe is still outstanding") + } + + @Test + fun `CVM-41-34 the frame round-trips through a progress notification`() { + val frame = OpenStreamFrame.chunk(token, 2.0, 7, "payload") + val decoded = JsonRpcCodec.decode(JsonRpcCodec.encode(frame.envelope.toNotification())) + val envelope = ProgressEnvelope.parseOrNull(decoded as JsonRpcNotification) + assertEquals(frame, OpenStreamFrame.parseOrNull(envelope!!)) + } + + @Test + fun `CVM-41-35 ignores an envelope belonging to another transfer profile`() { + val envelope = + ProgressEnvelope( + token = token, + progress = 1.0, + cvm = + buildJsonObject { + put(ProgressEnvelope.TYPE, JsonPrimitive(ProgressEnvelope.TYPE_OVERSIZED)) + put(ProgressEnvelope.FRAME_TYPE, JsonPrimitive("start")) + }, + ) + assertEquals(null, OpenStreamFrame.parseOrNull(envelope)) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/contextvm/core/CvmMessageEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/contextvm/core/CvmMessageEventTest.kt new file mode 100644 index 0000000000..3ce2747447 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/contextvm/core/CvmMessageEventTest.kt @@ -0,0 +1,144 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.contextvm.core + +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcCodec +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcId +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcRequest +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.isEphemeral +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * `CVM-CORE-02..06`: the kind 25910 envelope — content shape, addressing, + * correlation and the ephemeral-delivery consequence. + */ +class CvmMessageEventTest { + private val serverPubKey = "a".repeat(64) + private val requestEventId = "b".repeat(64) + + private val ping = JsonRpcRequest(JsonRpcId.Num(1), "ping") + + @Test + fun `CVM-CORE-02 content is a stringified JSON-RPC message rather than an embedded object`() { + val template = CvmMessageEvent.build(ping, serverPubKey) + + // The spec's examples print `content` unstringified for readability, + // which is the trap this asserts against: content is a String field + // whose own text is the JSON-RPC message. + assertEquals("""{"jsonrpc":"2.0","id":1,"method":"ping"}""", template.content) + + // And it must survive the trip back through the codec. + assertEquals(ping, JsonRpcCodec.decode(template.content)) + } + + @Test + fun `CVM-CORE-03 addresses the peer with a p tag`() { + val template = CvmMessageEvent.build(ping, serverPubKey) + assertContentEquals(arrayOf("p", serverPubKey), template.tags.first()) + } + + @Test + fun `CVM-CORE-04 correlates a response with an e tag naming the request event`() { + val template = CvmMessageEvent.build(ping, serverPubKey, inReplyTo = requestEventId) + val eTag = template.tags.first { it[0] == "e" } + assertContentEquals(arrayOf("e", requestEventId), eTag) + } + + @Test + fun `CVM-CORE-04 omits the e tag when the message is not a response`() { + val template = CvmMessageEvent.build(ping, serverPubKey) + assertFalse(template.tags.any { it.isNotEmpty() && it[0] == "e" }) + } + + @Test + fun `CVM-CORE-03 reads addressing and correlation back off a parsed event`() { + val event = event(CvmMessageEvent.build(ping, serverPubKey, inReplyTo = requestEventId).tags) + + assertEquals(serverPubKey, event.recipient()) + assertEquals(requestEventId, event.inReplyTo()) + assertEquals(ping, event.message()) + } + + @Test + fun `CVM-CORE-03 tolerates an unaddressed event rather than throwing`() { + val event = event(emptyArray()) + assertNull(event.recipient()) + assertNull(event.inReplyTo()) + } + + @Test + fun `CVM-CORE-06 kind 25910 is ephemeral so delivery has no replay`() { + // Consequence, not decoration: relays do not retain this kind, so a + // subscription must be live before the peer publishes. The transport's + // request API is built around this and the property is worth pinning. + assertTrue(CvmKinds.MESSAGE.isEphemeral()) + assertTrue(CvmKinds.isTransient(CvmKinds.MESSAGE)) + assertTrue(CvmKinds.isTransient(CvmKinds.EPHEMERAL_GIFT_WRAP)) + + // The CEP-19 motivation: the persistent wrap is *not* ephemeral, which + // is exactly why 21059 exists. + assertFalse(CvmKinds.isTransient(CvmKinds.GIFT_WRAP)) + } + + @Test + fun `CVM-35 discovery tags exclude routing tags but keep unknown ones`() { + val template = + CvmMessageEvent.build( + ping, + serverPubKey, + inReplyTo = requestEventId, + extraTags = + listOf( + CvmTags.flag(CvmTags.SUPPORT_ENCRYPTION), + arrayOf("some_future_tag", "value"), + ), + ) + + val discovery = event(template.tags).discoveryTags().map { it[0] } + + assertFalse(discovery.contains("p"), "p is routing, not discovery") + assertFalse(discovery.contains("e"), "e is routing, not discovery") + assertTrue(discovery.contains(CvmTags.SUPPORT_ENCRYPTION)) + assertTrue( + discovery.contains("some_future_tag"), + "CEP-35 requires unknown discovery tags to be preserved", + ) + } + + private fun event(tags: Array>) = + CvmMessageEvent( + Event( + id = "c".repeat(64), + pubKey = "d".repeat(64), + createdAt = 1_700_000_000L, + kind = CvmMessageEvent.KIND, + tags = tags, + content = JsonRpcCodec.encode(ping), + sig = "e".repeat(128), + ), + ) +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/contextvm/jsonrpc/JsonRpcCodecTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/contextvm/jsonrpc/JsonRpcCodecTest.kt new file mode 100644 index 0000000000..a30d440449 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/contextvm/jsonrpc/JsonRpcCodecTest.kt @@ -0,0 +1,236 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.contextvm.jsonrpc + +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * `CVM-CORE-01`: the JSON-RPC layer round-trips every message class faithfully, + * and rejects payloads that are not well-formed JSON-RPC 2.0. + * + * Rule ids come from `quartz/plans/2026-09-17-cordn-interop.md` §6.5. When a CEP + * revises, the failing test names say what changed. + */ +class JsonRpcCodecTest { + private val toolsCall = + JsonRpcRequest( + id = JsonRpcId.Num(2), + method = "tools/call", + params = + buildJsonObject { + put("name", JsonPrimitive("kp_publish")) + put( + "arguments", + buildJsonObject { + put("kp_ref", JsonPrimitive("abc")) + put("kp_64", JsonPrimitive("BASE64")) + }, + ) + }, + ) + + @Test + fun `CVM-CORE-01 round-trips a request`() { + assertEquals(toolsCall, JsonRpcCodec.decode(JsonRpcCodec.encode(toolsCall))) + } + + @Test + fun `CVM-CORE-01 round-trips a notification`() { + val notification = + JsonRpcNotification( + method = "notifications/progress", + params = + buildJsonObject { + put("progressToken", JsonPrimitive("req-123")) + put("progress", JsonPrimitive(1)) + }, + ) + assertEquals(notification, JsonRpcCodec.decode(JsonRpcCodec.encode(notification))) + } + + @Test + fun `CVM-CORE-01 round-trips a success response`() { + val success = + JsonRpcSuccess( + id = JsonRpcId.Num(2), + result = buildJsonObject { put("cursor", JsonPrimitive(7)) }, + ) + assertEquals(success, JsonRpcCodec.decode(JsonRpcCodec.encode(success))) + } + + @Test + fun `CVM-CORE-01 round-trips an error response with data`() { + val failure = + JsonRpcFailure( + id = JsonRpcId.Num(2), + error = + JsonRpcError( + code = JsonRpcError.PAYMENT_REQUIRED, + message = "Payment Required", + data = buildJsonObject { put("instructions", JsonPrimitive("pay then retry")) }, + ), + ) + assertEquals(failure, JsonRpcCodec.decode(JsonRpcCodec.encode(failure))) + } + + @Test + fun `CVM-CORE-01 preserves a string id distinctly from a numeric one`() { + // MCP implementations use both. Normalising to String would make a + // string "2" and a numeric 2 indistinguishable on the wire, so the two + // must stay separate types through a round trip. + val text = toolsCall.copy(id = JsonRpcId.Text("2")) + val encodedText = JsonRpcCodec.encode(text) + val encodedNum = JsonRpcCodec.encode(toolsCall) + + assertTrue(encodedText.contains("\"id\":\"2\""), "string id must encode quoted: $encodedText") + assertTrue(encodedNum.contains("\"id\":2"), "numeric id must encode bare: $encodedNum") + assertEquals(text, JsonRpcCodec.decode(encodedText)) + assertEquals(toolsCall, JsonRpcCodec.decode(encodedNum)) + } + + @Test + fun `CVM-CORE-01 omits absent params rather than emitting null`() { + val encoded = JsonRpcCodec.encode(JsonRpcNotification("notifications/initialized")) + assertEquals("""{"jsonrpc":"2.0","method":"notifications/initialized"}""", encoded) + } + + @Test + fun `CVM-CORE-01 keeps an unknown member out of the way of decoding`() { + // MCP grows fields and CEP-35 tells us to tolerate what we do not know. + val decoded = + JsonRpcCodec.decode( + """{"jsonrpc":"2.0","id":1,"method":"ping","futureField":{"x":1}}""", + ) + assertEquals(JsonRpcRequest(JsonRpcId.Num(1), "ping"), decoded) + } + + @Test + fun `CVM-CORE-01 accepts a null id on an error response`() { + // JSON-RPC allows a null id when the request could not be parsed. + val decoded = + JsonRpcCodec.decode( + """{"jsonrpc":"2.0","id":null,"error":{"code":-32700,"message":"Parse error"}}""", + ) + assertTrue(decoded is JsonRpcFailure) + assertNull(decoded.id) + assertEquals(JsonRpcError.PARSE_ERROR, decoded.error.code) + } + + @Test + fun `CVM-CORE-01 treats a method without an id as a notification`() { + val decoded = JsonRpcCodec.decode("""{"jsonrpc":"2.0","method":"notifications/initialized"}""") + assertEquals(JsonRpcNotification("notifications/initialized"), decoded) + } + + @Test + fun `CVM-CORE-01 preserves the params tree untouched`() { + // ContextVM transports MCP unmodified, so anything inside params has to + // survive verbatim -- including nesting we assign no meaning to. + val decoded = JsonRpcCodec.decode(JsonRpcCodec.encode(toolsCall)) as JsonRpcRequest + val arguments = decoded.params!!["arguments"]!!.jsonObject + assertEquals("BASE64", arguments["kp_64"]!!.jsonPrimitive.content) + } + + // --- rejections: CVM-CORE-05 --- + + @Test + fun `CVM-CORE-05 rejects a wrong jsonrpc version`() { + assertFailsWith { + JsonRpcCodec.decode("""{"jsonrpc":"1.0","id":1,"method":"ping"}""") + } + } + + @Test + fun `CVM-CORE-05 rejects a missing jsonrpc member`() { + assertFailsWith { + JsonRpcCodec.decode("""{"id":1,"method":"ping"}""") + } + } + + @Test + fun `CVM-CORE-05 rejects a response carrying both result and error`() { + assertFailsWith { + JsonRpcCodec.decode( + """{"jsonrpc":"2.0","id":1,"result":{},"error":{"code":-1,"message":"x"}}""", + ) + } + } + + @Test + fun `CVM-CORE-05 rejects a message with neither method nor result nor error`() { + assertFailsWith { + JsonRpcCodec.decode("""{"jsonrpc":"2.0","id":1}""") + } + } + + @Test + fun `CVM-CORE-05 rejects a method combined with a response body`() { + assertFailsWith { + JsonRpcCodec.decode("""{"jsonrpc":"2.0","id":1,"method":"ping","result":{}}""") + } + } + + @Test + fun `CVM-CORE-05 rejects a success response without an id`() { + assertFailsWith { + JsonRpcCodec.decode("""{"jsonrpc":"2.0","result":{}}""") + } + } + + @Test + fun `CVM-CORE-05 rejects a non-object payload`() { + assertFailsWith { JsonRpcCodec.decode("""["jsonrpc","2.0"]""") } + } + + @Test + fun `CVM-CORE-05 rejects malformed JSON`() { + assertFailsWith { JsonRpcCodec.decode("""{"jsonrpc":"2.0",""") } + } + + @Test + fun `CVM-CORE-05 rejects a fractional id`() { + assertFailsWith { + JsonRpcCodec.decode("""{"jsonrpc":"2.0","id":1.5,"method":"ping"}""") + } + } + + @Test + fun `CVM-CORE-05 rejects non-object params`() { + assertFailsWith { + JsonRpcCodec.decode("""{"jsonrpc":"2.0","id":1,"method":"ping","params":[1,2]}""") + } + } + + @Test + fun `CVM-CORE-05 rejects an error object missing its code`() { + assertFailsWith { + JsonRpcCodec.decode("""{"jsonrpc":"2.0","id":1,"error":{"message":"x"}}""") + } + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cordn/appEncryptedMedia/CordnBlobUploadTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cordn/appEncryptedMedia/CordnBlobUploadTest.kt new file mode 100644 index 0000000000..7d1be0ed02 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cordn/appEncryptedMedia/CordnBlobUploadTest.kt @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.appEncryptedMedia + +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.utils.sha256.sha256 +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertNull + +/** + * What the blob host is told, asserted field by field. + * + * Every one of these is a privacy property that regresses *without failing*: + * change the content type and the upload still works, the group still reads + * the file, and the only difference is that a server now knows it is holding a + * JPEG. There is no downstream check that would catch it, so these assertions + * are the check. + */ +class CordnBlobUploadTest { + private val key = ByteArray(32) { it.toByte() } + private val file = "a private photo".encodeToByteArray() + private val sealed = CordnMediaEncryption.encrypt(file, key, "image/jpeg", "holiday.jpg") + + @Test + fun `the host is given the ciphertext and its hash names the blob`() { + val blob = CordnBlobUpload.of(sealed) + + // Blossom addresses a blob by the hash of what it stores. + assertContentEquals(sealed.ciphertext, blob.bytes) + assertEquals(sha256(sealed.ciphertext).toHexKey(), blob.hash) + assertEquals(sealed.ciphertext.size.toLong(), blob.length) + } + + @Test + fun `the host hash is not the imeta hash`() { + val blob = CordnBlobUpload.of(sealed) + + // Two hashes on purpose. Uploading under the plaintext hash would let + // anyone holding the original file prove this account uploaded it, and + // would make the blob's address a fingerprint of its contents. + assertNotEquals(sealed.plaintextHash.toHexKey(), blob.hash) + assertEquals(sha256(file).toHexKey(), sealed.plaintextHash.toHexKey()) + } + + @Test + fun `the declared type is opaque rather than the real one`() { + val blob = CordnBlobUpload.of(sealed) + + assertEquals("application/octet-stream", blob.contentType) + assertEquals(CordnBlobUpload.OPAQUE, blob.contentType) + assertNotEquals(sealed.mimeType, blob.contentType) + } + + @Test + fun `the real filename does not reach the host`() { + val blob = CordnBlobUpload.of(sealed) + + assertEquals(blob.hash, blob.baseFileName) + assertNotEquals("holiday.jpg", blob.baseFileName) + } + + @Test + fun `no alt text and no content warning`() { + val blob = CordnBlobUpload.of(sealed) + + // Both are plaintext on a Blossom upload. An alt string describing a + // private photo is that photo's caption, given to the one party that + // was supposed to see nothing. + assertNull(blob.alt) + assertNull(blob.sensitiveContent) + } + + @Test + fun `upload rather than the media endpoint`() { + // `/media` asks the server to re-encode. Re-encoding ciphertext + // destroys it, so an account with "optimize uploads" on would break + // every attachment and only the recipient would find out. + assertFalse(CordnBlobUpload.of(sealed).useMediaEndpoint) + } + + @Test + fun `the same file uploaded twice is a different blob`() { + // The nonce is fresh per encryption (see CordnMediaEncryptionTest), so + // the ciphertext hash differs. A host therefore cannot tell that the + // same picture was sent to two groups. + val again = CordnMediaEncryption.encrypt(file, key, "image/jpeg", "holiday.jpg") + + assertNotEquals(CordnBlobUpload.of(sealed).hash, CordnBlobUpload.of(again).hash) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cordn/appEncryptedMedia/CordnMediaEncryptionTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cordn/appEncryptedMedia/CordnMediaEncryptionTest.kt new file mode 100644 index 0000000000..572c412b28 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cordn/appEncryptedMedia/CordnMediaEncryptionTest.kt @@ -0,0 +1,236 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.appEncryptedMedia + +import com.vitorpamplona.quartz.marmot.mip04EncryptedMedia.Mip04MediaEncryption +import com.vitorpamplona.quartz.utils.sha256.sha256 +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class CordnMediaEncryptionTest { + private val key = ByteArray(32) { it.toByte() } + private val file = "a small photo".encodeToByteArray() + + private fun roundTrip( + mime: String = "image/jpeg", + name: String = "photo.jpg", + ): CordnEncryptedMedia = CordnMediaEncryption.encrypt(file, key, mime, name) + + @Test + fun `a file round-trips`() { + val sealed = roundTrip() + + val opened = + CordnMediaEncryption.decrypt( + sealed.ciphertext, + key, + sealed.nonce, + sealed.plaintextHash, + sealed.mimeType, + sealed.filename, + ) + + assertContentEquals(file, opened) + assertContentEquals(sha256(file), sealed.plaintextHash) + } + + @Test + fun `the same file encrypted twice never reuses a nonce`() { + // The property this codec leans on harder than MIP-04 does: cordn uses + // the exporter output directly, so every file in an epoch shares one + // key and the nonce is all that separates two keystreams. + val nonces = (1..64).map { roundTrip().nonce.toList() }.toSet() + + assertEquals(64, nonces.size, "a nonce repeated under a shared key is a keystream break") + } + + @Test + fun `renaming a file breaks it because the name is authenticated`() { + val sealed = roundTrip(name = "photo.jpg") + + assertFailsWith { + CordnMediaEncryption.decrypt(sealed.ciphertext, key, sealed.nonce, sealed.plaintextHash, sealed.mimeType, "invoice.pdf") + } + } + + @Test + fun `changing the declared type breaks it`() { + val sealed = roundTrip(mime = "image/jpeg") + + assertFailsWith { + CordnMediaEncryption.decrypt(sealed.ciphertext, key, sealed.nonce, sealed.plaintextHash, "application/pdf", sealed.filename) + } + } + + @Test + fun `a hash that does not describe the plaintext is refused`() { + val sealed = roundTrip() + val lying = sealed.plaintextHash.copyOf().also { it[0] = (it[0] + 1).toByte() } + + assertFailsWith { + CordnMediaEncryption.decrypt(sealed.ciphertext, key, sealed.nonce, lying, sealed.mimeType, sealed.filename) + } + } + + @Test + fun `a cordn blob is not a Marmot blob`() { + // §4.5: same exporter context word, different key derivation and + // different AAD. Confusing the two must fail loudly rather than + // produce plausible garbage, and this is the test that says so. + val sealed = roundTrip() + + assertFailsWith { + Mip04MediaEncryption.decrypt( + sealed.ciphertext, + key, + sealed.nonce, + sealed.plaintextHash, + sealed.mimeType, + sealed.filename, + ) + } + } + + @Test + fun `the cordn file key is not the Marmot file key for the same inputs`() { + val marmot = Mip04MediaEncryption.deriveFileKey(key, sha256(file), "image/jpeg", "photo.jpg") + + // cordn uses the exporter output as-is; Marmot expands it. If these + // ever matched, one of the two implementations would be wrong. + assertNotEquals(key.toList(), marmot.toList()) + } + + @Test + fun `an imeta tag round-trips through parse`() { + val sealed = roundTrip() + val tag = CordnMediaTag.build(sealed, url = "https://blossom.example.com/abc", dimensions = "800x600") + + val parsed = CordnMediaTag.parseAll(arrayOf(tag)).single() + + assertEquals("https://blossom.example.com/abc", parsed.url) + assertEquals(sealed.mimeType, parsed.mimeType) + assertEquals(sealed.filename, parsed.filename) + assertContentEquals(sealed.nonce, parsed.nonceBytes) + assertContentEquals(sealed.plaintextHash, parsed.hashBytes) + assertEquals("800x600", parsed.dimensions) + assertTrue(parsed.isImage) + assertFalse(parsed.isAudio) + } + + @Test + fun `a half-formed attachment is dropped rather than half-parsed`() { + // The only thing a caller could do with a partial descriptor is start + // a decrypt that must fail. A message with one broken attachment + // should still show its other attachments and its text. + val good = CordnMediaTag.build(roundTrip(), url = "https://blossom.example.com/ok") + val noNonce = arrayOf("imeta", "url https://blossom.example.com/bad", "m image/png", "filename x.png", "x " + "ab".repeat(32)) + val shortNonce = arrayOf("imeta", "url https://blossom.example.com/bad", "m image/png", "filename x.png", "x " + "ab".repeat(32), "n abcd") + + val parsed = CordnMediaTag.parseAll(arrayOf(noNonce, good, shortNonce)) + + assertEquals(1, parsed.size) + assertEquals("https://blossom.example.com/ok", parsed.single().url) + } + + @Test + fun `a filename containing a space survives the tag`() { + // imeta fields are "key value" strings, so only the FIRST space + // separates them. Splitting on every space would truncate any filename + // a person actually typed. + val sealed = roundTrip(name = "my holiday photo.jpg") + val parsed = CordnMediaTag.parseAll(arrayOf(CordnMediaTag.build(sealed, url = "https://b.example.com/x"))).single() + + assertEquals("my holiday photo.jpg", parsed.filename) + } + + @Test + fun `the tag carries the version and never the key`() { + // spec/applications/encrypted-media.md §4: `v cordn-em-v1` is required, + // and §3.1 says the key is "never transmitted, never stored in imeta". + // Amethyst used to do the opposite of both — ship a random key in a `k` + // field and omit `v` — and cordn.net rejected every attachment. + val tag = CordnMediaTag.build(roundTrip(), url = "https://b.example.com/y") + + assertTrue(tag.any { it == "${CordnMediaTag.VERSION} ${CordnMediaTag.VERSION_V1}" }, "no version field") + assertFalse(tag.any { it.startsWith("k ") }, "the file key went on the wire") + } + + @Test + fun `the display hints round-trip including the waveform`() { + // The recorder measures amplitudes and the composer preview already + // draws them; without somewhere to put them they were dropped at upload + // and the sender's own voice note came back bar-less. + val sealed = roundTrip(mime = "audio/mp4a-latm", name = "note.mp4") + val tag = + CordnMediaTag.build( + media = sealed, + url = "https://b.example.com/a", + dimensions = "800x600", + blurhash = "LEHV6n", + thumbhash = "1QcSHQ", + alt = "a spoken note", + waveform = listOf(0.1f, 0.5f, 1.0f), + ) + + val parsed = CordnMediaTag.parseAll(arrayOf(tag)).single() + + assertEquals("800x600", parsed.dimensions) + assertEquals("LEHV6n", parsed.blurhash) + assertEquals("1QcSHQ", parsed.thumbhash) + assertEquals("a spoken note", parsed.alt) + assertEquals(listOf(0.1f, 0.5f, 1.0f), parsed.waveform) + assertTrue(parsed.isAudio) + } + + @Test + fun `a hint that is absent or malformed costs the hint rather than the attachment`() { + // Every one of these is optional, so a reader that cannot make sense of + // one must still be able to fetch and open the file. + val tag = CordnMediaTag.build(roundTrip(), url = "https://b.example.com/b") + val brokenWave = tag + "${CordnMediaTag.WAVEFORM} not numbers" + + val bare = CordnMediaTag.parseAll(arrayOf(tag)).single() + val broken = CordnMediaTag.parseAll(arrayOf(brokenWave)).single() + + assertNull(bare.waveform) + assertNull(bare.alt) + assertNull(broken.waveform, "a malformed waveform should be dropped, not kept half-parsed") + assertEquals("https://b.example.com/b", broken.url) + } + + @Test + fun `a tag with no version or an unknown one is rejected`() { + // §4 makes this a MUST: the bytes under an unrecognised version are a + // format we have not agreed on, so guessing is worse than dropping. + val tag = CordnMediaTag.build(roundTrip(), url = "https://b.example.com/z") + val noVersion = tag.filterNot { it.startsWith("${CordnMediaTag.VERSION} ") }.toTypedArray() + val futureVersion = noVersion + "${CordnMediaTag.VERSION} cordn-em-v2" + + assertTrue(CordnMediaTag.parseAll(arrayOf(noVersion)).isEmpty(), "a tag with no version parsed") + assertTrue(CordnMediaTag.parseAll(arrayOf(futureVersion)).isEmpty(), "an unknown version parsed") + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cordn/appGroupRef/CordnGroupRefTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cordn/appGroupRef/CordnGroupRefTest.kt new file mode 100644 index 0000000000..8b35445a23 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cordn/appGroupRef/CordnGroupRefTest.kt @@ -0,0 +1,118 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.appGroupRef + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * `spec/applications/group-ref.md`. + * + * The three golden strings are copied from the reference implementation's own + * suite (`packages/core/src/groupRef.test.ts`), where they are cross-checked + * against an independent TLV+bech32 assembly. That makes them a genuine + * cross-implementation vector rather than a record of what our encoder happens + * to emit — the thing a round-trip test can never tell you. + */ +class CordnGroupRefTest { + private val gid = "550e8400-e29b-41d4-a716-446655440000" + private val pubKey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + private val relays = listOf("wss://relay.example.com", "wss://backup.example.com") + + private val goldenGidOnly = + "cordn1qqjr2dfsv5urgvps94jnywtz956rzep594snwvfk956rgd3kx56ngdpsxqcrqy4yh7d" + private val goldenGidPubKey = + "cordn1qysqzg69v7y6hn00qy352euf40x77qfrg4ncn27dauqjx3t83x4ummcqys6n2vr98q6rqvpdv5erjc3dxsckgdpdvymnzd3dxs6rvd34x56rgvpsxqcqfqnsvd" + private val goldenFull = + "cordn1qgthwumn8ghj7un9d3shjtn90psk6urvv5hxxmmdqgv8wumn8ghj7cnpvd4h2upwv4uxzmtsd3jjucm0d5qjqqfrg4ncn27dauqjx3t83x4ummcpydzk0zdtehhszg69v7y6hn00qqjr2dfsv5urgvps94jnywtz956rzep594snwvfk956rgd3kx56ngdpsxqcrqv7vzv4" + + @Test + fun weEncodeByteForByteWithTheReferenceImplementation() { + assertEquals(goldenGidOnly, CordnGroupRef(gid).encode()) + assertEquals(goldenGidPubKey, CordnGroupRef(gid, pubKey).encode()) + assertEquals(goldenFull, CordnGroupRef(gid, pubKey, relays).encode()) + } + + @Test + fun weDecodeTheReferenceImplementationsOutput() { + assertEquals(CordnGroupRef(gid), CordnGroupRef.decode(goldenGidOnly)) + assertEquals(CordnGroupRef(gid, pubKey), CordnGroupRef.decode(goldenGidPubKey)) + assertEquals(CordnGroupRef(gid, pubKey, relays), CordnGroupRef.decode(goldenFull)) + } + + @Test + fun theGidRoundTripsByteForByte() { + // §4.1: no trimming, no re-encoding. A gid is the coordinator's cursor + // key, so a decoder that tidied one would silently address another + // stream — or none. + val awkward = listOf(" leading", "trailing ", " ", "emoji-👍", "MiXeDcAsE", "a/b?c=d") + awkward.forEach { + assertEquals(it, CordnGroupRef.decode(CordnGroupRef(it).encode()).gid, "gid '$it' must survive the round trip") + } + } + + @Test + fun aRelayWithoutACoordinatorIsInvalid() { + // §5: a relay names where to reach *a coordinator*; with none named it + // has no referent. + assertFailsWith { + CordnGroupRef(gid, coordinatorPubKey = null, relays = listOf("wss://relay.example.com")) + } + } + + @Test + fun aWrongPrefixIsRejectedEvenWhenTheChecksumIsFine() { + // An `nprofile` is a perfectly valid bech32 string. It is not a group + // ref, and the prefix is the only thing that says so. + val notCordn = CordnGroupRef(gid).encode().replaceFirst("cordn1", "nprofile1") + assertFailsWith { CordnGroupRef.decode(notCordn) } + } + + @Test + fun corruptionIsRejectedRatherThanTruncated() { + // quartz's NIP-19 Tlv.parse stops silently at a bad tuple, which is + // right there and wrong here: dropping the tail of this ref turns one + // that names a coordinator into one that reaches for a default. + val valid = CordnGroupRef(gid, pubKey).encode() + val truncated = valid.dropLast(10) + assertNull(CordnGroupRef.decodeOrNull(truncated), "a truncated ref must not decode") + } + + @Test + fun mixedCaseIsRejected() { + val valid = CordnGroupRef(gid).encode() + val mixed = valid.take(valid.length / 2) + valid.drop(valid.length / 2).uppercase() + assertFailsWith { CordnGroupRef.decode(mixed) } + // All-upper is legal bech32 and must still decode. + assertEquals(gid, CordnGroupRef.decode(valid.uppercase()).gid) + } + + @Test + fun anEmptyOrOversizedGidIsRejected() { + assertFailsWith { CordnGroupRef("") } + // The TLV length field is one byte, so 255 is the hard ceiling. + assertTrue(CordnGroupRef("a".repeat(255)).encode().isNotEmpty()) + assertFailsWith { CordnGroupRef("a".repeat(256)) } + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cordn/spec01GroupMetadata/CordnGroupMetadataTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cordn/spec01GroupMetadata/CordnGroupMetadataTest.kt new file mode 100644 index 0000000000..5e3d493112 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cordn/spec01GroupMetadata/CordnGroupMetadataTest.kt @@ -0,0 +1,133 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.spec01GroupMetadata + +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** `spec/01.md` — the `cordn_group_metadata` extension (`0xC04D`). */ +class CordnGroupMetadataTest { + private val alice = "11".repeat(32) + private val bob = "22".repeat(32) + + @Test + fun theLayoutIsUint16LengthsNotMlsVarints() { + // Hand-derived from spec/01.md §3 rather than round-tripped, because a + // round trip agrees with itself no matter which length encoding we + // picked -- and the spec names two incompatible ones (see the codec's + // KDoc). 14 bytes: version, then five length-prefixed fields. + // + // 0001 version = 1 + // 0002 4869 name = "Hi" + // 0000 description + // 0000 admin_pubkeys + // 0000 icon + // 0000 image_url + assertEquals( + "0001000248690000000000000000", + CordnGroupMetadata(name = "Hi").encode().toHexKey(), + ) + } + + @Test + fun adminPubkeysAreConcatenatedRawKeys() { + val encoded = CordnGroupMetadata(name = "", adminPubkeys = listOf(alice, bob)).encode().toHexKey() + // version, empty name, empty description, then 64 bytes of admins. + assertTrue(encoded.startsWith("0001" + "0000" + "0000" + "0040"), "admins field must declare 0x40 = 64 bytes") + assertTrue(encoded.contains(alice + bob), "admins are raw 32-byte keys back to back, in order") + } + + @Test + fun anEmptyAdminListMeansEgalitarianNotBootstrap() { + // spec/01.md §5.3. Marmot reads an empty admin set as "bootstrap, gate + // still open"; cordn reads it as a permanent statement that everyone is + // equal. Same bytes, different meaning -- the reason neither side's + // authorization code can be reused for the other. + assertTrue(CordnGroupMetadata(name = "open").isEgalitarian) + assertTrue(!CordnGroupMetadata(name = "run", adminPubkeys = listOf(alice)).isEgalitarian) + } + + @Test + fun versionZeroIsRejected() { + val v0 = CordnGroupMetadata(name = "Hi").encode().also { it[1] = 0 } + assertFailsWith { CordnGroupMetadata.decode(v0) } + } + + @Test + fun trailingBytesAreRejected() { + // A future version appends fields, so trailing bytes are not harmless + // padding -- they mean this payload was written by something we cannot + // fully read, and §4 says only a version bump may introduce them. + val extra = CordnGroupMetadata(name = "Hi").encode() + byteArrayOf(0) + assertFailsWith { CordnGroupMetadata.decode(extra) } + } + + @Test + fun adminPubkeysNotAMultipleOf32AreRejected() { + // §9. Silently truncating would drop an admin, which is the direction + // that fails open. + val bad = CordnGroupMetadata(name = "", adminPubkeys = listOf(alice)).encode().dropLast(3).toByteArray() + assertFailsWith { CordnGroupMetadata.decode(bad) } + } + + @Test + fun invalidUtf8IsRejectedRatherThanReplaced() { + // §9 says reject. Kotlin's decodeToString() would substitute U+FFFD and + // hand back a group named something nobody chose. + // version=1, name length=2, name = C3 28 (a truncated 2-byte sequence), + // then four empty fields. Structurally valid, so the only thing that can + // reject it is the UTF-8 check. + val broken = byteArrayOf(0, 1, 0, 2) + byteArrayOf(0xC3.toByte(), 0x28) + ByteArray(8) + val error = assertFailsWith { CordnGroupMetadata.decode(broken) } + assertTrue( + error.message?.contains("UTF-8") == true, + "must fail on the encoding, not incidentally on length: got '${error.message}'", + ) + } + + @Test + fun duplicateAdminsAreRejected() { + assertFailsWith { CordnGroupMetadata(name = "", adminPubkeys = listOf(alice, alice)) } + } + + @Test + fun aFullPayloadRoundTrips() { + val full = + CordnGroupMetadata( + name = "Design 🎨", + description = "where the work happens", + adminPubkeys = listOf(alice, bob), + icon = "🎨", + imageUrl = "https://example.com/a.png", + ) + assertEquals(full, CordnGroupMetadata.decode(full.encode())) + } + + @Test + fun aGroupWithNoMetadataExtensionReadsAsNull() { + // §6: omitting it entirely is valid, so this must not throw. + assertNull(CordnGroupMetadata.fromExtensions(emptyList())) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cordn/spec02Envelopes/CordnAnnotationIndexTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cordn/spec02Envelopes/CordnAnnotationIndexTest.kt new file mode 100644 index 0000000000..6dc636a727 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cordn/spec02Envelopes/CordnAnnotationIndexTest.kt @@ -0,0 +1,255 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.spec02Envelopes + +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnMessageReferences.PinOp +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnMessageReferences.Target +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * The authorization rules for annotations, which is what this index really is. + * + * Nothing about "fold reactions onto messages" is interesting. What is + * interesting is who is allowed to change whose message, and those rules differ + * per annotation — author-only for edits and deletions, any-member for pins — + * so each one gets a test that fails loudly if it is loosened. + */ +class CordnAnnotationIndexTest { + private val alice = "aa".repeat(32) + private val bob = "bb".repeat(32) + + private var cursor = 0L + + private fun message( + author: HexKey, + kind: Int, + content: String, + tags: Array> = emptyArray(), + createdAt: Long = 1_757_000_000L, + ): CordnDeliveredMessage = + CordnDeliveredMessage( + envelope = CordnEnvelope.build(author, createdAt, kind, tags, content), + cursor = ++cursor, + ) + + private fun CordnDeliveredMessage.asTarget() = Target(envelope.id, envelope.pubKey, envelope.kind, envelope.tags) + + @Test + fun `reactions gather by emoji and by who sent them`() { + val note = message(alice, CordnMessageKinds.TEXT, "ship it") + val index = + CordnAnnotationIndex.of( + listOf( + note, + message(bob, CordnMessageKinds.REACTION, "+", CordnMessageReferences.reactionTags(note.asTarget())), + message(alice, CordnMessageKinds.REACTION, "+", CordnMessageReferences.reactionTags(note.asTarget())), + message(bob, CordnMessageKinds.REACTION, "🎉", CordnMessageReferences.reactionTags(note.asTarget())), + ), + ) + + assertEquals(setOf(alice, bob), index.reactions[note.envelope.id]?.get("+")) + assertEquals(setOf(bob), index.reactions[note.envelope.id]?.get("🎉")) + } + + @Test + fun `only the author may edit and the newest edit wins`() { + val note = message(alice, CordnMessageKinds.TEXT, "original", createdAt = 100) + val tags = CordnMessageReferences.editTags(note.asTarget()) + + val index = + CordnAnnotationIndex.of( + listOf( + note, + message(bob, CordnMessageKinds.EDIT, "bob rewrote this", tags, createdAt = 200), + message(alice, CordnMessageKinds.EDIT, "first fix", tags, createdAt = 300), + message(alice, CordnMessageKinds.EDIT, "final fix", tags, createdAt = 400), + ), + ) + + assertEquals("final fix", index.contentOf(note.envelope.id)) + assertTrue(index.isEdited(note.envelope.id)) + } + + @Test + fun `an edit by someone else changes nothing at all`() { + // Stated separately because it is the whole point: without the author + // check, anyone in the group can rewrite anyone's words. + val note = message(alice, CordnMessageKinds.TEXT, "original") + val index = + CordnAnnotationIndex.of( + listOf( + note, + message( + bob, + CordnMessageKinds.EDIT, + "bob rewrote this", + CordnMessageReferences.editTags(note.asTarget()), + ), + ), + ) + + assertEquals("original", index.contentOf(note.envelope.id)) + assertTrue(!index.isEdited(note.envelope.id)) + } + + @Test + fun `only the author may delete`() { + val mine = message(alice, CordnMessageKinds.TEXT, "mine") + val theirs = message(bob, CordnMessageKinds.TEXT, "theirs") + + val index = + CordnAnnotationIndex.of( + listOf( + mine, + theirs, + message(alice, CordnMessageKinds.DELETION, "", CordnMessageReferences.deleteTags(mine.asTarget())), + message(alice, CordnMessageKinds.DELETION, "", CordnMessageReferences.deleteTags(theirs.asTarget())), + ), + ) + + assertTrue(index.isDeleted(mine.envelope.id)) + assertTrue(!index.isDeleted(theirs.envelope.id), "alice must not be able to delete bob's message") + } + + @Test + fun `a deletion whose k disagrees with the target is not a deletion`() { + val note = message(alice, CordnMessageKinds.TEXT, "mine") + val wrongKind = + arrayOf( + arrayOf("e", note.envelope.id, "", alice), + arrayOf("k", CordnMessageKinds.THREAD_REPLY.toString()), + ) + + val index = CordnAnnotationIndex.of(listOf(note, message(alice, CordnMessageKinds.DELETION, "", wrongKind))) + + assertTrue(!index.isDeleted(note.envelope.id), "a mismatched k is a mismatch, not a typo to forgive") + } + + @Test + fun `a deleted message stops accepting edits`() { + // Order is load-bearing in the fold: deletions are resolved before + // edits so a late edit cannot resurrect withdrawn text. + val note = message(alice, CordnMessageKinds.TEXT, "regrettable", createdAt = 100) + val index = + CordnAnnotationIndex.of( + listOf( + note, + message( + alice, + CordnMessageKinds.DELETION, + "", + CordnMessageReferences.deleteTags(note.asTarget()), + createdAt = 200, + ), + message( + alice, + CordnMessageKinds.EDIT, + "back again", + CordnMessageReferences.editTags(note.asTarget()), + createdAt = 300, + ), + ), + ) + + assertTrue(index.isDeleted(note.envelope.id)) + assertTrue(!index.isEdited(note.envelope.id), "an edit must not undo a deletion") + } + + @Test + fun `any member may pin and the last write wins`() { + val note = message(alice, CordnMessageKinds.TEXT, "important", createdAt = 100) + val t = note.asTarget() + + val pinnedByBob = + CordnAnnotationIndex.of( + listOf(note, message(bob, CordnMessageKinds.PIN, "", CordnMessageReferences.pinTags(t, PinOp.ADD), 200)), + ) + assertTrue(pinnedByBob.isPinned(note.envelope.id), "pinning is any-member, unlike editing") + assertEquals(bob, pinnedByBob.pins[note.envelope.id]?.pinnedBy) + + val thenUnpinnedByAlice = + CordnAnnotationIndex.of( + listOf( + note, + message(bob, CordnMessageKinds.PIN, "", CordnMessageReferences.pinTags(t, PinOp.ADD), 200), + message(alice, CordnMessageKinds.PIN, "", CordnMessageReferences.pinTags(t, PinOp.REMOVE), 300), + ), + ) + assertTrue(!thenUnpinnedByAlice.isPinned(note.envelope.id), "the newest op wins") + } + + @Test + fun `same-second pins break the tie on the cursor`() { + // Two clients acting in the same second must agree on the outcome. The + // coordinator's cursor is the only total order both can see. + val note = message(alice, CordnMessageKinds.TEXT, "important", createdAt = 100) + val t = note.asTarget() + + val index = + CordnAnnotationIndex.of( + listOf( + note, + message(bob, CordnMessageKinds.PIN, "", CordnMessageReferences.pinTags(t, PinOp.ADD), 200), + message(alice, CordnMessageKinds.PIN, "", CordnMessageReferences.pinTags(t, PinOp.REMOVE), 200), + ), + ) + + assertTrue(!index.isPinned(note.envelope.id), "the later cursor decides when the timestamps match") + } + + @Test + fun `pinned messages come back newest first`() { + val first = message(alice, CordnMessageKinds.TEXT, "one", createdAt = 100) + val second = message(alice, CordnMessageKinds.TEXT, "two", createdAt = 110) + + val index = + CordnAnnotationIndex.of( + listOf( + first, + second, + message(alice, CordnMessageKinds.PIN, "", CordnMessageReferences.pinTags(first.asTarget(), PinOp.ADD), 200), + message(bob, CordnMessageKinds.PIN, "", CordnMessageReferences.pinTags(second.asTarget(), PinOp.ADD), 300), + ), + ) + + assertEquals(listOf(second.envelope.id, first.envelope.id), index.pinnedIds()) + } + + @Test + fun `an annotation pointing at nothing is ignored`() { + // Real case: catch-up starts after the target, so the annotation + // arrives without it. Dropping it beats rendering an orphan row. + val absent = Target("f".repeat(64), alice, CordnMessageKinds.TEXT) + val index = + CordnAnnotationIndex.of( + listOf( + message(alice, CordnMessageKinds.EDIT, "x", CordnMessageReferences.editTags(absent)), + message(alice, CordnMessageKinds.DELETION, "", CordnMessageReferences.deleteTags(absent)), + ), + ) + + assertTrue(index.edits.isEmpty()) + assertTrue(index.deleted.isEmpty()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cordn/spec02Envelopes/CordnEnvelopeTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cordn/spec02Envelopes/CordnEnvelopeTest.kt new file mode 100644 index 0000000000..ff3e2137af --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cordn/spec02Envelopes/CordnEnvelopeTest.kt @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.spec02Envelopes + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** `spec/02.md` — the Nostr-shaped, unsigned application envelope. */ +class CordnEnvelopeTest { + private val alice = "11".repeat(32) + private val bob = "22".repeat(32) + + private fun chat( + pubKey: String = alice, + content: String = "hello", + ) = CordnEnvelope.build(pubKey = pubKey, createdAt = 1_700_000_000L, kind = 9, content = content) + + @Test + fun theIdIsNip01OverTheUnsignedFields() { + val envelope = chat() + assertEquals(envelope.computedId(), envelope.id) + assertEquals(64, envelope.id.length) + } + + @Test + fun aRewrittenBodyIsRejected() { + // §4: the receiver recomputes. Without that the envelope carries no + // integrity at all -- there is no signature to fall back on. + val tampered = chat().toJson().replace("\"hello\"", "\"goodbye\"") + assertFailsWith { + CordnEnvelope.decode(tampered.encodeToByteArray(), alice) + } + } + + @Test + fun anEnvelopeCannotClaimAnotherMembersPubkey() { + // §5, and the reason decode() demands the MLS sender identity rather + // than offering it. Bob's envelope is internally consistent -- its id + // hashes correctly over bob's pubkey -- so only the cross-check against + // the MLS sender catches it. + val fromBob = chat(pubKey = bob) + assertEquals(fromBob.computedId(), fromBob.id, "the forgery is self-consistent") + + val error = + assertFailsWith { + CordnEnvelope.decode(fromBob.encode(), senderIdentity = alice) + } + assertTrue(error.message?.contains("MLS sender") == true, "got '${error.message}'") + } + + @Test + fun aSigFieldIsRejected() { + // §2 and §8: absence of `sig` is an interop requirement, not a default. + // One carrying a signature came from something following different + // rules, and accepting it would let that signature look meaningful. + val withSig = chat().toJson().dropLast(1) + ",\"sig\":\"00\"}" + assertFailsWith { + CordnEnvelope.decode(withSig.encodeToByteArray(), alice) + } + } + + @Test + fun tagsSurviveTheRoundTrip() { + val reply = + CordnEnvelope.build( + pubKey = alice, + createdAt = 1_700_000_001L, + kind = 1111, + tags = arrayOf(arrayOf("E", "aa".repeat(32)), arrayOf("K", "9")), + content = "agreed", + ) + val decoded = CordnEnvelope.decode(reply.encode(), alice) + assertEquals(reply, decoded) + assertEquals("aa".repeat(32), decoded.tags[0][1]) + } + + @Test + fun theEncodingIsUtf8Json() { + val unicode = chat(content = "こんにちは 👋") + val decoded = CordnEnvelope.decode(unicode.encode(), alice) + assertEquals("こんにちは 👋", decoded.content) + assertEquals(unicode.id, decoded.id, "the id must hash the same over non-ASCII content") + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cordn/spec02Envelopes/CordnMessageKindsTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cordn/spec02Envelopes/CordnMessageKindsTest.kt new file mode 100644 index 0000000000..3acef99dd3 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cordn/spec02Envelopes/CordnMessageKindsTest.kt @@ -0,0 +1,260 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.spec02Envelopes + +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnMessageReferences.PinOp +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnMessageReferences.Target +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The cordn kind catalog and its reference format. + * + * `spec/02.md` §6 names chat, thread-reply and reaction and then says there is + * "no required set of kind values", so edits (1010), deletions (5) and pins + * (1011) are the reference client's conventions rather than protocol. We follow + * them; these tests are what "follow" means concretely, and are the place to + * look when cordn-web changes something. + */ +class CordnMessageKindsTest { + private val alice = "aa".repeat(32) + private val bob = "bb".repeat(32) + + private fun target( + id: String = "1".repeat(64), + pubKey: String = alice, + kind: Int = CordnMessageKinds.TEXT, + tags: TagArray = emptyArray(), + ) = Target(id, pubKey, kind, tags) + + private fun TagArray.value(name: String): String? = firstOrNull { it.isNotEmpty() && it[0] == name }?.getOrNull(1) + + @Test + fun `annotations never render as their own row`() { + listOf(CordnMessageKinds.REACTION, CordnMessageKinds.EDIT, CordnMessageKinds.DELETION, CordnMessageKinds.PIN) + .forEach { assertTrue(CordnMessageKinds.isAnnotation(it), "kind $it modifies a message, it is not one") } + + listOf(CordnMessageKinds.TEXT, CordnMessageKinds.THREAD_REPLY, CordnMessageKinds.SYSTEM) + .forEach { assertTrue(!CordnMessageKinds.isAnnotation(it), "kind $it is a message in its own right") } + + assertTrue(CordnMessageKinds.isSystem(CordnMessageKinds.SYSTEM)) + assertTrue(CordnMessageKinds.SYSTEM < 0, "a system row is derived, never sent — it must not collide with a wire kind") + } + + @Test + fun `the numbers are cordn-web's and differ from Marmot's on purpose`() { + // Pinned because they are the interop surface with the only other cordn + // client, and because two of them disagree with Marmot (1009 edit, no + // pin) by decision rather than accident. + assertEquals(9, CordnMessageKinds.TEXT) + assertEquals(1111, CordnMessageKinds.THREAD_REPLY) + assertEquals(7, CordnMessageKinds.REACTION) + assertEquals(1010, CordnMessageKinds.EDIT) + assertEquals(5, CordnMessageKinds.DELETION) + assertEquals(1011, CordnMessageKinds.PIN) + } + + // ---- outbound ------------------------------------------------------ + + @Test + fun `plain text is kind 9 and trimmed`() { + val out = CordnMessageReferences.outbound(" hello ") + assertEquals(CordnMessageKinds.TEXT, out.kind) + assertEquals("hello", out.content) + assertTrue(out.tags.isEmpty()) + } + + @Test + fun `a reply is kind 1111 and carries NIP-22 root and parent tags`() { + val out = CordnMessageReferences.outbound("sure", replyTo = target()) + assertEquals(CordnMessageKinds.THREAD_REPLY, out.kind) + // First reply in a thread: the target is its own root. + assertEquals("1".repeat(64), out.tags.value("E")) + assertEquals("1".repeat(64), out.tags.value("e")) + assertEquals(alice, out.tags.value("P")) + assertEquals("9", out.tags.value("K")) + assertEquals("9", out.tags.value("k")) + } + + @Test + fun `replying to a reply keeps the original root`() { + // The case that makes threads flat instead of nested if it is wrong: + // the root must come from the parent's own E/K/P, not from the parent. + val rootId = "9".repeat(64) + val parent = + target( + id = "2".repeat(64), + pubKey = bob, + kind = CordnMessageKinds.THREAD_REPLY, + tags = + arrayOf( + arrayOf("E", rootId, "", alice), + arrayOf("K", "9"), + arrayOf("P", alice), + ), + ) + + val out = CordnMessageReferences.outbound("agreed", replyTo = parent) + + assertEquals(rootId, out.tags.value("E"), "the thread root must survive a nested reply") + assertEquals(alice, out.tags.value("P")) + assertEquals("9", out.tags.value("K")) + assertEquals("2".repeat(64), out.tags.value("e"), "the parent is the message replied to") + assertEquals(bob, out.tags.value("p")) + assertEquals("1111", out.tags.value("k")) + } + + @Test + fun `a reaction keeps its content untrimmed`() { + // The emoji is the payload. Trimming it would change what was sent. + val out = CordnMessageReferences.outbound(" 🤙 ", reactionTo = target()) + assertEquals(CordnMessageKinds.REACTION, out.kind) + assertEquals(" 🤙 ", out.content) + assertEquals("9", out.tags.value("k")) + } + + @Test + fun `an edit is trimmed and keeps extra tags`() { + val out = + CordnMessageReferences.outbound( + " fixed ", + extraTags = arrayOf(arrayOf("imeta", "url https://example.invalid/x")), + editTo = target(), + ) + assertEquals(CordnMessageKinds.EDIT, out.kind) + assertEquals("fixed", out.content) + assertEquals("url https://example.invalid/x", out.tags.value("imeta")) + } + + @Test + fun `a deletion carries no content and does not name a recipient`() { + val out = CordnMessageReferences.outbound("ignored", deleteTo = target()) + assertEquals(CordnMessageKinds.DELETION, out.kind) + assertEquals("", out.content) + assertNull(out.tags.value("p"), "a deletion names what is removed, not who to notify") + assertEquals("9", out.tags.value("k")) + } + + @Test + fun `a pin carries an op tag and defaults to add`() { + val add = CordnMessageReferences.outbound("", pinTo = target()) + assertEquals(CordnMessageKinds.PIN, add.kind) + assertEquals("add", add.tags.value("op")) + + val remove = CordnMessageReferences.outbound("", pinTo = target(), pinOp = PinOp.REMOVE) + assertEquals("remove", remove.tags.value("op")) + } + + // ---- inbound ------------------------------------------------------- + + @Test + fun `a reaction needs kind 7 with e p k and content`() { + val good = CordnMessageReferences.reactionTags(target()) + assertNotNull(CordnMessageReferences.reaction(7, "+", good)) + + assertNull(CordnMessageReferences.reaction(9, "+", good), "wrong kind") + assertNull(CordnMessageReferences.reaction(7, " ", good), "a blank reaction is an invisible chip") + assertNull(CordnMessageReferences.reaction(7, "+", arrayOf(arrayOf("e", "1".repeat(64)))), "no p or k") + assertNull( + CordnMessageReferences.reaction(7, "+", arrayOf(arrayOf("e", "x"), arrayOf("p", alice), arrayOf("k", "nine"))), + "a non-numeric k is not a kind", + ) + } + + @Test + fun `a thread needs both the uppercase root and the lowercase parent`() { + val full = CordnMessageReferences.replyTags(target()) + assertNotNull(CordnMessageReferences.thread(full)) + + // Half a thread tag is treated as no thread: guessing reparents a reply + // under the wrong message, which is worse than showing it unthreaded. + assertNull(CordnMessageReferences.thread(full.filterNot { it[0] == "E" }.toTypedArray())) + assertNull(CordnMessageReferences.thread(full.filterNot { it[0] == "k" }.toTypedArray())) + assertNull(CordnMessageReferences.thread(emptyArray())) + } + + @Test + fun `a thread pubkey falls back to the e-tag's fourth element`() { + val tags = + arrayOf( + arrayOf("E", "9".repeat(64), "", alice), + arrayOf("K", "9"), + arrayOf("e", "2".repeat(64), "", bob), + arrayOf("k", "9"), + ) + val thread = assertNotNull(CordnMessageReferences.thread(tags)) + assertEquals(alice, thread.rootPubKey) + assertEquals(bob, thread.parentPubKey) + } + + @Test + fun `an edit needs kind 1010 with an e tag and text`() { + val tags = CordnMessageReferences.editTags(target()) + assertEquals("1".repeat(64), assertNotNull(CordnMessageReferences.edit(1010, "new", tags)).targetId) + + assertNull(CordnMessageReferences.edit(1009, "new", tags), "1009 is Marmot's edit kind, not cordn's") + assertNull(CordnMessageReferences.edit(1010, " ", tags), "an edit to nothing is a deletion") + assertNull(CordnMessageReferences.edit(1010, "new", emptyArray())) + } + + @Test + fun `a deletion needs kind 5 with e and a numeric k`() { + val tags = CordnMessageReferences.deleteTags(target()) + val ref = assertNotNull(CordnMessageReferences.delete(5, tags)) + assertEquals(CordnMessageKinds.TEXT, ref.targetKind) + + assertNull(CordnMessageReferences.delete(9, tags)) + assertNull(CordnMessageReferences.delete(5, arrayOf(arrayOf("e", "1".repeat(64)))), "no k") + } + + @Test + fun `a pin needs kind 1011 with an e tag and a known op`() { + assertEquals( + PinOp.ADD, + assertNotNull(CordnMessageReferences.pin(1011, CordnMessageReferences.pinTags(target(), PinOp.ADD))).op, + ) + assertEquals( + PinOp.REMOVE, + assertNotNull(CordnMessageReferences.pin(1011, CordnMessageReferences.pinTags(target(), PinOp.REMOVE))).op, + ) + + assertNull( + CordnMessageReferences.pin(1011, arrayOf(arrayOf("e", "1".repeat(64)), arrayOf("op", "toggle"))), + "an unknown op is neither a pin nor an unpin", + ) + assertNull(CordnMessageReferences.pin(1011, arrayOf(arrayOf("e", "1".repeat(64)))), "no op") + } + + @Test + fun `every builder produces tags its own parser accepts`() { + // The failure this guards is a client that emits what it cannot read. + val t = target() + assertNotNull(CordnMessageReferences.thread(CordnMessageReferences.replyTags(t))) + assertNotNull(CordnMessageReferences.reaction(7, "+", CordnMessageReferences.reactionTags(t))) + assertNotNull(CordnMessageReferences.edit(1010, "x", CordnMessageReferences.editTags(t))) + assertNotNull(CordnMessageReferences.delete(5, CordnMessageReferences.deleteTags(t))) + assertNotNull(CordnMessageReferences.pin(1011, CordnMessageReferences.pinTags(t, PinOp.ADD))) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cordn/sync/GroupInboxTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cordn/sync/GroupInboxTest.kt new file mode 100644 index 0000000000..ee3b01e99f --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cordn/sync/GroupInboxTest.kt @@ -0,0 +1,152 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.sync + +import com.vitorpamplona.quartz.cordn.spec00Coordinator.GroupMessage +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +/** + * The ingestion rules, which are the part of cordn where a mistake is silent. + * + * Every wrong answer here produces a client that works until it doesn't: an + * epoch applied twice desynchronizes MLS several commits later, and a cursor + * that refuses to advance stalls a group forever with no error anywhere. + */ +class GroupInboxTest { + private var cursor = 0L + + private fun msg( + sealed: String, + gid: String = "g1", + ) = GroupMessage(gid = gid, cursor = ++cursor, sealedBase64 = sealed, at = 1_700_000_000L + cursor) + + @Test + fun ourOwnCommitComesBackAsConfirmationNotAsWork() { + // The failure this prevents: feeding our own Commit through MLS again + // advances the epoch twice, and nothing complains until messages stop + // decrypting several epochs later. + val inbox = GroupInbox() + val commit = msg("Y29tbWl0") + inbox.expectEcho(PendingEpochOperation(commit.sealedBase64, localStateApplied = true)) + + assertIs(inbox.accept(commit)) + } + + @Test + fun anUnappliedCommitEchoIsWorkNotConfirmation() { + // Crash recovery: we posted, the coordinator took it, we died before + // adopting the new epoch. That echo is the only copy of the Commit we + // will ever be handed. + val inbox = GroupInbox() + val commit = msg("Y29tbWl0") + inbox.expectEcho(PendingEpochOperation(commit.sealedBase64, localStateApplied = false)) + + assertIs(inbox.accept(commit)) + } + + @Test + fun echoesAreMatchedOnCiphertextNotOnCursor() { + // spec/03.md §4 requires a fresh nonce per payload, so the sealed form + // is unique to one posting. Matching on cursor would break the moment a + // coordinator renumbered; matching on plaintext would mean decrypting + // our own traffic to recognise it. + val inbox = GroupInbox() + val mine = msg("bWluZQ==") + inbox.expectEcho(PendingEpochOperation(mine.sealedBase64)) + + // Same cursor space, different bytes: somebody else's Commit. + assertIs(inbox.accept(msg("dGhlaXJz"))) + // Ours, whenever it shows up. + assertIs(inbox.accept(mine.copy(cursor = ++cursor))) + } + + @Test + fun anEchoIsConsumedOnceSoAReplayIsStillProcessed() { + // A coordinator that served the same record twice must not be able to + // make us skip a genuine Commit that happens to repeat bytes. + val inbox = GroupInbox() + val commit = msg("Y29tbWl0") + inbox.expectEcho(PendingEpochOperation(commit.sealedBase64)) + + assertIs(inbox.accept(commit)) + assertIs(inbox.accept(commit.copy(cursor = ++cursor))) + } + + @Test + fun theCursorAdvancesOnEveryOutcome() { + // Including the ones that do no work. A cursor that only moved for + // messages we processed would re-fetch every skipped one forever. + val inbox = GroupInbox() + val mine = msg("bWluZQ==") + inbox.expectEcho(PendingEpochOperation(mine.sealedBase64)) + inbox.accept(mine) + assertEquals(mine.cursor, inbox.cursor.fetchCursor) + + val own = msg("b3du") + inbox.recordOwnMessage(own.cursor) + inbox.accept(own) + assertEquals(own.cursor, inbox.cursor.fetchCursor) + + val theirs = msg("dGhlaXJz") + inbox.accept(theirs) + assertEquals(theirs.cursor, inbox.cursor.fetchCursor) + } + + @Test + fun anUndecryptableMessageStillAdvancesTheCursor() { + // A message sealed under an epoch we never had is unreadable forever. + // Refusing to move past it stalls the group behind it permanently. + val inbox = GroupInbox() + val stale = msg("dW5yZWFkYWJsZQ==") + assertIs(inbox.accept(stale)) + inbox.skipUnprocessable(stale.cursor) + assertEquals(stale.cursor, inbox.cursor.fetchCursor) + } + + @Test + fun lastCursorNeverGoesBackwards() { + val inbox = GroupInbox() + inbox.accept(msg("YQ==").copy(cursor = 10)) + inbox.accept(msg("Yg==").copy(cursor = 4)) + assertEquals(10L, inbox.cursor.lastCursor, "lastCursor is a high-water mark") + assertEquals(4L, inbox.cursor.fetchCursor, "fetchCursor follows delivery order") + } + + @Test + fun aFirstFetchSendsNoCursorAtAll() { + // The coordinator's schema types `after` as a POSITIVE int, so 0 is not + // "from the beginning" -- it is out of range. + assertEquals(null, GroupCursor().afterOrNull()) + assertEquals(7L, GroupCursor(fetchCursor = 7).afterOrNull()) + } + + @Test + fun ourOwnApplicationMessagesAreRecognisedByCursor() { + val inbox = GroupInbox() + val sent = msg("aGVsbG8=") + inbox.recordOwnMessage(sent.cursor) + assertIs(inbox.accept(sent)) + assertTrue(inbox.pending().isEmpty()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/AuditRegressionTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/AuditRegressionTest.kt index ab0579b6df..08b44cca86 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/AuditRegressionTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/AuditRegressionTest.kt @@ -36,6 +36,7 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertIs +import kotlin.test.assertNotEquals /** Regressions found in the audit: each failed before its fix. */ class AuditRegressionTest { @@ -111,6 +112,111 @@ class AuditRegressionTest { assertFalse(name in node.linkedAddressIds()) } + // The `d` must separate two addressable targets. kind 39999 is addressable, so a colliding + // `d` means the second tagging silently replaces the first. + @Test + fun twoAddressableTargetsByTheSameAuthorGetDifferentDTags() { + val first = TaggingTarget.ByAddress(Address(39999, bob, "good-tag")) + val second = TaggingTarget.ByAddress(Address(39999, bob, "other-tag")) + + assertNotEquals( + EventTagging.dTag("awesome-tag", first, alice), + EventTagging.dTag("awesome-tag", second, alice), + ) + } + + // Same idea across the other two coordinate segments: only one of the three may differ. + @Test + fun addressableTargetsDifferingOnlyByKindGetDifferentDTags() { + assertNotEquals( + EventTagging.dTag("awesome-tag", TaggingTarget.ByAddress(Address(39999, bob, "tag")), alice), + EventTagging.dTag("awesome-tag", TaggingTarget.ByAddress(Address(30023, bob, "tag")), alice), + ) + } + + @Test + fun addressableTargetsDifferingOnlyByAuthorGetDifferentDTags() { + assertNotEquals( + EventTagging.dTag("awesome-tag", TaggingTarget.ByAddress(Address(39999, bob, "tag")), alice), + EventTagging.dTag("awesome-tag", TaggingTarget.ByAddress(Address(39999, other, "tag")), alice), + ) + } + + // ...while the `d` stays deterministic, which is what lets a re-tag replace its own assertion + // instead of piling up a second one. `event-taggings.md` § "The assertion d-tag (normative)": + // `--`, only hash8 unique. The hashes are SHA-256 prefixes of the full + // coordinate computed outside this codebase, so the test cross-checks the derivation rather + // than restating it: a change of hash input would silently orphan every assertion signed. + @Test + fun theAddressablePrefixIsAuthorThenDTagThenTheHashOfTheWholeCoordinate() { + assertEquals( + "event-tag-awesome-tag-bbbbbbbb-good-tag-6a5e1c40-aaaaaaaa", + EventTagging.dTag("awesome-tag", TaggingTarget.ByAddress(Address(39999, bob, "good-tag")), alice), + ) + assertEquals( + "event-tag-awesome-tag-bbbbbbbb-other-tag-e89b797c-aaaaaaaa", + EventTagging.dTag("awesome-tag", TaggingTarget.ByAddress(Address(39999, bob, "other-tag")), alice), + ) + // same author8 AND same d16 as the first; only the kind differs, so only hash8 separates them + assertEquals( + "event-tag-awesome-tag-bbbbbbbb-good-tag-96856fd8-aaaaaaaa", + EventTagging.dTag("awesome-tag", TaggingTarget.ByAddress(Address(30023, bob, "good-tag")), alice), + ) + } + + // The draft spells this case out: an empty `d` yields an empty `d16`, hence the double hyphen. + @Test + fun anEmptyDTagSegmentLeavesADoubleHyphen() { + assertEquals( + "event-tag-awesome-tag-bbbbbbbb--76600e1a-aaaaaaaa", + EventTagging.dTag("awesome-tag", TaggingTarget.ByAddress(Address(39999, bob, "")), alice), + ) + } + + // d16 is the first 16 UTF-16 code units, verbatim and truncated — decoration, not identity. + @Test + fun aLongDTagIsTruncatedToSixteenCharactersInTheDecoration() { + val d = "a-very-long-d-tag-that-exceeds-sixteen" + assertEquals( + "event-tag-awesome-tag-bbbbbbbb-a-very-long-d-ta-58899d62-aaaaaaaa", + EventTagging.dTag("awesome-tag", TaggingTarget.ByAddress(Address(39999, bob, d)), alice), + ) + } + + // Truncating at 16 UTF-16 code units can cut an astral character in half. The id is hashed + // from the intact char while the wire bytes get `?` in its place, so the relay re-hashes + // different bytes and rejects the event. The half character is dropped instead. + @Test + fun aDTagCutMidAstralCharacterDoesNotLeaveHalfACharacterInTheD() { + // 15 ASCII then an emoji: the 16th code unit is the high surrogate of the pair. + val dTag = "a".repeat(15) + "\uD83D\uDE00" + "tail" + val d = EventTagging.dTag("awesome-tag", TaggingTarget.ByAddress(Address(39999, bob, dTag)), alice) + + assertFalse(d.any { it.isHighSurrogate() || it.isLowSurrogate() }, "the `d` still carries half a character: $d") + // 15 units of decoration, not 16, and hash8 is over the untruncated coordinate. + assertEquals("event-tag-awesome-tag-bbbbbbbb-${"a".repeat(15)}-15c09d87-aaaaaaaa", d) + } + + // The property the above protects: what is hashed for the id must survive UTF-8 encoding, + // or the relay recomputes a different id. Pins the bytes, not just the string. + @Test + fun theDSurvivesAUtf8RoundTripByteForByte() { + val dTag = "a".repeat(15) + "\uD83D\uDE00" + "tail" + val d = EventTagging.dTag("awesome-tag", TaggingTarget.ByAddress(Address(39999, bob, dTag)), alice) + + assertEquals(d, d.encodeToByteArray().decodeToString(), "the `d` does not survive UTF-8 encoding") + } + + // A plain event is still named by its own id: it already covers the whole event, and hashing + // it would only cost a round of SHA-256 per assertion built. The `e` branch is unchanged. + @Test + fun plainEventTargetsStillUseTheirOwnId() { + assertEquals( + "event-tag-awesome-tag-11111111-aaaaaaaa", + EventTagging.dTag("awesome-tag", TaggingTarget.ByEventId("1".repeat(64)), alice), + ) + } + // Authored JSON: an explicit null in a list field must not make the whole section unreadable. @Test fun explicitNullListsInTheWordSectionAreTolerated() { diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/TapestryExtensionsTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/TapestryExtensionsTest.kt index 8e4b8a72a6..f244c1da1d 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/TapestryExtensionsTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/TapestryExtensionsTest.kt @@ -247,7 +247,10 @@ class TapestryExtensionsTest { assertEquals( listOf( - listOf("d", "event-tag-awesome-tag-cccccccc-aaaaaaaa"), + // --: cccccccc is the assistant, good-tag the target's own + // `d`, and 4d7a80b1 = sha256("39999:${"c".repeat(64)}:good-tag").take(8), the one + // segment that makes two of the assistant's tags land on different addresses. + listOf("d", "event-tag-awesome-tag-cccccccc-good-tag-4d7a80b1-aaaaaaaa"), listOf("z", "39998:$ta:nostr-event-tag"), listOf("z", "39999:$bob:tagging:awesome-tag-tagging"), listOf("a", "39999:$assistant:good-tag"), diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/graperank/GrapeRankPublisherTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/graperank/GrapeRankPublisherTest.kt index a5ef8d76eb..241e13ed53 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/graperank/GrapeRankPublisherTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/graperank/GrapeRankPublisherTest.kt @@ -27,8 +27,8 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip01Core.store.IEventStore import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore -import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent -import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent +import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent +import com.vitorpamplona.quartz.nip85TrustedAssertions.users.UserAssertionEvent import kotlinx.coroutines.runBlocking import kotlin.test.Test import kotlin.test.assertEquals @@ -48,8 +48,8 @@ class GrapeRankPublisherTest { provider: String, ): Map = store - .query(Filter(kinds = listOf(ContactCardEvent.KIND), authors = listOf(provider))) - .filterIsInstance() + .query(Filter(kinds = listOf(UserAssertionEvent.KIND), authors = listOf(provider))) + .filterIsInstance() .associate { it.aboutUser() to it.rank() } @Test @@ -72,7 +72,7 @@ class GrapeRankPublisherTest { assertEquals(mapOf(a to 50, b to 10), cardsByTarget(store, provider)) val firstIds = store - .query(Filter(kinds = listOf(ContactCardEvent.KIND), authors = listOf(provider))) + .query(Filter(kinds = listOf(UserAssertionEvent.KIND), authors = listOf(provider))) .map { it.id } .toSet() @@ -83,7 +83,7 @@ class GrapeRankPublisherTest { assertEquals(0, r2.retracted) val secondIds = store - .query(Filter(kinds = listOf(ContactCardEvent.KIND), authors = listOf(provider))) + .query(Filter(kinds = listOf(UserAssertionEvent.KIND), authors = listOf(provider))) .map { it.id } .toSet() assertEquals(firstIds, secondIds) @@ -98,7 +98,7 @@ class GrapeRankPublisherTest { assertEquals(mapOf(a to 60, c to 5), cardsByTarget(store, provider)) // …but its kind:5 tombstone remains, ready to sync to relays. - val deletions = store.query(Filter(kinds = listOf(DeletionEvent.KIND), authors = listOf(provider))) + val deletions = store.query(Filter(kinds = listOf(DeletionRequestEvent.KIND), authors = listOf(provider))) assertEquals(1, deletions.size) store.close() @@ -137,10 +137,10 @@ class GrapeRankPublisherTest { val a = hexKey(0xA) - suspend fun cardFor(target: String): ContactCardEvent = + suspend fun cardFor(target: String): UserAssertionEvent = store - .query(Filter(kinds = listOf(ContactCardEvent.KIND), authors = listOf(provider))) - .filterIsInstance() + .query(Filter(kinds = listOf(UserAssertionEvent.KIND), authors = listOf(provider))) + .filterIsInstance() .first { it.aboutUser() == target } // A card carrying rank + followers + hops persists all three tags. diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/nip82SoftwareApps/SoftwareApplicationEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/nip82SoftwareApps/SoftwareApplicationEventTest.kt index c92f07fb5a..39bd9b45f4 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/nip82SoftwareApps/SoftwareApplicationEventTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/nip82SoftwareApps/SoftwareApplicationEventTest.kt @@ -32,15 +32,16 @@ import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.asset.SoftwareAss import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.asset.apkCertificateHash import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.asset.platform import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.asset.versionCode -import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.SoftwareReleaseEvent -import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.asSoftwareRelease import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.isNip82SoftwareRelease import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.shared.Platform import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtag +import com.vitorpamplona.quartz.nip51Lists.releaseArtifactSet.ReleaseArtifactSetEvent +import com.vitorpamplona.quartz.utils.EventFactory import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertIs import kotlin.test.assertNotNull import kotlin.test.assertTrue @@ -107,7 +108,7 @@ class SoftwareApplicationEventTest { id = "0".repeat(64), pubKey = "1".repeat(64), createdAt = 0, - kind = SoftwareReleaseEvent.KIND, + kind = ReleaseArtifactSetEvent.KIND, tags = arrayOf( arrayOf("d", "com.example.app@1.0.0"), @@ -126,7 +127,7 @@ class SoftwareApplicationEventTest { id = "0".repeat(64), pubKey = "1".repeat(64), createdAt = 0, - kind = SoftwareReleaseEvent.KIND, + kind = ReleaseArtifactSetEvent.KIND, tags = arrayOf(arrayOf("d", "some-uuid"), arrayOf("title", "My Release")), content = "", sig = "", @@ -135,13 +136,12 @@ class SoftwareApplicationEventTest { } @Test - fun asSoftwareRelease_exposesParsedFields() { - val event = - Event( + fun releaseArtifactSet_exposesParsedNip82Fields() { + val release = + ReleaseArtifactSetEvent( id = "0".repeat(64), pubKey = "1".repeat(64), createdAt = 0, - kind = SoftwareReleaseEvent.KIND, tags = arrayOf( arrayOf("d", "com.example.app@1.0.0"), @@ -152,12 +152,84 @@ class SoftwareApplicationEventTest { content = "Initial release", sig = "", ) - val release = event.asSoftwareRelease() assertEquals("com.example.app", release.appId()) assertEquals("1.0.0", release.version()) assertEquals("main", release.channel()) } + @Test + fun eventFactory_parsesNip82ReleaseAsReleaseArtifactSet() { + val event = + EventFactory.create( + id = "0".repeat(64), + pubKey = "1".repeat(64), + createdAt = 0, + kind = ReleaseArtifactSetEvent.KIND, + tags = + arrayOf( + arrayOf("d", "com.example.app@1.0.0"), + arrayOf("i", "com.example.app"), + arrayOf("version", "1.0.0"), + arrayOf("c", "main"), + arrayOf("e", "2".repeat(64)), + ), + content = "Initial release", + sig = "", + ) + val release = assertIs(event) + assertTrue(release.isNip82SoftwareRelease()) + assertEquals("com.example.app", release.appId()) + assertEquals("1.0.0", release.version()) + assertEquals("main", release.channel()) + assertEquals(1, release.assets().size) + assertEquals("Initial release", release.releaseNotes()) + } + + @Test + fun buildSoftwareRelease_roundTripsNip82Fields() { + val template = + ReleaseArtifactSetEvent.buildSoftwareRelease( + appId = "com.example.app", + version = "2.0.0", + channel = "beta", + assets = emptyList(), + releaseNotes = "notes", + ) + assertEquals(ReleaseArtifactSetEvent.KIND, template.kind) + val tagsByName = template.tags.associate { it[0] to it.getOrNull(1) } + assertEquals("com.example.app@2.0.0", tagsByName["d"]) + assertEquals("com.example.app", tagsByName["i"]) + assertEquals("2.0.0", tagsByName["version"]) + assertEquals("beta", tagsByName["c"]) + assertEquals("notes", template.content) + } + + @Test + fun searchIndex_includesReleaseNotesOnlyForNip82() { + val nip82 = + ReleaseArtifactSetEvent( + id = "0".repeat(64), + pubKey = "1".repeat(64), + createdAt = 0, + tags = arrayOf(arrayOf("d", "com.example.app@1.0.0"), arrayOf("i", "com.example.app"), arrayOf("version", "1.0.0")), + content = "Fixed the crash on startup", + sig = "", + ) + assertEquals("Fixed the crash on startup", nip82.indexableContent()) + + // A NIP-51 set can carry encrypted private items in content: never index it. + val nip51 = + ReleaseArtifactSetEvent( + id = "0".repeat(64), + pubKey = "1".repeat(64), + createdAt = 0, + tags = arrayOf(arrayOf("d", "some-uuid"), arrayOf("title", "My Release")), + content = "ciphertext?iv=abc", + sig = "", + ) + assertEquals("My Release", nip51.indexableContent()) + } + @Test fun parses_realWorldAmethystEvent() { // Real NIP-82 event published for Amethyst — used as a regression fixture so renderer diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/nip85TrustedAssertions/AssertionEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/nip85TrustedAssertions/AssertionEventTest.kt index 7eccdaf3dd..bc225b0cb9 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/nip85TrustedAssertions/AssertionEventTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/nip85TrustedAssertions/AssertionEventTest.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.quartz.experimental.nip85TrustedAssertions import com.vitorpamplona.quartz.nip85TrustedAssertions.addressables.AddressableAssertionEvent import com.vitorpamplona.quartz.nip85TrustedAssertions.events.EventAssertionEvent import com.vitorpamplona.quartz.nip85TrustedAssertions.externalIds.ExternalIdAssertionEvent -import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent +import com.vitorpamplona.quartz.nip85TrustedAssertions.users.UserAssertionEvent import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull @@ -36,7 +36,7 @@ class AssertionEventTest { fun parseUserAssertionWithAllTags() { val targetUser = "e88a691e98d9987c964521dff60025f60700378a4879180dcbbb4a5027850411" val event = - ContactCardEvent( + UserAssertionEvent( id = "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234", pubKey = servicePubKey, createdAt = 1700000000, @@ -89,7 +89,7 @@ class AssertionEventTest { @Test fun parseUserAssertionMinimalTags() { val event = - ContactCardEvent( + UserAssertionEvent( id = "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234", pubKey = servicePubKey, createdAt = 1700000000, @@ -228,7 +228,7 @@ class AssertionEventTest { @Test fun eventKindsAreCorrect() { - assertEquals(30382, ContactCardEvent.KIND) + assertEquals(30382, UserAssertionEvent.KIND) assertEquals(30383, EventAssertionEvent.KIND) assertEquals(30384, AddressableAssertionEvent.KIND) assertEquals(30385, ExternalIdAssertionEvent.KIND) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/nip85TrustedAssertions/ContactCardPetNameTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/nip85TrustedAssertions/UserAssertionPetNameTest.kt similarity index 88% rename from quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/nip85TrustedAssertions/ContactCardPetNameTest.kt rename to quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/nip85TrustedAssertions/UserAssertionPetNameTest.kt index 17d1f2d14b..fb330c5182 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/nip85TrustedAssertions/ContactCardPetNameTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/nip85TrustedAssertions/UserAssertionPetNameTest.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.quartz.experimental.nip85TrustedAssertions import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag -import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent +import com.vitorpamplona.quartz.nip85TrustedAssertions.users.UserAssertionEvent import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.PetNameTag import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.SummaryTag import kotlinx.coroutines.test.runTest @@ -32,19 +32,19 @@ import kotlin.test.assertEquals import kotlin.test.assertNull import kotlin.test.assertTrue -class ContactCardPetNameTest { +class UserAssertionPetNameTest { val signer = NostrSignerInternal(KeyPair()) val targetUser = "e88a691e98d9987c964521dff60025f60700378a4879180dcbbb4a5027850411" - private suspend fun ContactCardEvent.privatePetName() = privateTags(signer)?.firstNotNullOfOrNull(PetNameTag::parse) + private suspend fun UserAssertionEvent.privatePetName() = privateTags(signer)?.firstNotNullOfOrNull(PetNameTag::parse) - private suspend fun ContactCardEvent.privateSummary() = privateTags(signer)?.firstNotNullOfOrNull(SummaryTag::parse) + private suspend fun UserAssertionEvent.privateSummary() = privateTags(signer)?.firstNotNullOfOrNull(SummaryTag::parse) @Test fun createKeepsPetNameAndSummaryEncrypted() = runTest { val card = - ContactCardEvent.create( + UserAssertionEvent.create( targetUser = targetUser, petName = "Bob from work", summary = "Met at the conference", @@ -66,7 +66,7 @@ class ContactCardPetNameTest { fun updateReplacesPetNameAndKeepsOtherPrivateTags() = runTest { val card = - ContactCardEvent.create( + UserAssertionEvent.create( targetUser = targetUser, petName = "Bob", summary = "old summary", @@ -77,7 +77,7 @@ class ContactCardPetNameTest { val updated = signer.sign( - ContactCardEvent.updatePetNameAndSummary( + UserAssertionEvent.updatePetNameAndSummary( earlierVersion = card, petName = "Bobby", summary = "new summary", @@ -102,7 +102,7 @@ class ContactCardPetNameTest { fun updateWithNullsClearsBothFields() = runTest { val card = - ContactCardEvent.create( + UserAssertionEvent.create( targetUser = targetUser, petName = "Bob", summary = "summary", @@ -111,7 +111,7 @@ class ContactCardPetNameTest { val cleared = signer.sign( - ContactCardEvent.updatePetNameAndSummary( + UserAssertionEvent.updatePetNameAndSummary( earlierVersion = card, petName = null, summary = null, @@ -130,7 +130,7 @@ class ContactCardPetNameTest { val newEmoji = EmojiUrlTag("soapbox", "https://new.example/soapbox.png") val card = - ContactCardEvent.create( + UserAssertionEvent.create( targetUser = targetUser, petName = "Bob :wave:", emojis = listOf(oldEmoji), @@ -140,7 +140,7 @@ class ContactCardPetNameTest { val updated = signer.sign( - ContactCardEvent.updatePetNameAndSummary( + UserAssertionEvent.updatePetNameAndSummary( earlierVersion = card, petName = "Bob :soapbox:", emojis = listOf(newEmoji), @@ -159,7 +159,7 @@ class ContactCardPetNameTest { runTest { // a card that (incorrectly) carries public petname/summary tags val card = - ContactCardEvent.create( + UserAssertionEvent.create( targetUser = targetUser, signer = signer, publicInitializer = { @@ -171,7 +171,7 @@ class ContactCardPetNameTest { val updated = signer.sign( - ContactCardEvent.updatePetNameAndSummary( + UserAssertionEvent.updatePetNameAndSummary( earlierVersion = card, petName = "private bob", signer = signer, diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotGroupImageTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotGroupImageTest.kt index 6af28d836a..e89867717e 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotGroupImageTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotGroupImageTest.kt @@ -24,7 +24,7 @@ import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupImageCipher import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupImageEncryption import com.vitorpamplona.quartz.marmot.mip01Groups.Mip01ImageCrypto -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.codec.TlsReader import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip44Encryption.crypto.ChaCha20Poly1305 diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/protocolCore/MlsCandidateGraphTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/protocolCore/MlsCandidateGraphTest.kt index 9c5ec40a54..e482fd29d1 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/protocolCore/MlsCandidateGraphTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/protocolCore/MlsCandidateGraphTest.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.quartz.marmot.protocolCore -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupState +import com.vitorpamplona.quartz.mls.group.MlsGroup +import com.vitorpamplona.quartz.mls.group.MlsGroupState import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.utils.sha256.sha256 import kotlin.test.Test diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/protocolCore/MlsCandidateStateEngineAuthorizationTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/protocolCore/MlsCandidateStateEngineAuthorizationTest.kt new file mode 100644 index 0000000000..af4443fa60 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/protocolCore/MlsCandidateStateEngineAuthorizationTest.kt @@ -0,0 +1,129 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.marmot.protocolCore + +import com.vitorpamplona.quartz.marmot.groups.MarmotGroupPolicy +import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData +import com.vitorpamplona.quartz.mls.group.MlsGroup +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * The convergence engine's authorization gate, over real MLS groups. + * + * `MlsCandidateGraphTest` proves the graph algebra; this proves the one thing + * that decides whether a fork is *allowed* rather than merely well formed. + * + * It exists because the gate was silently off. MIP-03's rules used to live + * inside `MlsGroup` and ran on every path by construction; moving them behind + * `MlsGroupPolicy` made them something a caller has to ask for, and + * `MlsGroup.restore` defaults to `Permissive`, whose `authorizeCommit` does + * nothing. The engine restored without a policy, so `isAuthorized` returned + * true for every commit it could parse — a non-admin could remove an admin and + * convergence would accept the branch. + * + * Nothing failed while it was broken: the gate only says *no* to commits no + * honest client sends, so every existing test passed either way. That is the + * shape of bug this file is here to catch. + */ +class MlsCandidateStateEngineAuthorizationTest { + private val engine = MlsCandidateStateEngine() + + private fun account(seed: Byte) = ByteArray(32) { seed } + + /** + * A group whose only admin is Alice, with Bob as an ordinary member. + * + * Built through `MarmotGroupPolicy` rather than the default so the group + * is the one Marmot actually runs; a group created permissively would not + * carry the rules the gate reads. + */ + private fun adminGroup(): Pair { + val aliceId = account(0x0a) + val groupData = + MarmotGroupData( + nostrGroupId = ByteArray(32) { 0x11 }.toHexKey(), + name = "Admins", + adminPubkeys = listOf(aliceId.toHexKey()), + ) + + val alice = + MlsGroup.create( + identity = aliceId, + initialExtensions = listOf(groupData.toExtension()), + policy = MarmotGroupPolicy, + ) + val bobBundle = alice.createKeyPackage(identity = account(0x0b), signingKey = ByteArray(32) { 1 }) + val addBob = alice.addMember(bobBundle.keyPackage.toTlsBytes()) + + // Bob is restored WITHOUT Marmot's policy on purpose. The local gate + // refuses to author an unauthorized commit, which is correct and is + // why this fixture cannot use it: convergence exists to judge commits + // that arrived from somebody else's client, and a hostile or merely + // non-compliant one does not censor itself. A permissive Bob is the + // honest stand-in for that peer. + val bob = MlsGroup.processWelcome(addBob.welcomeBytes!!, bobBundle) + + return alice to bob + } + + @Test + fun `the group really does name one admin`() { + // Guards the fixture, not the engine. If the admin set came back empty + // the test below would pass through isAuthorized's bootstrap + // short-circuit and prove nothing at all. + val (alice, _) = adminGroup() + assertTrue(MarmotGroupPolicy.adminIdentitiesIn(alice.extensions).isNotEmpty(), "fixture names no admin") + } + + @Test + fun `a non-admin may not commit the removal of an admin`() { + val (alice, bob) = adminGroup() + val parent = alice.saveState() + + // Bob is an ordinary member. Removing Alice is exactly the change + // MIP-03 reserves to admins, and the admin-depletion guard refuses it + // besides — she is the only one. + val bobRemovesAlice = bob.removeMember(targetLeafIndex = 0) + + assertFalse( + engine.isAuthorized(parent, bobRemovesAlice.framedCommitBytes), + "a non-admin removing the only admin must not be authorized", + ) + } + + @Test + fun `an admin may commit the same kind of change`() { + // The other half: a gate that says no to everything would also satisfy + // the test above, so prove it still says yes where it should. Alice + // authors through her own (Marmot) policy, which is the real path. + val (alice, _) = adminGroup() + val parent = alice.saveState() + val aliceRemovesBob = MlsGroup.restore(parent, MarmotGroupPolicy).removeMember(targetLeafIndex = 1) + + assertTrue( + engine.isAuthorized(parent, aliceRemovesBob.framedCommitBytes), + "the admin's own removal of an ordinary member must stay authorized", + ) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/BinaryTreeTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/BinaryTreeTest.kt similarity index 99% rename from quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/BinaryTreeTest.kt rename to quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/BinaryTreeTest.kt index 95923a6c3f..3209318fa3 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/BinaryTreeTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/BinaryTreeTest.kt @@ -18,9 +18,9 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls +package com.vitorpamplona.quartz.mls -import com.vitorpamplona.quartz.marmot.mls.tree.BinaryTree +import com.vitorpamplona.quartz.mls.tree.BinaryTree import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFails diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/KeyScheduleTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/KeyScheduleTest.kt similarity index 97% rename from quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/KeyScheduleTest.kt rename to quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/KeyScheduleTest.kt index a74c41b7a2..92ab553f5d 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/KeyScheduleTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/KeyScheduleTest.kt @@ -18,11 +18,11 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls +package com.vitorpamplona.quartz.mls -import com.vitorpamplona.quartz.marmot.mls.crypto.MlsCryptoProvider -import com.vitorpamplona.quartz.marmot.mls.messages.GroupContext -import com.vitorpamplona.quartz.marmot.mls.schedule.KeySchedule +import com.vitorpamplona.quartz.mls.crypto.MlsCryptoProvider +import com.vitorpamplona.quartz.mls.messages.GroupContext +import com.vitorpamplona.quartz.mls.schedule.KeySchedule import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsTypesTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/MlsTypesTest.kt similarity index 88% rename from quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsTypesTest.kt rename to quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/MlsTypesTest.kt index 8d4a7c864e..f1c712a91a 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsTypesTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/MlsTypesTest.kt @@ -18,24 +18,24 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls +package com.vitorpamplona.quartz.mls -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.framing.ContentType -import com.vitorpamplona.quartz.marmot.mls.framing.MlsMessage -import com.vitorpamplona.quartz.marmot.mls.framing.PrivateMessage -import com.vitorpamplona.quartz.marmot.mls.framing.WireFormat -import com.vitorpamplona.quartz.marmot.mls.messages.Commit -import com.vitorpamplona.quartz.marmot.mls.messages.GroupContext -import com.vitorpamplona.quartz.marmot.mls.messages.Proposal -import com.vitorpamplona.quartz.marmot.mls.messages.ProposalOrRef -import com.vitorpamplona.quartz.marmot.mls.tree.Capabilities -import com.vitorpamplona.quartz.marmot.mls.tree.Credential -import com.vitorpamplona.quartz.marmot.mls.tree.Extension -import com.vitorpamplona.quartz.marmot.mls.tree.LeafNode -import com.vitorpamplona.quartz.marmot.mls.tree.LeafNodeSource -import com.vitorpamplona.quartz.marmot.mls.tree.Lifetime -import com.vitorpamplona.quartz.marmot.mls.tree.ParentNode +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.framing.ContentType +import com.vitorpamplona.quartz.mls.framing.MlsMessage +import com.vitorpamplona.quartz.mls.framing.PrivateMessage +import com.vitorpamplona.quartz.mls.framing.WireFormat +import com.vitorpamplona.quartz.mls.messages.Commit +import com.vitorpamplona.quartz.mls.messages.GroupContext +import com.vitorpamplona.quartz.mls.messages.Proposal +import com.vitorpamplona.quartz.mls.messages.ProposalOrRef +import com.vitorpamplona.quartz.mls.tree.Capabilities +import com.vitorpamplona.quartz.mls.tree.Credential +import com.vitorpamplona.quartz.mls.tree.Extension +import com.vitorpamplona.quartz.mls.tree.LeafNode +import com.vitorpamplona.quartz.mls.tree.LeafNodeSource +import com.vitorpamplona.quartz.mls.tree.Lifetime +import com.vitorpamplona.quartz.mls.tree.ParentNode import kotlin.test.Test import kotlin.test.assertContentEquals import kotlin.test.assertEquals diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/TlsCodecTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/TlsCodecTest.kt similarity index 97% rename from quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/TlsCodecTest.kt rename to quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/TlsCodecTest.kt index 3f56f4637d..b685e4b6f0 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/TlsCodecTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/TlsCodecTest.kt @@ -18,11 +18,11 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls +package com.vitorpamplona.quartz.mls -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.codec.TlsSerializable -import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.codec.TlsSerializable +import com.vitorpamplona.quartz.mls.codec.TlsWriter import kotlin.test.Test import kotlin.test.assertContentEquals import kotlin.test.assertEquals diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroupNegativeTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/group/MlsGroupNegativeTest.kt similarity index 97% rename from quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroupNegativeTest.kt rename to quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/group/MlsGroupNegativeTest.kt index 4ebd900fe1..6f41f3911f 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroupNegativeTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/group/MlsGroupNegativeTest.kt @@ -18,12 +18,12 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.group +package com.vitorpamplona.quartz.mls.group -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.framing.MlsMessage -import com.vitorpamplona.quartz.marmot.mls.framing.PublicMessage -import com.vitorpamplona.quartz.marmot.mls.framing.WireFormat +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.framing.MlsMessage +import com.vitorpamplona.quartz.mls.framing.PublicMessage +import com.vitorpamplona.quartz.mls.framing.WireFormat import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/interop/CryptoBasicsInteropTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/interop/CryptoBasicsInteropTest.kt similarity index 98% rename from quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/interop/CryptoBasicsInteropTest.kt rename to quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/interop/CryptoBasicsInteropTest.kt index a4df60ed34..93ed531265 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/interop/CryptoBasicsInteropTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/interop/CryptoBasicsInteropTest.kt @@ -18,10 +18,10 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.interop +package com.vitorpamplona.quartz.mls.interop import com.vitorpamplona.quartz.TestResourceLoader -import com.vitorpamplona.quartz.marmot.mls.crypto.MlsCryptoProvider +import com.vitorpamplona.quartz.mls.crypto.MlsCryptoProvider import com.vitorpamplona.quartz.nip01Core.core.JsonMapper import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/interop/KeyScheduleInteropTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/interop/KeyScheduleInteropTest.kt similarity index 98% rename from quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/interop/KeyScheduleInteropTest.kt rename to quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/interop/KeyScheduleInteropTest.kt index 9bf86aa232..136a815c47 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/interop/KeyScheduleInteropTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/interop/KeyScheduleInteropTest.kt @@ -18,10 +18,10 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.interop +package com.vitorpamplona.quartz.mls.interop import com.vitorpamplona.quartz.TestResourceLoader -import com.vitorpamplona.quartz.marmot.mls.schedule.KeySchedule +import com.vitorpamplona.quartz.mls.schedule.KeySchedule import com.vitorpamplona.quartz.nip01Core.core.JsonMapper import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/interop/MessageSerializationInteropTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/interop/MessageSerializationInteropTest.kt similarity index 94% rename from quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/interop/MessageSerializationInteropTest.kt rename to quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/interop/MessageSerializationInteropTest.kt index cb66e138b6..a7075c5a2f 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/interop/MessageSerializationInteropTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/interop/MessageSerializationInteropTest.kt @@ -18,16 +18,16 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.interop +package com.vitorpamplona.quartz.mls.interop import com.vitorpamplona.quartz.TestResourceLoader -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter -import com.vitorpamplona.quartz.marmot.mls.framing.MlsMessage -import com.vitorpamplona.quartz.marmot.mls.framing.WireFormat -import com.vitorpamplona.quartz.marmot.mls.messages.Commit -import com.vitorpamplona.quartz.marmot.mls.messages.MlsKeyPackage -import com.vitorpamplona.quartz.marmot.mls.tree.RatchetTree +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.codec.TlsWriter +import com.vitorpamplona.quartz.mls.framing.MlsMessage +import com.vitorpamplona.quartz.mls.framing.WireFormat +import com.vitorpamplona.quartz.mls.messages.Commit +import com.vitorpamplona.quartz.mls.messages.MlsKeyPackage +import com.vitorpamplona.quartz.mls.tree.RatchetTree import com.vitorpamplona.quartz.nip01Core.core.JsonMapper import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import kotlin.test.Test diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/interop/MlsInteropVectors.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/interop/MlsInteropVectors.kt similarity index 99% rename from quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/interop/MlsInteropVectors.kt rename to quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/interop/MlsInteropVectors.kt index ccc4b09645..7eca2558be 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/interop/MlsInteropVectors.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/interop/MlsInteropVectors.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.interop +package com.vitorpamplona.quartz.mls.interop import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/interop/PassiveClientInteropTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/interop/PassiveClientInteropTest.kt similarity index 96% rename from quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/interop/PassiveClientInteropTest.kt rename to quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/interop/PassiveClientInteropTest.kt index abc82d58ca..41f8d77679 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/interop/PassiveClientInteropTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/interop/PassiveClientInteropTest.kt @@ -18,12 +18,12 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.interop +package com.vitorpamplona.quartz.mls.interop import com.vitorpamplona.quartz.TestResourceLoader -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.framing.MlsMessage -import com.vitorpamplona.quartz.marmot.mls.framing.WireFormat +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.framing.MlsMessage +import com.vitorpamplona.quartz.mls.framing.WireFormat import com.vitorpamplona.quartz.nip01Core.core.JsonMapper import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import kotlin.test.Test diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/interop/SecretTreeInteropTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/interop/SecretTreeInteropTest.kt similarity index 97% rename from quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/interop/SecretTreeInteropTest.kt rename to quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/interop/SecretTreeInteropTest.kt index a3c3537c4a..b2fec38ca9 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/interop/SecretTreeInteropTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/interop/SecretTreeInteropTest.kt @@ -18,10 +18,10 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.interop +package com.vitorpamplona.quartz.mls.interop import com.vitorpamplona.quartz.TestResourceLoader -import com.vitorpamplona.quartz.marmot.mls.schedule.SecretTree +import com.vitorpamplona.quartz.mls.schedule.SecretTree import com.vitorpamplona.quartz.nip01Core.core.JsonMapper import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/interop/TranscriptHashInteropTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/interop/TranscriptHashInteropTest.kt similarity index 96% rename from quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/interop/TranscriptHashInteropTest.kt rename to quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/interop/TranscriptHashInteropTest.kt index 5695c4d1a2..a6e1cfb510 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/interop/TranscriptHashInteropTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/interop/TranscriptHashInteropTest.kt @@ -18,11 +18,11 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.interop +package com.vitorpamplona.quartz.mls.interop import com.vitorpamplona.quartz.TestResourceLoader -import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter -import com.vitorpamplona.quartz.marmot.mls.crypto.MlsCryptoProvider +import com.vitorpamplona.quartz.mls.codec.TlsWriter +import com.vitorpamplona.quartz.mls.crypto.MlsCryptoProvider import com.vitorpamplona.quartz.nip01Core.core.JsonMapper import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/interop/TreeKemInteropTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/interop/TreeKemInteropTest.kt similarity index 94% rename from quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/interop/TreeKemInteropTest.kt rename to quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/interop/TreeKemInteropTest.kt index e3ff383ca4..9a1c326d02 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/interop/TreeKemInteropTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/interop/TreeKemInteropTest.kt @@ -18,12 +18,12 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.interop +package com.vitorpamplona.quartz.mls.interop import com.vitorpamplona.quartz.TestResourceLoader -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter -import com.vitorpamplona.quartz.marmot.mls.tree.RatchetTree +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.codec.TlsWriter +import com.vitorpamplona.quartz.mls.tree.RatchetTree import com.vitorpamplona.quartz.nip01Core.core.JsonMapper import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/interop/TreeMathInteropTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/interop/TreeMathInteropTest.kt similarity index 97% rename from quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/interop/TreeMathInteropTest.kt rename to quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/interop/TreeMathInteropTest.kt index 9b29f57f66..92e64bebfa 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/interop/TreeMathInteropTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/interop/TreeMathInteropTest.kt @@ -18,10 +18,10 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.interop +package com.vitorpamplona.quartz.mls.interop import com.vitorpamplona.quartz.TestResourceLoader -import com.vitorpamplona.quartz.marmot.mls.tree.BinaryTree +import com.vitorpamplona.quartz.mls.tree.BinaryTree import com.vitorpamplona.quartz.nip01Core.core.JsonMapper import kotlin.test.Test import kotlin.test.assertEquals diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/interop/TreeOperationsInteropTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/interop/TreeOperationsInteropTest.kt similarity index 95% rename from quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/interop/TreeOperationsInteropTest.kt rename to quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/interop/TreeOperationsInteropTest.kt index b65b69de0b..b18acc5349 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/interop/TreeOperationsInteropTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/interop/TreeOperationsInteropTest.kt @@ -18,15 +18,15 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.interop +package com.vitorpamplona.quartz.mls.interop import com.vitorpamplona.quartz.TestResourceLoader -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter -import com.vitorpamplona.quartz.marmot.mls.messages.Proposal -import com.vitorpamplona.quartz.marmot.mls.tree.BinaryTree -import com.vitorpamplona.quartz.marmot.mls.tree.RatchetTree -import com.vitorpamplona.quartz.marmot.mls.tree.TreeNode +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.codec.TlsWriter +import com.vitorpamplona.quartz.mls.messages.Proposal +import com.vitorpamplona.quartz.mls.tree.BinaryTree +import com.vitorpamplona.quartz.mls.tree.RatchetTree +import com.vitorpamplona.quartz.mls.tree.TreeNode import com.vitorpamplona.quartz.nip01Core.core.JsonMapper import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/interop/TreeValidationInteropTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/interop/TreeValidationInteropTest.kt similarity index 93% rename from quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/interop/TreeValidationInteropTest.kt rename to quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/interop/TreeValidationInteropTest.kt index dabb75ed9d..bbd2dddb4a 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/interop/TreeValidationInteropTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/interop/TreeValidationInteropTest.kt @@ -18,13 +18,13 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.interop +package com.vitorpamplona.quartz.mls.interop import com.vitorpamplona.quartz.TestResourceLoader -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter -import com.vitorpamplona.quartz.marmot.mls.tree.BinaryTree -import com.vitorpamplona.quartz.marmot.mls.tree.RatchetTree +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.codec.TlsWriter +import com.vitorpamplona.quartz.mls.tree.BinaryTree +import com.vitorpamplona.quartz.mls.tree.RatchetTree import com.vitorpamplona.quartz.nip01Core.core.JsonMapper import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/interop/WelcomeInteropTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/interop/WelcomeInteropTest.kt similarity index 96% rename from quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/interop/WelcomeInteropTest.kt rename to quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/interop/WelcomeInteropTest.kt index e87e4425a3..15dea6ccee 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/interop/WelcomeInteropTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/mls/interop/WelcomeInteropTest.kt @@ -18,19 +18,19 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.interop +package com.vitorpamplona.quartz.mls.interop import com.vitorpamplona.quartz.TestResourceLoader -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.crypto.MlsCryptoProvider -import com.vitorpamplona.quartz.marmot.mls.crypto.X25519 -import com.vitorpamplona.quartz.marmot.mls.framing.MlsMessage -import com.vitorpamplona.quartz.marmot.mls.framing.WireFormat -import com.vitorpamplona.quartz.marmot.mls.messages.GroupInfo -import com.vitorpamplona.quartz.marmot.mls.messages.GroupSecrets -import com.vitorpamplona.quartz.marmot.mls.messages.MlsKeyPackage -import com.vitorpamplona.quartz.marmot.mls.messages.Welcome -import com.vitorpamplona.quartz.marmot.mls.tree.RatchetTree +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.crypto.MlsCryptoProvider +import com.vitorpamplona.quartz.mls.crypto.X25519 +import com.vitorpamplona.quartz.mls.framing.MlsMessage +import com.vitorpamplona.quartz.mls.framing.WireFormat +import com.vitorpamplona.quartz.mls.messages.GroupInfo +import com.vitorpamplona.quartz.mls.messages.GroupSecrets +import com.vitorpamplona.quartz.mls.messages.MlsKeyPackage +import com.vitorpamplona.quartz.mls.messages.Welcome +import com.vitorpamplona.quartz.mls.tree.RatchetTree import com.vitorpamplona.quartz.nip01Core.core.JsonMapper import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import kotlin.test.Test diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/cache/projection/EventStoreProjectionTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/cache/projection/EventStoreProjectionTest.kt index d16e03b68f..be617307b3 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/cache/projection/EventStoreProjectionTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/cache/projection/EventStoreProjectionTest.kt @@ -29,9 +29,9 @@ import com.vitorpamplona.quartz.nip01Core.store.ObservableEventStore import com.vitorpamplona.quartz.nip01Core.store.ObservableEventStore.StoreChange import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtag -import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent import com.vitorpamplona.quartz.nip40Expiration.expiration import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent import com.vitorpamplona.quartz.utils.TimeUtils @@ -186,13 +186,13 @@ class EventStoreProjectionTest { fun addressableUpdateMutatesSlotInPlace() = runBlocking { val time = TimeUtils.now() - val v1 = signer.sign(LongTextNoteEvent.build("blog v1", "title", dTag = "blog", createdAt = time)) + val v1 = signer.sign(LongFormContentEvent.build("blog v1", "title", dTag = "blog", createdAt = time)) observable.insert(v1) val projection = - projectionOf( + projectionOf( Filter( - kinds = listOf(LongTextNoteEvent.KIND), + kinds = listOf(LongFormContentEvent.KIND), authors = listOf(v1.pubKey), tags = mapOf("d" to listOf("blog")), ), @@ -201,7 +201,7 @@ class EventStoreProjectionTest { val seedList = projection.items val slot = seedList[0] - val v2 = signer.sign(LongTextNoteEvent.build("blog v2", "title", dTag = "blog", createdAt = time + 1)) + val v2 = signer.sign(LongFormContentEvent.build("blog v2", "title", dTag = "blog", createdAt = time + 1)) observable.insert(v2) awaitFlow(slot) { it.id == v2.id } @@ -220,7 +220,7 @@ class EventStoreProjectionTest { // v1 carries tag "nostr"; v2 changes the tag to "bitcoin". val v1 = signer.sign( - LongTextNoteEvent.build("blog v1", "title", dTag = "blog", createdAt = time) { + LongFormContentEvent.build("blog v1", "title", dTag = "blog", createdAt = time) { hashtag("nostr") }, ) @@ -228,9 +228,9 @@ class EventStoreProjectionTest { // Filter narrows to events that ALSO carry hashtag "nostr". val projection = - projectionOf( + projectionOf( Filter( - kinds = listOf(LongTextNoteEvent.KIND), + kinds = listOf(LongFormContentEvent.KIND), authors = listOf(v1.pubKey), tags = mapOf("t" to listOf("nostr")), ), @@ -240,7 +240,7 @@ class EventStoreProjectionTest { val v2 = signer.sign( - LongTextNoteEvent.build("blog v2", "title", dTag = "blog", createdAt = time + 1) { + LongFormContentEvent.build("blog v2", "title", dTag = "blog", createdAt = time + 1) { hashtag("bitcoin") }, ) @@ -300,7 +300,7 @@ class EventStoreProjectionTest { projection.awaitLoaded() assertEquals(2, projection.items.size) - val deletion = signer.sign(DeletionEvent.build(listOf(a))) + val deletion = signer.sign(DeletionRequestEvent.build(listOf(a))) observable.insert(deletion) val after = projection.awaitItems { it.size == 1 } @@ -322,7 +322,7 @@ class EventStoreProjectionTest { val seed = projection.items assertEquals(1, seed.size) - val foreignDeletion = otherSigner.sign(DeletionEvent.build(listOf(a))) + val foreignDeletion = otherSigner.sign(DeletionRequestEvent.build(listOf(a))) observable.insert(foreignDeletion) // Give the projection time to process the event. diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AddressableTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AddressableTest.kt index 759d1b223b..8c26a66714 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AddressableTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AddressableTest.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.quartz.nip01Core.store.sqlite import androidx.sqlite.SQLiteException import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent import com.vitorpamplona.quartz.utils.TimeUtils import kotlin.test.Test import kotlin.test.assertEquals @@ -36,9 +36,9 @@ class AddressableTest : BaseDBTest() { fun testReplacingAddressables() = forEachDB { db -> val time = TimeUtils.now() - val version1 = signer.sign(LongTextNoteEvent.build("my cool blog, version 1", "title", dTag = "my-cool-blog", createdAt = time)) - val version2 = signer.sign(LongTextNoteEvent.build("my cool blog, version 2", "title", dTag = "my-cool-blog", createdAt = time + 1)) - val version3 = signer.sign(LongTextNoteEvent.build("my cool blog, version 3", "title", dTag = "my-cool-blog", createdAt = time + 2)) + val version1 = signer.sign(LongFormContentEvent.build("my cool blog, version 1", "title", dTag = "my-cool-blog", createdAt = time)) + val version2 = signer.sign(LongFormContentEvent.build("my cool blog, version 2", "title", dTag = "my-cool-blog", createdAt = time + 1)) + val version3 = signer.sign(LongFormContentEvent.build("my cool blog, version 3", "title", dTag = "my-cool-blog", createdAt = time + 2)) val addressableQuery = Filter(kinds = listOf(version1.kind), authors = listOf(version1.pubKey), tags = mapOf("d" to listOf(version1.dTag()))) @@ -65,9 +65,9 @@ class AddressableTest : BaseDBTest() { fun testBlockingOldAddressables() = forEachDB { db -> val time = TimeUtils.now() - val version1 = signer.sign(LongTextNoteEvent.build("my cool blog, version 1", "title", dTag = "my-cool-blog", createdAt = time)) - val version2 = signer.sign(LongTextNoteEvent.build("my cool blog, version 2", "title", dTag = "my-cool-blog", createdAt = time + 1)) - val version3 = signer.sign(LongTextNoteEvent.build("my cool blog, version 3", "title", dTag = "my-cool-blog", createdAt = time + 2)) + val version1 = signer.sign(LongFormContentEvent.build("my cool blog, version 1", "title", dTag = "my-cool-blog", createdAt = time)) + val version2 = signer.sign(LongFormContentEvent.build("my cool blog, version 2", "title", dTag = "my-cool-blog", createdAt = time + 1)) + val version3 = signer.sign(LongFormContentEvent.build("my cool blog, version 3", "title", dTag = "my-cool-blog", createdAt = time + 2)) val addressableQuery = Filter(kinds = listOf(version1.kind), authors = listOf(version1.pubKey), tags = mapOf("d" to listOf(version1.dTag()))) @@ -97,7 +97,7 @@ class AddressableTest : BaseDBTest() { // different content -> different ids. NIP-01 says the lower-id wins. val a = signer.sign( - LongTextNoteEvent.build( + LongFormContentEvent.build( "version A", "title", dTag = "tie", @@ -106,7 +106,7 @@ class AddressableTest : BaseDBTest() { ) val b = signer.sign( - LongTextNoteEvent.build( + LongFormContentEvent.build( "version B", "title", dTag = "tie", @@ -129,7 +129,7 @@ class AddressableTest : BaseDBTest() { val time = TimeUtils.now() val a = signer.sign( - LongTextNoteEvent.build( + LongFormContentEvent.build( "version A", "title", dTag = "tie", @@ -138,7 +138,7 @@ class AddressableTest : BaseDBTest() { ) val b = signer.sign( - LongTextNoteEvent.build( + LongFormContentEvent.build( "version B", "title", dTag = "tie", diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AuthorsMissingOutboxTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AuthorsMissingOutboxTest.kt index 103eb82d3c..d70bdd6952 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AuthorsMissingOutboxTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AuthorsMissingOutboxTest.kt @@ -21,7 +21,7 @@ package com.vitorpamplona.quartz.nip01Core.store.sqlite import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync -import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent @@ -83,7 +83,7 @@ class AuthorsMissingOutboxTest : BaseDBTest() { // NIP-09: the author deletes their own relay list. No 10002 row // remains, so the anti-join reports them as missing again. - db.insert(signer.sign(DeletionEvent.build(listOf(relayList)))) + db.insert(signer.sign(DeletionRequestEvent.build(listOf(relayList)))) assertEquals(setOf(signer.pubKey), db.authorsMissingOutbox().toSet()) } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/BasicTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/BasicTest.kt index 6c695ff744..bff21ff649 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/BasicTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/BasicTest.kt @@ -27,7 +27,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtag import com.vitorpamplona.quartz.nip01Core.tags.hashtags.isTaggedHash -import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent import kotlin.test.Test @@ -261,7 +261,7 @@ class BasicTest : BaseDBTest() { forEachDB { db -> // Pre-load a target event and a deletion that blocks re-insertion. val deletedTarget = signer.sign(TextNoteEvent.build("target", createdAt = 1)) - val deletion = signer.sign(DeletionEvent.build(listOf(deletedTarget), createdAt = 100)) + val deletion = signer.sign(DeletionRequestEvent.build(listOf(deletedTarget), createdAt = 100)) db.insert(deletion) db.assertQuery(deletion, Filter(ids = listOf(deletion.id))) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionTest.kt index 811e9b5323..62ab2e4183 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionTest.kt @@ -24,9 +24,9 @@ import androidx.sqlite.SQLiteException import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync -import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.utils.TimeUtils import kotlin.test.Test @@ -51,7 +51,7 @@ class DeletionTest : BaseDBTest() { db.assertQuery(note2, Filter(ids = listOf(note2.id))) db.assertQuery(note3, Filter(ids = listOf(note3.id))) - val deletion = signer.sign(DeletionEvent.build(listOf(note1))) + val deletion = signer.sign(DeletionRequestEvent.build(listOf(note1))) db.insert(deletion) @@ -74,9 +74,9 @@ class DeletionTest : BaseDBTest() { fun testInsertDeleteEventOfAddressable() = forEachDB { db -> val time = TimeUtils.now() - val note1 = signer.sign(LongTextNoteEvent.build("my cool blog, version 1", "title", dTag = "my-cool-blog", createdAt = time)) - val note2 = signer.sign(LongTextNoteEvent.build("my cool blog, version 2", "title", dTag = "my-cool-blog", createdAt = time + 1)) - val note3 = signer.sign(LongTextNoteEvent.build("my cool blog, version 3", "title", dTag = "my-cool-blog", createdAt = time + 2)) + val note1 = signer.sign(LongFormContentEvent.build("my cool blog, version 1", "title", dTag = "my-cool-blog", createdAt = time)) + val note2 = signer.sign(LongFormContentEvent.build("my cool blog, version 2", "title", dTag = "my-cool-blog", createdAt = time + 1)) + val note3 = signer.sign(LongFormContentEvent.build("my cool blog, version 3", "title", dTag = "my-cool-blog", createdAt = time + 2)) db.insert(note1) @@ -95,7 +95,7 @@ class DeletionTest : BaseDBTest() { // Deletion must be timestamped >= note3.createdAt for it to // remove the latest addressable per NIP-09. - val deletion = signer.sign(DeletionEvent.build(listOf(note1), createdAt = time + 100)) + val deletion = signer.sign(DeletionRequestEvent.build(listOf(note1), createdAt = time + 100)) db.insert(deletion) @@ -118,9 +118,9 @@ class DeletionTest : BaseDBTest() { fun testInsertDeleteEventOfAddressable2() = forEachDB { db -> val time = TimeUtils.now() - val note1 = signer.sign(LongTextNoteEvent.build("my cool blog, version 1", "title", dTag = "my-cool-blog", createdAt = time)) - val note2 = signer.sign(LongTextNoteEvent.build("my cool blog, version 2", "title", dTag = "my-cool-blog", createdAt = time + 1)) - val note3 = signer.sign(LongTextNoteEvent.build("my cool blog, version 3", "title", dTag = "my-cool-blog", createdAt = time + 2)) + val note1 = signer.sign(LongFormContentEvent.build("my cool blog, version 1", "title", dTag = "my-cool-blog", createdAt = time)) + val note2 = signer.sign(LongFormContentEvent.build("my cool blog, version 2", "title", dTag = "my-cool-blog", createdAt = time + 1)) + val note3 = signer.sign(LongFormContentEvent.build("my cool blog, version 3", "title", dTag = "my-cool-blog", createdAt = time + 2)) db.insert(note1) db.insert(note2) @@ -130,7 +130,7 @@ class DeletionTest : BaseDBTest() { db.assertQuery(null, Filter(ids = listOf(note2.id))) db.assertQuery(note3, Filter(ids = listOf(note3.id))) - val deletion = signer.sign(DeletionEvent.buildAddressOnly(listOf(note1), createdAt = time + 100)) + val deletion = signer.sign(DeletionRequestEvent.buildAddressOnly(listOf(note1), createdAt = time + 100)) db.insert(deletion) @@ -165,7 +165,7 @@ class DeletionTest : BaseDBTest() { db.assertQuery(wrap1, Filter(ids = listOf(wrap1.id))) db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id))) - val randomDeletionToWrap = signer.sign(DeletionEvent.build(listOf(wrap1))) + val randomDeletionToWrap = signer.sign(DeletionRequestEvent.build(listOf(wrap1))) db.insert(randomDeletionToWrap) @@ -173,7 +173,7 @@ class DeletionTest : BaseDBTest() { db.assertQuery(wrap1, Filter(ids = listOf(wrap1.id))) db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id))) - val deletion = me.sign(DeletionEvent.build(listOf(wrap1))) + val deletion = me.sign(DeletionRequestEvent.build(listOf(wrap1))) db.insert(deletion) @@ -427,14 +427,14 @@ class DeletionTest : BaseDBTest() { // A deletion event from a *different* author must not remove the // target — only the original author can delete via NIP-09. - val strangerDeletion = stranger.sign(DeletionEvent.build(listOf(target))) + val strangerDeletion = stranger.sign(DeletionRequestEvent.build(listOf(target))) db.insert(strangerDeletion) db.assertQuery(target, Filter(ids = listOf(target.id))) db.assertQuery(strangerDeletion, Filter(ids = listOf(strangerDeletion.id))) // The owner's deletion still works after a failed third-party attempt. - val ownerDeletion = owner.sign(DeletionEvent.build(listOf(target))) + val ownerDeletion = owner.sign(DeletionRequestEvent.build(listOf(target))) db.insert(ownerDeletion) db.assertQuery(null, Filter(ids = listOf(target.id))) } @@ -447,7 +447,7 @@ class DeletionTest : BaseDBTest() { // a second deletion that removes the first. Cross-author // deletion of a kind-5 must NOT work. val target = signer.sign(TextNoteEvent.build("target")) - val firstDeletion = signer.sign(DeletionEvent.build(listOf(target), createdAt = 100)) + val firstDeletion = signer.sign(DeletionRequestEvent.build(listOf(target), createdAt = 100)) db.insert(target) db.insert(firstDeletion) @@ -457,13 +457,13 @@ class DeletionTest : BaseDBTest() { // Stranger cannot delete the kind-5. val stranger = NostrSignerSync() val strangerDeletion = - stranger.sign(DeletionEvent.build(listOf(firstDeletion), createdAt = 200)) + stranger.sign(DeletionRequestEvent.build(listOf(firstDeletion), createdAt = 200)) db.insert(strangerDeletion) db.assertQuery(firstDeletion, Filter(ids = listOf(firstDeletion.id))) // Same author can delete their previous kind-5. val secondDeletion = - signer.sign(DeletionEvent.build(listOf(firstDeletion), createdAt = 300)) + signer.sign(DeletionRequestEvent.build(listOf(firstDeletion), createdAt = 300)) db.insert(secondDeletion) db.assertQuery(null, Filter(ids = listOf(firstDeletion.id))) db.assertQuery(secondDeletion, Filter(ids = listOf(secondDeletion.id))) @@ -493,17 +493,17 @@ class DeletionTest : BaseDBTest() { // Inner author (the "sender") cannot delete a wrap addressed to // someone else — sender pubkey != wrap's pubkey_owner_hash. - val senderDeletion = sender.sign(DeletionEvent.build(listOf(wrap))) + val senderDeletion = sender.sign(DeletionRequestEvent.build(listOf(wrap))) db.insert(senderDeletion) db.assertQuery(wrap, Filter(ids = listOf(wrap.id))) // An unrelated third party can't delete it either. - val unrelatedDeletion = unrelated.sign(DeletionEvent.build(listOf(wrap))) + val unrelatedDeletion = unrelated.sign(DeletionRequestEvent.build(listOf(wrap))) db.insert(unrelatedDeletion) db.assertQuery(wrap, Filter(ids = listOf(wrap.id))) // The recipient (whose pubkey matches pubkey_owner_hash) can. - val recipientDeletion = recipient.sign(DeletionEvent.build(listOf(wrap))) + val recipientDeletion = recipient.sign(DeletionRequestEvent.build(listOf(wrap))) db.insert(recipientDeletion) db.assertQuery(null, Filter(ids = listOf(wrap.id))) } @@ -514,7 +514,7 @@ class DeletionTest : BaseDBTest() { // Old addressable (will be deleted by deletion request) val old = signer.sign( - LongTextNoteEvent.build( + LongFormContentEvent.build( "version 1", "title", dTag = "blog-1", @@ -528,7 +528,7 @@ class DeletionTest : BaseDBTest() { // Deletion targeting the address with created_at = 1500 val deletion = signer.sign( - DeletionEvent.buildAddressOnly( + DeletionRequestEvent.buildAddressOnly( listOf(old), createdAt = 1500, ), @@ -542,7 +542,7 @@ class DeletionTest : BaseDBTest() { // created_at AFTER the deletion. NIP-09 says it must be kept. val newer = signer.sign( - LongTextNoteEvent.build( + LongFormContentEvent.build( "version 2", "title", dTag = "blog-1", diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/InsertOutcomeClassificationTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/InsertOutcomeClassificationTest.kt new file mode 100644 index 0000000000..2bdcba4dc0 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/InsertOutcomeClassificationTest.kt @@ -0,0 +1,133 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.store.sqlite + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip01Core.store.IEventStore +import com.vitorpamplona.quartz.nip01Core.store.RejectionReason +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * `batchInsert` must name *why* a row didn't land, because the relay turns + * that reason into the NIP-01 answer: [RejectionReason.DUPLICATE] and + * [RejectionReason.SUPERSEDED] are `OK true` ("already covered"), everything + * else is `OK false`, which clients retry. + * + * This suite pins the classification **independently of the SQLite driver's + * exception text**. The bundled JVM driver raises + * `UNIQUE constraint failed: event_headers.id`; Android's raises an + * `android.database.SQLException` whose message is `null`. Reading the text + * was therefore enough on one target and wrong on the other — a duplicate + * came back as `error: SQLException`, an `OK false` the client re-offers + * forever. Every case below runs on both targets and so fails on either if + * the classifier ever goes back to trusting a message. + */ +class InsertOutcomeClassificationTest : BaseDBTest() { + val signer = NostrSignerSync() + + private fun assertRejected( + expectedReason: String, + outcome: IEventStore.InsertOutcome, + ) { + assertTrue(outcome is IEventStore.InsertOutcome.Rejected, "expected Rejected, got $outcome") + assertEquals(expectedReason, outcome.reason) + } + + @Test + fun duplicateIdIsRejectedAsDuplicate() = + forEachDB { db -> + val event = signer.sign(TextNoteEvent.build("hello", createdAt = TimeUtils.now())) + + assertEquals(IEventStore.InsertOutcome.Accepted, db.batchInsert(listOf(event))[0]) + assertRejected(RejectionReason.DUPLICATE, db.batchInsert(listOf(event))[0]) + } + + @Test + fun reofferingAStoredReplaceableIsRejectedAsDuplicate() = + forEachDB { db -> + // Byte-for-byte the stored version, so it violates the id index *and* + // replaceable_idx — and which one SQLite reports first is the driver's + // choice. "Already have this event" is the answer that holds on all of + // them. + val event = signer.sign(MetadataEvent.createNew("Vitor", createdAt = TimeUtils.now())) + + assertEquals(IEventStore.InsertOutcome.Accepted, db.batchInsert(listOf(event))[0]) + assertRejected(RejectionReason.DUPLICATE, db.batchInsert(listOf(event))[0]) + } + + @Test + fun olderReplaceableIsRejectedAsSuperseded() = + forEachDB { db -> + val time = TimeUtils.now() + val older = signer.sign(MetadataEvent.createNew("Vitor 1", createdAt = time)) + val newer = signer.sign(MetadataEvent.createNew("Vitor 2", createdAt = time + 1)) + + assertEquals(IEventStore.InsertOutcome.Accepted, db.batchInsert(listOf(newer))[0]) + assertRejected(RejectionReason.SUPERSEDED, db.batchInsert(listOf(older))[0]) + } + + @Test + fun sameSecondReplaceableTieLoserIsRejectedAsSuperseded() = + forEachDB { db -> + val time = TimeUtils.now() + // NIP-01 breaks a created_at tie by lowest id, so the winner is + // decided by sorting, not by insertion order. + val (winner, loser) = + listOf( + signer.sign(MetadataEvent.createNew("Vitor A", createdAt = time)), + signer.sign(MetadataEvent.createNew("Vitor B", createdAt = time)), + ).sortedBy { it.id } + + assertEquals(IEventStore.InsertOutcome.Accepted, db.batchInsert(listOf(winner))[0]) + assertRejected(RejectionReason.SUPERSEDED, db.batchInsert(listOf(loser))[0]) + } + + @Test + fun olderAddressableIsRejectedAsSuperseded() = + forEachDB { db -> + val time = TimeUtils.now() + val older = signer.sign(LongFormContentEvent.build("v1", "title", dTag = "blog", createdAt = time)) + val newer = signer.sign(LongFormContentEvent.build("v2", "title", dTag = "blog", createdAt = time + 1)) + + assertEquals(IEventStore.InsertOutcome.Accepted, db.batchInsert(listOf(newer))[0]) + assertRejected(RejectionReason.SUPERSEDED, db.batchInsert(listOf(older))[0]) + } + + @Test + fun aNewerVersionStillLandsAtAnOccupiedCoordinate() = + forEachDB { db -> + // The guard against the classifier over-claiming: a coordinate is + // occupied here too, but this version wins, so nothing is rejected. + val time = TimeUtils.now() + val older = signer.sign(MetadataEvent.createNew("Vitor 1", createdAt = time)) + val newer = signer.sign(MetadataEvent.createNew("Vitor 2", createdAt = time + 1)) + + assertEquals(IEventStore.InsertOutcome.Accepted, db.batchInsert(listOf(older))[0]) + assertEquals(IEventStore.InsertOutcome.Accepted, db.batchInsert(listOf(newer))[0]) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryAssemblerTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryAssemblerTest.kt index ac034e6af3..9abb9aa952 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryAssemblerTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryAssemblerTest.kt @@ -27,7 +27,7 @@ import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent -import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent +import com.vitorpamplona.quartz.nip85TrustedAssertions.users.UserAssertionEvent import kotlin.test.Test import kotlin.test.assertEquals @@ -980,11 +980,11 @@ class QueryAssemblerTest : BaseDBTest() { } @Test - fun testContactCardDownloadFromTrustedKeys() = + fun testUserAssertionDownloadFromTrustedKeys() = forEachDB { db -> val filter = Filter( - kinds = listOf(ContactCardEvent.KIND), + kinds = listOf(UserAssertionEvent.KIND), authors = listOf("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"), tags = mapOf( diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SearchTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SearchTest.kt index 7f238e30e6..23024b8be5 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SearchTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SearchTest.kt @@ -29,10 +29,10 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent -import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent +import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarCollectionEvent import com.vitorpamplona.quartz.utils.TimeUtils import kotlin.test.Test @@ -134,7 +134,7 @@ class SearchTest : BaseDBTest() { val time = TimeUtils.now() val v1 = signer.sign( - LongTextNoteEvent.build( + LongFormContentEvent.build( "first version uniqalpha", title = "blog title", dTag = "fts-rotation", @@ -143,7 +143,7 @@ class SearchTest : BaseDBTest() { ) val v2 = signer.sign( - LongTextNoteEvent.build( + LongFormContentEvent.build( "second version uniqbeta", title = "blog title", dTag = "fts-rotation", @@ -163,10 +163,10 @@ class SearchTest : BaseDBTest() { @Test fun testNewlySearchableKinds() = forEachDB { db -> - // CalendarEvent indexes the title tag plus the free-text content. + // CalendarCollectionEvent indexes the title tag plus the free-text content. val cal = signer.sign( - CalendarEvent.build( + CalendarCollectionEvent.build( title = "uniqtitle Meetup", content = "annual uniqbody gathering", ), @@ -254,7 +254,7 @@ class SearchTest : BaseDBTest() { // events that were stored before their kind became searchable. val cal = signer.sign( - CalendarEvent.build( + CalendarCollectionEvent.build( title = "uniqtitle Meetup", content = "annual uniqbody gathering", ), diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip17Dm/files/ChatMessageEncryptedFileHeaderWaveformTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip17Dm/files/ChatMessageEncryptedFileHeaderWaveformTest.kt new file mode 100644 index 0000000000..80c83fcf10 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip17Dm/files/ChatMessageEncryptedFileHeaderWaveformTest.kt @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip17Dm.files + +import com.vitorpamplona.quartz.experimental.audio.header.tags.WaveformTag +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * The waveform an encrypted voice message carries. + * + * The renderer asks the event for this to decide between drawing real bars and + * a bare transport, so the tag name has to keep matching what a sender writes — + * which is what these assert, rather than the parser, which is WaveformTag's. + */ +class ChatMessageEncryptedFileHeaderWaveformTest { + private fun event(vararg tags: Array) = + ChatMessageEncryptedFileHeaderEvent( + id = "00".repeat(32), + pubKey = "11".repeat(32), + createdAt = 1_700_000_000L, + tags = arrayOf(*tags), + content = "https://blossom.example/abc", + sig = "sig", + ) + + @Test + fun readsTheWaveformASenderAttached() { + val wave = listOf(0.0f, 0.5f, 1.0f) + + assertEquals(wave, event(WaveformTag.assemble(wave)).waveform()) + } + + @Test + fun aFileWithNoWaveformHasNone() { + assertNull(event(arrayOf("m", "audio/mp4")).waveform()) + } + + @Test + fun anUnparseableWaveformIsNoWaveformRatherThanACrash() { + // A peer can put anything in a tag, and a voice message that fails to + // render at all is worse than one that renders without bars. + assertNull(event(arrayOf(WaveformTag.TAG_NAME, "not json")).waveform()) + assertNull(event(arrayOf(WaveformTag.TAG_NAME, "[]")).waveform()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/ChannelSettingsTagTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/ChannelSettingsTagTest.kt index 080e477790..6f0f7e4b28 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/ChannelSettingsTagTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/ChannelSettingsTagTest.kt @@ -21,7 +21,7 @@ package com.vitorpamplona.quartz.nip29RelayGroups import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent -import com.vitorpamplona.quartz.nip29RelayGroups.moderation.EditMetadataEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.GroupEditMetadataEvent import com.vitorpamplona.quartz.utils.EventFactory import kotlin.test.Test import kotlin.test.assertEquals @@ -48,22 +48,22 @@ class ChannelSettingsTagTest { @Test fun editEmitsVisibilityTagOnlyWhenSet() { - val priv = EditMetadataEvent.build(gid, visibility = "private") + val priv = GroupEditMetadataEvent.build(gid, visibility = "private") assertEquals("private", tagValue(priv.tags, "visibility")) - val open = EditMetadataEvent.build(gid, visibility = "open") + val open = GroupEditMetadataEvent.build(gid, visibility = "open") assertEquals("open", tagValue(open.tags, "visibility")) // Absent by default so an ordinary metadata edit doesn't reclassify visibility. - assertNull(tagValue(EditMetadataEvent.build(gid, name = "x").tags, "visibility")) + assertNull(tagValue(GroupEditMetadataEvent.build(gid, name = "x").tags, "visibility")) } @Test fun editEmitsArchivedTagAsTrueFalse() { - assertEquals("true", tagValue(EditMetadataEvent.build(gid, archived = true).tags, "archived")) - assertEquals("false", tagValue(EditMetadataEvent.build(gid, archived = false).tags, "archived")) + assertEquals("true", tagValue(GroupEditMetadataEvent.build(gid, archived = true).tags, "archived")) + assertEquals("false", tagValue(GroupEditMetadataEvent.build(gid, archived = false).tags, "archived")) // Null archived means "don't touch it" — no tag emitted. - assertNull(tagValue(EditMetadataEvent.build(gid, name = "x").tags, "archived")) + assertNull(tagValue(GroupEditMetadataEvent.build(gid, name = "x").tags, "archived")) } @Test diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/Nip29ArmadaInteropTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/Nip29ArmadaInteropTest.kt index dae29d1b8c..6d7949047d 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/Nip29ArmadaInteropTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/Nip29ArmadaInteropTest.kt @@ -32,16 +32,16 @@ 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.GroupParticipantsEvent -import com.vitorpamplona.quartz.nip29RelayGroups.metadata.SupportedRolesEvent +import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupRolesEvent import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateGroupEvent -import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateInviteEvent -import com.vitorpamplona.quartz.nip29RelayGroups.moderation.DeleteEventEvent import com.vitorpamplona.quartz.nip29RelayGroups.moderation.DeleteGroupEvent -import com.vitorpamplona.quartz.nip29RelayGroups.moderation.EditMetadataEvent -import com.vitorpamplona.quartz.nip29RelayGroups.moderation.PutUserEvent -import com.vitorpamplona.quartz.nip29RelayGroups.moderation.RemoveUserEvent -import com.vitorpamplona.quartz.nip29RelayGroups.request.JoinRequestEvent -import com.vitorpamplona.quartz.nip29RelayGroups.request.LeaveRequestEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.GroupCreateInviteEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.GroupDeleteEventEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.GroupEditMetadataEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.GroupPutUserEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.GroupRemoveUserEvent +import com.vitorpamplona.quartz.nip29RelayGroups.request.GroupJoinRequestEvent +import com.vitorpamplona.quartz.nip29RelayGroups.request.GroupLeaveRequestEvent import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.GroupTag import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.SimpleGroupListEvent import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent @@ -169,12 +169,12 @@ class Nip29ArmadaInteropTest { fun parsesGroupRoles() { val e = parse( - SupportedRolesEvent.KIND, + GroupRolesEvent.KIND, arrayOf( arrayOf("role", "admin", "Full control"), arrayOf("role", "moderator"), ), - ) as SupportedRolesEvent + ) as GroupRolesEvent val roles = e.roles() assertEquals("admin", roles[0].name) assertEquals("Full control", roles[0].description) @@ -202,37 +202,37 @@ class Nip29ArmadaInteropTest { assertTrue(parse(CreateGroupEvent.KIND, arrayOf(arrayOf("h", gid)), pubKey = alice) is CreateGroupEvent) assertTrue(parse(DeleteGroupEvent.KIND, arrayOf(arrayOf("h", gid)), pubKey = alice) is DeleteGroupEvent) - val put = parse(PutUserEvent.KIND, arrayOf(arrayOf("h", gid), arrayOf("p", bob, "moderator")), pubKey = alice) as PutUserEvent + val put = parse(GroupPutUserEvent.KIND, arrayOf(arrayOf("h", gid), arrayOf("p", bob, "moderator")), pubKey = alice) as GroupPutUserEvent assertEquals(gid, put.groupId()) assertEquals(listOf(bob), put.userPubKeys()) - val remove = parse(RemoveUserEvent.KIND, arrayOf(arrayOf("h", gid), arrayOf("p", bob)), content = "spam", pubKey = alice) as RemoveUserEvent + val remove = parse(GroupRemoveUserEvent.KIND, arrayOf(arrayOf("h", gid), arrayOf("p", bob)), content = "spam", pubKey = alice) as GroupRemoveUserEvent assertEquals(listOf(bob), remove.userPubKeys()) assertEquals("spam", remove.content) - val del = parse(DeleteEventEvent.KIND, arrayOf(arrayOf("h", gid), arrayOf("e", "de".repeat(32))), pubKey = alice) as DeleteEventEvent + val del = parse(GroupDeleteEventEvent.KIND, arrayOf(arrayOf("h", gid), arrayOf("e", "de".repeat(32))), pubKey = alice) as GroupDeleteEventEvent assertEquals(listOf("de".repeat(32)), del.deletedEventIds()) - val invite = parse(CreateInviteEvent.KIND, arrayOf(arrayOf("h", gid), arrayOf("code", "abc123")), pubKey = alice) as CreateInviteEvent + val invite = parse(GroupCreateInviteEvent.KIND, arrayOf(arrayOf("h", gid), arrayOf("code", "abc123")), pubKey = alice) as GroupCreateInviteEvent assertEquals("abc123", invite.code()) - val edit = parse(EditMetadataEvent.KIND, arrayOf(arrayOf("h", gid), arrayOf("name", "N"), arrayOf("public"), arrayOf("open")), pubKey = alice) as EditMetadataEvent + val edit = parse(GroupEditMetadataEvent.KIND, arrayOf(arrayOf("h", gid), arrayOf("name", "N"), arrayOf("public"), arrayOf("open")), pubKey = alice) as GroupEditMetadataEvent assertEquals("N", edit.name()) assertEquals(gid, edit.groupId()) } @Test fun parsesJoinAndLeaveRequests() { - val join = parse(JoinRequestEvent.KIND, arrayOf(arrayOf("h", gid), arrayOf("code", "inv1")), content = "let me in", pubKey = alice) as JoinRequestEvent + val join = parse(GroupJoinRequestEvent.KIND, arrayOf(arrayOf("h", gid), arrayOf("code", "inv1")), content = "let me in", pubKey = alice) as GroupJoinRequestEvent assertEquals(gid, join.groupId()) assertEquals("inv1", join.inviteCode()) assertEquals("let me in", join.content) // Armada also sends a join with no code. - val joinNoCode = parse(JoinRequestEvent.KIND, arrayOf(arrayOf("h", gid)), pubKey = alice) as JoinRequestEvent + val joinNoCode = parse(GroupJoinRequestEvent.KIND, arrayOf(arrayOf("h", gid)), pubKey = alice) as GroupJoinRequestEvent assertNull(joinNoCode.inviteCode()) - val leave = parse(LeaveRequestEvent.KIND, arrayOf(arrayOf("h", gid)), pubKey = alice) as LeaveRequestEvent + val leave = parse(GroupLeaveRequestEvent.KIND, arrayOf(arrayOf("h", gid)), pubKey = alice) as GroupLeaveRequestEvent assertEquals(gid, leave.groupId()) } @@ -399,7 +399,7 @@ class Nip29ArmadaInteropTest { @Test fun buildsEditMetadataWithTopicsAndGeohashes() { val edit = - EditMetadataEvent.build( + GroupEditMetadataEvent.build( groupId = gid, name = "Bitcoin Devs", hashtags = listOf("bitcoin"), @@ -412,12 +412,12 @@ class Nip29ArmadaInteropTest { @Test fun buildsCreateAndModerationEvents() { assertTrue(CreateGroupEvent.build(gid).tags.any { it[0] == "h" && it[1] == gid }) - assertTrue(CreateInviteEvent.build(gid, "code42").tags.any { it[0] == "code" && it[1] == "code42" }) + assertTrue(GroupCreateInviteEvent.build(gid, "code42").tags.any { it[0] == "code" && it[1] == "code42" }) - val put = PutUserEvent.build(gid, listOf(bob to listOf("moderator"))).tags + val put = GroupPutUserEvent.build(gid, listOf(bob to listOf("moderator"))).tags assertTrue(put.any { it[0] == "p" && it[1] == bob && it.contains("moderator") }) - val remove = RemoveUserEvent.build(gid, listOf(bob)).tags + val remove = GroupRemoveUserEvent.build(gid, listOf(bob)).tags assertTrue(remove.any { it[0] == "p" && it[1] == bob }) // relay29 forks are avoided by NOT emitting `previous` when none provided. @@ -466,12 +466,12 @@ class Nip29ArmadaInteropTest { @Test fun buildsJoinLeaveAndGroupScopedChat() { - val join = JoinRequestEvent.build(gid, reason = "hi", inviteCode = "inv9") + val join = GroupJoinRequestEvent.build(gid, reason = "hi", inviteCode = "inv9") assertEquals("hi", join.content) assertTrue(join.tags.any { it[0] == "h" && it[1] == gid }) assertTrue(join.tags.any { it[0] == "code" && it[1] == "inv9" }) - assertTrue(LeaveRequestEvent.build(gid).tags.any { it[0] == "h" && it[1] == gid }) + assertTrue(GroupLeaveRequestEvent.build(gid).tags.any { it[0] == "h" && it[1] == gid }) // Group-scope a plain kind-9 chat message via the shared `hTag` helper. val chat = ChatEvent.build("gm") { hTag(gid) } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/PinEventsTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/PinEventsTest.kt index b5ee8e80f3..27dea39abd 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/PinEventsTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/PinEventsTest.kt @@ -24,7 +24,7 @@ import com.vitorpamplona.quartz.buzz.cwChannelWindow.ThreadSummaryContent import com.vitorpamplona.quartz.buzz.cwChannelWindow.ThreadSummaryEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupPinnedEvent -import com.vitorpamplona.quartz.nip29RelayGroups.moderation.UpdatePinListEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.GroupUpdatePinListEvent import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag import com.vitorpamplona.quartz.utils.EventFactory import kotlin.test.Test @@ -90,19 +90,19 @@ class PinEventsTest { @Test fun updatePinListEventParsesGroupAndIds() { val tags = arrayOf(arrayOf("h", gid), arrayOf("e", id1), arrayOf("e", id2)) - val event: Event = EventFactory.create("00".repeat(32), relaySelf, 100, UpdatePinListEvent.KIND, tags, "", "22".repeat(64)) + val event: Event = EventFactory.create("00".repeat(32), relaySelf, 100, GroupUpdatePinListEvent.KIND, tags, "", "22".repeat(64)) - assertEquals(true, event is UpdatePinListEvent) - event as UpdatePinListEvent + assertEquals(true, event is GroupUpdatePinListEvent) + event as GroupUpdatePinListEvent assertEquals(gid, event.groupId()) assertEquals(listOf(id1, id2), event.pinnedEventIds()) } @Test fun updatePinListBuildCarriesHTagAndFullList() { - val template = UpdatePinListEvent.build(gid, listOf(id1, id2)) + val template = GroupUpdatePinListEvent.build(gid, listOf(id1, id2)) - assertEquals(UpdatePinListEvent.KIND, template.kind) + assertEquals(GroupUpdatePinListEvent.KIND, template.kind) assertEquals(gid, template.tags.firstOrNull { it[0] == GroupIdTag.TAG_NAME }?.getOrNull(1)) assertEquals(listOf(id1, id2), template.tags.filter { it[0] == "e" }.map { it[1] }) } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/SubgroupTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/SubgroupTest.kt index cc4360e828..58d16706f6 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/SubgroupTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/SubgroupTest.kt @@ -21,7 +21,7 @@ package com.vitorpamplona.quartz.nip29RelayGroups import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent -import com.vitorpamplona.quartz.nip29RelayGroups.moderation.EditMetadataEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.GroupEditMetadataEvent import com.vitorpamplona.quartz.utils.EventFactory import kotlin.test.Test import kotlin.test.assertEquals @@ -66,8 +66,8 @@ class SubgroupTest { @Test fun editMetadataRoundTripsParentAndChildren() { - val template = EditMetadataEvent.build("nostr", name = "Nostr", parent = "social", children = listOf("nip29")) - val event = EventFactory.create(id, relaySelf, template.createdAt, EditMetadataEvent.KIND, template.tags, "", sig) as EditMetadataEvent + val template = GroupEditMetadataEvent.build("nostr", name = "Nostr", parent = "social", children = listOf("nip29")) + val event = EventFactory.create(id, relaySelf, template.createdAt, GroupEditMetadataEvent.KIND, template.tags, "", sig) as GroupEditMetadataEvent assertEquals("nostr", event.groupId()) assertEquals("social", event.parent()) @@ -76,8 +76,8 @@ class SubgroupTest { @Test fun editMetadataWithoutParentRoots() { - val template = EditMetadataEvent.build("nostr", name = "Nostr") - val event = EventFactory.create(id, relaySelf, template.createdAt, EditMetadataEvent.KIND, template.tags, "", sig) as EditMetadataEvent + val template = GroupEditMetadataEvent.build("nostr", name = "Nostr") + val event = EventFactory.create(id, relaySelf, template.createdAt, GroupEditMetadataEvent.KIND, template.tags, "", sig) as GroupEditMetadataEvent assertNull(event.parent()) } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/BunkerRequestProcessorConcurrencyTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/BunkerRequestProcessorConcurrencyTest.kt index 7ee9fdc303..0a2bdce894 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/BunkerRequestProcessorConcurrencyTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/BunkerRequestProcessorConcurrencyTest.kt @@ -30,8 +30,8 @@ import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestSign import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import com.vitorpamplona.quartz.nip57Zaps.PrivateZapEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapRequestEvent import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.launch import kotlinx.coroutines.test.runTest @@ -101,7 +101,7 @@ class BunkerRequestProcessorConcurrencyTest { fromPublicKey: HexKey, ) = "" - override suspend fun decryptZapEvent(event: LnZapRequestEvent): LnZapPrivateEvent = throw NotImplementedError() + override suspend fun decryptZapEvent(event: ZapRequestEvent): PrivateZapEvent = throw NotImplementedError() override suspend fun deriveKey(nonce: HexKey): HexKey = throw NotImplementedError() diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/BunkerRequestProcessorTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/BunkerRequestProcessorTest.kt index 3533bb2959..ea7339b628 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/BunkerRequestProcessorTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/BunkerRequestProcessorTest.kt @@ -40,8 +40,8 @@ import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseEvent import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseGetRelays import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponsePong import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponsePublicKey -import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import com.vitorpamplona.quartz.nip57Zaps.PrivateZapEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapRequestEvent import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals @@ -116,7 +116,7 @@ class BunkerRequestProcessorTest { return "nip44dec:$ciphertext" } - override suspend fun decryptZapEvent(event: LnZapRequestEvent): LnZapPrivateEvent = throw NotImplementedError() + override suspend fun decryptZapEvent(event: ZapRequestEvent): PrivateZapEvent = throw NotImplementedError() override suspend fun deriveKey(nonce: HexKey): HexKey = throw NotImplementedError() @@ -371,7 +371,7 @@ class BunkerRequestProcessorTest { fromPublicKey: HexKey, ) = "" - override suspend fun decryptZapEvent(event: LnZapRequestEvent): LnZapPrivateEvent = throw NotImplementedError() + override suspend fun decryptZapEvent(event: ZapRequestEvent): PrivateZapEvent = throw NotImplementedError() override suspend fun deriveKey(nonce: HexKey): HexKey = throw NotImplementedError() diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerServiceTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerServiceTest.kt index 3dcf86afd1..db6c888430 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerServiceTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerServiceTest.kt @@ -38,8 +38,8 @@ import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseEvent import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponsePublicKey import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import com.vitorpamplona.quartz.nip57Zaps.PrivateZapEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapRequestEvent import com.vitorpamplona.quartz.utils.RandomInstance import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -112,7 +112,7 @@ class NostrConnectSignerServiceTest { fromPublicKey: HexKey, ) = ciphertext - override suspend fun decryptZapEvent(event: LnZapRequestEvent): LnZapPrivateEvent = throw NotImplementedError() + override suspend fun decryptZapEvent(event: ZapRequestEvent): PrivateZapEvent = throw NotImplementedError() override suspend fun deriveKey(nonce: HexKey): HexKey = throw NotImplementedError() diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcOutgoingMetadataTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcOutgoingMetadataTest.kt index 8bddfc1446..c7020dc022 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcOutgoingMetadataTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcOutgoingMetadataTest.kt @@ -84,7 +84,7 @@ class NwcOutgoingMetadataTest { /** * THE INTEROP PROPERTY. NIP-57 sets a zap invoice's `description_hash` to the * sha256 of the raw JSON the LNURL callback received in `nostr=` — which is - * `LnZapRequestEvent.toJson()` (see LightningAddressResolver). A wallet that + * `ZapRequestEvent.toJson()` (see LightningAddressResolver). A wallet that * binds a stored zap request to the invoice it labels hashes the bytes of the * `nostr` member, so anything short of byte-identity reads as a forged event * and the row is silently stored unlabelled. diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcRefusalRoundTripTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcRefusalRoundTripTest.kt index e90ea7e6a2..9c69def4ca 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcRefusalRoundTripTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcRefusalRoundTripTest.kt @@ -23,8 +23,8 @@ package com.vitorpamplona.quartz.nip47WalletConnect import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip47WalletConnect.cache.NostrWalletConnectResponseCache -import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent -import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcRequestEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcResponseEvent import com.vitorpamplona.quartz.nip47WalletConnect.rpc.IErrorResponseLike import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceMethod import kotlinx.coroutines.test.runTest @@ -50,14 +50,14 @@ class NwcRefusalRoundTripTest { for (useNip44 in listOf(false, true)) { val request = - LnZapPaymentRequestEvent.createRequest( + NwcRequestEvent.createRequest( PayInvoiceMethod.create("lnbc50n1pjtest"), walletSigner.pubKey, clientSigner, useNip44 = useNip44, ) - // Encrypted by hand rather than through LnZapPaymentResponseEvent.createResponse, + // Encrypted by hand rather than through NwcResponseEvent.createResponse, // which re-serializes a Response: the point is the wallet's bytes, verbatim. val encrypted = if (useNip44) { @@ -66,9 +66,9 @@ class NwcRefusalRoundTripTest { walletSigner.nip04Encrypt(walletJson, clientSigner.pubKey) } val reply = - walletSigner.sign( + walletSigner.sign( request.createdAt, - LnZapPaymentResponseEvent.KIND, + NwcResponseEvent.KIND, arrayOf(arrayOf("p", clientSigner.pubKey), arrayOf("e", request.id)), encrypted, ) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcRequestEventTest.kt similarity index 93% rename from quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestEventTest.kt rename to quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcRequestEventTest.kt index 07fa5ac130..2b3f56f594 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestEventTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcRequestEventTest.kt @@ -24,7 +24,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal -import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcRequestEvent import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBalanceMethod import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeInvoiceMethod import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceMethod @@ -36,10 +36,10 @@ import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue -class LnZapPaymentRequestEventTest { +class NwcRequestEventTest { @Test fun testEventKind() { - assertEquals(23194, LnZapPaymentRequestEvent.KIND) + assertEquals(23194, NwcRequestEvent.KIND) } @Test @@ -52,7 +52,7 @@ class LnZapPaymentRequestEventTest { walletKeyPair.pubKey.toHexKey() val event = - LnZapPaymentRequestEvent.create( + NwcRequestEvent.create( lnInvoice = "lnbc50n1...", walletServicePubkey = walletServicePubkey, signer = clientSigner, @@ -77,7 +77,7 @@ class LnZapPaymentRequestEventTest { val request = GetBalanceMethod.create() val event = - LnZapPaymentRequestEvent.createRequest( + NwcRequestEvent.createRequest( request = request, walletServicePubkey = walletServicePubkey, signer = clientSigner, @@ -101,7 +101,7 @@ class LnZapPaymentRequestEventTest { walletKeyPair.pubKey.toHexKey() val event = - LnZapPaymentRequestEvent.create( + NwcRequestEvent.create( lnInvoice = "lnbc50n1...", walletServicePubkey = walletServicePubkey, signer = clientSigner, @@ -125,7 +125,7 @@ class LnZapPaymentRequestEventTest { val request = MakeInvoiceMethod.create(5000L, "test payment") val event = - LnZapPaymentRequestEvent.createRequest( + NwcRequestEvent.createRequest( request = request, walletServicePubkey = walletServicePubkey, signer = clientSigner, @@ -150,7 +150,7 @@ class LnZapPaymentRequestEventTest { walletKeyPair.pubKey.toHexKey() val event = - LnZapPaymentRequestEvent.create( + NwcRequestEvent.create( lnInvoice = "lnbc50n1...", walletServicePubkey = walletServicePubkey, signer = clientSigner, diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestNip44EventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcRequestNip44EventTest.kt similarity index 92% rename from quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestNip44EventTest.kt rename to quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcRequestNip44EventTest.kt index 4e9a209c09..1a6c7ae24f 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestNip44EventTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcRequestNip44EventTest.kt @@ -24,7 +24,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal -import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent +import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcRequestEvent import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBalanceMethod import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetInfoMethod import kotlinx.coroutines.test.runTest @@ -32,7 +32,7 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertIs -class LnZapPaymentRequestNip44EventTest { +class NwcRequestNip44EventTest { @Test fun testCreateRequestWithNip44() = runTest { @@ -44,7 +44,7 @@ class LnZapPaymentRequestNip44EventTest { val request = GetBalanceMethod.Companion.create() val event = - LnZapPaymentRequestEvent.Companion.createRequest( + NwcRequestEvent.Companion.createRequest( request = request, walletServicePubkey = walletServicePubkey, signer = clientSigner, @@ -68,7 +68,7 @@ class LnZapPaymentRequestNip44EventTest { val request = GetInfoMethod.Companion.create() val event = - LnZapPaymentRequestEvent.Companion.createRequest( + NwcRequestEvent.Companion.createRequest( request = request, walletServicePubkey = walletServicePubkey, signer = clientSigner, diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip50Search/SearchFieldExtractorTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip50Search/SearchFieldExtractorTest.kt index 3075095a39..51b77ca13f 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip50Search/SearchFieldExtractorTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip50Search/SearchFieldExtractorTest.kt @@ -33,15 +33,15 @@ import com.vitorpamplona.quartz.nip15Marketplace.product.ProductEvent import com.vitorpamplona.quartz.nip15Marketplace.stall.StallEvent import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent -import com.vitorpamplona.quartz.nip29RelayGroups.moderation.EditMetadataEvent +import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.GroupEditMetadataEvent import com.vitorpamplona.quartz.nip32Labeling.LabelEvent import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent import com.vitorpamplona.quartz.nip5aStaticWebsites.NamedSiteEvent import com.vitorpamplona.quartz.nip69P2pOrderEvents.P2POrderEvent -import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent +import com.vitorpamplona.quartz.nip85TrustedAssertions.users.UserAssertionEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent @@ -72,7 +72,7 @@ class SearchFieldExtractorTest { @Test fun longFormDecomposesIntoTitleSummaryHashtagsContent() { val tags = arrayOf(arrayOf("d", "post"), arrayOf("title", "My Post"), arrayOf("summary", "tl;dr"), arrayOf("t", "nostr"), arrayOf("t", "search")) - val fields = SearchFieldExtractor.extract(LongTextNoteEvent("2".repeat(64), alice, 1L, tags, "the whole article", "")) + val fields = SearchFieldExtractor.extract(LongFormContentEvent("2".repeat(64), alice, 1L, tags, "the whole article", "")) assertEquals(IndexableFields.Tiered(primary = listOf("My Post"), secondary = listOf("tl;dr"), text = "the whole article", hashtags = listOf("nostr", "search")), fields) } @@ -153,7 +153,7 @@ class SearchFieldExtractorTest { } @Test - fun contactCardsDecomposeIntoPetnameSummaryAndTopics() { + fun userAssertionsDecomposeIntoPetnameSummaryAndTopics() { // A provider's petname for a person is that provider's NAME for them, // so it lands where kind 0's name does. topics() reads `t` tags, so // the tiers() funnel carries them once, as hashtags. @@ -165,7 +165,7 @@ class SearchFieldExtractorTest { arrayOf("t", "bitcoin"), arrayOf("rank", "87"), ) - val fields = SearchFieldExtractor.extract(ContactCardEvent("f".repeat(64), alice, 1L, tags, "", "")) + val fields = SearchFieldExtractor.extract(UserAssertionEvent("f".repeat(64), alice, 1L, tags, "", "")) assertEquals( IndexableFields.Tiered( primary = listOf("Verified Human"), @@ -177,24 +177,24 @@ class SearchFieldExtractorTest { } @Test - fun contactCardsCarryTopicsEvenWithNoPublicPetname() { + fun userAssertionsCarryTopicsEvenWithNoPublicPetname() { // THE SHAPE THIS LIBRARY ITSELF PUBLISHES: build() puts petname and // summary in the NIP-44 content, so a card's only public text is its // topics. They must still reach the backend -- through the hashtag // role, once -- and a hashtags-only extraction must not normalize to // None (Tiered.isEmpty() compares against a fully-empty Tiered). val tags = arrayOf(arrayOf("d", alice), arrayOf("t", "bitcoin"), arrayOf("t", "nostr"), arrayOf("rank", "87")) - val fields = SearchFieldExtractor.extract(ContactCardEvent("2a".repeat(32), alice, 1L, tags, "encrypted", "")) + val fields = SearchFieldExtractor.extract(UserAssertionEvent("2a".repeat(32), alice, 1L, tags, "encrypted", "")) assertEquals(IndexableFields.Tiered(hashtags = listOf("bitcoin", "nostr")), fields) } @Test - fun contactCardsWithNoPublicTextExtractNothing() { + fun userAssertionsWithNoPublicTextExtractNothing() { // The petname and summary of a private card live in the NIP-44 // encrypted content, which is never indexed -- so a card carrying only // scores has nothing to search. val tags = arrayOf(arrayOf("d", alice), arrayOf("rank", "87"), arrayOf("followers", "1200")) - assertEquals(IndexableFields.None, SearchFieldExtractor.extract(ContactCardEvent("1a".repeat(32), alice, 1L, tags, "encrypted", ""))) + assertEquals(IndexableFields.None, SearchFieldExtractor.extract(UserAssertionEvent("1a".repeat(32), alice, 1L, tags, "encrypted", ""))) } @Test @@ -270,7 +270,7 @@ class SearchFieldExtractorTest { // kind 9002 edits what kind 39000 publishes; it was the only half of // the pair without a branch. Its hashtags() is `t`: carried once. val tags = arrayOf(arrayOf("h", "grp"), arrayOf("name", "Nostr Devs"), arrayOf("about", "we build"), arrayOf("t", "nostr")) - val fields = SearchFieldExtractor.extract(EditMetadataEvent("23".repeat(32), alice, 1L, tags, "", "")) + val fields = SearchFieldExtractor.extract(GroupEditMetadataEvent("23".repeat(32), alice, 1L, tags, "", "")) assertEquals( IndexableFields.Tiered(primary = listOf("Nostr Devs"), secondary = listOf("we build"), hashtags = listOf("nostr")), fields, diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/RelayFeedsListEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/FavoriteRelayListEventTest.kt similarity index 87% rename from quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/RelayFeedsListEventTest.kt rename to quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/FavoriteRelayListEventTest.kt index 40af5a2166..62e54bdc8f 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/RelayFeedsListEventTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/FavoriteRelayListEventTest.kt @@ -28,7 +28,7 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue -class RelayFeedsListEventTest { +class FavoriteRelayListEventTest { private val signer = NostrSignerInternal("nsec10g0wheggqn9dawlc0yuv6adnat6n09anr7eyykevw2dm8xa5fffs0wsdsr".nsecToKeyPair()) private val damus = RelayUrlNormalizer.normalize("wss://relay.damus.io") @@ -37,7 +37,7 @@ class RelayFeedsListEventTest { @Test fun kindMatchesSpec() { - assertEquals(10012, RelayFeedsListEvent.KIND) + assertEquals(10012, FavoriteRelayListEvent.KIND) } /** @@ -49,8 +49,8 @@ class RelayFeedsListEventTest { fun removingOneRelayKeepsTheOtherPublicEntriesPublic() = runTest { val fromAnotherClient = - signer.sign( - RelayFeedsListEvent.build( + signer.sign( + FavoriteRelayListEvent.build( publicRelays = listOf(damus, nosLol), signer = signer, createdAt = 1740669816, @@ -60,7 +60,7 @@ class RelayFeedsListEventTest { assertEquals(listOf(damus, nosLol), fromAnotherClient.publicRelays()) val afterRemoval = - RelayFeedsListEvent.updateRelayList( + FavoriteRelayListEvent.updateRelayList( earlierVersion = fromAnotherClient, relays = listOf(damus), signer = signer, @@ -79,8 +79,8 @@ class RelayFeedsListEventTest { fun privateRelaysStayPrivate() = runTest { val mixed = - signer.sign( - RelayFeedsListEvent.build( + signer.sign( + FavoriteRelayListEvent.build( publicRelays = listOf(damus), privateRelays = listOf(nosLol, primal), signer = signer, @@ -89,7 +89,7 @@ class RelayFeedsListEventTest { ) val afterRemoval = - RelayFeedsListEvent.updateRelayList( + FavoriteRelayListEvent.updateRelayList( earlierVersion = mixed, relays = listOf(damus, nosLol), signer = signer, @@ -104,8 +104,8 @@ class RelayFeedsListEventTest { fun newRelaysFollowTheConventionOfTheList() = runTest { val publicOnly = - signer.sign( - RelayFeedsListEvent.build( + signer.sign( + FavoriteRelayListEvent.build( publicRelays = listOf(damus), signer = signer, createdAt = 1740669816, @@ -113,7 +113,7 @@ class RelayFeedsListEventTest { ) val afterAdd = - RelayFeedsListEvent.updateRelayList( + FavoriteRelayListEvent.updateRelayList( earlierVersion = publicOnly, relays = listOf(damus, nosLol), signer = signer, @@ -127,8 +127,8 @@ class RelayFeedsListEventTest { ) val privateOnly = - signer.sign( - RelayFeedsListEvent.build( + signer.sign( + FavoriteRelayListEvent.build( privateRelays = listOf(damus), signer = signer, createdAt = 1740669816, @@ -136,7 +136,7 @@ class RelayFeedsListEventTest { ) val afterPrivateAdd = - RelayFeedsListEvent.updateRelayList( + FavoriteRelayListEvent.updateRelayList( earlierVersion = privateOnly, relays = listOf(damus, nosLol), signer = signer, @@ -151,8 +151,8 @@ class RelayFeedsListEventTest { fun keepsUnrelatedTags() = runTest { val withTitle = - signer.sign( - RelayFeedsListEvent.build( + signer.sign( + FavoriteRelayListEvent.build( publicRelays = listOf(damus, nosLol), signer = signer, createdAt = 1740669816, @@ -162,7 +162,7 @@ class RelayFeedsListEventTest { ) val afterRemoval = - RelayFeedsListEvent.updateRelayList( + FavoriteRelayListEvent.updateRelayList( earlierVersion = withTitle, relays = listOf(nosLol), signer = signer, diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/RelayListPublicEntriesTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/RelayListPublicEntriesTest.kt index a4eb27a872..484ecb6f2f 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/RelayListPublicEntriesTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/RelayListPublicEntriesTest.kt @@ -60,10 +60,10 @@ class RelayListPublicEntriesTest { ) @Test - fun relayFeedsListKeepsTheSurvivorPublic() = + fun favoriteRelayListKeepsTheSurvivorPublic() = runTest { - val before = publicListFromAnotherClient(RelayFeedsListEvent.KIND) - val after = RelayFeedsListEvent.updateRelayList(before, listOf(damus), signer, 1740669817) + val before = publicListFromAnotherClient(FavoriteRelayListEvent.KIND) + val after = FavoriteRelayListEvent.updateRelayList(before, listOf(damus), signer, 1740669817) assertEquals(listOf(damus), after.tags.relays()) } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip52Calendar/CalendarHintProviderTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip52Calendar/CalendarHintProviderTest.kt index dba5cf91cd..42fc62755e 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip52Calendar/CalendarHintProviderTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip52Calendar/CalendarHintProviderTest.kt @@ -26,7 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent -import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent +import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarCollectionEvent import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent import kotlin.test.Test import kotlin.test.assertEquals @@ -66,7 +66,7 @@ class CalendarHintProviderTest { ) private fun calendar(vararg tags: Array) = - CalendarEvent( + CalendarCollectionEvent( id = "33".repeat(32), pubKey = host, createdAt = 1700000000, diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip54Wiki/WikiCollaborationTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip54Wiki/WikiCollaborationTest.kt index 6a81dd3b56..1d015a2d68 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip54Wiki/WikiCollaborationTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip54Wiki/WikiCollaborationTest.kt @@ -36,7 +36,7 @@ class WikiCollaborationTest { private fun mergeRequest(vararg tags: Array) = WikiMergeRequestEvent("id", "pk", 0L, arrayOf(*tags), "why", "sig") - private fun coordinate() = "${WikiNoteEvent.KIND}:$destination:hot-ice-creams" + private fun coordinate() = "${WikiArticleEvent.KIND}:$destination:hot-ice-creams" // ---- kind 818 -------------------------------------------------------------------------- @@ -115,7 +115,7 @@ class WikiCollaborationTest { fun buildsWithTheSpecMarker() { val template = WikiMergeRequestEvent.build( - targetArticle = Address(WikiNoteEvent.KIND, destination, "hot-ice-creams"), + targetArticle = Address(WikiArticleEvent.KIND, destination, "hot-ice-creams"), destinationAuthor = destination, mergeSourceId = fork, explanation = "I added a section", @@ -180,7 +180,7 @@ class WikiCollaborationTest { 0L, arrayOf( arrayOf("d", "shell-structure"), - arrayOf("a", "${WikiNoteEvent.KIND}:$destination:thin-shell-structure"), + arrayOf("a", "${WikiArticleEvent.KIND}:$destination:thin-shell-structure"), ), "", "sig", @@ -232,11 +232,11 @@ class WikiCollaborationTest { @Test fun buildsARedirectWithANormalizedSlug() { val template = - WikiRedirectEvent.build("Shell Structure", Address(WikiNoteEvent.KIND, destination, "thin-shell-structure")) + WikiRedirectEvent.build("Shell Structure", Address(WikiArticleEvent.KIND, destination, "thin-shell-structure")) val tags = template.tags.associate { it[0] to it[1] } assertEquals("shell-structure", tags["d"]) - assertEquals("${WikiNoteEvent.KIND}:$destination:thin-shell-structure", tags["a"]) + assertEquals("${WikiArticleEvent.KIND}:$destination:thin-shell-structure", tags["a"]) assertEquals("", template.content) } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip57Zaps/LnZapRequestAnonTagTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip57Zaps/ZapRequestAnonTagTest.kt similarity index 91% rename from quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip57Zaps/LnZapRequestAnonTagTest.kt rename to quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip57Zaps/ZapRequestAnonTagTest.kt index e22a2030d5..8bf0e14306 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip57Zaps/LnZapRequestAnonTagTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip57Zaps/ZapRequestAnonTagTest.kt @@ -37,7 +37,7 @@ import kotlin.test.assertTrue * (encrypted `anon`) requests use throwaway keys — tagging anything derived * from them is either useless or, for the decrypted sender, a doxxing risk. */ -class LnZapRequestAnonTagTest { +class ZapRequestAnonTagTest { private val signer = NostrSignerInternal( "nsec10g0wheggqn9dawlc0yuv6adnat6n09anr7eyykevw2dm8xa5fffs0wsdsr".nsecToKeyPair(), @@ -57,8 +57,8 @@ class LnZapRequestAnonTagTest { sig = "b".repeat(128), ) - private suspend fun request(zapType: LnZapEvent.ZapType) = - LnZapRequestEvent.create( + private suspend fun request(zapType: ZapReceiptEvent.ZapType) = + ZapRequestEvent.create( zappedEvent = zappedEvent, relays = relays, signer = signer, @@ -71,7 +71,7 @@ class LnZapRequestAnonTagTest { @Test fun `public zap request has no anon tag and is signed by the sender`() = runTest { - val zapRequest = request(LnZapEvent.ZapType.PUBLIC) + val zapRequest = request(ZapReceiptEvent.ZapType.PUBLIC) assertFalse(zapRequest.hasAnonTag()) assertFalse(zapRequest.isPrivateZap()) @@ -81,7 +81,7 @@ class LnZapRequestAnonTagTest { @Test fun `anonymous zap request has anon tag but is not private`() = runTest { - val zapRequest = request(LnZapEvent.ZapType.ANONYMOUS) + val zapRequest = request(ZapReceiptEvent.ZapType.ANONYMOUS) assertTrue(zapRequest.hasAnonTag()) assertFalse(zapRequest.isPrivateZap()) @@ -98,12 +98,12 @@ class LnZapRequestAnonTagTest { fun `anonymous profile zap request keeps the message public`() = runTest { val zapRequest = - LnZapRequestEvent.create( + ZapRequestEvent.create( userHex = receiverPubKey, relays = relays, signer = signer, message = "great work", - zapType = LnZapEvent.ZapType.ANONYMOUS, + zapType = ZapReceiptEvent.ZapType.ANONYMOUS, ) assertTrue(zapRequest.hasAnonTag()) @@ -115,7 +115,7 @@ class LnZapRequestAnonTagTest { @Test fun `private zap request has anon tag and is private and hides the sender key`() = runTest { - val zapRequest = request(LnZapEvent.ZapType.PRIVATE) + val zapRequest = request(ZapReceiptEvent.ZapType.PRIVATE) assertTrue(zapRequest.hasAnonTag()) assertTrue(zapRequest.isPrivateZap()) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip57Zaps/LnZapRequestGroupTagTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip57Zaps/ZapRequestGroupTagTest.kt similarity index 97% rename from quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip57Zaps/LnZapRequestGroupTagTest.kt rename to quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip57Zaps/ZapRequestGroupTagTest.kt index 2553b9e31d..14bce2e8e9 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip57Zaps/LnZapRequestGroupTagTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip57Zaps/ZapRequestGroupTagTest.kt @@ -39,7 +39,7 @@ import kotlin.test.assertTrue * their rooms) never matches it — so a zap on a group message would be invisible exactly where it was * meant to be seen. Reactions already copy the tag (`ReactionAction`); this pins the same rule for zaps. */ -class LnZapRequestGroupTagTest { +class ZapRequestGroupTagTest { private val signer = DeterministicSigner( "nsec10g0wheggqn9dawlc0yuv6adnat6n09anr7eyykevw2dm8xa5fffs0wsdsr".nsecToKeyPair(), @@ -63,13 +63,13 @@ class LnZapRequestGroupTagTest { ) private suspend fun zapRequestFor(zapped: Event) = - LnZapRequestEvent.create( + ZapRequestEvent.create( zappedEvent = zapped, relays = relays, signer = nostrSigner, pollOption = null, message = "", - zapType = LnZapEvent.ZapType.PUBLIC, + zapType = ZapReceiptEvent.ZapType.PUBLIC, toUserPubHex = null, ) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip57Zaps/LnZapRequestKindTagTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip57Zaps/ZapRequestKindTagTest.kt similarity index 93% rename from quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip57Zaps/LnZapRequestKindTagTest.kt rename to quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip57Zaps/ZapRequestKindTagTest.kt index 0ba8202418..38e125424d 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip57Zaps/LnZapRequestKindTagTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip57Zaps/ZapRequestKindTagTest.kt @@ -32,7 +32,7 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue -class LnZapRequestKindTagTest { +class ZapRequestKindTagTest { private val signer = DeterministicSigner( "nsec10g0wheggqn9dawlc0yuv6adnat6n09anr7eyykevw2dm8xa5fffs0wsdsr".nsecToKeyPair(), @@ -52,13 +52,13 @@ class LnZapRequestKindTagTest { val relays = setOf(NormalizedRelayUrl("wss://relay.example.com/")) val zapRequest = - LnZapRequestEvent.create( + ZapRequestEvent.create( zappedEvent = publicMsg, relays = relays, signer = nostrSigner, pollOption = null, message = "", - zapType = LnZapEvent.ZapType.PUBLIC, + zapType = ZapReceiptEvent.ZapType.PUBLIC, toUserPubHex = null, ) @@ -84,13 +84,13 @@ class LnZapRequestKindTagTest { val relays = setOf(NormalizedRelayUrl("wss://relay.example.com/")) val zapRequest = - LnZapRequestEvent.create( + ZapRequestEvent.create( zappedEvent = kind1Event, relays = relays, signer = nostrSigner, pollOption = null, message = "", - zapType = LnZapEvent.ZapType.PUBLIC, + zapType = ZapReceiptEvent.ZapType.PUBLIC, toUserPubHex = null, ) @@ -105,12 +105,12 @@ class LnZapRequestKindTagTest { val relays = setOf(NormalizedRelayUrl("wss://relay.example.com/")) val zapRequest = - LnZapRequestEvent.create( + ZapRequestEvent.create( userHex = receiverPubKey, relays = relays, signer = nostrSigner, message = "", - zapType = LnZapEvent.ZapType.PUBLIC, + zapType = ZapReceiptEvent.ZapType.PUBLIC, ) val kTag = zapRequest.tags.firstOrNull { it[0] == "k" } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip57Zaps/validate/LnZapReceiptValidatorTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip57Zaps/validate/ZapReceiptValidatorTest.kt similarity index 68% rename from quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip57Zaps/validate/LnZapReceiptValidatorTest.kt rename to quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip57Zaps/validate/ZapReceiptValidatorTest.kt index a3ce50b8d2..5487d552e0 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip57Zaps/validate/LnZapReceiptValidatorTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip57Zaps/validate/ZapReceiptValidatorTest.kt @@ -20,13 +20,13 @@ */ package com.vitorpamplona.quartz.nip57Zaps.validate -import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertIs import kotlin.test.assertTrue -class LnZapReceiptValidatorTest { +class ZapReceiptValidatorTest { // Mainnet 100,000 sat invoice (= 100_000_000 msats). From LnInvoiceUtilTest. private val invoice100kSats = "lnbc1m1pjt9u0qsp553q90pj5mafzv20w45eqavned9tgwhl4q99n9s5ppcw24nzw3zeqpp5002kd3ktym67du86kj665fgaev7ka8ys7j5yz5fg686lr5e2gfkshp5dkk27nnuax05az3pk2r6ytxtvwn5j4xzsq9ajprhc7crjkmgvr3qxqyjw5qcqpjrzjqtzxvfsuxe4l92pf97tt4rcgpy2xalkmlwexh899wqxf83l8nwv4xzh0gvqq89qqqqqqqqlgqqqqq0gqvs9qxpqysgqx5mz04wd7kqu5zhhel9enr036hjrp4gga0nz084p2asjl36a0zmrk6mhqa249zsgqref2rlvhffm73u7rxgr47gden6rugup4ksvpzsqvds4pz" @@ -62,13 +62,13 @@ class LnZapReceiptValidatorTest { signerPubkey: String = providerPubkey, bolt11: String? = invoice100kSats, description: String? = zapRequestJson(), - ): LnZapEvent { + ): ZapReceiptEvent { val tags = mutableListOf>() tags.add(arrayOf("p", recipientPubkey)) if (bolt11 != null) tags.add(arrayOf("bolt11", bolt11)) if (description != null) tags.add(arrayOf("description", description)) - return LnZapEvent( + return ZapReceiptEvent( id = "f".repeat(64), pubKey = signerPubkey, createdAt = 1700000001, @@ -80,99 +80,99 @@ class LnZapReceiptValidatorTest { @Test fun `valid receipt passes all checks`() { - val result = LnZapReceiptValidator.validate(receipt(), providerPubkey, lnurlOfRecipient) - assertEquals(LnZapReceiptValidator.Result.Valid, result) + val result = ZapReceiptValidator.validate(receipt(), providerPubkey, lnurlOfRecipient) + assertEquals(ZapReceiptValidator.Result.Valid, result) } @Test fun `missing zap request fails with MISSING_ZAP_REQUEST`() { val r = receipt(description = null) - val result = LnZapReceiptValidator.validate(r, providerPubkey, lnurlOfRecipient) - val invalid = assertIs(result) - assertEquals(LnZapReceiptValidator.Result.Reason.MISSING_ZAP_REQUEST, invalid.reason) + val result = ZapReceiptValidator.validate(r, providerPubkey, lnurlOfRecipient) + val invalid = assertIs(result) + assertEquals(ZapReceiptValidator.Result.Reason.MISSING_ZAP_REQUEST, invalid.reason) } @Test fun `wrong signer fails with MISMATCHED_NOSTR_PUBKEY`() { val attackerPubkey = "1".repeat(64) - val result = LnZapReceiptValidator.validate(receipt(signerPubkey = attackerPubkey), providerPubkey, lnurlOfRecipient) - val invalid = assertIs(result) - assertEquals(LnZapReceiptValidator.Result.Reason.MISMATCHED_NOSTR_PUBKEY, invalid.reason) + val result = ZapReceiptValidator.validate(receipt(signerPubkey = attackerPubkey), providerPubkey, lnurlOfRecipient) + val invalid = assertIs(result) + assertEquals(ZapReceiptValidator.Result.Reason.MISMATCHED_NOSTR_PUBKEY, invalid.reason) } @Test fun `null expected pubkey skips the check`() { val attackerPubkey = "1".repeat(64) - val result = LnZapReceiptValidator.validate(receipt(signerPubkey = attackerPubkey), null, lnurlOfRecipient) - assertEquals(LnZapReceiptValidator.Result.Valid, result) + val result = ZapReceiptValidator.validate(receipt(signerPubkey = attackerPubkey), null, lnurlOfRecipient) + assertEquals(ZapReceiptValidator.Result.Valid, result) } @Test fun `missing bolt11 fails with MISSING_OR_BAD_BOLT11`() { - val result = LnZapReceiptValidator.validate(receipt(bolt11 = null), providerPubkey, lnurlOfRecipient) - val invalid = assertIs(result) - assertEquals(LnZapReceiptValidator.Result.Reason.MISSING_OR_BAD_BOLT11, invalid.reason) + val result = ZapReceiptValidator.validate(receipt(bolt11 = null), providerPubkey, lnurlOfRecipient) + val invalid = assertIs(result) + assertEquals(ZapReceiptValidator.Result.Reason.MISSING_OR_BAD_BOLT11, invalid.reason) } @Test fun `mismatched amount fails with MISMATCHED_AMOUNT`() { // bolt11 is 100,000,000 msats; request claims 50,000,000. val r = receipt(description = zapRequestJson(amountMillisats = 50_000_000L)) - val result = LnZapReceiptValidator.validate(r, providerPubkey, lnurlOfRecipient) - val invalid = assertIs(result) - assertEquals(LnZapReceiptValidator.Result.Reason.MISMATCHED_AMOUNT, invalid.reason) + val result = ZapReceiptValidator.validate(r, providerPubkey, lnurlOfRecipient) + val invalid = assertIs(result) + assertEquals(ZapReceiptValidator.Result.Reason.MISMATCHED_AMOUNT, invalid.reason) } @Test fun `missing amount tag is accepted when not strict`() { val r = receipt(description = zapRequestJson(amountMillisats = null)) - val result = LnZapReceiptValidator.validate(r, providerPubkey, lnurlOfRecipient, strictAmount = false) - assertEquals(LnZapReceiptValidator.Result.Valid, result) + val result = ZapReceiptValidator.validate(r, providerPubkey, lnurlOfRecipient, strictAmount = false) + assertEquals(ZapReceiptValidator.Result.Valid, result) } @Test fun `missing amount tag fails when strict`() { val r = receipt(description = zapRequestJson(amountMillisats = null)) - val result = LnZapReceiptValidator.validate(r, providerPubkey, lnurlOfRecipient, strictAmount = true) - val invalid = assertIs(result) - assertEquals(LnZapReceiptValidator.Result.Reason.MISMATCHED_AMOUNT, invalid.reason) + val result = ZapReceiptValidator.validate(r, providerPubkey, lnurlOfRecipient, strictAmount = true) + val invalid = assertIs(result) + assertEquals(ZapReceiptValidator.Result.Reason.MISMATCHED_AMOUNT, invalid.reason) } @Test fun `mismatched lnurl fails with MISMATCHED_LNURL`() { val r = receipt(description = zapRequestJson(lnurl = "other@example.org")) - val result = LnZapReceiptValidator.validate(r, providerPubkey, lnurlOfRecipient) - val invalid = assertIs(result) - assertEquals(LnZapReceiptValidator.Result.Reason.MISMATCHED_LNURL, invalid.reason) + val result = ZapReceiptValidator.validate(r, providerPubkey, lnurlOfRecipient) + val invalid = assertIs(result) + assertEquals(ZapReceiptValidator.Result.Reason.MISMATCHED_LNURL, invalid.reason) } @Test fun `missing lnurl tag is accepted`() { val r = receipt(description = zapRequestJson(lnurl = null)) - val result = LnZapReceiptValidator.validate(r, providerPubkey, lnurlOfRecipient) - assertEquals(LnZapReceiptValidator.Result.Valid, result) + val result = ZapReceiptValidator.validate(r, providerPubkey, lnurlOfRecipient) + assertEquals(ZapReceiptValidator.Result.Valid, result) } @Test fun `lnurl tag in URL form matches lud16 expectation`() { val r = receipt(description = zapRequestJson(lnurl = lnurlUrl)) - val result = LnZapReceiptValidator.validate(r, providerPubkey, lnurlOfRecipient) - assertEquals(LnZapReceiptValidator.Result.Valid, result) + val result = ZapReceiptValidator.validate(r, providerPubkey, lnurlOfRecipient) + assertEquals(ZapReceiptValidator.Result.Valid, result) } @Test fun `lnurl tag in bech32 form matches lud16 expectation`() { val bech32 = LnurlForm.urlToBech32(lnurlUrl) val r = receipt(description = zapRequestJson(lnurl = bech32)) - val result = LnZapReceiptValidator.validate(r, providerPubkey, lnurlOfRecipient) - assertEquals(LnZapReceiptValidator.Result.Valid, result) + val result = ZapReceiptValidator.validate(r, providerPubkey, lnurlOfRecipient) + assertEquals(ZapReceiptValidator.Result.Valid, result) } @Test fun `pubkey check is case-insensitive`() { val r = receipt(signerPubkey = providerPubkey.uppercase()) - val result = LnZapReceiptValidator.validate(r, providerPubkey, lnurlOfRecipient) - assertEquals(LnZapReceiptValidator.Result.Valid, result) + val result = ZapReceiptValidator.validate(r, providerPubkey, lnurlOfRecipient) + assertEquals(ZapReceiptValidator.Result.Valid, result) } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/SecretFactoryTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/SecretFactoryTest.kt new file mode 100644 index 0000000000..1d6d88c318 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/SecretFactoryTest.kt @@ -0,0 +1,150 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip60Cashu.mintApi + +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The reserve/derive split. + * + * Reserving is the durability boundary — it suspends and must persist a NUT-13 + * counter before any secret derived from it reaches a mint. Deriving is pure. + * Keeping them apart is what lets [RandomSecretFactory] stay free of storage + * and lets the mint layer see where the write happens. + */ +class SecretFactoryTest { + private val seed = ByteArray(64) { it.toByte() } + + // Keyset ids are hex by NUT-02, and NUT-13 derivation decodes them, so a + // placeholder like "keyset1" is rejected before any secret is produced. + private val keysetA = "009a1f293253e41e" + private val keysetB = "00ad268c4d1f5826" + + @Test + fun randomFactoryReservesNothing() = + runTest { + val reservation = RandomSecretFactory.reserve(keysetA, 3) + + assertNull(reservation.firstCounter, "random secrets keep no durable state") + assertEquals(keysetA, reservation.keysetId) + } + + @Test + fun randomFactoryDerivesDistinctSecrets() = + runTest { + val secrets = RandomSecretFactory.nextSecrets(keysetA, 4) + + assertEquals(4, secrets.size) + assertEquals(4, secrets.map { it.secretHex }.toSet().size, "random secrets must not repeat") + } + + @Test + fun deterministicFactoryReservesTheWholeBatchOnce() = + runTest { + val calls = mutableListOf>() + val factory = + DeterministicSecretFactory( + seedProvider = { seed }, + reserveCounters = { keysetId, count -> + calls.add(keysetId to count) + 10L + }, + ) + + val reservation = factory.reserve(keysetA, 5) + + assertEquals(listOf(keysetA to 5), calls, "one reservation for the batch, not one per secret") + assertEquals(10L, reservation.firstCounter) + } + + /** + * With no seed the factory falls back to random, so reserving would burn + * counters for secrets that are never derived from them. + */ + @Test + fun noSeedMeansNoCountersBurned() = + runTest { + var reserved = false + val factory = + DeterministicSecretFactory( + seedProvider = { null }, + reserveCounters = { _, _ -> + reserved = true + 0L + }, + ) + + val reservation = factory.reserve(keysetA, 3) + + assertTrue(!reserved, "the counter store must not be touched") + assertNull(reservation.firstCounter) + } + + /** Derivation is pure: the same reservation yields the same secrets. */ + @Test + fun deriveIsDeterministicForAReservation() = + runTest { + val factory = DeterministicSecretFactory(seedProvider = { seed }, reserveCounters = { _, _ -> 7L }) + val reservation = SecretReservation(keysetA, 7L) + + assertEquals(factory.derive(reservation, 3), factory.derive(reservation, 3)) + } + + /** Consecutive counters must give different secrets, or a reused index would be harmless — it is not. */ + @Test + fun eachCounterInABatchDerivesADifferentSecret() = + runTest { + val factory = DeterministicSecretFactory(seedProvider = { seed }, reserveCounters = { _, _ -> 0L }) + + val secrets = factory.derive(SecretReservation(keysetA, 0L), 4) + + assertEquals(4, secrets.map { it.secretHex }.toSet().size) + } + + /** NUT-13 derivation is keyset-aware: the same counter on another keyset is a different secret. */ + @Test + fun theSameCounterOnAnotherKeysetDerivesADifferentSecret() = + runTest { + val factory = DeterministicSecretFactory(seedProvider = { seed }, reserveCounters = { _, _ -> 0L }) + + val onA = factory.derive(SecretReservation(keysetA, 0L), 1).first() + val onB = factory.derive(SecretReservation(keysetB, 0L), 1).first() + + assertNotEquals(onA.secretHex, onB.secretHex) + } + + /** A reservation carrying no counter derives random secrets, whatever the factory. */ + @Test + fun aCounterlessReservationFallsBackToRandom() = + runTest { + val factory = DeterministicSecretFactory(seedProvider = { seed }, reserveCounters = { _, _ -> 0L }) + + val first = factory.derive(SecretReservation(keysetA, null), 2) + val second = factory.derive(SecretReservation(keysetA, null), 2) + + assertNotEquals(first.map { it.secretHex }, second.map { it.secretHex }, "random, so not reproducible") + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip61Nutzaps/NutzapInfoEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip61Nutzaps/NutzapInfoEventTest.kt index 4b9c4dd50e..06eb717482 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip61Nutzaps/NutzapInfoEventTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip61Nutzaps/NutzapInfoEventTest.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.quartz.nip61Nutzaps import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal -import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent import com.vitorpamplona.quartz.nip61Nutzaps.info.NutzapInfoEvent import com.vitorpamplona.quartz.nip61Nutzaps.info.tags.NutzapMintTag import com.vitorpamplona.quartz.utils.nsecToKeyPair @@ -75,7 +75,7 @@ class NutzapInfoEventTest { fun deletionTargetsTheReplaceableAddress() = runTest { val empty = signer.sign(NutzapInfoEvent.buildEmpty()) - val deletion = signer.sign(DeletionEvent.build(listOf(empty))) + val deletion = signer.sign(DeletionRequestEvent.build(listOf(empty))) // NIP-09 deletion of a replaceable event needs the `a` address // coordinate so compliant relays drop all versions, plus the `e` diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip61Nutzaps/NutzapRedemptionTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip61Nutzaps/NutzapRedemptionTest.kt new file mode 100644 index 0000000000..28500b137b --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip61Nutzaps/NutzapRedemptionTest.kt @@ -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.quartz.nip61Nutzaps + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip60Cashu.history.CashuSpendingHistoryEvent +import com.vitorpamplona.quartz.nip60Cashu.token.CashuTokenEvent +import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent +import com.vitorpamplona.quartz.nip61Nutzaps.redemption.buildNutzapRedemption +import com.vitorpamplona.quartz.utils.EventFactory +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +/** + * NIP-61 reuses the NIP-60 wallet kinds: redeemed tokens are kind 7375 wallet token + * events and redemptions are kind 7376 spending history events. Each kind has a + * single class, so what EventFactory builds must expose the NIP-61 fields. + */ +class NutzapRedemptionTest { + private val sender = "1".repeat(64) + private val nutzap = NutzapEvent("a".repeat(64), sender, 1, emptyArray(), "", "b".repeat(128)) + + @Test + fun redemptionIsParsedAsSpendingHistory() { + val template = CashuSpendingHistoryEvent.buildNutzapRedemption(EventHintBundle(nutzap), "encrypted") + assertEquals(CashuSpendingHistoryEvent.KIND, template.kind) + + val event = EventFactory.create("c".repeat(64), "2".repeat(64), 2, template.kind, template.tags, template.content, "d".repeat(128)) + val history = assertIs(event) + + assertEquals(listOf(nutzap.id), history.redeemedNutzaps().map { it.eventId }) + assertEquals(listOf(nutzap.id), history.redeemedReferences().map { it.eventId }) + assertEquals(listOf(sender), history.linkedPubKeys()) + } + + @Test + fun walletTokenKindHasOneClass() { + val event = EventFactory.create("c".repeat(64), "2".repeat(64), 2, CashuTokenEvent.KIND, emptyArray(), "", "d".repeat(128)) + assertIs(event) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip71Video/AddressableVideoEventAddressTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip71Video/AddressableVideoEventAddressTest.kt index e31e88f745..283756ee98 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip71Video/AddressableVideoEventAddressTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip71Video/AddressableVideoEventAddressTest.kt @@ -53,7 +53,7 @@ class AddressableVideoEventAddressTest { @Test fun verticalVideoAddressUsesDTag() { val event = - VideoVerticalEvent( + AddressableShortVideoEvent( id = "bfe2f2244fefc7cebc7b2eae825495f99dabb4649ee3f90ab1fa33bcd1e9bb9f", pubKey = pubkey, createdAt = 1780894816, @@ -62,13 +62,13 @@ class AddressableVideoEventAddressTest { sig = "", ) - assertAddressUsesDTag(VideoVerticalEvent.KIND, event) + assertAddressUsesDTag(AddressableShortVideoEvent.KIND, event) } @Test fun horizontalVideoAddressUsesDTag() { val event = - VideoHorizontalEvent( + AddressableNormalVideoEvent( id = "bfe2f2244fefc7cebc7b2eae825495f99dabb4649ee3f90ab1fa33bcd1e9bb9f", pubKey = pubkey, createdAt = 1780894816, @@ -77,13 +77,13 @@ class AddressableVideoEventAddressTest { sig = "", ) - assertAddressUsesDTag(VideoHorizontalEvent.KIND, event) + assertAddressUsesDTag(AddressableNormalVideoEvent.KIND, event) } @Test fun videoWithoutDTagFallsBackToEmptyAddress() { val event = - VideoVerticalEvent( + AddressableShortVideoEvent( id = "bfe2f2244fefc7cebc7b2eae825495f99dabb4649ee3f90ab1fa33bcd1e9bb9f", pubKey = pubkey, createdAt = 1780894816, @@ -93,7 +93,7 @@ class AddressableVideoEventAddressTest { ) assertEquals("", event.dTag()) - assertEquals(Address(VideoVerticalEvent.KIND, pubkey, ""), event.address()) - assertEquals("${VideoVerticalEvent.KIND}:$pubkey:", event.addressTag()) + assertEquals(Address(AddressableShortVideoEvent.KIND, pubkey, ""), event.address()) + assertEquals("${AddressableShortVideoEvent.KIND}:$pubkey:", event.addressTag()) } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/DeprecatedMergedEventApiTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/DeprecatedMergedEventApiTest.kt new file mode 100644 index 0000000000..740e463ab3 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/DeprecatedMergedEventApiTest.kt @@ -0,0 +1,68 @@ +/* + * 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. + */ +@file:Suppress("DEPRECATION") + +package com.vitorpamplona.quartz.utils + +import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.SoftwareReleaseEvent +import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.build +import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.buildDTag +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip60Cashu.history.CashuSpendingHistoryEvent +import com.vitorpamplona.quartz.nip60Cashu.token.CashuTokenEvent +import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent +import com.vitorpamplona.quartz.nip61Nutzaps.redemption.NutzapRedemptionEvent +import com.vitorpamplona.quartz.nip61Nutzaps.redemption.build +import com.vitorpamplona.quartz.nip61Nutzaps.token.TokenEvent +import com.vitorpamplona.quartz.nip61Nutzaps.token.build +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * The classes merged into another class keep their old companion calls compiling + * through the deprecated typealiases. If any of these stop resolving, external + * Quartz users break on upgrade. + */ +class DeprecatedMergedEventApiTest { + private val nutzap = NutzapEvent("a".repeat(64), "1".repeat(64), 1, emptyArray(), "", "b".repeat(128)) + + @Test + fun tokenEventBuildStillTakesEncryptedContent() { + val template = TokenEvent.build("encrypted") + assertEquals(CashuTokenEvent.KIND, template.kind) + assertEquals("encrypted", template.content) + } + + @Test + fun nutzapRedemptionBuildStillAddsRedeemedAndSenderTags() { + val template = NutzapRedemptionEvent.build(EventHintBundle(nutzap), "encrypted") + assertEquals(CashuSpendingHistoryEvent.KIND, template.kind) + assertEquals(listOf("e", "p"), template.tags.map { it[0] }) + } + + @Test + fun softwareReleaseBuildStillBuildsNip82Release() { + assertEquals("app@1.0", SoftwareReleaseEvent.buildDTag("app", "1.0")) + val template = SoftwareReleaseEvent.build("app", "1.0", "main", emptyList()) + assertEquals("app@1.0", template.tags.first { it[0] == "d" }[1]) + assertEquals("1.0", template.tags.first { it[0] == "version" }[1]) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/jcs/JsonCanonicalizationTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/jcs/JsonCanonicalizationTest.kt new file mode 100644 index 0000000000..ca871a7eeb --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/jcs/JsonCanonicalizationTest.kt @@ -0,0 +1,184 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.utils.jcs + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +/** + * RFC 8785 conformance. + * + * The number cases are the ones that matter: they are where an implementation + * that "works" silently produces a different hash from every other one. + */ +class JsonCanonicalizationTest { + @Test + fun `sorts object keys by UTF-16 code unit`() { + val input = linkedMapOf("b" to 1, "a" to 2, "C" to 3, "ä" to 4) + // Uppercase sorts before lowercase, and non-ASCII after both. + assertEquals("""{"C":3,"a":2,"b":1,"ä":4}""", JsonCanonicalization.canonicalize(input)) + } + + @Test + fun `sorts nested objects too`() { + val input = mapOf("z" to linkedMapOf("y" to 1, "x" to 2)) + assertEquals("""{"z":{"x":2,"y":1}}""", JsonCanonicalization.canonicalize(input)) + } + + @Test + fun `preserves array order`() { + assertEquals("""[3,1,2]""", JsonCanonicalization.canonicalize(listOf(3, 1, 2))) + } + + @Test + fun `emits no insignificant whitespace`() { + val input = mapOf("a" to listOf(1, mapOf("b" to true)), "c" to null) + assertEquals("""{"a":[1,{"b":true}],"c":null}""", JsonCanonicalization.canonicalize(input)) + } + + @Test + fun `escapes only what RFC 8785 requires`() { + val input = + mapOf( + "k" to + "a\"b\\c\nd\te" + '\u0008' + "f" + '\u000C' + "g\rh" + '\u0001' + "i", + ) + assertEquals( + "{\"k\":\"a\\\"b\\\\c\\nd\\te\\bf\\fg\\rh\\u0001i\"}", + JsonCanonicalization.canonicalize(input), + ) + } + + @Test + fun `leaves non-ASCII literal rather than escaping it`() { + // The canonical form is UTF-8. Escaping to \u would be a different + // byte sequence and therefore a different hash. + assertEquals("""{"k":"héllo → 🚀"}""", JsonCanonicalization.canonicalize(mapOf("k" to "héllo → 🚀"))) + } + + // --- numbers: ECMAScript Number::toString --- + + @Test + fun `renders integral values without a decimal point`() { + assertEquals("0", JsonCanonicalization.canonicalNumber(0.0)) + assertEquals("1", JsonCanonicalization.canonicalNumber(1.0)) + assertEquals("123", JsonCanonicalization.canonicalNumber(123.0)) + assertEquals("-123", JsonCanonicalization.canonicalNumber(-123.0)) + } + + @Test + fun `renders negative zero as zero`() { + // ECMAScript prints both zeroes as "0", so they canonicalize identically. + assertEquals("0", JsonCanonicalization.canonicalNumber(-0.0)) + } + + @Test + fun `renders fractions plainly inside the non-exponential range`() { + assertEquals("1.5", JsonCanonicalization.canonicalNumber(1.5)) + assertEquals("0.5", JsonCanonicalization.canonicalNumber(0.5)) + assertEquals("-0.5", JsonCanonicalization.canonicalNumber(-0.5)) + assertEquals("0.000001", JsonCanonicalization.canonicalNumber(0.000001)) + } + + @Test + fun `switches to exponential below 1e-6`() { + // The boundary ECMAScript defines: 1e-6 prints plainly, 1e-7 does not. + assertEquals("1e-7", JsonCanonicalization.canonicalNumber(1e-7)) + assertEquals("1.5e-7", JsonCanonicalization.canonicalNumber(1.5e-7)) + } + + @Test + fun `switches to exponential at 1e21 and carries a plus sign`() { + // JVM toString gives "1.0E21"; ECMAScript and therefore JCS want "1e+21". + assertEquals("1e+21", JsonCanonicalization.canonicalNumber(1e21)) + assertEquals("1e+30", JsonCanonicalization.canonicalNumber(1e30)) + assertEquals("1.5e+30", JsonCanonicalization.canonicalNumber(1.5e30)) + } + + @Test + fun `prints 1e20 plainly because it is still inside the range`() { + assertEquals("100000000000000000000", JsonCanonicalization.canonicalNumber(1e20)) + } + + @Test + fun `renders the extremes of the double range`() { + assertEquals("5e-324", JsonCanonicalization.canonicalNumber(Double.MIN_VALUE)) + assertEquals("1.7976931348623157e+308", JsonCanonicalization.canonicalNumber(Double.MAX_VALUE)) + } + + @Test + fun `renders values that need every significant digit`() { + assertEquals("0.1", JsonCanonicalization.canonicalNumber(0.1)) + assertEquals("0.30000000000000004", JsonCanonicalization.canonicalNumber(0.1 + 0.2)) + assertEquals("9007199254740991", JsonCanonicalization.canonicalNumber(9007199254740991.0)) + } + + @Test + fun `integers arrive through the same path as doubles`() { + assertEquals("""{"a":1,"b":2}""", JsonCanonicalization.canonicalize(mapOf("a" to 1, "b" to 2L))) + } + + @Test + fun `rejects values JSON cannot represent`() { + assertFailsWith { JsonCanonicalization.canonicalNumber(Double.NaN) } + assertFailsWith { + JsonCanonicalization.canonicalNumber(Double.POSITIVE_INFINITY) + } + } + + @Test + fun `rejects a non-string object key`() { + assertFailsWith { + JsonCanonicalization.canonicalize(mapOf(1 to "a")) + } + } + + @Test + fun `rejects a type it cannot represent`() { + assertFailsWith { + JsonCanonicalization.canonicalize(mapOf("k" to Any())) + } + } + + @Test + fun `canonicalizes the RFC 8785 number sample`() { + // The number array from RFC 8785's worked example. Each entry exercises a + // different branch: full significant digits, the upper exponential + // boundary, a stripped trailing zero, a small plain fraction, and the + // lower exponential boundary. + val input = + mapOf( + "numbers" to listOf(333333333.33333329, 1E30, 4.50, 2e-3, 0.000000000000000000000000001), + ) + assertEquals( + """{"numbers":[333333333.3333333,1e+30,4.5,0.002,1e-27]}""", + JsonCanonicalization.canonicalize(input), + ) + } + + @Test + fun `escapes a dollar sign and a solidus literally`() { + // Neither has a short escape in RFC 8785, and the solidus is explicitly + // NOT escaped even though JSON permits it. + assertEquals("{\"k\":\"$100/mo\"}", JsonCanonicalization.canonicalize(mapOf("k" to "\u0024100/mo"))) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256Test.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256Test.kt new file mode 100644 index 0000000000..d58c57ddb0 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256Test.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.utils.sha256 + +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Known-answer vectors (FIPS 180-2) for every platform's sha256 actual. The empty + * input case guards the Apple actual, which used to pin `addressOf(0)` on a + * zero-length array and throw ArrayIndexOutOfBoundsException. + */ +class Sha256Test { + private val emptyDigest = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + private val abcDigest = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + + private fun ByteArray.hex() = joinToString("") { (it.toInt() and 0xFF).toString(16).padStart(2, '0') } + + @Test + fun hashesEmptyInput() { + assertEquals(emptyDigest, sha256(ByteArray(0)).hex()) + } + + @Test + fun hashesAbc() { + assertEquals(abcDigest, sha256("abc".encodeToByteArray()).hex()) + } + + @Test + fun sha256IntoHashesEmptyInput() { + assertEquals(emptyDigest, sha256Into(ByteArray(32), ByteArray(0)).hex()) + } + + @Test + fun sha256IntoHashesOnlyThePrefix() { + val out = ByteArray(32) + assertEquals(abcDigest, sha256Into(out, "abcdef".encodeToByteArray(), 3).hex()) + assertEquals(emptyDigest, sha256Into(out, "abcdef".encodeToByteArray(), 0).hex()) + } +} diff --git a/quartz/src/commonTest/resources/cordn/coordinator-contracts.json b/quartz/src/commonTest/resources/cordn/coordinator-contracts.json new file mode 100644 index 0000000000..07d89923e7 --- /dev/null +++ b/quartz/src/commonTest/resources/cordn/coordinator-contracts.json @@ -0,0 +1,386 @@ +{ + "_comment": "Generated by quartz/tools/cordn-vector-gen from @cordn/core (MIT). Every payload here was validated by cordn's own zod schemas / bech32 codec. Do not hand-edit.", + "generator": { + "cordnCore": "0.5.5" + }, + "methods": { + "publishKeyPackage": "kp_publish", + "listAvailableKeyPackages": "kp_list", + "consumeKeyPackage": "kp_take", + "removeKeyPackages": "kp_remove", + "fetchPendingWelcomes": "welcome_take", + "storeWelcome": "welcome_store", + "storeJoinRequest": "join_request_store", + "fetchManyPendingJoinRequests": "join_request_take_many", + "postGroupMessage": "msg_post", + "fetchManyGroupMessages": "msg_fetch_many", + "subscribeManyGroupMessages": "msg_sub_many" + }, + "contracts": { + "kp_publish": { + "input": { + "kp_ref": "1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f", + "kp_64": "AAECAwQFBgc=" + }, + "output": { + "kp_ref": "1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f", + "last_resort": false, + "at": 1757000000 + }, + "rejects": [ + { + "kp_64": "AAECAwQFBgc=" + }, + { + "kp_ref": "1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f", + "keyPackageBase64": "AAECAwQFBgc=" + } + ] + }, + "kp_list": { + "input": {}, + "output": { + "keyPackages": [ + { + "pk": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "kp_ref": "1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f", + "last_resort": false, + "at": 1757000000 + }, + { + "pk": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "kp_ref": "2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e", + "last_resort": true, + "at": 1757000100 + } + ] + }, + "rejects": [ + { + "keyPackages": [ + { + "pk": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "kp_ref": "1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f", + "at": 1757000000 + } + ] + } + ] + }, + "kp_take": { + "input": { + "id": "1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f" + }, + "output": { + "keyPackage": { + "pk": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "kp_ref": "2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e", + "last_resort": true, + "at": 1757000100, + "event": { + "id": "0000000000000000000000000000000000000000000000000000000000000000", + "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "created_at": 1757000100, + "kind": 25910, + "tags": [ + [ + "p", + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + ] + ], + "content": "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"kp_publish\",\"arguments\":{\"kp_ref\":\"2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e\",\"kp_64\":\"AAECAwQFBgc=\"}}}", + "sig": "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + } + } + }, + "emptyOutput": { + "keyPackage": null + }, + "rejects": [ + { + "keyPackage": { + "pk": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "kp_ref": "2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e", + "last_resort": true, + "at": 1757000100 + } + } + ] + }, + "kp_remove": { + "input": { + "kp_refs": [ + "1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f", + "2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e" + ] + }, + "output": { + "kp_refs": [ + "1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f" + ] + }, + "rejects": [ + { + "kp_refs": "1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f" + } + ] + }, + "welcome_take": { + "input": {}, + "inputWithConsumed": { + "consumed": [ + { + "kp_ref": "1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f", + "at": 1757000000 + } + ] + }, + "output": { + "welcomes": [ + { + "kp_ref": "1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f", + "welcome_64": "V2VsY29tZQ==", + "at": 1757000200, + "after": 12 + }, + { + "kp_ref": "2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e", + "welcome_64": "V2VsY29tZTI=", + "at": 1757000300 + } + ] + }, + "rejects": [ + { + "consumed": [ + { + "kp_ref": "1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f" + } + ] + } + ] + }, + "welcome_store": { + "input": { + "target_pk": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "kp_ref": "2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e", + "welcome_64": "V2VsY29tZQ==", + "after": 12 + }, + "inputWithoutAfter": { + "target_pk": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "kp_ref": "2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e", + "welcome_64": "V2VsY29tZQ==" + }, + "output": { + "at": 1757000200 + }, + "rejects": [ + { + "kp_ref": "2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e", + "welcome_64": "V2VsY29tZQ==" + } + ] + }, + "join_request_store": { + "input": { + "gid": "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21", + "kp_ref": "1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f" + }, + "output": { + "at": 1757000400 + }, + "rejects": [ + { + "kp_ref": "1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f" + } + ] + }, + "join_request_take_many": { + "input": { + "groups": [ + { + "gid": "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21" + } + ] + }, + "inputWithConsumed": { + "groups": [ + { + "gid": "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21" + } + ], + "consumed": [ + { + "gid": "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21", + "pk": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "at": 1757000400 + } + ] + }, + "output": { + "requests": [ + { + "pk": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "kp_ref": "2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e", + "at": 1757000400, + "gid": "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21" + } + ] + }, + "rejects": [ + { + "requests": [ + { + "pk": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "kp_ref": "2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e", + "at": 1757000400 + } + ] + }, + { + "groups": [ + "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21" + ] + } + ] + }, + "msg_post": { + "input": { + "gid": "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21", + "msg_64": "c2VhbGVk" + }, + "output": { + "gid": "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21", + "cursor": 42, + "at": 1757000500 + }, + "rejects": [ + { + "gid": "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21" + } + ] + }, + "msg_fetch_many": { + "input": { + "groups": [ + { + "gid": "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21" + } + ] + }, + "inputWithCursor": { + "groups": [ + { + "gid": "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21", + "after": 42 + } + ] + }, + "output": { + "messages": [ + { + "gid": "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21", + "cursor": 43, + "msg_64": "c2VhbGVkLTE=", + "at": 1757000600 + }, + { + "gid": "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21", + "cursor": 44, + "msg_64": "c2VhbGVkLTI=", + "at": 1757000700 + } + ] + }, + "rejects": [ + { + "groups": [ + { + "gid": "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21", + "after": "42" + } + ] + } + ] + }, + "msg_sub_many": { + "input": { + "groups": [ + { + "gid": "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21", + "after": 42 + } + ] + }, + "output": { + "subscribed": true, + "groups": [ + "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21" + ] + }, + "streamFragment": { + "gid": "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21", + "cursor": 45, + "msg_64": "c2VhbGVkLTM=", + "at": 1757000800 + }, + "rejects": [ + { + "subscribed": false, + "groups": [ + "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21" + ] + } + ] + } + }, + "groupRefs": [ + { + "gid": "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21", + "encoded": "cordn1qqjrvep3vccxvdnp95exzvm9956xvvnr95ukzvty95mkxdnzx4jngepnvyerz8f868g" + }, + { + "gid": "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21", + "coordinatorPubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "encoded": "cordn1qysvenxvenxvenxvenxvenxvenxvenxvenxvenxvenxvenxvenxvenqqysmxgvtxxpnrvcfdxfsnxefdx3nryced89snzepdxa3nvc34v56xgvmpxgcs59pdwr" + }, + { + "gid": "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21", + "coordinatorPubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "relays": [ + "wss://relay.example.com/" + ], + "encoded": "cordn1qgv8wumn8ghj7un9d3shjtn90psk6urvv5hxxmmd9uqjpnxvenxvenxvenxvenxvenxvenxvenxvenxvenxvenxvenxvenxvqqjrvep3vccxvdnp95exzvm9956xvvnr95ukzvty95mkxdnzx4jngepnvyerzxaqmx8" + }, + { + "gid": "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21", + "coordinatorPubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "relays": [ + "wss://relay.example.com/", + "wss://relay2.example.com/" + ], + "encoded": "cordn1qgv8wumn8ghj7un9d3shjtn90psk6urvv5hxxmmd9uppjamnwvaz7tmjv4kxz7fj9ejhsctdwpkx2tnrdakj7qfqenxvenxvenxvenxvenxvenxvenxvenxvenxvenxvenxvenxvenxqqfpkvsckvvrxxesj6vnpxdjj6drxxf3j6wtpx9jz6dmrxe3r2ef5vsekzv33f7g84r" + }, + { + "gid": "a", + "encoded": "cordn1qqqkzhjmr98" + }, + { + "gid": "grupo-café-éàü", + "encoded": "cordn1qqfxwun4wphj6cmpvmp6jtwr48p6psauqarqhy" + } + ], + "groupRefUppercase": { + "gid": "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21", + "coordinatorPubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "encoded": "CORDN1QYSVENXVENXVENXVENXVENXVENXVENXVENXVENXVENXVENXVENXVENQQYSMXGVTXXPNRVCFDXFSNXEFDX3NRYCED89SNZEPDXA3NVC34V56XGVMPXGCS59PDWR" + }, + "groupRefRejects": [ + "cordn1qqqqq", + "nostr1qqqqq", + "CORDN1QQQQQ", + "cordn", + "" + ] +} diff --git a/quartz/src/commonTest/resources/tsmls/README.md b/quartz/src/commonTest/resources/tsmls/README.md new file mode 100644 index 0000000000..adb9ff0c4e --- /dev/null +++ b/quartz/src/commonTest/resources/tsmls/README.md @@ -0,0 +1,35 @@ +# ts-mls interop fixtures + +Generated by **ts-mls** (the reference MLS implementation cordn's own client +uses), not by us. They are the cross-implementation half of +`spec00Coordinator`/`spec03Payloads` testing: everything else in this module +proves we agree with ourselves. + +Copied from **Staircase** — , +`conformance/fixtures/gen/`, commit `d9dd1a0` — which is MIT licensed +(Copyright (c) 2026 relay.tools). The generator that produced them is +`conformance/fixtures-gen/gen.ts` in that repository. + +Only the files our tests read are vendored; the originals include multi-device, +media and three-member fixtures we have no use for yet. + +## The lifecycle these describe + +Alice (`aa…aa`) creates a group with metadata, adds Bob (`bb…bb`), and sends an +application message. `gid` is the delivery id. + +| File | What | +| ---- | ---- | +| `alice.pk`, `bob.pk`, `gid` | the actors and the delivery group id | +| `bob-kp.bin`, `bob-privkp.bin`, `bob-kpref.hex` | Bob's KeyPackage, its private half in ts-mls's `privateKeyPackageEncoder` layout, and its RFC 9420 KeyPackageRef | +| `bob-lastresort-kp.bin`, `bob-lastresort-kpref.hex` | the same marked last-resort, via `app_data_dictionary` component `0x0004` | +| `meta-1.json` | the `cordn_group_metadata` Alice created the group with | +| `exporter-e0.hex` | `MLS-Exporter("cordn","group-payload",32)` at epoch 0 | +| `commit-add.b64`, `commit-add-sealed.b64` | the Add commit, raw and sealed under the PRE-commit epoch key | +| `welcome.b64` | the Welcome that admits Bob | +| `exporter-e1.hex` | the same exporter at epoch 1 | +| `app-1.b64`, `app-1-sealed.b64`, `envelope-1.json` | Alice's application message: MLS bytes, sealed form, and the envelope inside it | + +Note `app-1` carries `authenticated_data` = UTF-8 of Alice's pubkey. cordn +rejects an application message without it, and the spec never mentions it — see +`spec02Envelopes/CordnApplicationMessage`. diff --git a/quartz/src/commonTest/resources/tsmls/alice.pk b/quartz/src/commonTest/resources/tsmls/alice.pk new file mode 100644 index 0000000000..71b7a71962 --- /dev/null +++ b/quartz/src/commonTest/resources/tsmls/alice.pk @@ -0,0 +1 @@ +aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \ No newline at end of file diff --git a/quartz/src/commonTest/resources/tsmls/app-1-sealed.b64 b/quartz/src/commonTest/resources/tsmls/app-1-sealed.b64 new file mode 100644 index 0000000000..854339faff --- /dev/null +++ b/quartz/src/commonTest/resources/tsmls/app-1-sealed.b64 @@ -0,0 +1 @@ +Vx7fA90kNPdkQTos2EUfMxTQyaxr31Z8Du+6xHDZW+EFSAIWBeWx7cYYk/o+f9hkX0WpRTIJAW18xlMSHZqaNJZTdKlKLA1BXo74LlmWkF2CklFSWzFCrVAZkdXLEFWv37CA/jqyx5vj92iECOFpPUjHupu/frnJs1SAsFnp+YVp8aejAf11BcaBIcJOOXhn8gy0vcK9BUSq5YEuSswWXFAdG/R8NmSV59f1+Juh0tr1/eJLeosiQH2jp/42k3yAfjRTQqAXxKOko/yqhmVyZzu/j/Mvu3xlVDEj8L4QrS8BshyYwmpRHCyQUNdCVGAkXcVcE5pUtqC7uqTrGZ2C0kA3AQvevSoFc4m+juTKzn/lYNrFmShNKQYuY2Ztv0XBXNnSHk46rOLcagB7tpHfBAZV12TDlfUBMuX1WgiI8vw5HTIuNHANIENo/ETNrTgT8mW6zM2WZBfoR3HJXfQWrmAtRsgsZ0mj1kwav+J8pMCYTRdnQeKNRuFq/NePSH3xRA7LREaQj3EafaYVSjP/cGDOOg3TFRdp8BSUrYqVN07FKyssDQkB/9ktCxUOWEkfZiI+1ScVa+XGdXpuzLK6mh1RIK900zLyDUbLCTh4Vab65eqV1qOJppKGWkW+KanMlRsNt68Y/eLFuAfzb/Ck8E3+ERqLXnH57NoH4e5C4QfG4i/s8cT1mgzZhD50Z/LnIAj+LYLpu0sMgeJiJXTbsQ== \ No newline at end of file diff --git a/quartz/src/commonTest/resources/tsmls/app-1.b64 b/quartz/src/commonTest/resources/tsmls/app-1.b64 new file mode 100644 index 0000000000..3e8a5b8716 --- /dev/null +++ b/quartz/src/commonTest/resources/tsmls/app-1.b64 @@ -0,0 +1 @@ +AAEAAhtzdGFpcmNhc2UtY29uZm9ybWFuY2UtZ2lkLTEAAAAAAAAAAQFAQGFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWEcClw/xgK9wHoxslXko2Mtf6lsYARPc+TLlYT9qEF6ofy1MNiHjW4JdFsUYHHvJlehr2R4Z03Urnxq9RZeUxQ3+NwuJQj83Uu3ooow9VBYbbqvW5nAngQOzmnbtIt+GxzfxQWRQyHIkiJpNaoKMVmmbjDhWMQ6ZrXZNxmKHFTU5IT+jqcaF1yzbR5a+mbftI9vTYKrQwzfcVduVUFoUePKFb+4W6TR7AGM8FBxidJPaS0l+Q7WHaK5pinNi+q1bpk5O5TigivuypkJMsLBid3mbcYylQf7Vhz0UGSlHJX0qFvsZIflInpBdUWqbSZVcjIu6AQNqyE0mu1kIuZD229x4687PwxN0wEZkBmQ9k02SzwyBtFwnPp4scQpqiV7cZSMMfjA85Hm2MngZJZmftIGq66GPXPm9WWD/3Jfo/oh6Q4/Y2QoIvVkDC/uo60dudwsDcFnOgJ10XM3w50eyfbpLLxdd9GKtOYsmK6dGxiZl46tZ4fPSh+PLemtnleSP8Bdh4+jyfwmP0mt5p8amyY0fSZPOC2iwP2l \ No newline at end of file diff --git a/quartz/src/commonTest/resources/tsmls/app-2-sealed.b64 b/quartz/src/commonTest/resources/tsmls/app-2-sealed.b64 new file mode 100644 index 0000000000..789bba0abc --- /dev/null +++ b/quartz/src/commonTest/resources/tsmls/app-2-sealed.b64 @@ -0,0 +1 @@ +vflv1/yY10H3C5/V8XD78iY30V4UCV1RvwGyDLdCHGvtpMDlUYfD4vhs6q1X9bAZP/Xi0+82AS1Jv9rwOAB0qvbsZI1PPM20mK8qGk3SXrBMDd0ZephmAFTuDAcw0ioSd67O1FUepBG/5uyMr/Zk9NCS87RCcXGtY0M0OzcDz34ERrlUwlxMM9d2gPMgZAs8b1FuQf4uaKbuTE9XYYqaSXRbPDDTZ5RX7LFMPo9hqxbkWd/J/CPGvx0nzd4E/e3TjD5GdySW9huEXWvSQ9ietfHtj2UB/my9JP3JhJtUtewTCyTHLGdcYUZJwjDukPPFVp1v8snYKeis1r021ZvQmwQCeW6zKxGnT1qqbCWfsN8dibF8dRP/iOkML6gkFq9TFohAjExq8AVhde5uBy7OUtudcWD+Pl7wEVbwUpbLu166KoiIIAOEIJ6cRV87dvjMYPP5t50bnTbf4P0BPHBeDcQ1wnJ2x+id6qzEREXe1B3hDOIDky5JGUyLzA7u8qeR0hPLZle0B+uhJaM6pqgNBHl1ZzPL1ifSa6ZK/kJTOH/nk8agM06RsAR42scieP5XTbIhxC/mjFyAvWxvCO0Sd05qPiVRL6xg82KGPs7pXfNcRsrziLpLRN229RxiPCt3x6JgjA9KLFN65jNnofcEtp/OR1lvimtd/HW6qb5vxpXayAUbO1TyPIyj/0GlmQUHs4NIGCTD0vTdgXHGtuE4ZDqXG+Xd+rtl25Q7JdYmM/wfyr6sq7Iv4s7kt7tXjaUwkWCodxVbFHTmbdAQAujOXFaymOcJdhXapZgUmp0hqI+NacgXeBbtEe+gB90elgSCMRJp3NUp \ No newline at end of file diff --git a/quartz/src/commonTest/resources/tsmls/app-2.b64 b/quartz/src/commonTest/resources/tsmls/app-2.b64 new file mode 100644 index 0000000000..28081d449e --- /dev/null +++ b/quartz/src/commonTest/resources/tsmls/app-2.b64 @@ -0,0 +1 @@ +AAEAAhtzdGFpcmNhc2UtY29uZm9ybWFuY2UtZ2lkLTEAAAAAAAAAAgFAQGFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWEc9O2FAe3ZQqYftiKbAFK9k30vIRV5VdnsMbkGC0HQBhwc/60MQyixbwotWEqvz8i8J/Go608Y61remWpuyl6zbxQETHEnkLcMTJJuJSFZZyKQXFbE+Rnsj1JdBTB7P9PjtOgpTbsyvPO31D2MeMymblzXkjMMw/3hUXZVCoDKiWmIuS6AahAS1vmkEzvJjaTItFP/pkTIk3FmcREouSajlhio3L70BC02clU6sGcu68Kb3OulfviPDc0AwLuvD2sOfLctYI6CHQQs9eFHafA48yalsMfR0lZyFvrIpr8KNurzOW7qcDo0iqfnuHy5vFPlPwn3qKB/XxP30e+whdog060TgYdbvMCeWXEMYdPOuOgpcSXaTMZnXeqZZTyh/PPLWAbzmFrRsfQoRWmktBikW3/nZyE6j3c8FGywrMiq7/kE8AXkC70Wh2nnLwA3PTbzvqo3uVXq3J06YNhi+DUCgVNoEZfcK5fpTCDoo7CLBJFofhXrrccM4pPCpm8wkpEvyeLi97nKOhsBnAIjTif8zdrrN0yhZVvlePuCUhFRDtVbcN9hR+6R/3sXHDMZ0rvDlpG2QHeZ/OtXOouLUngOE1L9h+q5iuYAsI7cZeXmdiv2+4Ut2I4X8038bWrNwbjffD2vpVQCYMuIgkk9FEQ= \ No newline at end of file diff --git a/quartz/src/commonTest/resources/tsmls/bob-kp.bin b/quartz/src/commonTest/resources/tsmls/bob-kp.bin new file mode 100644 index 0000000000..79106a1ee4 Binary files /dev/null and b/quartz/src/commonTest/resources/tsmls/bob-kp.bin differ diff --git a/quartz/src/commonTest/resources/tsmls/bob-kpref.hex b/quartz/src/commonTest/resources/tsmls/bob-kpref.hex new file mode 100644 index 0000000000..49ff3ea2c3 --- /dev/null +++ b/quartz/src/commonTest/resources/tsmls/bob-kpref.hex @@ -0,0 +1 @@ +946816156873cffa538f63644a2d44624d48e8fbb6b0958c58531d80f0e75f33 \ No newline at end of file diff --git a/quartz/src/commonTest/resources/tsmls/bob-lastresort-kp.bin b/quartz/src/commonTest/resources/tsmls/bob-lastresort-kp.bin new file mode 100644 index 0000000000..2d125d7aac Binary files /dev/null and b/quartz/src/commonTest/resources/tsmls/bob-lastresort-kp.bin differ diff --git a/quartz/src/commonTest/resources/tsmls/bob-lastresort-kpref.hex b/quartz/src/commonTest/resources/tsmls/bob-lastresort-kpref.hex new file mode 100644 index 0000000000..17bcb50589 --- /dev/null +++ b/quartz/src/commonTest/resources/tsmls/bob-lastresort-kpref.hex @@ -0,0 +1 @@ +a8b705cda5b133634d31f4e2f4438186b25b91d23d159600663ba2560fc58ffd \ No newline at end of file diff --git a/quartz/src/commonTest/resources/tsmls/bob-privkp.bin b/quartz/src/commonTest/resources/tsmls/bob-privkp.bin new file mode 100644 index 0000000000..962baaba15 Binary files /dev/null and b/quartz/src/commonTest/resources/tsmls/bob-privkp.bin differ diff --git a/quartz/src/commonTest/resources/tsmls/bob.pk b/quartz/src/commonTest/resources/tsmls/bob.pk new file mode 100644 index 0000000000..0e71009335 --- /dev/null +++ b/quartz/src/commonTest/resources/tsmls/bob.pk @@ -0,0 +1 @@ +bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb \ No newline at end of file diff --git a/quartz/src/commonTest/resources/tsmls/bob2-kp.bin b/quartz/src/commonTest/resources/tsmls/bob2-kp.bin new file mode 100644 index 0000000000..f38381c97e Binary files /dev/null and b/quartz/src/commonTest/resources/tsmls/bob2-kp.bin differ diff --git a/quartz/src/commonTest/resources/tsmls/bob2-kpref.hex b/quartz/src/commonTest/resources/tsmls/bob2-kpref.hex new file mode 100644 index 0000000000..5054894b76 --- /dev/null +++ b/quartz/src/commonTest/resources/tsmls/bob2-kpref.hex @@ -0,0 +1 @@ +c58c248465ee592c041b961981be8080bc6e772a5743138dbf3ccee8c3c0fcdb \ No newline at end of file diff --git a/quartz/src/commonTest/resources/tsmls/bob2-privkp.bin b/quartz/src/commonTest/resources/tsmls/bob2-privkp.bin new file mode 100644 index 0000000000..7ed2524b42 Binary files /dev/null and b/quartz/src/commonTest/resources/tsmls/bob2-privkp.bin differ diff --git a/quartz/src/commonTest/resources/tsmls/commit-add-sealed.b64 b/quartz/src/commonTest/resources/tsmls/commit-add-sealed.b64 new file mode 100644 index 0000000000..aeedfe4d25 --- /dev/null +++ b/quartz/src/commonTest/resources/tsmls/commit-add-sealed.b64 @@ -0,0 +1 @@ +U2UAiQ2F5QLquDurFnYpGoSEH7koKkwAKyz6Bxyxr8UULVcaPJoBwr5jq6UYnUR7vR3RhmwkIrjYqd85r/lNljvcwyTzS0kWr3fBr3lM+8r8kB3pDzNlmKEm3wXR1nWIw52inGzTBfamyK/I6RtEjoK1FMCzt5/+h2ArjhHi1q58lbUI6SwPLtyYCYvpQRoP+Kb6uJdRdJyG33RZZ77J65ami45ANgaOWT5iegHEdnRPBCYLT8s1JNya/ejnV/ojBlQpMrjPPROuDsGYb8Cp18Q1msU3ASYMbtno8iYYkq1aMJU0O/sgKicXN0e+fDlMZfODfN2T7hzVrzhRR7ItuMNR85cjO+pHg+KW8invyD1L7QzFlALYlJPXiBY2bHmcGA/XVAPcnGpz/qV/EFm9gvuOol7sOnL+o4zwCyoC2SRNp/pe/9vH+48jI6YZ6uCkQY5j+AtsyOa2dDSfQ0vdUMKr+QgdDVaOloRXp+AR2AG5HS98pq32XUfCevZCV3Nru8bhnzH491YunbFjIiSJ/uCxTsYfjkquRXoKpwJ4mdV1wumbyRWmJIVQvdLPE+neP2HhM0k6CNcZKovfUBpGvBl7/0Mi2ax1qFfc6e1uvulAtUNDPDwzh0VKpioIgDjvGkFjmVawKbuEAzHfD19WJq4fe6YEKbcB8wfPN5sOu58UB7UamZ/B7RS3hnXeCog+DpRli9dtaVxmHCKp4G+eKOV3yRHTrHOyMqxx1lrCB8hVxRi38q+hrLT3IjIrU8V2mNjHF1ONLyFeQHjcBPlCycIs+MmWp0401I5dFntaTw== \ No newline at end of file diff --git a/quartz/src/commonTest/resources/tsmls/commit-add.b64 b/quartz/src/commonTest/resources/tsmls/commit-add.b64 new file mode 100644 index 0000000000..cfc348f2ed --- /dev/null +++ b/quartz/src/commonTest/resources/tsmls/commit-add.b64 @@ -0,0 +1 @@ +AAEAAhtzdGFpcmNhc2UtY29uZm9ybWFuY2UtZ2lkLTEAAAAAAAAAAAMAHBR1JYMyulPydTNkijeWsL5bfIOcs3Lj3Svtb4lB+v/Sc3pqj2WxtHZgaVlZ+0gztUw+NqETsgSCLtUH1u6Twez81oull+LvM9JQdXacjSWqa0KhiNPU/AgCcwwLV29UriXPhPOgRlY713EZqwlewxgS2UB6I80XISbpFNHuiMFZiXCqraYewU7UhYsyjgWsax91Mc9ARHub9pI5NIICy/oiOEibO3RQfF1PZitbaMjWwDqmWmnd/Im7ZmAglL/HjYuFawYqiNlErPlC5WuudirWsaS9Kd02JWZicFjMaFtaMAZh3VSt2VQ3tmBIPTeoHtCL+728frA+Q60fw2s5MjU8vvfNTbXnRrMHYB+UTCHAFvTHFSI/1Pv/6o5y690AIkDWKmzdZUjAloK2mrcXgWAJuQEYODgTPtHTLiAzVC5vwoewqOjJv0yF0/WQv3XvzuIFlKev43uTmFWU281ZSu15hUaSEb4lx2zsph2cfxpMAKGq0N+uImcht16UoooN1aZ9DkwxcPVQKGQeHM/sw841i9AqDsspq9UIhWWCLFWmh0R1zY2AL/PZRy52N/vLZSo5+UocA9CgqOWuKPht0O4hO9sKYQD3hhdOeEiJffHO7N9UJgm3zruZ3BYZqyHA8uF8+UaKCA03VN4oGWFA+dZOgU/Yq9pJjQr8e3BrPkOSRlOtz7/pWMEBzUPHX7h02sIykjtNLJFW \ No newline at end of file diff --git a/quartz/src/commonTest/resources/tsmls/commit-meta-sealed.b64 b/quartz/src/commonTest/resources/tsmls/commit-meta-sealed.b64 new file mode 100644 index 0000000000..0d1f6a4eb8 --- /dev/null +++ b/quartz/src/commonTest/resources/tsmls/commit-meta-sealed.b64 @@ -0,0 +1 @@ +VsgX7mX1z0ujSl2MOaQ58uj3BZ3UcMiG5gp0EGi1/6HynI8+BUU2WLTH7+La4DEHsX6M8JdKY9OVC9ComRQSU4p21G0Czfg6uo6kGYuBukUn9QUFwcBTr6sPXQJSNWwRcFuLKtqc+I+3OOla74xDnZhq4UptkML15/0CJdH3XWJDqzmjVDK0XFVU8D4jqrPhE06Mrfgz5Hp4kmSdlFQufT0L2s+lF1NBz84P4z1/tQe7CiLnf6b0a5Gw5erraW/P7bhMUN2hRm4TVsD9haz2BL1bqPuF+YgyoMa7FqZPlLYSIbqFjmBx11jTV/W3HFp29G8pt57HqBD5pZdNqQJT1mu4oNJjpP2tV9LjZ5i5K9ic1SufSTMk79WC3Hzxa0+iqABVtJC832qZmoFlFQK5kho+oHjRgbrUaKTml2kMVs1KockNBmbfiZNIDj59XPf759uvrLW2vrYDOoR57qeKSmAiZ/vXyaNgr9lwJpO7ORnXlf6n2/PwX0I4j9z9BxbtqqTjeRPObyrcEDhhc1NCwdvseabIy8FCnrxhfz1CeUjVZBNELEdcWojKEg7a/873lZNvksEk6rfnreZ1C/EbzDW2L+vtMo53oYwgIbhrPZsa4Miom1SnZ/LcYHDWlGE7SZFxotceJFsTlk8ahHz8JJhViK/RbGJFxuoGxtRfCRWfcacDc9Ea7LrvM98jugPnKei0SEMRIfqfvdaBu4NKYA6M0SB5KMh6dP+HdGWH6g9Ip0fX8xIiv6qeiku4lkcDxIg3rSg+/IbNwU6w4+ZWQHxEXSGTFCQqgIv2yHiMg1ZYXGjpdn9wOlLtxlpM3Jtlg38Lur/zPESCTCVEjrhsTdj+jAQegV0PQc5jH4AskPD4w0aQ54/RlulxLjqb+vy1XBM+yWuSFss0dned1Qz9rFotPQwBzG8J4nNeJcPcsVt2F2KWpU20a57m6PtVv7d6kYaEgXu/oxQhQBS1sNKaUeDE9bIcBM1yBl1DSreUwn2acQ== \ No newline at end of file diff --git a/quartz/src/commonTest/resources/tsmls/commit-meta.b64 b/quartz/src/commonTest/resources/tsmls/commit-meta.b64 new file mode 100644 index 0000000000..5e811bf91b --- /dev/null +++ b/quartz/src/commonTest/resources/tsmls/commit-meta.b64 @@ -0,0 +1 @@ +AAEAAhtzdGFpcmNhc2UtY29uZm9ybWFuY2UtZ2lkLTEAAAAAAAAAAQMAHGwxwtIyuyJLoVfk7uV6IjH3qlUnETdM12IZcrpCjTUNnvdEiR7tRiOQs0hv08jwpI6mw3uJ3aXEpUeQcAvs/Wunm1NuZX4itKRRT/eUfxicX1itiDHu0yknHCxwDQT2Z7blCF+Y5TPLk7bvK6Wui7bQ3gtbr7G0OYnc7yX6qez37sXOHCXK2FVoffuF71OWZj68hv3f4VFRKpQiynDL3ZJ4KSAEOyFjZsDKAPyzaXxw4fRhvGd0zn4PahKVqQqC88LNgLRmhsdrpa0aCB/F0NUYR3ezkmGTuCXHETxvKjnkE+3DM/s9GydkxHSfmYwjcqXAQF+obYnm+5gwTmzqu68o0q2irCq3pSp5VfevEmKanNgvujmLgWocJ+DO0ONSjWGCGOYfQXWOGKI1kVHmw6AneHNhVKkdBCPnCxMOEP6TYi6NBWCLld48lFp4HNDr4TICkFTNjJOHkmWLVOP4Bt8yTS4le98AcT9ybNPaHx+LqyKUXbr2MDogGCRJhiycZ3AN/xJLZH77XmO4CoFCzlTxUqej2T77fEbpVNW2LAPYPj0OsgVJICaKf3s1rYOvu6nB0gKsBhA5mJE1mPR5S2NPapzHmIG8vLKK9v8WkzW+M6BdpX/RysD08Cupl8cF7V1EM+zPWuSWlE24IfvYMeS358UuwU7lsM0st9keis+7flK1j6xpaAjven70JMUmPdfjcV23BiyFJ1pM+srhj+QKzCLSqEwSU00RXusZ/+hgbDrEd/LFnG1wtjdaLiQwpGgreHvqDczpvAkC+r2H61cZQ4ysXSjJ6z7TqyfTZiy90UNA07Zy7GShj0Xc/TNI1N5rbffPynOJ+qhD1FN/3CV2F6fXu8oY/mp4660mmT3xdmOjtr7XsSFII/LZGTN/apxiseZ49UPZ0Le3 \ No newline at end of file diff --git a/quartz/src/commonTest/resources/tsmls/envelope-1.json b/quartz/src/commonTest/resources/tsmls/envelope-1.json new file mode 100644 index 0000000000..95156d7ee3 --- /dev/null +++ b/quartz/src/commonTest/resources/tsmls/envelope-1.json @@ -0,0 +1 @@ +{"pubkey":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","created_at":1700000000,"kind":9,"tags":[["p","bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"]],"content":"hello from ts-mls","id":"92726892b7665f0c2bf53733e52f50c01f202a756696e58eb84e64177ce72601"} \ No newline at end of file diff --git a/quartz/src/commonTest/resources/tsmls/envelope-2.json b/quartz/src/commonTest/resources/tsmls/envelope-2.json new file mode 100644 index 0000000000..1ac321a1c4 --- /dev/null +++ b/quartz/src/commonTest/resources/tsmls/envelope-2.json @@ -0,0 +1 @@ +{"pubkey":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","created_at":1700000002,"kind":1111,"tags":[["e","92726892b7665f0c2bf53733e52f50c01f202a756696e58eb84e64177ce72601","","aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"],["k","9"]],"content":"reply at epoch 2 ✨","id":"e73aa0bc24b3f5fcb02547b00045d006859427888cc8f549048b1fd6ddf12234"} \ No newline at end of file diff --git a/quartz/src/commonTest/resources/tsmls/exporter-e0.hex b/quartz/src/commonTest/resources/tsmls/exporter-e0.hex new file mode 100644 index 0000000000..d245a85273 --- /dev/null +++ b/quartz/src/commonTest/resources/tsmls/exporter-e0.hex @@ -0,0 +1 @@ +141ec831ebd0c989ad977e6dd9b28eeaee8a608ff7fe137d52f571aa4045b680 \ No newline at end of file diff --git a/quartz/src/commonTest/resources/tsmls/exporter-e1.hex b/quartz/src/commonTest/resources/tsmls/exporter-e1.hex new file mode 100644 index 0000000000..8888211f9a --- /dev/null +++ b/quartz/src/commonTest/resources/tsmls/exporter-e1.hex @@ -0,0 +1 @@ +b4d177e140624c206b3e1890138b17cc2a2f889c3569faf3ccea77e6d6ed3d31 \ No newline at end of file diff --git a/quartz/src/commonTest/resources/tsmls/exporter-e2.hex b/quartz/src/commonTest/resources/tsmls/exporter-e2.hex new file mode 100644 index 0000000000..95b5d76f8e --- /dev/null +++ b/quartz/src/commonTest/resources/tsmls/exporter-e2.hex @@ -0,0 +1 @@ +b0ba537f4b2256e7c3473fbd078ebbc0362a81b0b5a0bcd0572e5b8ea0e7a593 \ No newline at end of file diff --git a/quartz/src/commonTest/resources/tsmls/gid b/quartz/src/commonTest/resources/tsmls/gid new file mode 100644 index 0000000000..41e91eb013 --- /dev/null +++ b/quartz/src/commonTest/resources/tsmls/gid @@ -0,0 +1 @@ +staircase-conformance-gid-1 \ No newline at end of file diff --git a/quartz/src/commonTest/resources/tsmls/meta-1.json b/quartz/src/commonTest/resources/tsmls/meta-1.json new file mode 100644 index 0000000000..3f2d3189b3 --- /dev/null +++ b/quartz/src/commonTest/resources/tsmls/meta-1.json @@ -0,0 +1 @@ +{"name":"Conformance","description":"ts-mls ↔ staircase","adminPubkeys":["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"],"icon":"🪜"} \ No newline at end of file diff --git a/quartz/src/commonTest/resources/tsmls/meta-2.json b/quartz/src/commonTest/resources/tsmls/meta-2.json new file mode 100644 index 0000000000..aa36fd56a7 --- /dev/null +++ b/quartz/src/commonTest/resources/tsmls/meta-2.json @@ -0,0 +1 @@ +{"name":"Conformance (renamed)","description":"updated by ts-mls","adminPubkeys":["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"],"icon":"🪜","imageUrl":"https://example.invalid/g.png"} \ No newline at end of file diff --git a/quartz/src/commonTest/resources/tsmls/welcome.b64 b/quartz/src/commonTest/resources/tsmls/welcome.b64 new file mode 100644 index 0000000000..23adf90bc0 --- /dev/null +++ b/quartz/src/commonTest/resources/tsmls/welcome.b64 @@ -0,0 +1 @@ +AAEAAwABQHYglGgWFWhzz/pTj2NkSi1EYk1I6Pu2sJWMWFMdgPDnXzMgwoylwN0z/xCGTlObRBCnAtpCHmMJQmrrOOp2fXqpzD4zKK8OcMS/7X6046dq4S6jkvlqTOav50y1xZN8yI8iAbhQC99j+HDTzv2iCrTITCLyBEhcQ2+/u3EYUtX2UrD2qzWKM9quqjGiO5azt3hX7IFJ83O/xBdze3rewaEfoS9NuRaO1tyJtS39QG+HMGyU/V+DtyUWNdGlinmEcT35JljIip3wnwyxvf41bWTleS6Xk5qfhfAeHvmYFmHowKRwyd5rsnAHs+Lb1rOqWA1CmtSAYi0Kh0aXQSmsHiFPZvZhHV+y8kXAWwtqr58LWebQSPv6iwEb6wbgXKic6PSSrSdtbv1XgjjPVHdA696J5cxFsQpPL8/cX+UZJZVg98mLibBxbyNZDxDPWXB2im3cgpn+UoqoOaxlx0RfFZkOEaRPd+48QsITaRrbw4vs2Hoq6cQI1sUlMa8wwbNMtG4Ybm7foMaGaZmHG8REMfzcbi3NyolGmxBWEaNbrLXclrAqcH2gojj0B8jXiYGsA5D4moEiqPybwUdxdPD/DYzp0rvAbw6bWpWmin4c9r2Vmvvt5bhPd8cn+hmQchKvscAh0mf5GXTwa/rsypesJtF+8xo0st4bdUptfkJzbaadc6h4h31a+5/798GghhGkcteE0hPHzEHXAwnEaN8/yyMPRK6JRmqcLPqKNz4oPybPezu3+yi4DSlLYmStIynd/6jJKRoc/b3Yvjs2FTdT2hJgHtb1taOq4Tq8TxXaeILAIc6WX/bFnYc8IIb6XMMOoY7HBakN7sI5v18a7M1hmOSeycz62s3z1zn+gDfhHgh6Kpi4BqNFNTufKMJ3IF/Tm32I01Ao43ISKlqroQaL2GCZfLZE0aeYgKZT4HZhVS2KPN1CJ2H7bQC2fccoD6VbfXeXU2OP03bmjuY/JQdV6idY1CZeZ0y6ZkUxg2ab0FvRosarlsjeWt/JR5zuiIgqQmPWcjtu8fu2LjYgUO0yeN+/CgqfKWMXEemZXJMUUkx5ywF8qx5LAKNGZZBLpM2vDV1Ia3BPQhrzjGI3z5jTPW7SHZ2m0FSHMSEmPZg/wpELbBl8D/72qA6QyKs+15rChIITvPd/Cr7HgjvYfTO3wLYSn101r3H7eurqQJ6akChbfAOIA0d7/6eQNf0l5HR8M/3dwS9UwoH+6KguLCNMNfZDH4vCj+qTljS0Bff2H9Aq6OmUZZXQF8b+VQJuUAS8WLyVPx50SGEFiGqeymYgbcOnCXQ3ARbJjN6JkHnnehbNZhgpOciOQiE= \ No newline at end of file diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/Ed25519.jvmAndroid.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/mls/crypto/Ed25519.jvmAndroid.kt similarity index 98% rename from quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/Ed25519.jvmAndroid.kt rename to quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/mls/crypto/Ed25519.jvmAndroid.kt index fa500dc60a..027d6d8a8d 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/Ed25519.jvmAndroid.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/mls/crypto/Ed25519.jvmAndroid.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.crypto +package com.vitorpamplona.quartz.mls.crypto import com.vitorpamplona.quartz.utils.RandomInstance import io.github.andreypfau.kotlinx.crypto.Sha512 @@ -115,7 +115,7 @@ actual object Ed25519 { } actual fun keyPairFromSeed(seed: ByteArray): Ed25519KeyPair { - require(seed.size == SEED_LENGTH) { "Seed must be 32 bytes" } + require(seed.size == SEED_LENGTH) { "Ed25519 seed must be $SEED_LENGTH bytes, was ${seed.size}" } val publicKey = derivePublicKey(seed) return Ed25519KeyPair(seed + publicKey, publicKey) } diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/X25519.jvmAndroid.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/mls/crypto/X25519.jvmAndroid.kt similarity index 99% rename from quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/X25519.jvmAndroid.kt rename to quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/mls/crypto/X25519.jvmAndroid.kt index 68d0e477f4..43193cba33 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/X25519.jvmAndroid.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/mls/crypto/X25519.jvmAndroid.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.crypto +package com.vitorpamplona.quartz.mls.crypto import com.vitorpamplona.quartz.utils.RandomInstance diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/CashuMintOperations.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/CashuMintOperations.kt index bedae4d049..024413a720 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/CashuMintOperations.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/CashuMintOperations.kt @@ -723,7 +723,7 @@ class CashuMintOperations( */ private suspend fun fetchInputFeePpkByKeyset(): Map = client.keysets().keysets.associate { it.id to (it.inputFeePpk ?: 0L) } - private fun createBlindedOutputs( + private suspend fun createBlindedOutputs( amount: Long, keyset: KeysetDto, ): List = secretOutputsFor(splitAmounts(amount), keyset) @@ -735,7 +735,7 @@ class CashuMintOperations( * [SecretFactory.nextSecret] per amount instead would take the * @Synchronized lock + dirty `AccountSettings.saveable` N times. */ - private fun secretOutputsFor( + private suspend fun secretOutputsFor( amounts: List, keyset: KeysetDto, ): List { @@ -744,7 +744,12 @@ class CashuMintOperations( // [secretFactory] decides whether those bytes are pure-random or // NUT-13-derived from a wallet seed; either way the on-wire shape // is identical so the mint can't tell which scheme we're using. - val derived = secretFactory.nextSecrets(keyset.id, amounts.size) + // Two steps on purpose. reserve() suspends and persists the NUT-13 + // counter; derive() is pure. Keeping them apart means the durability + // boundary is visible here, at the only layer that can await it, rather + // than hidden inside secret derivation. + val reservation = secretFactory.reserve(keyset.id, amounts.size) + val derived = secretFactory.derive(reservation, amounts.size) return amounts.mapIndexed { i, amount -> val pair = derived[i] val bTick = Bdhke.blind(pair.secretHex.encodeToByteArray(), pair.blindingFactor) diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/contextvm/cep04Encryption/CvmGiftWrapCryptoTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/contextvm/cep04Encryption/CvmGiftWrapCryptoTest.kt new file mode 100644 index 0000000000..a2e6127b95 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/contextvm/cep04Encryption/CvmGiftWrapCryptoTest.kt @@ -0,0 +1,170 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.contextvm.cep04Encryption + +import com.vitorpamplona.quartz.contextvm.core.CvmKinds +import com.vitorpamplona.quartz.contextvm.core.CvmMessageEvent +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcId +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcRequest +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +/** + * `CVM-4-*`: the gift wrap round trip against real NIP-44 and secp256k1. + * + * Lives in jvmTest rather than commonTest because it needs the secp256k1 JNI. + */ +class CvmGiftWrapCryptoTest { + private val clientSigner = NostrSignerInternal(KeyPair()) + private val serverSigner = NostrSignerInternal(KeyPair()) + private val crypto = CvmGiftWrap() + + private suspend fun innerMessage(): Event = + CvmMessageEvent.create( + message = JsonRpcRequest(JsonRpcId.Num(1), "tools/list"), + recipient = serverSigner.pubKey, + signer = clientSigner, + ) + + @Test + fun `CVM-4-10 round-trips a wrapped message and recovers the signed inner event`() = + runTest { + val inner = innerMessage() + val wrap = crypto.wrap(inner, serverSigner.pubKey) + + assertEquals(CvmKinds.EPHEMERAL_GIFT_WRAP, wrap.kind) + + val unwrapped = crypto.unwrap(wrap, serverSigner) + assertEquals(inner.id, unwrapped.id) + assertEquals(inner.pubKey, unwrapped.pubKey) + assertEquals(inner.content, unwrapped.content) + assertEquals(CvmKinds.MESSAGE, unwrapped.kind) + } + + @Test + fun `CVM-4-11 the inner event is signed by the real client, not the wrap key`() = + runTest { + // CEP-4 signs the inner event first and encrypts the whole signed + // event. The wrap's own signature only proves the throwaway key + // signed it, so authorship comes from the inner one. + val inner = innerMessage() + val wrap = crypto.wrap(inner, serverSigner.pubKey) + + assertEquals(clientSigner.pubKey, crypto.unwrap(wrap, serverSigner).pubKey) + assertNotEquals(clientSigner.pubKey, wrap.pubKey, "the wrap must not be signed by the client key") + } + + @Test + fun `CVM-4-12 each wrap uses a fresh throwaway key`() = + runTest { + val inner = innerMessage() + val first = crypto.wrap(inner, serverSigner.pubKey) + val second = crypto.wrap(inner, serverSigner.pubKey) + + assertNotEquals( + first.pubKey, + second.pubKey, + "reusing a wrap key would link two messages from the same sender", + ) + } + + @Test + fun `CVM-4-13 the wrap addresses the recipient with a p tag`() = + runTest { + val wrap = crypto.wrap(innerMessage(), serverSigner.pubKey) + val pTag = wrap.tags.first { it[0] == "p" } + assertEquals(serverSigner.pubKey, pTag[1]) + } + + @Test + fun `CVM-4-14 the wrap timestamp is the real send time, not NIP-59's shifted one`() = + runTest { + // Found live, not by reading: this test used to assert the + // opposite, and it passed. The reference ContextVM server + // subscribes with `since = now` when it connects — the obvious + // filter for a live request stream — so a relay drops a wrap dated + // in the past and the request is never delivered. It does not + // fail; it times out, which is why a fixture server with no + // `since` filter could not catch it. + val before = TimeUtils.now() + val wrap = crypto.wrap(innerMessage(), serverSigner.pubKey) + val after = TimeUtils.now() + + assertTrue(wrap.createdAt >= before, "a backdated wrap is invisible to a `since = now` subscriber") + assertTrue(wrap.createdAt <= after, "and a future-dated one is invisible to an `until` filter") + } + + @Test + fun `CVM-4-15 rejects an inner event whose signature does not verify`() = + runTest { + // Without this check, anyone able to encrypt to us could claim any + // pubkey: the wrap signature proves nothing about the inner author. + val inner = innerMessage() + val forged = + Event( + id = inner.id, + pubKey = inner.pubKey, + createdAt = inner.createdAt, + kind = inner.kind, + tags = inner.tags, + content = inner.content, + sig = "00".repeat(64), + ) + val wrap = crypto.wrap(forged, serverSigner.pubKey) + + assertFailsWith { crypto.unwrap(wrap, serverSigner) } + } + + @Test + fun `CVM-4-16 rejects a wrap addressed to someone else`() = + runTest { + val wrap = crypto.wrap(innerMessage(), serverSigner.pubKey) + val eavesdropper = NostrSignerInternal(KeyPair()) + assertFailsWith { crypto.unwrap(wrap, eavesdropper) } + } + + @Test + fun `CVM-4-17 rejects a non-gift-wrap kind on both paths`() = + runTest { + val inner = innerMessage() + assertFailsWith { crypto.wrap(inner, serverSigner.pubKey, kind = 1) } + assertFailsWith { crypto.unwrap(inner, serverSigner) } + } + + @Test + fun `CVM-19-10 a persistent-mode client produces kind 1059`() = + runTest { + val persistent = CvmGiftWrap(giftWrapMode = GiftWrapMode.PERSISTENT) + val wrap = persistent.wrap(innerMessage(), serverSigner.pubKey) + assertEquals(CvmKinds.GIFT_WRAP, wrap.kind) + + // Either kind must unwrap regardless of which mode we prefer. + assertEquals(CvmKinds.MESSAGE, crypto.unwrap(wrap, serverSigner).kind) + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/contextvm/mcp/CvmMcpClientTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/contextvm/mcp/CvmMcpClientTest.kt new file mode 100644 index 0000000000..1baf6c2801 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/contextvm/mcp/CvmMcpClientTest.kt @@ -0,0 +1,370 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.contextvm.mcp + +import com.vitorpamplona.quartz.contextvm.cep04Encryption.CvmGiftWrap +import com.vitorpamplona.quartz.contextvm.cep04Encryption.EncryptionMode +import com.vitorpamplona.quartz.contextvm.cep22OversizedTransfer.OversizedTransferSender +import com.vitorpamplona.quartz.contextvm.cep41OpenStreams.OpenStreamFrame +import com.vitorpamplona.quartz.contextvm.fixture.CvmFixtureServer +import com.vitorpamplona.quartz.contextvm.fixture.CvmRequest +import com.vitorpamplona.quartz.contextvm.fixture.InMemoryRelayPool +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcCodec +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcError +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcFailure +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcId +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcMessage +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcSuccess +import com.vitorpamplona.quartz.contextvm.transfer.ProgressToken +import com.vitorpamplona.quartz.contextvm.transport.CvmTransport +import com.vitorpamplona.quartz.contextvm.transport.DualSigner +import com.vitorpamplona.quartz.contextvm.transport.TimeoutMode +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.yield +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** The MCP client end to end, including both transfer profiles. */ +class CvmMcpClientTest { + private val relays = InMemoryRelayPool() + private val serverSigner = NostrSignerInternal(KeyPair()) + private val clientSigner = NostrSignerInternal(KeyPair()) + private val plaintext = CvmGiftWrap(encryptionMode = EncryptionMode.DISABLED) + + private fun client() = + CvmMcpClient( + CvmTransport( + relays = relays, + signers = DualSigner(clientSigner, clientSigner), + serverPubKey = serverSigner.pubKey, + crypto = plaintext, + ), + ) + + private fun fixture(handler: suspend (CvmRequest) -> JsonRpcMessage) = + CvmFixtureServer( + relays = relays, + signer = serverSigner, + crypto = plaintext, + handler = handler, + ) + + /** The progressToken the client derives for the first call it makes. */ + private val firstCallToken = ProgressToken.Text("call-0") + + @Test + fun `a second call on one client still correlates`() = + runTest { + // Every test here made exactly one call, so a server that answered + // with a constant JSON-RPC id passed all of them -- and our own + // fixture did precisely that until a two-call cordn test hung on it. + // Ids advance per call and the client refuses a stale one, which is + // correct and invisible until something makes the second call. + val client = client() + val fixture = fixture { request -> JsonRpcSuccess(request.id, buildJsonObject { put("ok", JsonPrimitive(true)) }) } + fixture.start() + + val results = + coroutineScope { + val pending = + async { + listOf( + client.callTool("first", timeoutMs = 5_000), + client.callTool("second", timeoutMs = 5_000), + ) + } + while (!pending.isCompleted) { + yield() + fixture.pump() + yield() + } + pending.await() + } + + assertEquals(2, results.size) + results.forEach { assertFalse(it.isError) } + } + + @Test + fun `a stale response id is ignored rather than answering the wrong call`() = + runTest { + // The guard the test above depends on. A server pinned to id 0 + // answers the first call and nothing after it -- the request must + // time out rather than accept an answer to a question we already + // asked. + val client = client() + val fixture = fixture { JsonRpcSuccess(JsonRpcId.Num(0), buildJsonObject {}) } + fixture.start() + + coroutineScope { + val first = async { client.callTool("first", timeoutMs = 5_000) } + yield() + fixture.pump() + first.await() + } + + val second = + coroutineScope { + val pending = async { runCatching { client.callTool("second", timeoutMs = 200) } } + repeat(20) { + yield() + fixture.pump() + yield() + } + pending + } + assertTrue(second.await().isFailure, "a response carrying a stale id must not resolve the new call") + } + + @Test + fun `a tool call returns the server result`() = + runTest { + val fixture = + fixture { + JsonRpcSuccess(JsonRpcId.Num(0), buildJsonObject { put("cursor", JsonPrimitive(7)) }) + } + fixture.start() + + val result = + coroutineScope { + val pending = async { client().callTool("msg_post", timeoutMs = 5_000) } + yield() + fixture.pump() + pending.await() + } + + assertFalse(result.isError) + assertEquals( + 7, + result.result!! + .jsonObject["cursor"]!! + .jsonPrimitive.content + .toInt(), + ) + } + + @Test + fun `a tool call always carries a progressToken`() = + runTest { + // Without one a server MUST NOT start either transfer profile, so + // omitting it would silently cap every response at one relay event. + val fixture = fixture { JsonRpcSuccess(JsonRpcId.Num(0), buildJsonObject {}) } + fixture.start() + + coroutineScope { + val pending = async { client().callTool("x", timeoutMs = 5_000) } + yield() + fixture.pump() + pending.await() + } + + val meta = fixture.handledParams.first()!![McpParams.META]!!.jsonObject + assertEquals("call-0", meta[McpParams.PROGRESS_TOKEN]!!.jsonPrimitive.content) + } + + @Test + fun `an error response surfaces as an error rather than a throw`() = + runTest { + val fixture = + fixture { + JsonRpcFailure( + JsonRpcId.Num(0), + JsonRpcError(JsonRpcError.PAYMENT_REQUIRED, "Payment Required"), + ) + } + fixture.start() + + val result = + coroutineScope { + val pending = async { client().callTool("priced", timeoutMs = 5_000) } + yield() + fixture.pump() + pending.await() + } + + assertTrue(result.isError) + assertEquals(JsonRpcError.PAYMENT_REQUIRED, result.error!!.code) + assertNull(result.result) + } + + @Test + fun `a CEP-22 transfer reassembles into the effective response`() = + runTest { + val big = buildJsonObject { put("text", JsonPrimitive("x".repeat(400))) } + val serialized = JsonRpcCodec.encode(JsonRpcSuccess(JsonRpcId.Num(0), big)) + + // NO direct response, and that is the whole point: a chunked + // response REPLACES the direct one. An earlier version of this test + // had the fixture also answer normally, which meant the call was + // ended by that answer and the reassembly only had to win a + // tie-break. Against the reference coordinator, which sends frames + // and nothing else, the same code hung until its deadline. + val fixture = fixture { error("a chunked response is the only response") } + fixture.start() + + val result = + coroutineScope { + val pending = async { client().callTool("big", timeoutMs = 5_000) } + yield() + + OversizedTransferSender(chunkChars = 64).frame(firstCallToken, serialized).forEach { frame -> + fixture.reply(frame.envelope.toNotification(), clientSigner.pubKey, "0".repeat(64)) + } + pending.await() + } + + assertEquals( + "x".repeat(400), + result.result!! + .jsonObject["text"]!! + .jsonPrimitive.content, + "the reassembled payload is the response", + ) + } + + @Test + fun `a CEP-41 subscription ends on its budget instead of throwing`() = + runTest { + // The other half of the rule above, and the one that made every + // live subscription an exception: if `close` does not complete the + // request, an open-ended subscription has NO response to wait for, + // so its budget running out is the only way it can end. Under + // TimeoutMode.TOTAL that is a normal return, not a failure. + val fixture = fixture { error("a subscription has no response to give") } + fixture.start() + + val live = mutableListOf() + + val result = + coroutineScope { + val pending = + async { + client().callTool("sub", timeoutMs = 5_000, timeoutMode = TimeoutMode.TOTAL) { live += it } + } + yield() + + listOf( + OpenStreamFrame.start(firstCallToken, 1.0), + OpenStreamFrame.chunk(firstCallToken, 2.0, 0, "pushed"), + OpenStreamFrame.close(firstCallToken, 3.0, lastChunkIndex = 0), + ).forEach { frame -> + fixture.reply(frame.envelope.toNotification(), clientSigner.pubKey, "0".repeat(64)) + } + + // No pump: nothing answers, and the budget is the exit. + pending.await() + } + + assertEquals(listOf("pushed"), live, "fragments arrive as they are pushed") + assertEquals(listOf("pushed"), result.streamed) + assertNull(result.result, "there was no response, and that is not an error") + assertFalse(result.isError) + } + + @Test + fun `a CEP-41 stream delivers fragments but close does not complete the call`() = + runTest { + // The rule worth pinning end to end: close says no more frames, and + // the request is still only finished by its own JSON-RPC response. + val fixture = + fixture { + JsonRpcSuccess(JsonRpcId.Num(0), buildJsonObject { put("done", JsonPrimitive(true)) }) + } + fixture.start() + + val live = mutableListOf() + + val result = + coroutineScope { + val pending = + async { + client().callTool("stream", timeoutMs = 5_000) { live += it } + } + yield() + + listOf( + OpenStreamFrame.start(firstCallToken, 1.0), + OpenStreamFrame.chunk(firstCallToken, 2.0, 0, "Hello"), + OpenStreamFrame.chunk(firstCallToken, 3.0, 1, " world"), + OpenStreamFrame.close(firstCallToken, 4.0, lastChunkIndex = 1), + ).forEach { frame -> + fixture.reply(frame.envelope.toNotification(), clientSigner.pubKey, "0".repeat(64)) + } + + // Only now does the request's own response arrive. + fixture.pump() + pending.await() + } + + assertEquals(listOf("Hello", " world"), live) + assertEquals(listOf("Hello", " world"), result.streamed) + assertEquals( + true, + result.result!! + .jsonObject["done"]!! + .jsonPrimitive.content + .toBoolean(), + "the call is completed by its JSON-RPC response, not by close", + ) + } + + @Test + fun `initialize completes the handshake and sends initialized`() = + runTest { + val fixture = + fixture { + JsonRpcSuccess( + JsonRpcId.Num(0), + buildJsonObject { put("protocolVersion", JsonPrimitive(CvmMcpClient.PROTOCOL_VERSION)) }, + ) + } + fixture.start() + + coroutineScope { + val pending = async { client().initialize() } + yield() + fixture.pump() + pending.await() + } + + // The notification rides after the response, unsubscribed, so it + // shows up on the wire rather than in a correlation slot. + val methods = + relays.published.mapNotNull { event -> + runCatching { JsonRpcCodec.decode(event.content) }.getOrNull() + } + assertTrue( + methods.any { it is com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcNotification && it.method == McpMethods.INITIALIZED }, + "the client must tell the server it is ready", + ) + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/contextvm/transport/CvmTransportTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/contextvm/transport/CvmTransportTest.kt new file mode 100644 index 0000000000..60bf2981a8 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/contextvm/transport/CvmTransportTest.kt @@ -0,0 +1,572 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.contextvm.transport + +import com.vitorpamplona.quartz.contextvm.cep04Encryption.CvmEncryptionException +import com.vitorpamplona.quartz.contextvm.cep04Encryption.CvmGiftWrap +import com.vitorpamplona.quartz.contextvm.cep04Encryption.EncryptionMode +import com.vitorpamplona.quartz.contextvm.cep04Encryption.GiftWrapMode +import com.vitorpamplona.quartz.contextvm.core.CvmKinds +import com.vitorpamplona.quartz.contextvm.core.CvmMessageEvent +import com.vitorpamplona.quartz.contextvm.core.CvmTags +import com.vitorpamplona.quartz.contextvm.fixture.CvmFixtureServer +import com.vitorpamplona.quartz.contextvm.fixture.CvmRequest +import com.vitorpamplona.quartz.contextvm.fixture.FixtureFaults +import com.vitorpamplona.quartz.contextvm.fixture.InMemoryRelayPool +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcId +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcMessage +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcRequest +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcSuccess +import com.vitorpamplona.quartz.contextvm.mcp.McpParams +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.yield +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +/** + * `CVM-CORE-*` and `CVM-16-*` driven end to end against the Tier C fixture. + * + * The fixture is what makes the negative half testable: no real server produces + * a mismatched correlation tag or a malformed body on request. + */ +class CvmTransportTest { + private val relays = InMemoryRelayPool() + private val serverSigner = NostrSignerInternal(KeyPair()) + private val stableSigner = NostrSignerInternal(KeyPair()) + private val ephemeralSigner = NostrSignerInternal(KeyPair()) + private val signers = DualSigner(stableSigner, ephemeralSigner) + + private fun server( + faults: FixtureFaults = FixtureFaults(), + injectClientPubkey: Boolean = false, + discoveryTags: List> = emptyList(), + handler: suspend (CvmRequest) -> JsonRpcMessage = { + JsonRpcSuccess(JsonRpcId.Num(0), buildJsonObject { put("ok", JsonPrimitive(true)) }) + }, + ) = CvmFixtureServer( + relays = relays, + signer = serverSigner, + faults = faults, + injectClientPubkey = injectClientPubkey, + discoveryTags = discoveryTags, + handler = handler, + ) + + private fun transport(crypto: CvmGiftWrap = CvmGiftWrap()) = + CvmTransport( + relays = relays, + signers = signers, + serverPubKey = serverSigner.pubKey, + crypto = crypto, + ) + + /** Runs a request while pumping the fixture, so the answer arrives in-flight. */ + private suspend fun exchange( + fixture: CvmFixtureServer, + transport: CvmTransport, + request: JsonRpcRequest, + identity: DualSigner.Identity = DualSigner.Identity.EPHEMERAL, + timeoutMs: Long = 5_000, + ) = coroutineScope { + val pending = async { transport.request(request, identity, timeoutMs) } + yield() + fixture.pump() + pending.await() + } + + @Test + fun `CVM-CORE-10 completes a request against the fixture`() = + runTest { + val fixture = server() + fixture.start() + + val response = + exchange(fixture, transport(), JsonRpcRequest(JsonRpcId.Num(0), "tools/list")) + + assertIs(response) + } + + @Test + fun `CVM-CORE-11 subscribes before publishing so nothing is dropped`() = + runTest { + // The property that matters: an ephemeral kind published with nobody + // listening is gone. If the transport ever published first, the + // request event would land in `dropped`. + val fixture = server() + fixture.start() + + exchange(fixture, transport(), JsonRpcRequest(JsonRpcId.Num(0), "ping")) + + assertTrue(relays.dropped.isEmpty(), "dropped: ${relays.dropped.map { it.kind }}") + } + + @Test + fun `CVM-CORE-12 the relay drops an event nobody is subscribed to`() = + runTest { + // Guards the guard: confirms the fixture relay really does model + // ephemeral delivery, so the previous test is meaningful. + val fixture = server() + // deliberately not started + val transport = transport() + + assertFailsWith { + transport.request(JsonRpcRequest(JsonRpcId.Num(0), "ping"), timeoutMs = 50) + } + assertTrue(relays.dropped.isNotEmpty()) + } + + @Test + fun `CVM-CORE-13 ignores a response whose JSON-RPC id does not match`() = + runTest { + val fixture = server(faults = FixtureFaults(mismatchedResponseId = true)) + fixture.start() + + assertFailsWith { + exchange(fixture, transport(), JsonRpcRequest(JsonRpcId.Num(7), "ping"), timeoutMs = 200) + } + } + + @Test + fun `CVM-CORE-14 ignores a response correlated to a different request event`() = + runTest { + val fixture = server(faults = FixtureFaults(wrongCorrelationTag = true)) + fixture.start() + + assertFailsWith { + exchange(fixture, transport(), JsonRpcRequest(JsonRpcId.Num(0), "ping"), timeoutMs = 200) + } + } + + @Test + fun `CVM-CORE-15 accepts a response that omits the e tag but matches by id`() = + runTest { + // The `e` tag is the stronger signal but a peer may omit it; the + // JSON-RPC id still correlates. + val fixture = server(faults = FixtureFaults(omitCorrelationTag = true)) + fixture.start() + + val response = exchange(fixture, transport(), JsonRpcRequest(JsonRpcId.Num(0), "ping")) + assertIs(response) + } + + @Test + fun `CVM-CORE-15b ignores a correctly correlated response from the wrong signer`() = + runTest { + // The forgery this closes. A client subscribes to everything + // `p`-tagged to its own key, so anyone on the relay can gift-wrap + // a well-formed response to it — the wrap's signature proves only + // that its throwaway key signed it, and the inner signature is + // verified without knowing who the signer ought to be. + // + // The forged event is published straight to the pool rather than + // through a second fixture. A fixture subscribes for traffic + // addressed to itself and would never see a request `p`-tagged to + // the real server, so it would answer nothing and this test would + // pass by timing out for the wrong reason. (It did, at first.) + // + // The impostor does everything right except be the coordinator: + // matching JSON-RPC id, addressed to the very key this call is + // listening on, and no `e` tag — which CVM-CORE-15 establishes is + // accepted on its own. Identity is the coordinator's pubkey and + // nothing else (spec/00.md §8.5). Without that check a stranger + // answers `kp_take` with their own KeyPackage and we invite them. + val impostor = NostrSignerInternal(KeyPair()) + val forged = + CvmMessageEvent.create( + JsonRpcSuccess(JsonRpcId.Num(0), buildJsonObject { put("ok", JsonPrimitive(true)) }), + ephemeralSigner.pubKey, + impostor, + ) + + val transport = transport() + val pending = async { transport.request(JsonRpcRequest(JsonRpcId.Num(0), "ping"), timeoutMs = 300) } + yield() + relays.publish(CvmGiftWrap().wrap(forged, ephemeralSigner.pubKey)) + + assertFailsWith { pending.await() } + } + + @Test + fun `CVM-CORE-16 keeps waiting through a malformed payload`() = + runTest { + // A peer sending garbage must not fail an unrelated in-flight call. + val fixture = server(faults = FixtureFaults(malformedResponse = true)) + fixture.start() + + assertFailsWith { + exchange(fixture, transport(), JsonRpcRequest(JsonRpcId.Num(0), "ping"), timeoutMs = 200) + } + } + + @Test + fun `CVM-CORE-17 routes notifications without resolving the request`() = + runTest { + val fixture = server(faults = FixtureFaults(noisePrefix = 3)) + fixture.start() + + val seen = mutableListOf() + val transport = transport() + + val response = + coroutineScope { + val pending = + async { + transport.request(JsonRpcRequest(JsonRpcId.Num(0), "ping"), timeoutMs = 5_000) { + seen += it.method + // null: a notification does not answer the call. + null + } + } + yield() + fixture.pump() + pending.await() + } + + assertEquals(3, seen.size, "every notification is delivered") + assertIs(response, "and none of them completed the request") + } + + @Test + fun `CVM-4-20 the request goes out encrypted by default`() = + runTest { + val fixture = server() + fixture.start() + + exchange(fixture, transport(), JsonRpcRequest(JsonRpcId.Num(0), "ping")) + + val first = relays.published.first() + assertTrue(CvmKinds.isGiftWrap(first.kind), "kind ${first.kind} is not a wrap") + assertNotEquals(ephemeralSigner.pubKey, first.pubKey, "the wrap hides the sender") + } + + @Test + fun `CVM-4-21 a disabled-encryption client publishes the bare message kind`() = + runTest { + val fixture = + CvmFixtureServer( + relays = relays, + signer = serverSigner, + crypto = CvmGiftWrap(encryptionMode = EncryptionMode.DISABLED), + handler = { request -> JsonRpcSuccess(request.id, buildJsonObject {}) }, + ) + fixture.start() + + exchange( + fixture, + transport(CvmGiftWrap(encryptionMode = EncryptionMode.DISABLED)), + JsonRpcRequest(JsonRpcId.Num(0), "ping"), + ) + + assertEquals(CvmKinds.MESSAGE, relays.published.first().kind) + } + + @Test + fun `CVM-16-01 the server derives clientPubkey rather than trusting the client`() = + runTest { + val fixture = server(injectClientPubkey = true) + fixture.start() + + exchange( + fixture, + transport(CvmGiftWrap(encryptionMode = EncryptionMode.DISABLED)), + JsonRpcRequest(JsonRpcId.Num(0), "tools/call", buildJsonObject { put("name", JsonPrimitive("x")) }), + ) + + val meta = fixture.handledParams.first()?.get(McpParams.META) as JsonObject + assertEquals( + ephemeralSigner.pubKey, + (meta[McpParams.CLIENT_PUBKEY] as JsonPrimitive).content, + "the injected identity is the event signer, not anything the client claimed", + ) + } + + @Test + fun `CVM-16-02 injection is off unless the server opts in`() = + runTest { + val fixture = server(injectClientPubkey = false) + fixture.start() + + exchange( + fixture, + transport(CvmGiftWrap(encryptionMode = EncryptionMode.DISABLED)), + JsonRpcRequest(JsonRpcId.Num(0), "ping"), + ) + + assertTrue(fixture.handledParams.first()?.containsKey(McpParams.META) != true) + } + + @Test + fun `CVM-35-10 learns the server baseline from its first direct message`() = + runTest { + val fixture = + server( + discoveryTags = + listOf( + arrayOf("name", "Fixture"), + CvmTags.flag(CvmTags.SUPPORT_OPEN_STREAM), + arrayOf("unknown_future", "keep-me"), + ), + ) + fixture.start() + + val transport = transport() + exchange(fixture, transport, JsonRpcRequest(JsonRpcId.Num(0), "ping")) + + val peer = transport.peer!! + assertEquals("Fixture", peer.name) + assertTrue(peer.supportsOpenStream) + assertTrue( + peer.unknownTags.any { it[0] == "unknown_future" }, + "CEP-35 requires unknown discovery tags to survive", + ) + } + + @Test + fun `CVM-4-22 a peer that declares no encryption gets a refusal, not an unreadable wrap`() = + runTest { + // The point of EncryptionMode.REQUIRED. Before negotiation was + // wired up its input was a constant true, so this could never fire: + // we would have sent a wrap the peer cannot open and the caller + // would have seen a timeout with no reason. + val fixture = server(discoveryTags = listOf(arrayOf("name", "Plaintext only"))) + fixture.start() + val transport = transport() + + // First request establishes the baseline and still goes out under + // the optimistic assumption — the peer has not spoken yet. + exchange(fixture, transport, JsonRpcRequest(JsonRpcId.Num(0), "ping")) + assertFalse(transport.peer!!.supportsEncryption) + + val thrown = + runCatching { + exchange(fixture, transport, JsonRpcRequest(JsonRpcId.Num(1), "ping")) + }.exceptionOrNull() + + assertTrue(thrown is CvmEncryptionException, "expected a stated refusal, got $thrown") + } + + @Test + fun `CVM-4-23 a peer that declares encryption keeps getting wraps`() = + runTest { + val fixture = + server( + discoveryTags = listOf(arrayOf("name", "Encrypts"), CvmTags.flag(CvmTags.SUPPORT_ENCRYPTION)), + ) + fixture.start() + val transport = transport() + + exchange(fixture, transport, JsonRpcRequest(JsonRpcId.Num(0), "ping")) + val second = + exchange( + fixture, + transport, // id 0 again: the fixture answers every request with id 0 and the + // transport correctly ignores a mismatched id (CVM-CORE-13). + JsonRpcRequest(JsonRpcId.Num(0), "ping"), + ) + + assertTrue(second is JsonRpcSuccess) + assertTrue(relays.published.all { CvmKinds.isGiftWrap(it.kind) }) + } + + @Test + fun `CVM-4-24 a peer that declares nothing at all is not read as declaring no`() = + runTest { + // The case that decides the whole design. A live cordn coordinator's + // kind-25910 responses carry only the routing tags `p` and `e` + // (observed on the public relays, 2026-09-23). Reading that silence + // as a full CEP-35 surface would conclude it supports nothing, and + // REQUIRED would refuse to talk to every deployed coordinator. + val fixture = server(discoveryTags = emptyList()) + fixture.start() + val transport = transport() + + exchange(fixture, transport, JsonRpcRequest(JsonRpcId.Num(0), "ping")) + val second = + exchange( + fixture, + transport, // id 0 again: the fixture answers every request with id 0 and the + // transport correctly ignores a mismatched id (CVM-CORE-13). + JsonRpcRequest(JsonRpcId.Num(0), "ping"), + ) + + assertTrue(second is JsonRpcSuccess, "a silent peer must stay reachable") + assertTrue(relays.published.all { CvmKinds.isGiftWrap(it.kind) }) + } + + @Test + fun `CVM-19-05 the wrap kind follows what the peer declared`() = + runTest { + // CEP-19: preferring 21059 must never mean refusing a 1059-only + // server. A declared surface without the ephemeral flag downgrades. + // The surface must declare encryption, or OPTIONAL would send the + // second request unwrapped and the wrap kind would never be picked + // at all - which made an earlier version of this test vacuous. + assertEquals( + listOf(CvmKinds.GIFT_WRAP), + wrapKindsOfSecondRequest( + discoveryTags = listOf(arrayOf(CvmTags.SUPPORT_ENCRYPTION, "true"), arrayOf("name", "Persistent only")), + ), + ) + } + + @Test + fun `CVM-19-06 a peer that declares ephemeral support gets the ephemeral wrap`() = + runTest { + assertEquals( + listOf(CvmKinds.EPHEMERAL_GIFT_WRAP), + wrapKindsOfSecondRequest( + discoveryTags = + listOf( + arrayOf(CvmTags.SUPPORT_ENCRYPTION, "true"), + arrayOf(CvmTags.SUPPORT_ENCRYPTION_EPHEMERAL, "true"), + ), + ), + ) + } + + /** + * Runs two requests against a server declaring [discoveryTags] and returns + * the kinds we published on the second one - by then the peer's surface has + * been learned from its first reply. + */ + private suspend fun wrapKindsOfSecondRequest(discoveryTags: List>): List { + val fixture = server(discoveryTags = discoveryTags) + fixture.start() + val transport = transport(CvmGiftWrap(giftWrapMode = GiftWrapMode.EPHEMERAL, encryptionMode = EncryptionMode.OPTIONAL)) + + exchange(fixture, transport, JsonRpcRequest(JsonRpcId.Num(0), "ping")) + relays.published.clear() + // id 0 again: the fixture answers every request with id 0 and the + // transport correctly ignores a mismatched id (CVM-CORE-13). + exchange(fixture, transport, JsonRpcRequest(JsonRpcId.Num(0), "ping")) + + // Only what WE sent: a wrap addressed to the server. The fixture's own + // replies are wrapped too and would otherwise be counted. + val ours = relays.published.filter { e -> e.tags.any { it.size >= 2 && it[0] == "p" && it[1] == serverSigner.pubKey } } + assertTrue(ours.isNotEmpty(), "the second request should have been published") + return ours.map { it.kind }.distinct() + } + + @Test + fun `CVM-35-12 the session's first message declares this side's surface`() = + runTest { + // CEP-35 is symmetric, and a peer only offers a profile the other + // side declared: the reference ContextVM server chunks a CEP-22 + // response, and opens a CEP-41 stream, only for a client that said + // it can take one. A session that declared nothing capped every + // response at a single relay event. + // + // Read off the inner event, because that is where the server reads + // it: the wrap's own tags are just routing, and a surface put there + // would be both unread and public. + val seen = mutableListOf>() + + // Deliberately NOT the handshake: the surface has to ride whatever + // the first message is, or a client that opens with a tool call + // never declares at all. + declaring(seen, CvmGiftWrap(encryptionMode = EncryptionMode.DISABLED), "msg_fetch_many") + + val declared = seen.single() + assertTrue(CvmTags.SUPPORT_OVERSIZED_TRANSFER in declared, "no CEP-22 flag in $declared") + assertTrue(CvmTags.SUPPORT_OPEN_STREAM in declared, "no CEP-41 flag in $declared") + // Encryption is off here, so there is no wrap we could open. + assertFalse(CvmTags.SUPPORT_ENCRYPTION in declared, "declared a wrap it will not open") + } + + @Test + fun `CVM-35-13 an encrypting client declares both wrap kinds`() = + runTest { + // Receive, not prefer. This client emits 1059, and still says it + // takes 21059, because the transport subscribes to both - saying + // otherwise would tell the peer to withhold something we can read. + val seen = mutableListOf>() + + declaring(seen, CvmGiftWrap(giftWrapMode = GiftWrapMode.PERSISTENT, encryptionMode = EncryptionMode.OPTIONAL), "ping") + + val declared = seen.single() + assertTrue(CvmTags.SUPPORT_ENCRYPTION in declared, "no CEP-4 flag in $declared") + assertTrue(CvmTags.SUPPORT_ENCRYPTION_EPHEMERAL in declared, "no CEP-19 flag in $declared") + } + + @Test + fun `CVM-35-14 the surface is declared once, not on every message`() = + runTest { + val seen = mutableListOf>() + + declaring(seen, CvmGiftWrap(), "ping", "ping") + + assertEquals(2, seen.size, "both requests should have reached the server") + assertTrue(seen.first().isNotEmpty(), "the first message declared nothing") + assertEquals(emptyList(), seen.last(), "re-declared on a later message") + } + + /** + * Runs [methods] in one session, collecting each request's declared + * surface - the single-element tags on the event the server unwrapped. + */ + private suspend fun declaring( + into: MutableList>, + crypto: CvmGiftWrap, + vararg methods: String, + ) { + val fixture = + server(handler = { request -> + into += + request.event.tags + .filter { it.size == 1 } + .map { it[0] } + JsonRpcSuccess(JsonRpcId.Num(0), buildJsonObject { put("ok", JsonPrimitive(true)) }) + }) + fixture.start() + val transport = transport(crypto) + methods.forEach { exchange(fixture, transport, JsonRpcRequest(JsonRpcId.Num(0), it)) } + } + + @Test + fun `CVM-35-11 the stable identity is used only when asked for`() = + runTest { + val fixture = server(injectClientPubkey = true) + fixture.start() + + exchange( + fixture, + transport(CvmGiftWrap(encryptionMode = EncryptionMode.DISABLED)), + JsonRpcRequest(JsonRpcId.Num(0), "kp_publish"), + identity = DualSigner.Identity.STABLE, + ) + + val meta = fixture.handledParams.first()?.get(McpParams.META) as JsonObject + assertEquals(stableSigner.pubKey, (meta[McpParams.CLIENT_PUBKEY] as JsonPrimitive).content) + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/cordn/groups/CordnAdminPolicyTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/cordn/groups/CordnAdminPolicyTest.kt new file mode 100644 index 0000000000..5d830daf33 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/cordn/groups/CordnAdminPolicyTest.kt @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.groups + +import com.vitorpamplona.quartz.cordn.spec01GroupMetadata.CordnGroupMetadata +import com.vitorpamplona.quartz.mls.group.MlsGroup +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * That `admin_pubkeys` actually gates commits, the way the reference client + * gates its `addMember` / `removeMember` / `updateGroupMetadata`. + * + * The engine calls [CordnGroupPolicy.authorizeCommit] before building a local + * commit AND before applying an inbound one, so these tests cover both + * directions at once: a commit this policy refuses to build is the same commit + * it refuses to apply from someone else. That matters more than the local error + * — applying a commit the rest of the group rejects forks the epoch. + */ +class CordnAdminPolicyTest { + private val alice = "a1".repeat(32) + private val bob = "b2".repeat(32) + + private fun identity(pubKeyHex: String) = CordnCredential.of(pubKeyHex).identity + + /** A group created by [creator] whose metadata names [admins]. */ + private fun group( + creator: String, + admins: List, + ): MlsGroup = + MlsGroup.create( + identity = identity(creator), + policy = CordnGroupPolicy, + initialExtensions = listOf(CordnGroupMetadata(name = "Admin test", adminPubkeys = admins).toExtension()), + ) + + @Test + fun anEmptyAdminListLetsAnyMemberRewriteMetadata() { + val egalitarian = group(creator = alice, admins = emptyList()) + val before = egalitarian.epoch + + // spec/01.md §5.3: empty means egalitarian, permanently — not a + // bootstrap window that later closes. + egalitarian.proposeGroupContextExtensions(egalitarian.extensions) + egalitarian.commit() + + assertEquals(before + 1, egalitarian.epoch) + assertTrue(CordnGroupPolicy.isLocalAdmin(egalitarian.view())) + } + + @Test + fun aNonAdminCannotRewriteMetadata() { + val group = group(creator = alice, admins = listOf(bob)) + assertFalse(CordnGroupPolicy.isLocalAdmin(group.view()), "alice must not be an admin or this proves nothing") + + group.proposeGroupContextExtensions(group.extensions) + assertFailsWith { group.commit() } + } + + @Test + fun anAdminCan() { + val group = group(creator = alice, admins = listOf(alice)) + assertTrue(CordnGroupPolicy.isLocalAdmin(group.view())) + val before = group.epoch + + group.proposeGroupContextExtensions(group.extensions) + group.commit() + + assertEquals(before + 1, group.epoch) + } + + /** + * The regression that matters most: a cordn credential stores the account + * key as 64 ASCII hex characters, so hexing the credential bytes yields 128 + * characters. An admin check that compared an account pubkey against that + * would match nothing — and since the gate only fires when a commit needs + * an admin, the failure would look like "admins can never commit" rather + * than like a decoding bug. + */ + @Test + fun theAdminListIsComparedInTheCredentialsOwnEncoding() { + val group = group(creator = alice, admins = listOf(alice)) + + val credentialHex = group.view().let { it.memberIdentityHex(it.myLeafIndex) } + assertEquals(128, credentialHex?.length, "a cordn credential hexes to twice the pubkey length") + assertTrue(credentialHex != alice, "if these were equal this test would be vacuous") + + assertTrue(group.view().let { CordnGroupPolicy.isAdminLeaf(it, it.myLeafIndex) }) + } + + @Test + fun anUpdateIsNotAnAdminAction() { + val group = group(creator = alice, admins = listOf(bob)) + val before = group.epoch + + // Only add, remove and group_context_extensions are gated. Keeping the + // rest open is what stops a non-admin being trapped in a group whose + // keys they may never rotate. + group.proposeSigningKeyRotation() + group.commit() + + assertEquals(before + 1, group.epoch) + } + + @Test + fun anEmptyCommitIsNotAnAdminAction() { + val group = group(creator = alice, admins = listOf(bob)) + val before = group.epoch + + group.commit() + + assertEquals(before + 1, group.epoch) + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/cordn/interop/BothCredentialProfilesTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/cordn/interop/BothCredentialProfilesTest.kt new file mode 100644 index 0000000000..509bf22c45 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/cordn/interop/BothCredentialProfilesTest.kt @@ -0,0 +1,139 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.interop + +import com.vitorpamplona.quartz.cordn.groups.CordnCredential +import com.vitorpamplona.quartz.cordn.groups.CordnGroupPolicy +import com.vitorpamplona.quartz.cordn.spec01GroupMetadata.CordnGroupMetadata +import com.vitorpamplona.quartz.marmot.groups.MarmotGroupPolicy +import com.vitorpamplona.quartz.mls.group.MlsGroup +import com.vitorpamplona.quartz.mls.tree.Credential +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertNull + +/** + * One engine, both credential encodings, side by side. + * + * §4.1 of `quartz/plans/2026-09-17-cordn-interop.md` records a hard + * incompatibility: Marmot writes a Nostr pubkey into a BasicCredential as the + * **raw 32 bytes**, cordn as **64 ASCII bytes of lowercase hex**. The decision + * was to implement both rather than wait for either ecosystem to move, which + * only works if the two profiles cannot be confused for one another — a leaf + * read under the wrong binding must come back as "not mine", never as a + * plausible-looking wrong pubkey. + * + * That is what this pins. It is cheap to get wrong in a way no single-binding + * test would notice: `MlsGroup.memberIdentityHex` hex-encodes credential bytes, + * so run over a cordn leaf it returns 128 characters of hex-of-hex — a string + * that looks like a pubkey, compares unequal to every real one, and would + * quietly drop a member from a UI list. + */ +class BothCredentialProfilesTest { + private val aliceHex = "aa".repeat(32) + private val bobHex = "bb".repeat(32) + + private fun marmotGroup() = MlsGroup.create(aliceHex.hexToByteArray(), policy = MarmotGroupPolicy) + + private fun cordnGroup() = + MlsGroup.create( + identity = CordnCredential.of(aliceHex).identity, + policy = CordnGroupPolicy, + initialExtensions = listOf(CordnGroupMetadata(name = "both profiles").toExtension()), + ) + + @Test + fun `the two encodings cannot collide, by length alone`() { + val marmot = Credential.Basic(aliceHex.hexToByteArray()) + val cordn = CordnCredential.of(aliceHex) + + assertEquals(32, marmot.identity.size) + assertEquals(64, cordn.identity.size) + assertNotEquals(marmot.identity.toHexKey(), cordn.identity.toHexKey()) + + // The disambiguation is structural, not heuristic: cordn's reader wants + // exactly 64 bytes, Marmot's wants exactly 32, and no byte string is + // both. Neither binding needs to guess which profile a leaf belongs to. + assertNull(CordnCredential.identityOrNull(marmot), "a Marmot credential must not read as a cordn identity") + } + + @Test + fun `a cordn credential round-trips through its own reader`() { + assertEquals(aliceHex, CordnCredential.identityOrNull(CordnCredential.of(aliceHex))) + assertContentEquals(aliceHex.encodeToByteArray(), CordnCredential.of(aliceHex).identity) + } + + @Test + fun `both groups run on the same engine at once`() { + // Not a formality: since Stage 1 the profile arrives as a constructor + // argument, so a leaked default or a shared mutable would show up as + // one group adopting the other's rules. + val marmot = marmotGroup() + val cordn = cordnGroup() + + assertEquals(0L, marmot.epoch) + assertEquals(0L, cordn.epoch) + + // Each group reports its own creator under its own encoding. + assertEquals(setOf(aliceHex), CordnCredential.memberIdentities(cordn)) + assertEquals(aliceHex, marmot.memberIdentityHex(0)) + + // And a message in each still opens. + listOf(marmot, cordn).forEach { group -> + val sealed = group.encrypt("hello".encodeToByteArray()) + assertEquals("hello", group.decrypt(sealed).content.decodeToString()) + } + } + + @Test + fun `reading a cordn leaf with the raw-bytes helper gives hex-of-hex, not a pubkey`() { + // The trap, made explicit so nobody "fixes" CordnCredential.membersOf + // back into memberIdentityHex. 64 ASCII bytes hex-encode to 128 chars. + val cordn = cordnGroup() + + assertEquals(128, cordn.memberIdentityHex(0)?.length) + assertNotEquals(aliceHex, cordn.memberIdentityHex(0)) + assertEquals(aliceHex, CordnCredential.identityOrNull(cordn.members().first { it.first == 0 }.second)) + } + + @Test + fun `each profile admits a joiner carrying its own encoding`() { + val cordn = cordnGroup() + val bobsKeyPackage = cordnGroup().createKeyPackage(CordnCredential.of(bobHex).identity, ByteArray(0)) + + cordn.addMember(bobsKeyPackage.keyPackage.toTlsBytes()) + + assertEquals(setOf(aliceHex, bobHex), CordnCredential.memberIdentities(cordn)) + + val marmot = marmotGroup() + val bobsMarmotKeyPackage = marmotGroup().createKeyPackage(bobHex.hexToByteArray(), ByteArray(0)) + + marmot.addMember(bobsMarmotKeyPackage.keyPackage.toTlsBytes()) + + assertEquals(bobHex, marmot.memberIdentityHex(1)) + // ...and the joiner each admitted is invisible to the other binding. + assertNull(CordnCredential.identityOrNull(marmot.members().first { it.first == 1 }.second)) + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/cordn/interop/CoordinatorContractVectorTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/cordn/interop/CoordinatorContractVectorTest.kt new file mode 100644 index 0000000000..9c7d5a4d70 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/cordn/interop/CoordinatorContractVectorTest.kt @@ -0,0 +1,339 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.interop + +import com.vitorpamplona.quartz.contextvm.cep04Encryption.CvmGiftWrap +import com.vitorpamplona.quartz.contextvm.cep04Encryption.EncryptionMode +import com.vitorpamplona.quartz.contextvm.fixture.CvmFixtureServer +import com.vitorpamplona.quartz.contextvm.fixture.InMemoryRelayPool +import com.vitorpamplona.quartz.contextvm.mcp.CvmMcpClient +import com.vitorpamplona.quartz.contextvm.transport.CvmTransport +import com.vitorpamplona.quartz.contextvm.transport.DualSigner +import com.vitorpamplona.quartz.cordn.fixture.CordnFixtureCoordinator +import com.vitorpamplona.quartz.cordn.spec00Coordinator.AvailableKeyPackage +import com.vitorpamplona.quartz.cordn.spec00Coordinator.ConsumedJoinRequestRef +import com.vitorpamplona.quartz.cordn.spec00Coordinator.ConsumedWelcomeRef +import com.vitorpamplona.quartz.cordn.spec00Coordinator.CoordinatorClient +import com.vitorpamplona.quartz.cordn.spec00Coordinator.CoordinatorMethod +import com.vitorpamplona.quartz.cordn.spec00Coordinator.GroupMessage +import com.vitorpamplona.quartz.cordn.spec00Coordinator.JoinRequest +import com.vitorpamplona.quartz.cordn.spec00Coordinator.PendingWelcome +import com.vitorpamplona.quartz.cordn.spec00Coordinator.PostedMessage +import com.vitorpamplona.quartz.cordn.spec00Coordinator.PublishedKeyPackage +import com.vitorpamplona.quartz.cordn.spec00Coordinator.TakenKeyPackage +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.yield +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * [CoordinatorClient] against cordn's own wire contracts. + * + * `resources/cordn/coordinator-contracts.json` is generated by + * `quartz/tools/cordn-vector-gen` from **`@cordn/core`** (MIT) — the package + * the reference coordinator and client both import. Every payload in it was + * accepted by their zod schema, and every `rejects` entry was refused by it. + * + * So this suite asks the two questions our own fixture cannot: does the JSON + * we put on the wire match what their coordinator parses, and can we read what + * theirs sends back? Both directions run through the real ContextVM transport, + * so a regression in argument assembly, field naming or result parsing fails + * here rather than against a live coordinator. + * + * What it does NOT cover is the coordinator's behaviour — ordering, cursor + * assignment, admission control. That needs Tier B, which is blocked; see + * `quartz/plans/2026-09-17-cordn-interop.md` §7. + */ +class CoordinatorContractVectorTest { + private val vectors: JsonObject = + Json + .parseToJsonElement( + checkNotNull(CoordinatorContractVectorTest::class.java.getResourceAsStream("/cordn/coordinator-contracts.json")) { + "missing cordn contract vectors" + }.use { it.readBytes() } + .decodeToString(), + ).jsonObject + + private fun contract(method: String): JsonObject = vectors["contracts"]!!.jsonObject[method]!!.jsonObject + + private fun input( + method: String, + key: String = "input", + ): JsonObject = contract(method)[key]!!.jsonObject + + private fun output( + method: String, + key: String = "output", + ): JsonObject = contract(method)[key]!!.jsonObject + + private val relays = InMemoryRelayPool() + private val serverSigner = NostrSignerInternal(KeyPair()) + private val stableSigner = NostrSignerInternal(KeyPair()) + private val ephemeralSigner = NostrSignerInternal(KeyPair()) + private val plaintext = CvmGiftWrap(encryptionMode = EncryptionMode.DISABLED) + private val coordinator = CordnFixtureCoordinator() + + private val client = + CoordinatorClient( + CvmMcpClient( + CvmTransport( + relays = relays, + signers = DualSigner(stableSigner, ephemeralSigner), + serverPubKey = serverSigner.pubKey, + crypto = plaintext, + ), + ), + ) + + private fun serve() = + CvmFixtureServer( + relays = relays, + signer = serverSigner, + crypto = plaintext, + injectClientPubkey = true, + handler = coordinator::handle, + ).also { it.start() } + + /** Runs [block] while pumping the fixture, since the relay callback cannot suspend. */ + private suspend fun driving(block: suspend () -> T): T = + coroutineScope { + val server = serve() + val work = async { block() } + while (!work.isCompleted) { + yield() + server.pump() + yield() + } + work.await() + } + + /** The arguments the coordinator saw for [method], which must be its only call. */ + private fun sent(method: String): JsonObject { + val calls = coordinator.calls.filter { it.method == method } + assertEquals(1, calls.size, "expected exactly one $method call, saw ${coordinator.calls.map { it.method }}") + return calls.single().arguments + } + + private suspend fun scripted( + method: String, + result: JsonObject, + block: suspend () -> Unit, + ) { + coordinator.scriptedResults[method] = result + driving(block) + } + + // ---- what we send ---------------------------------------------------- + + @Test + fun `kp_publish arguments match cordn's schema`() = + runTest { + driving { client.publishKeyPackage("1f".repeat(16), "AAECAwQFBgc=") } + assertEquals(input("kp_publish"), sent("kp_publish")) + } + + @Test + fun `kp_remove arguments match cordn's schema`() = + runTest { + driving { client.removeKeyPackages(listOf("1f".repeat(16), "2e".repeat(16))) } + assertEquals(input("kp_remove"), sent("kp_remove")) + } + + @Test + fun `kp_take arguments match cordn's schema`() = + runTest { + scripted("kp_take", output("kp_take", "emptyOutput")) { client.takeKeyPackage("1f".repeat(16)) } + assertEquals(input("kp_take"), sent("kp_take")) + } + + @Test + fun `welcome_store arguments match cordn's schema, with and without a cursor`() = + runTest { + driving { + client.storeWelcome("b".repeat(64), "2e".repeat(16), "V2VsY29tZQ==", after = 12) + } + assertEquals(input("welcome_store"), sent("welcome_store")) + + coordinator.calls.clear() + driving { client.storeWelcome("b".repeat(64), "2e".repeat(16), "V2VsY29tZQ==") } + assertEquals(input("welcome_store", "inputWithoutAfter"), sent("welcome_store")) + } + + @Test + fun `welcome_take omits consumed when there is nothing to retire`() = + runTest { + driving { client.takeWelcomes() } + assertEquals(input("welcome_take"), sent("welcome_take")) + + coordinator.calls.clear() + driving { client.takeWelcomes(listOf(ConsumedWelcomeRef("1f".repeat(16), 1757000000L))) } + assertEquals(input("welcome_take", "inputWithConsumed"), sent("welcome_take")) + } + + @Test + fun `join_request arguments match cordn's schema`() = + runTest { + val gid = "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21" + driving { client.storeJoinRequest(gid, "1f".repeat(16)) } + assertEquals(input("join_request_store"), sent("join_request_store")) + + coordinator.calls.clear() + driving { client.takeJoinRequests(listOf(gid)) } + assertEquals(input("join_request_take_many"), sent("join_request_take_many")) + + coordinator.calls.clear() + driving { + client.takeJoinRequests(listOf(gid), listOf(ConsumedJoinRequestRef(gid, "b".repeat(64), 1757000400L))) + } + assertEquals(input("join_request_take_many", "inputWithConsumed"), sent("join_request_take_many")) + } + + @Test + fun `msg_post arguments match cordn's schema`() = + runTest { + driving { client.postMessage("6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21", "c2VhbGVk") } + assertEquals(input("msg_post"), sent("msg_post")) + } + + @Test + fun `a first fetch omits after entirely rather than sending zero`() = + runTest { + // `after` is typed as a number, so 0 is a real cursor, not "from + // the start" -- sending it would silently skip the first message. + val gid = "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21" + driving { client.fetchMessages(mapOf(gid to null)) } + assertEquals(input("msg_fetch_many"), sent("msg_fetch_many")) + + coordinator.calls.clear() + driving { client.fetchMessages(mapOf(gid to 42L)) } + assertEquals(input("msg_fetch_many", "inputWithCursor"), sent("msg_fetch_many")) + } + + // ---- what we read ---------------------------------------------------- + + @Test + fun `kp_publish result parses`() = + runTest { + lateinit var seen: PublishedKeyPackage + scripted("kp_publish", output("kp_publish")) { + seen = client.publishKeyPackage("1f".repeat(16), "AAECAwQFBgc=") + } + assertEquals("1f".repeat(16), seen.keyPackageRef) + assertEquals(false, seen.lastResort) + assertEquals(1757000000L, seen.at) + } + + @Test + fun `kp_list result parses, last_resort and all`() = + runTest { + lateinit var seen: List + scripted("kp_list", output("kp_list")) { seen = client.listKeyPackages() } + assertEquals(2, seen.size) + assertEquals("a".repeat(64), seen[0].pubKey) + assertEquals(false, seen[0].lastResort) + assertEquals(true, seen[1].lastResort) + assertEquals(1757000100L, seen[1].at) + } + + @Test + fun `kp_take result carries the publication event, and a null one means nothing held`() = + runTest { + lateinit var taken: TakenKeyPackage + scripted("kp_take", output("kp_take")) { taken = client.takeKeyPackage("2e".repeat(16))!! } + assertEquals("b".repeat(64), taken.pubKey) + assertEquals(true, taken.lastResort) + assertEquals(25910, taken.publicationEvent.kind) + // Read back out of the signed payload, not from a sibling field. + assertEquals("AAECAwQFBgc=", taken.keyPackageBase64()) + + coordinator.calls.clear() + coordinator.scriptedResults.clear() + var empty: TakenKeyPackage? = null + scripted("kp_take", output("kp_take", "emptyOutput")) { empty = client.takeKeyPackage("2e".repeat(16)) } + assertNull(empty, "`keyPackage: null` means the coordinator holds nothing, not an error") + } + + @Test + fun `welcome_take result parses, including the optional resume cursor`() = + runTest { + lateinit var seen: List + scripted("welcome_take", output("welcome_take")) { seen = client.takeWelcomes() } + assertEquals(2, seen.size) + assertEquals(12L, seen[0].after) + assertNull(seen[1].after, "`after` is optional; absent must not become 0") + assertEquals("V2VsY29tZQ==", seen[0].welcomeBase64) + } + + @Test + fun `join_request_take_many result parses, gid and all`() = + runTest { + lateinit var seen: List + scripted("join_request_take_many", output("join_request_take_many")) { + seen = client.takeJoinRequests(listOf("6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21")) + } + assertEquals(1, seen.size) + // The single-group variant of this record has no `gid`; the many + // variant does, and we read the many one. + assertEquals("6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21", seen[0].gid) + assertEquals("b".repeat(64), seen[0].pubKey) + } + + @Test + fun `msg_post and msg_fetch_many results parse`() = + runTest { + lateinit var posted: PostedMessage + scripted("msg_post", output("msg_post")) { + posted = client.postMessage("6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21", "c2VhbGVk") + } + assertEquals(42L, posted.cursor) + + coordinator.calls.clear() + coordinator.scriptedResults.clear() + lateinit var fetched: List + scripted("msg_fetch_many", output("msg_fetch_many")) { + fetched = client.fetchMessages(mapOf("6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21" to 42L)) + } + assertEquals(listOf(43L, 44L), fetched.map { it.cursor }) + assertEquals("c2VhbGVkLTE=", fetched[0].sealedBase64) + } + + @Test + fun `every coordinator tool name matches cordn's own table`() = + runTest { + val theirs = + vectors["methods"]!! + .jsonObject.values + .map { it.toString().trim('"') } + .toSet() + val ours = CoordinatorMethod.entries.map { it.wire }.toSet() + assertEquals(theirs, ours, "the tool name set must match cordn's COORDINATOR_METHODS exactly") + assertTrue(theirs.size == 11) + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/cordn/interop/CordnApplicationMessageTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/cordn/interop/CordnApplicationMessageTest.kt new file mode 100644 index 0000000000..b5cba8d981 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/cordn/interop/CordnApplicationMessageTest.kt @@ -0,0 +1,119 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.interop + +import com.vitorpamplona.quartz.cordn.groups.CordnCredential +import com.vitorpamplona.quartz.cordn.groups.CordnGroupPolicy +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnApplicationMessage +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnEnvelope +import com.vitorpamplona.quartz.mls.group.MlsGroup +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** + * The SEND direction, which the ts-mls fixtures cannot cover. + * + * Those fixtures were written by ts-mls, so they prove we read what cordn + * writes. Nothing in them would notice if we stopped writing the + * `authenticated_data` binding on the way out — and cordn rejects an + * application message that arrives without it + * (`packages/cli/src/groupSync.ts:247`), so the symptom would be every peer + * silently dropping everything we send, with our own client perfectly happy. + */ +class CordnApplicationMessageTest { + private val alice = "aa".repeat(32) + private val bob = "bb".repeat(32) + + private fun group() = MlsGroup.create(identity = CordnCredential.of(alice).identity, policy = CordnGroupPolicy) + + private fun envelope(content: String = "hello") = CordnEnvelope.build(pubKey = alice, createdAt = 1_700_000_000L, kind = 9, content = content) + + @Test + fun `a sealed message carries the sender in authenticated_data`() { + val group = group() + val sealed = CordnApplicationMessage.seal(group, alice, envelope()) + + // Decrypt through a second view of the same epoch so the ratchet is not + // the thing under test. + val decrypted = + group.decrypt( + com.vitorpamplona.quartz.cordn.spec03Payloads.SealedPayload + .open( + sealed, + com.vitorpamplona.quartz.cordn.spec03Payloads.SealedPayload + .applicationKey(group), + ), + ) + + assertContentEquals( + alice.encodeToByteArray(), + decrypted.authenticatedData, + "cordn binds the sender here, and a peer refuses the message without it", + ) + } + + @Test + fun `the authenticated sender is what open reports, not the envelope`() { + val group = group() + val received = CordnApplicationMessage.open(group, CordnApplicationMessage.seal(group, alice, envelope())) + + assertEquals(alice, received.sender) + assertEquals("hello", received.envelope.content) + assertEquals(group.leafIndex, received.senderLeafIndex) + } + + @Test + fun `an envelope claiming a different pubkey is refused at the sender`() { + // Caught before it goes out rather than at every peer, which is the + // difference between an error here and a message nobody accepts. + val group = group() + val forged = CordnEnvelope.build(pubKey = bob, createdAt = 1_700_000_000L, kind = 9, content = "not me") + + assertFailsWith { CordnApplicationMessage.seal(group, alice, forged) } + } + + @Test + fun `a message with no authenticated sender is rejected on receive`() { + // The engine's default is an empty AAD, which is right for Marmot and + // fatal for cordn. Treating empty as "unknown sender" instead of a + // rejection would let anyone omit the field and let the envelope's own + // pubkey fill the gap unchallenged. + val group = group() + val bare = group.encrypt(envelope().encode()) + val decrypted = group.decrypt(bare) + + val error = assertFailsWith { CordnApplicationMessage.open(decrypted) } + assertTrue(error.message?.contains("authenticated sender") == true, "got '${error.message}'") + } + + @Test + fun `a round trip survives non-ASCII content`() { + val group = group() + val text = "reply at epoch 2 ✨ 🪜" + val received = CordnApplicationMessage.open(group, CordnApplicationMessage.seal(group, alice, envelope(text))) + + assertEquals(text, received.envelope.content) + assertEquals(received.envelope.computedId(), received.envelope.id) + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/cordn/interop/CordnGroupRefVectorTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/cordn/interop/CordnGroupRefVectorTest.kt new file mode 100644 index 0000000000..14a5ed3095 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/cordn/interop/CordnGroupRefVectorTest.kt @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.interop + +import com.vitorpamplona.quartz.cordn.appGroupRef.CordnGroupRef +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFails + +/** + * `cordn1…` refs against cordn's own bech32 codec. + * + * The strings below came out of `@cordn/core`'s `encodeGroupRef`, not out of + * ours (see `quartz/tools/cordn-vector-gen`). A group ref is the one cordn + * artifact a user copies and pastes by hand, so a divergence here is not a + * protocol nuance — it is a link that works in one client and not the other. + * + * Both directions are checked: we must decode what they produce, and produce + * byte-identical output for the same input. The second half is the one that + * catches TLV ordering, since §3 lets a decoder accept any order and would hide + * the difference. + */ +class CordnGroupRefVectorTest { + private val vectors: JsonObject = + Json + .parseToJsonElement( + checkNotNull(CordnGroupRefVectorTest::class.java.getResourceAsStream("/cordn/coordinator-contracts.json")) { + "missing cordn contract vectors" + }.use { it.readBytes() } + .decodeToString(), + ).jsonObject + + private fun JsonObject.text(key: String) = this[key]?.jsonPrimitive?.content + + private fun JsonObject.toRef() = + CordnGroupRef( + gid = text("gid")!!, + coordinatorPubKey = text("coordinatorPubkey"), + relays = this["relays"]?.jsonArray?.map { it.jsonPrimitive.content }.orEmpty(), + ) + + @Test + fun `we decode every ref cordn encodes`() { + val cases = vectors["groupRefs"]!!.jsonArray.map { it.jsonObject } + assertEquals(6, cases.size, "the vector file lost cases") + + cases.forEach { case -> + val decoded = CordnGroupRef.decode(case.text("encoded")!!) + assertEquals(case.toRef(), decoded, "decoding ${case.text("encoded")}") + } + } + + @Test + fun `we encode byte-identically to cordn`() { + vectors["groupRefs"]!!.jsonArray.map { it.jsonObject }.forEach { case -> + assertEquals( + case.text("encoded"), + case.toRef().encode(), + "encoding gid=${case.text("gid")}", + ) + } + } + + @Test + fun `an all-uppercase ref decodes, as it does for cordn`() { + // Bech32 forbids mixed case but permits uppercase, and their + // `decodeGroupRef` accepts it even though their `isGroupRef` screen + // does not. A ref pasted in caps has to keep working. + val case = vectors["groupRefUppercase"]!!.jsonObject + assertEquals(case.toRef(), CordnGroupRef.decode(case.text("encoded")!!)) + } + + @Test + fun `we refuse everything cordn refuses`() { + vectors["groupRefRejects"]!!.jsonArray.forEach { + val bad = it.jsonPrimitive.content + assertFails("must refuse ${bad.ifEmpty { "" }}") { CordnGroupRef.decode(bad) } + } + } + + @Test + fun `a non-ASCII gid survives byte for byte`() { + // §4.1: the coordinator keys its cursor space on these bytes, so any + // normalisation on our side silently splits a group in two. + val case = vectors["groupRefs"]!!.jsonArray.map { it.jsonObject }.last { it.text("gid")!!.any { c -> c.code > 127 } } + val decoded = CordnGroupRef.decode(case.text("encoded")!!) + assertEquals(case.text("gid"), decoded.gid) + assertEquals(case.text("encoded"), decoded.encode()) + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/cordn/interop/CordnLifecycleInteropTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/cordn/interop/CordnLifecycleInteropTest.kt new file mode 100644 index 0000000000..5915d75df1 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/cordn/interop/CordnLifecycleInteropTest.kt @@ -0,0 +1,284 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.interop + +import com.vitorpamplona.quartz.cordn.appGroupRef.CordnGroupRef +import com.vitorpamplona.quartz.cordn.groups.CordnCredential +import com.vitorpamplona.quartz.cordn.groups.CordnGroupPolicy +import com.vitorpamplona.quartz.cordn.spec01GroupMetadata.CordnGroupMetadata +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnApplicationMessage +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnEnvelope +import com.vitorpamplona.quartz.cordn.spec03Payloads.SealedPayload +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.framing.ContentType +import com.vitorpamplona.quartz.mls.group.MlsGroup +import com.vitorpamplona.quartz.mls.messages.MlsKeyPackage +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * A full cordn group lifecycle, against fixtures **ts-mls** produced. + * + * Every other test in this module proves we agree with ourselves. This one + * proves we agree with the implementation cordn's own client runs: Alice + * creates a group in TypeScript, adds Bob, and sends a message, and we join as + * Bob and read it. + * + * Each step is a separate test because each fails for a different reason, and + * a single end-to-end assertion would tell you only that something in a chain + * of six broke. + */ +class CordnLifecycleInteropTest { + private val alice = TsMlsFixtures.text("alice.pk") + private val bob = TsMlsFixtures.text("bob.pk") + + private fun bobBundle() = TsMlsPrivateKeyPackage.decode(TsMlsFixtures.bytes("bob-kp.bin"), TsMlsFixtures.bytes("bob-privkp.bin")) + + /** Bob's group at epoch 1, joined from the ts-mls Welcome. */ + private fun bobJoined(): MlsGroup = MlsGroup.processWelcome(TsMlsFixtures.b64("welcome.b64"), bobBundle(), CordnGroupPolicy) + + // ---- 1. their KeyPackage, our decoder ---------------------------------- + + @Test + fun `we read a ts-mls KeyPackage and compute the same KeyPackageRef`() { + val keyPackage = MlsKeyPackage.decodeTls(TlsReader(TsMlsFixtures.bytes("bob-kp.bin"))) + + assertEquals( + bob, + CordnCredential.identityOrNull(keyPackage.leafNode), + "the credential must decode as cordn's 64-ASCII-hex identity", + ) + // kp_ref is the coordinator's primary key for a KeyPackage. Disagreeing + // here means every kp_take and every Welcome addressed to us misses. + assertEquals(TsMlsFixtures.text("bob-kpref.hex"), keyPackage.reference().toHexKey()) + } + + @Test + fun `we recognise a ts-mls last-resort KeyPackage`() { + // cordn marks it with app_data_dictionary component 0x0004; Marmot's + // MIP-era profile uses bare extension 0x000A. Our reader accepts both, + // and this pins the cordn carrier against a real one. + val lastResort = MlsKeyPackage.decodeTls(TlsReader(TsMlsFixtures.bytes("bob-lastresort-kp.bin"))) + assertTrue(lastResort.isLastResort()) + + val ordinary = MlsKeyPackage.decodeTls(TlsReader(TsMlsFixtures.bytes("bob-kp.bin"))) + assertTrue(!ordinary.isLastResort(), "and must not see it where there is none") + } + + @Test + fun `the ts-mls private key package round-trips through our codec`() { + val bundle = bobBundle() + assertEquals(32, TsMlsPrivateKeyPackage.seedOf(bundle.signaturePrivateKey).size) + assertContentEquals( + TsMlsFixtures.bytes("bob-privkp.bin"), + TsMlsPrivateKeyPackage.encode(bundle), + "re-encoding must reproduce ts-mls's bytes, PKCS#8 wrapper included", + ) + } + + // ---- 2. their seal, our exporter --------------------------------------- + + @Test + fun `we unseal a ts-mls commit with the published epoch-0 exporter`() { + // spec/03.md §5: a Commit is sealed under the epoch it transitions FROM, + // so every member still at that epoch can read it before advancing. + // Sealing it under the new epoch would lock out exactly the members it + // is addressed to. + val opened = SealedPayload.open(TsMlsFixtures.text("commit-add-sealed.b64"), TsMlsFixtures.hex("exporter-e0.hex")) + assertContentEquals(TsMlsFixtures.b64("commit-add.b64"), opened) + } + + @Test + fun `we unseal a ts-mls application message with the epoch-1 exporter`() { + val opened = SealedPayload.open(TsMlsFixtures.text("app-1-sealed.b64"), TsMlsFixtures.hex("exporter-e1.hex")) + assertContentEquals(TsMlsFixtures.b64("app-1.b64"), opened) + } + + // ---- 3. their Welcome, our join --------------------------------------- + + @Test + fun `we join the group from the ts-mls Welcome`() { + val group = bobJoined() + + assertEquals(1L, group.epoch, "the Welcome admits us at epoch 1") + assertEquals(bob, CordnCredential.identityOrNull(group.members()[group.leafIndex].second)) + assertEquals( + setOf(alice, bob), + CordnCredential.memberIdentities(group), + // Not group.currentMemberIdentities(): that hexes the credential + // bytes, which for cordn's already-hex identity gives 128 + // characters of hex-of-hex. The trap is real enough that cordn has + // its own accessor. + ) + } + + @Test + fun `our derived exporter matches theirs byte for byte`() { + // The single strongest assertion available. The exporter sits at the + // end of the whole key schedule, so agreeing on it means the tree, the + // transcript hash, the commit secret and every epoch secret matched. A + // one-bit divergence anywhere upstream produces 32 completely different + // bytes here. + val group = bobJoined() + val exporter = CordnGroupPolicy.PAYLOAD_EXPORTER + + assertContentEquals( + TsMlsFixtures.hex("exporter-e1.hex"), + group.exporterSecret(exporter.label, exporter.context, exporter.length), + ) + } + + @Test + fun `we read the group metadata ts-mls put in the GroupContext`() { + // Also proves the RFC 9420 §12.1.7 fix: the old engine refused any + // extension type outside a hardcoded list, and 0xC04D is not in it. + val metadata = CordnGroupMetadata.fromExtensions(bobJoined().extensions) + + assertEquals("Conformance", metadata?.name) + assertEquals(listOf(alice), metadata?.adminPubkeys) + assertEquals("🪜", metadata?.icon, "the emoji must survive as UTF-8") + assertTrue(!metadata!!.isEgalitarian, "this group names an admin") + } + + // ---- 4. their message, our reader ------------------------------------- + + @Test + fun `we open a ts-mls application message end to end`() { + // The whole path: unseal, MLS-decrypt, read the authenticated sender out + // of AAD, and hold the unsigned envelope to it. + val received = CordnApplicationMessage.open(bobJoined(), TsMlsFixtures.text("app-1-sealed.b64")) + + assertEquals(alice, received.sender, "the sender comes from authenticated_data, not the envelope") + assertEquals("hello from ts-mls", received.envelope.content) + assertEquals(9, received.envelope.kind) + assertEquals(bob, received.envelope.tags.single()[1], "the `p` tag names Bob") + } + + @Test + fun `the envelope id we recompute matches the one ts-mls wrote`() { + // spec/02.md §4 makes the receiver recompute it. If our NIP-01 + // serialization differed by so much as a space, every message would be + // rejected as tampered. + val expected = CordnEnvelope.decode(TsMlsFixtures.bytes("envelope-1.json"), senderIdentity = alice) + val received = CordnApplicationMessage.open(bobJoined(), TsMlsFixtures.text("app-1-sealed.b64")) + + assertEquals(expected.id, received.envelope.id) + assertEquals(expected.id, received.envelope.computedId()) + } + + // ---- 5. their metadata commit, our epoch advance ----------------------- + + /** + * Bob at epoch 2, having processed ts-mls's metadata commit. + * + * The commit arrives PRIVATE-framed, not public. That is a cordn/Marmot + * difference worth noticing: Marmot publishes commits as PublicMessage + * inside a kind-445 event, while cordn seals everything to the coordinator + * and frames handshake traffic privately, so `decrypt` is the entry point + * and `processFramedCommit` is not. + */ + private fun bobAtEpoch2(): MlsGroup { + val group = bobJoined() + val commit = SealedPayload.open(TsMlsFixtures.text("commit-meta-sealed.b64"), TsMlsFixtures.hex("exporter-e1.hex")) + val decrypted = group.decrypt(commit) + assertEquals(ContentType.COMMIT, decrypted.contentType, "cordn frames handshake messages privately") + return group + } + + @Test + fun `we apply a ts-mls GroupContextExtensions commit that renames the group`() { + // The strongest available check on the RFC 9420 §12.1.7 fix. Before it, + // the engine refused any extension type outside a hardcoded list, so + // this real commit -- carrying 0xC04D -- would have been rejected and + // Bob would have sat at epoch 1 forever while everyone else moved on. + val group = bobAtEpoch2() + + assertEquals(2L, group.epoch) + assertEquals("Conformance (renamed)", CordnGroupMetadata.fromExtensions(group.extensions)?.name) + assertEquals( + "https://example.invalid/g.png", + CordnGroupMetadata.fromExtensions(group.extensions)?.imageUrl, + ) + } + + @Test + fun `our exporter still matches theirs after the epoch advance`() { + // Joining agreed at epoch 1; this proves the commit itself -- tree + // mutation, transcript hash, key schedule -- agreed too. + val group = bobAtEpoch2() + val exporter = CordnGroupPolicy.PAYLOAD_EXPORTER + + assertContentEquals( + TsMlsFixtures.hex("exporter-e2.hex"), + group.exporterSecret(exporter.label, exporter.context, exporter.length), + ) + } + + @Test + fun `we read a threaded reply sent at epoch 2`() { + val received = CordnApplicationMessage.open(bobAtEpoch2(), TsMlsFixtures.text("app-2-sealed.b64")) + + assertEquals(alice, received.sender) + assertEquals(1111, received.envelope.kind, "NIP-22 threaded reply, per spec/02.md §6") + assertEquals("reply at epoch 2 \u2728", received.envelope.content) + assertEquals( + CordnEnvelope.decode(TsMlsFixtures.bytes("envelope-2.json"), senderIdentity = alice).id, + received.envelope.id, + ) + } + + // ---- 6. their KeyPackage, our group ------------------------------------ + + @Test + fun `we can add a ts-mls member to a group we created`() { + // The reverse direction, as far as it goes without running their client: + // a group built by our engine under CordnGroupPolicy accepts a real + // ts-mls KeyPackage and produces a Welcome for it. A capability or + // required_capabilities mismatch between the two profiles would fail + // exactly here. + val group = + MlsGroup.create( + identity = CordnCredential.of(alice).identity, + policy = CordnGroupPolicy, + initialExtensions = listOf(CordnGroupMetadata(name = "from Kotlin").toExtension()), + ) + + val result = group.addMember(TsMlsFixtures.bytes("bob2-kp.bin")) + + assertEquals(1L, group.epoch) + assertEquals(setOf(alice, bob), CordnCredential.memberIdentities(group)) + assertTrue(result.welcomeBytes != null, "a Welcome must be produced for the joiner") + } + + // ---- 7. the delivery id ------------------------------------------------ + + @Test + fun `the gid survives a group-ref round trip`() { + // spec/applications/group-ref.md §4.1: byte for byte, no normalisation. + // This gid is a plain string rather than a UUID, which is exactly the + // case a decoder that "tidied" its input would break. + val gid = TsMlsFixtures.text("gid") + assertEquals(gid, CordnGroupRef.decode(CordnGroupRef(gid).encode()).gid) + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/cordn/interop/KotlinArtifactProducerTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/cordn/interop/KotlinArtifactProducerTest.kt new file mode 100644 index 0000000000..73c726fe72 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/cordn/interop/KotlinArtifactProducerTest.kt @@ -0,0 +1,186 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.interop + +import com.vitorpamplona.quartz.cordn.groups.CordnCredential +import com.vitorpamplona.quartz.cordn.groups.CordnGroupPolicy +import com.vitorpamplona.quartz.cordn.spec01GroupMetadata.CordnGroupMetadata +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnApplicationMessage +import com.vitorpamplona.quartz.cordn.spec02Envelopes.CordnEnvelope +import com.vitorpamplona.quartz.cordn.spec03Payloads.SealedPayload +import com.vitorpamplona.quartz.mls.group.MlsGroup +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import java.io.File +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * Produces the artifacts Staircase's `conformance/fixtures-gen/verify.ts` reads, + * so **ts-mls** can check our output rather than the other way round. + * + * [CordnLifecycleInteropTest] proves we can read what ts-mls writes. That is + * only half of interoperating: a client can parse everything correctly and + * still emit something nobody else accepts — and the failure mode is worse, + * because our own tests stay green while every peer silently drops us. + * + * This test writes files; `cordn/interop/verify-with-ts-mls.sh` feeds them to + * ts-mls. The split is deliberate: producing the artifacts needs no toolchain + * beyond Gradle and runs in CI unconditionally, while running ts-mls needs bun + * and a cordn checkout. A green run here means the artifacts exist and our own + * engine round-trips them; it does **not** by itself mean ts-mls accepted them. + * + * Output goes to `-Dcordn.interop.out`, defaulting to `build/interop/kotlin`. + */ +@OptIn(ExperimentalEncodingApi::class) +class KotlinArtifactProducerTest { + private val alice = "cc".repeat(32) + + private val out = + File(System.getProperty("cordn.interop.out") ?: "build/interop/kotlin").also { it.mkdirs() } + + private fun write( + name: String, + value: String, + ) = File(out, name).writeText(value) + + private val metadata = + CordnGroupMetadata( + name = "Kotlin conformance", + description = "quartz MLS via the cordn binding", + adminPubkeys = listOf(alice), + icon = "💎", + ) + + private val updatedMetadata = metadata.copy(name = "Kotlin conformance (renamed)") + + private fun exporterHex(group: MlsGroup): String = CordnGroupPolicy.PAYLOAD_EXPORTER.let { group.exporterSecret(it.label, it.context, it.length) }.toHexKey() + + @Test + fun `produce the artifacts ts-mls verifies`() { + // --- epoch 0: alice creates a cordn group carrying metadata --------- + val group = + MlsGroup.create( + identity = CordnCredential.of(alice).identity, + policy = CordnGroupPolicy, + initialExtensions = listOf(metadata.toExtension()), + ) + write("k-alice.pk", alice) + write("k-meta.json", metadataJson(metadata)) + + // --- epoch 1: add THEIR key package --------------------------------- + // bob2 is a real ts-mls KeyPackage. Using ours would make this a test + // of our own encoder talking to itself. + val add = group.addMember(TsMlsFixtures.bytes("bob2-kp.bin")) + val welcome = assertNotNull(add.welcomeBytes, "adding a member must produce a Welcome") + assertEquals(1L, group.epoch) + + write("k-welcome.b64", Base64.encode(welcome)) + write("k-exporter-e1.hex", exporterHex(group)) + + // --- an application message at epoch 1 ------------------------------- + val envelope = + CordnEnvelope.build( + pubKey = alice, + createdAt = 1_700_000_100L, + kind = 9, + content = "hello from quartz 💎", + ) + write("k-app-sealed.b64", CordnApplicationMessage.seal(group, alice, envelope)) + write("k-envelope.json", envelopeJson(envelope)) + + // --- epoch 2: a metadata change ------------------------------------- + // Sealed with the PRE-commit key the commit itself reports. Sealing + // under the post-commit epoch would lock out every member still at + // epoch 1 — which is everyone this commit is addressed to. + group.proposeGroupContextExtensions( + group.extensions.filterNot { it.extensionType == CordnGroupMetadata.EXTENSION_TYPE } + + updatedMetadata.toExtension(), + ) + val commit = group.commit() + assertEquals(2L, group.epoch) + assertTrue(commit.preCommitExporterSecret.isNotEmpty(), "the policy must supply a commit exporter") + + write("k-commit-meta-sealed.b64", SealedPayload.seal(commit.framedCommitBytes, commit.preCommitExporterSecret)) + write("k-meta-2.json", metadataJson(updatedMetadata)) + write("k-exporter-e2.hex", exporterHex(group)) + + // Our own round trip, so a broken artifact fails here rather than only + // in a script someone may not run. + assertEquals( + updatedMetadata.name, + CordnGroupMetadata.fromExtensions(group.extensions)?.name, + ) + assertEquals( + 9, + listOf( + "k-alice.pk", + "k-meta.json", + "k-welcome.b64", + "k-exporter-e1.hex", + "k-app-sealed.b64", + "k-envelope.json", + "k-commit-meta-sealed.b64", + "k-meta-2.json", + "k-exporter-e2.hex", + ).count { File(out, it).exists() }, + "every artifact verify.ts reads must be written", + ) + } + + /** verify.ts compares name, description and adminPubkeys against the extension. */ + private fun metadataJson(meta: CordnGroupMetadata) = + buildString { + append("{\"name\":").append(jsonString(meta.name)) + append(",\"description\":").append(jsonString(meta.description)) + append(",\"adminPubkeys\":[").append(meta.adminPubkeys.joinToString(",") { jsonString(it) }).append("]") + append(",\"icon\":").append(jsonString(meta.icon)) + append("}") + } + + /** verify.ts compares id, content and pubkey. */ + private fun envelopeJson(envelope: CordnEnvelope) = + buildString { + append("{\"id\":").append(jsonString(envelope.id)) + append(",\"pubkey\":").append(jsonString(envelope.pubKey)) + append(",\"created_at\":").append(envelope.createdAt) + append(",\"kind\":").append(envelope.kind) + append(",\"content\":").append(jsonString(envelope.content)) + append("}") + } + + private fun jsonString(value: String) = + buildString { + append('"') + value.forEach { + when { + it == '"' -> append("\\\"") + it == '\\' -> append("\\\\") + it.code < 0x20 -> append("\\u%04x".format(it.code)) + else -> append(it) + } + } + append('"') + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/cordn/interop/TsMlsFixtures.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/cordn/interop/TsMlsFixtures.kt new file mode 100644 index 0000000000..5628e53cd9 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/cordn/interop/TsMlsFixtures.kt @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.interop + +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.codec.TlsWriter +import com.vitorpamplona.quartz.mls.crypto.Ed25519 +import com.vitorpamplona.quartz.mls.messages.KeyPackageBundle +import com.vitorpamplona.quartz.mls.messages.MlsKeyPackage +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +/** Reads the vendored ts-mls fixtures. See `resources/tsmls/README.md`. */ +@OptIn(ExperimentalEncodingApi::class) +object TsMlsFixtures { + private fun resource(name: String): ByteArray = + checkNotNull(TsMlsFixtures::class.java.getResourceAsStream("/tsmls/$name")) { + "missing ts-mls fixture '$name'" + }.use { it.readBytes() } + + fun bytes(name: String): ByteArray = resource(name) + + fun text(name: String): String = resource(name).decodeToString().trim() + + fun b64(name: String): ByteArray = Base64.decode(text(name)) + + fun hex(name: String): ByteArray = text(name).chunked(2).map { it.toInt(16).toByte() }.toByteArray() +} + +/** + * ts-mls's `privateKeyPackageEncoder` layout: + * + * ``` + * opaque init_private_key || opaque hpke_private_key || opaque signature_private_key + * ``` + * + * The two implementations store the Ed25519 signing key differently and neither + * is wrong: ts-mls (noble/WebCrypto) writes a 48-byte PKCS#8 `PrivateKeyInfo` + * wrapping the 32-byte seed; Quartz keeps `seed || public`. The seed is the only + * thing either really holds, so that is what converts here, and the public half + * is re-derived rather than trusted — a pair that disagrees with itself cannot + * be built this way. + * + * Layout documented by Staircase (`cordn-core/…/KeyPackageBundleCodec.kt`, MIT); + * this is our own implementation of it. + */ +object TsMlsPrivateKeyPackage { + /** RFC 8410 PKCS#8 PrivateKeyInfo prefix for Ed25519; the 32-byte seed follows. */ + private val PKCS8_ED25519_PREFIX = + "302e020100300506032b657004220420".chunked(2).map { it.toInt(16).toByte() }.toByteArray() + + private const val SEED_SIZE = 32 + + /** Accepts a bare seed, ts-mls's PKCS#8 blob, or Quartz's `seed || public`. */ + fun seedOf(privateKey: ByteArray): ByteArray = + when (privateKey.size) { + SEED_SIZE -> privateKey + 48 -> { + require(privateKey.copyOfRange(0, 16).contentEquals(PKCS8_ED25519_PREFIX)) { + "unexpected PKCS#8 Ed25519 header" + } + privateKey.copyOfRange(16, 48) + } + 64 -> privateKey.copyOfRange(0, SEED_SIZE) + else -> throw IllegalArgumentException("unsupported Ed25519 private key length ${privateKey.size}") + } + + fun decode( + keyPackageBytes: ByteArray, + privateBytes: ByteArray, + ): KeyPackageBundle { + val reader = TlsReader(privateBytes) + val initKey = reader.readOpaqueVarInt() + val encryptionKey = reader.readOpaqueVarInt() + val signingSeed = seedOf(reader.readOpaqueVarInt()) + require(!reader.hasRemaining) { "trailing bytes in ts-mls private key package" } + + return KeyPackageBundle( + keyPackage = MlsKeyPackage.decodeTls(TlsReader(keyPackageBytes)), + initPrivateKey = initKey, + encryptionPrivateKey = encryptionKey, + signaturePrivateKey = Ed25519.keyPairFromSeed(signingSeed).privateKey, + ) + } + + /** The inverse, so a round trip can be asserted. */ + fun encode(bundle: KeyPackageBundle): ByteArray { + val writer = TlsWriter() + writer.putOpaqueVarInt(bundle.initPrivateKey) + writer.putOpaqueVarInt(bundle.encryptionPrivateKey) + writer.putOpaqueVarInt(PKCS8_ED25519_PREFIX + seedOf(bundle.signaturePrivateKey)) + return writer.toByteArray() + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/cordn/spec00Coordinator/CoordinatorClientTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/cordn/spec00Coordinator/CoordinatorClientTest.kt new file mode 100644 index 0000000000..2be514cef7 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/cordn/spec00Coordinator/CoordinatorClientTest.kt @@ -0,0 +1,223 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.spec00Coordinator + +import com.vitorpamplona.quartz.contextvm.cep04Encryption.CvmGiftWrap +import com.vitorpamplona.quartz.contextvm.cep04Encryption.EncryptionMode +import com.vitorpamplona.quartz.contextvm.fixture.CvmFixtureServer +import com.vitorpamplona.quartz.contextvm.fixture.InMemoryRelayPool +import com.vitorpamplona.quartz.contextvm.mcp.CvmMcpClient +import com.vitorpamplona.quartz.contextvm.transport.CvmTransport +import com.vitorpamplona.quartz.contextvm.transport.DualSigner +import com.vitorpamplona.quartz.cordn.fixture.CordnFixtureCoordinator +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.yield +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The coordinator client against an in-memory coordinator. + * + * The interesting assertions are not "the call returned" but "the coordinator + * learned only what `spec/00.md` §8 says it learns" — which is checkable only + * by standing on the coordinator's side of the wire, which is what + * [CordnFixtureCoordinator] is for. + */ +class CoordinatorClientTest { + private val relays = InMemoryRelayPool() + private val serverSigner = NostrSignerInternal(KeyPair()) + private val stableSigner = NostrSignerInternal(KeyPair()) + private val ephemeralSigner = NostrSignerInternal(KeyPair()) + private val plaintext = CvmGiftWrap(encryptionMode = EncryptionMode.DISABLED) + + private fun client() = + CoordinatorClient( + CvmMcpClient( + CvmTransport( + relays = relays, + signers = DualSigner(stableSigner, ephemeralSigner), + serverPubKey = serverSigner.pubKey, + crypto = plaintext, + ), + ), + ) + + private fun serve(coordinator: CordnFixtureCoordinator) = + CvmFixtureServer( + relays = relays, + signer = serverSigner, + crypto = plaintext, + injectClientPubkey = true, + handler = coordinator::handle, + ).also { it.start() } + + /** Runs [block] while pumping the fixture, since the relay callback cannot suspend. */ + private suspend fun driving( + server: CvmFixtureServer, + block: suspend () -> T, + ): T = + coroutineScope { + val work = async { block() } + while (!work.isCompleted) { + yield() + server.pump() + yield() + } + work.await() + } + + @Test + fun `the message path never touches the stable identity`() = + runTest { + // The privacy claim of spec/00.md §8, checked from the coordinator's + // side. A client that signed msg_post with the account key would + // still work perfectly -- and would tie every message to a real + // npub on a server that keeps ordered history forever. + val coordinator = CordnFixtureCoordinator() + val server = serve(coordinator) + val client = client() + + driving(server) { + client.postMessage("group-1", "c2VhbGVk") + client.fetchMessages(mapOf("group-1" to null)) + client.listKeyPackages() + } + + assertTrue(coordinator.calls.isNotEmpty()) + coordinator.calls.forEach { + assertEquals( + ephemeralSigner.pubKey, + it.callerPubKey, + "${it.method} must not be attributable to the account identity", + ) + } + } + + @Test + fun `publishing and joining are attributable, because they name you by design`() = + runTest { + // The other half. These are not leaks to be fixed: a KeyPackage + // publication that did not name its owner would bind nothing, and a + // join request that did not name the asker could not be acted on. + val coordinator = CordnFixtureCoordinator() + val server = serve(coordinator) + val client = client() + + driving(server) { + client.publishKeyPackage("ref-1", "a2V5") + client.storeJoinRequest("group-1", "ref-1") + client.takeWelcomes() + } + + coordinator.calls.forEach { + assertEquals(stableSigner.pubKey, it.callerPubKey, "${it.method} must name the account") + } + } + + @Test + fun `a coordinator rejection surfaces as an exception, not an empty result`() = + runTest { + val coordinator = CordnFixtureCoordinator(rejectPublication = true) + val server = serve(coordinator) + val client = client() + + assertFailsWith { + driving(server) { client.publishKeyPackage("ref-1", "a2V5") } + } + } + + @Test + fun `fetch returns a group's messages after its cursor`() = + runTest { + val coordinator = CordnFixtureCoordinator() + coordinator.seed("group-1", "b25l") + val second = coordinator.seed("group-1", "dHdv") + coordinator.seed("group-1", "dGhyZWU=") + coordinator.seed("group-2", "b3RoZXI=") + val server = serve(coordinator) + val client = client() + + val page = driving(server) { client.fetchMessages(mapOf("group-1" to second)) } + + assertEquals(listOf("dGhyZWU="), page.map { it.sealedBase64 }) + assertEquals("group-1", page.single().gid, "another group's stream must not leak into this one") + } + + @Test + fun `a first fetch omits the cursor rather than sending zero`() = + runTest { + // The schema types `after` as a positive int, so 0 is not "from the + // beginning" -- it is out of range, and a coordinator validating its + // own contract would reject the call. + val coordinator = CordnFixtureCoordinator() + coordinator.seed("group-1", "b25l") + val server = serve(coordinator) + val client = client() + + val page = driving(server) { client.fetchMessages(mapOf("group-1" to null)) } + assertEquals(1, page.size) + } + + @Test + fun `taking a key package nobody published returns null`() = + runTest { + val coordinator = CordnFixtureCoordinator() + val server = serve(coordinator) + val client = client() + + assertNull(driving(server) { client.takeKeyPackage("nobody") }) + } + + @Test + fun `posting reports the cursor the coordinator assigned`() = + runTest { + val coordinator = CordnFixtureCoordinator() + val server = serve(coordinator) + val client = client() + + val posted = driving(server) { client.postMessage("group-1", "c2VhbGVk") } + + assertEquals("group-1", posted.gid) + assertTrue(posted.cursor > 0) + assertEquals(listOf("c2VhbGVk"), coordinator.posted("group-1")) + } + + @Test + fun `welcomes carry the resume cursor when the inviter set one`() = + runTest { + // Without it a joiner replays a group's whole history, all of it + // sealed under epochs it has no key for. + val coordinator = CordnFixtureCoordinator() + coordinator.seedWelcome("ref-1", "d2VsY29tZQ==", after = 42, targetPubKey = stableSigner.pubKey) + val server = serve(coordinator) + val client = client() + + val welcomes = driving(server) { client.takeWelcomes() } + assertEquals(42L, welcomes.single().after) + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/cordn/spec00Coordinator/CoordinatorServerInfoTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/cordn/spec00Coordinator/CoordinatorServerInfoTest.kt new file mode 100644 index 0000000000..fa0cc912a6 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/cordn/spec00Coordinator/CoordinatorServerInfoTest.kt @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.spec00Coordinator + +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcError +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcFailure +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcId +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcNotification +import com.vitorpamplona.quartz.contextvm.jsonrpc.JsonRpcSuccess +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * `initialize` is the one coordinator call whose answer is pure claim: a name, + * a version, a capability object, none of it bound to anything but the key + * that was already signing the response (`spec/00.md` §8.5). So the only + * things worth asserting are that a claim survives the trip intact, and that + * every way a server can decline to make one leaves a null rather than a + * plausible-looking string on a settings screen. + */ +class CoordinatorServerInfoTest { + @Test + fun `reads name version protocol and capabilities`() { + val info = + CoordinatorServerInfo.from( + success( + """ + { + "protocolVersion": "2025-06-18", + "serverInfo": { "name": "cordn", "version": "0.5.7" }, + "capabilities": { "tools": {} } + } + """, + ), + )!! + + assertEquals("cordn", info.name) + assertEquals("0.5.7", info.version) + assertEquals("2025-06-18", info.protocolVersion) + assertEquals(JsonObject(emptyMap()), info.capabilities!!["tools"]) + assertFalse(info.isEmpty) + } + + @Test + fun `a handshake with no serverInfo is still a handshake`() { + // MCP lets a server omit serverInfo. Reporting that as a failed + // initialize would tell a user their coordinator is unreachable when + // it answered perfectly well. + val info = CoordinatorServerInfo.from(success("""{ "protocolVersion": "2025-06-18" }"""))!! + + assertNull(info.name) + assertNull(info.version) + assertEquals("2025-06-18", info.protocolVersion) + assertFalse(info.isEmpty, "a protocol version alone is worth showing") + } + + @Test + fun `an empty result is empty rather than a row of blanks`() { + val info = CoordinatorServerInfo.from(success("{}"))!! + + assertTrue(info.isEmpty) + assertNull(info.capabilities) + } + + @Test + fun `a JSON null name does not become the word null`() { + // jsonPrimitive.content renders JSON null as the four-character string + // "null", which is how a coordinator ends up listed as being called + // "null". Same for an empty string, which renders as a blank row. + val info = + CoordinatorServerInfo.from( + success("""{ "serverInfo": { "name": null, "version": "" }, "protocolVersion": null }"""), + )!! + + assertNull(info.name) + assertNull(info.version) + assertNull(info.protocolVersion) + assertTrue(info.isEmpty) + } + + @Test + fun `a non-success response yields nothing`() { + assertNull( + CoordinatorServerInfo.from( + JsonRpcFailure(JsonRpcId.Num(1), JsonRpcError(JsonRpcError.METHOD_NOT_FOUND, "no initialize here")), + ), + ) + assertNull(CoordinatorServerInfo.from(JsonRpcNotification("notifications/progress"))) + } + + @Test + fun `a result that is not an object yields nothing`() { + // A server answering `"result": "ok"` is not an MCP server; reading a + // name out of it would be inventing one. + assertNull(CoordinatorServerInfo.from(JsonRpcSuccess(JsonRpcId.Num(1), JsonPrimitive("ok")))) + } + + private fun success(result: String) = JsonRpcSuccess(JsonRpcId.Num(1), Json.parseToJsonElement(result.trimIndent())) +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/cordn/sync/CordnGroupSyncTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/cordn/sync/CordnGroupSyncTest.kt new file mode 100644 index 0000000000..429fb157ab --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/cordn/sync/CordnGroupSyncTest.kt @@ -0,0 +1,217 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.sync + +import com.vitorpamplona.quartz.contextvm.cep04Encryption.CvmGiftWrap +import com.vitorpamplona.quartz.contextvm.cep04Encryption.EncryptionMode +import com.vitorpamplona.quartz.contextvm.fixture.CvmFixtureServer +import com.vitorpamplona.quartz.contextvm.fixture.InMemoryRelayPool +import com.vitorpamplona.quartz.contextvm.mcp.CvmMcpClient +import com.vitorpamplona.quartz.contextvm.transport.CvmTransport +import com.vitorpamplona.quartz.contextvm.transport.DualSigner +import com.vitorpamplona.quartz.cordn.fixture.CordnFixtureCoordinator +import com.vitorpamplona.quartz.cordn.spec00Coordinator.CoordinatorClient +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.yield +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +/** Catch-up against a coordinator holding real history. */ +class CordnGroupSyncTest { + private val relays = InMemoryRelayPool() + private val serverSigner = NostrSignerInternal(KeyPair()) + private val plaintext = CvmGiftWrap(encryptionMode = EncryptionMode.DISABLED) + + private fun sync(coordinator: CordnFixtureCoordinator): Pair { + val signer = NostrSignerInternal(KeyPair()) + val client = + CoordinatorClient( + CvmMcpClient( + CvmTransport( + relays = relays, + signers = DualSigner(signer, signer), + serverPubKey = serverSigner.pubKey, + crypto = plaintext, + ), + ), + ) + val server = + CvmFixtureServer( + relays = relays, + signer = serverSigner, + crypto = plaintext, + injectClientPubkey = true, + handler = coordinator::handle, + ).also { it.start() } + return CordnGroupSync(client) to server + } + + private suspend fun driving( + server: CvmFixtureServer, + block: suspend () -> T, + ): T = + coroutineScope { + val work = async { block() } + while (!work.isCompleted) { + yield() + server.pump() + yield() + } + work.await() + } + + @Test + fun `catch-up drains a group and stops when the page comes back empty`() = + runTest { + val coordinator = CordnFixtureCoordinator() + repeat(5) { coordinator.seed("g1", "bXNnJGl0") } + val (sync, server) = sync(coordinator) + + val seen = mutableListOf() + val drained = driving(server) { sync.catchUp(listOf("g1")) { _, i -> seen += i } } + + assertEquals(5, drained) + assertEquals(5, seen.size) + assertEquals(5L, sync.inbox("g1").cursor.lastCursor) + } + + @Test + fun `catch-up resumes from a persisted cursor`() = + runTest { + val coordinator = CordnFixtureCoordinator() + coordinator.seed("g1", "b25l") + val second = coordinator.seed("g1", "dHdv") + coordinator.seed("g1", "dGhyZWU=") + val (sync, server) = sync(coordinator) + sync.restore("g1", GroupCursor(fetchCursor = second, lastCursor = second)) + + val drained = driving(server) { sync.catchUp(listOf("g1")) { _, _ -> } } + + assertEquals(1, drained, "only the message after the persisted cursor") + } + + @Test + fun `catch-up drains several groups independently`() = + runTest { + // spec/00.md §4: cursors are per group. One shared cursor would + // skip a quiet group's history the moment a busy one moved past it. + val coordinator = CordnFixtureCoordinator() + coordinator.seed("g1", "YQ==") + coordinator.seed("g2", "Yg==") + coordinator.seed("g1", "Yw==") + val (sync, server) = sync(coordinator) + + val perGroup = mutableMapOf() + driving(server) { + sync.catchUp(listOf("g1", "g2")) { gid, _ -> perGroup[gid] = (perGroup[gid] ?: 0) + 1 } + } + + assertEquals(2, perGroup["g1"]) + assertEquals(1, perGroup["g2"]) + } + + @Test + fun `a posted commit is recognised as our own when it comes back`() = + runTest { + // The whole point of the pending-operation table, end to end: the + // Commit we posted must come back as confirmation, never as work. + val coordinator = CordnFixtureCoordinator() + val (sync, server) = sync(coordinator) + + val seen = mutableListOf() + driving(server) { + sync.postCommit("g1", "Y29tbWl0") + sync.catchUp(listOf("g1")) { _, i -> seen += i } + } + + assertIs(seen.single()) + assertTrue(sync.unconfirmed().isEmpty(), "the pending operation is retired once confirmed") + } + + @Test + fun `a posted application message is not re-ingested as someone else's`() = + runTest { + val coordinator = CordnFixtureCoordinator() + val (sync, server) = sync(coordinator) + + val seen = mutableListOf() + driving(server) { + sync.postMessage("g1", "aGVsbG8=") + sync.catchUp(listOf("g1")) { _, i -> seen += i } + } + + assertIs(seen.single()) + } + + @Test + fun `a skipped message does not come back on the next catch-up`() = + runTest { + // The stall this prevents is invisible in a single pass: every + // outcome looks right, and only the SECOND catch-up reveals that + // the cursor never moved past the messages we chose not to process. + // In production that is a group that re-delivers its own echoes + // forever and never reaches the traffic behind them. + val coordinator = CordnFixtureCoordinator() + val (sync, server) = sync(coordinator) + + driving(server) { + sync.postCommit("g1", "Y29tbWl0") + sync.postMessage("g1", "aGVsbG8=") + sync.catchUp(listOf("g1")) { _, _ -> } + } + + val second = driving(server) { sync.catchUp(listOf("g1")) { _, _ -> } } + assertEquals(0, second, "both skipped messages must be behind the cursor") + } + + @Test + fun `an unconfirmed commit stays listed until its echo arrives`() = + runTest { + val coordinator = CordnFixtureCoordinator() + val (sync, server) = sync(coordinator) + + driving(server) { sync.postCommit("g1", "Y29tbWl0") } + + assertEquals(listOf("Y29tbWl0"), sync.unconfirmed()["g1"]?.map { it.sealedBase64 }) + } + + @Test + fun `cursors survive a round trip through persistence`() = + runTest { + val coordinator = CordnFixtureCoordinator() + repeat(3) { coordinator.seed("g1", "eA==") } + val (sync, server) = sync(coordinator) + driving(server) { sync.catchUp(listOf("g1")) { _, _ -> } } + + val saved = sync.cursors() + val (resumed, server2) = sync(coordinator) + saved.forEach { (gid, cursor) -> resumed.restore(gid, cursor) } + + val drained = driving(server2) { resumed.catchUp(listOf("g1")) { _, _ -> } } + assertEquals(0, drained, "a resumed client re-reads nothing") + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotConvergenceWiringTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotConvergenceWiringTest.kt index 8aafe8e10e..8565bc6982 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotConvergenceWiringTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotConvergenceWiringTest.kt @@ -20,11 +20,11 @@ */ package com.vitorpamplona.quartz.marmot +import com.vitorpamplona.quartz.marmot.groups.MlsGroupManager import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRotationManager import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEventEncryption -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupManager import com.vitorpamplona.quartz.marmot.protocolCore.ConvergencePolicy import com.vitorpamplona.quartz.marmot.protocolCore.ConvergenceStatus import com.vitorpamplona.quartz.marmot.protocolCore.GroupLifecycleState diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotMipBehaviorTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotMipBehaviorTest.kt index 9b35b3a6a8..32d09a3046 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotMipBehaviorTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotMipBehaviorTest.kt @@ -20,18 +20,20 @@ */ package com.vitorpamplona.quartz.marmot +import com.vitorpamplona.quartz.marmot.groups.MarmotGroupPolicy +import com.vitorpamplona.quartz.marmot.groups.MlsGroupManager +import com.vitorpamplona.quartz.marmot.groups.isLocalAdmin import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupManager -import com.vitorpamplona.quartz.marmot.mls.messages.KeyPackageBundle +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.group.MlsGroup +import com.vitorpamplona.quartz.mls.messages.KeyPackageBundle import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal -import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent +import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import kotlinx.coroutines.runBlocking import kotlin.test.Test @@ -57,7 +59,7 @@ class MarmotMipBehaviorTest { private fun createGroupManager(): MlsGroupManager = MlsGroupManager(TestGroupStateStore()) private fun createStandaloneKeyPackage(identity: String): KeyPackageBundle { - val tempGroup = MlsGroup.create(identity.hexToByteArray()) + val tempGroup = MlsGroup.create(identity.hexToByteArray(), policy = MarmotGroupPolicy) return tempGroup.createKeyPackage(identity.hexToByteArray(), ByteArray(0)) } @@ -67,7 +69,7 @@ class MarmotMipBehaviorTest { @Test fun create_installsRequiredCapabilitiesExtension() { - val alice = MlsGroup.create(aliceId.hexToByteArray()) + val alice = MlsGroup.create(aliceId.hexToByteArray(), policy = MarmotGroupPolicy) // RFC 9420 §13.3: required_capabilities is extension type 0x0003. val reqCaps = alice.extensions.find { it.extensionType == 0x0003 } @@ -348,8 +350,8 @@ class MarmotMipBehaviorTest { // Bob unwraps: kind:1059 → kind:13 (Seal) → kind:444 (Rumor). val sealed = delivery.giftWrapEvent.unwrapThrowing(bobSigner) - assertEquals(SealedRumorEvent.KIND, sealed.kind, "Middle layer MUST be NIP-59 Seal kind:13") - assertIs(sealed) + assertEquals(SealEvent.KIND, sealed.kind, "Middle layer MUST be NIP-59 Seal kind:13") + assertIs(sealed) val rumor = sealed.unsealThrowing(bobSigner) assertEquals(WelcomeEvent.KIND, rumor.kind, "Innermost rumor MUST be kind:444") @@ -405,10 +407,10 @@ class MarmotMipBehaviorTest { // must reject because Remove is admin-only. val proposals = listOf( - com.vitorpamplona.quartz.marmot.mls.group + com.vitorpamplona.quartz.mls.group .PendingProposal( proposal = - com.vitorpamplona.quartz.marmot.mls.messages + com.vitorpamplona.quartz.mls.messages .Proposal .Remove(removedLeafIndex = 0), senderLeafIndex = 1, @@ -416,7 +418,7 @@ class MarmotMipBehaviorTest { ) val ex = assertFailsWith { - alice.enforceAuthorizedProposalSet(proposals, committerLeafIndex = 1) + MarmotGroupPolicy.enforceAuthorizedProposalSet(alice.view(), proposals, committerLeafIndex = 1) } assertTrue( ex.message!!.contains("non-admin members may only commit"), @@ -444,17 +446,17 @@ class MarmotMipBehaviorTest { // proposal list passes. val proposals = listOf( - com.vitorpamplona.quartz.marmot.mls.group + com.vitorpamplona.quartz.mls.group .PendingProposal( proposal = - com.vitorpamplona.quartz.marmot.mls.messages + com.vitorpamplona.quartz.mls.messages .Proposal .SelfRemove(), senderLeafIndex = 99, ), ) // Should not throw. - alice.enforceAuthorizedProposalSet(proposals, committerLeafIndex = 0) + MarmotGroupPolicy.enforceAuthorizedProposalSet(alice.view(), proposals, committerLeafIndex = 0) } @Test @@ -470,10 +472,10 @@ class MarmotMipBehaviorTest { val alice = manager.getGroup(groupId)!! val proposals = listOf( - com.vitorpamplona.quartz.marmot.mls.group + com.vitorpamplona.quartz.mls.group .PendingProposal( proposal = - com.vitorpamplona.quartz.marmot.mls.messages + com.vitorpamplona.quartz.mls.messages .Proposal .GroupContextExtensions( extensions = @@ -488,7 +490,7 @@ class MarmotMipBehaviorTest { ), ) assertFailsWith { - alice.enforceNoAdminDepletion(proposals) + MarmotGroupPolicy.enforceNoAdminDepletion(alice.view(), proposals) } } @@ -517,7 +519,7 @@ class MarmotMipBehaviorTest { val staged = alice.pendingProposalsSnapshot() assertEquals(1, staged.size, "buildSelfRemoveProposalMessage must also stage to pending pool") val entry = staged.single() - assertIs(entry.proposal) + assertIs(entry.proposal) assertEquals(alice.leafIndex, entry.senderLeafIndex) // The captured AC bytes are what RFC 9420 §5.2's MakeProposalRef // hashes — must be present so a peer's commit referencing this @@ -574,7 +576,7 @@ class MarmotMipBehaviorTest { val (alice, bob) = build2MemberGroupWithBobJoined() val proposal = - com.vitorpamplona.quartz.marmot.mls.messages + com.vitorpamplona.quartz.mls.messages .Proposal .SelfRemove() val before = alice.pendingProposalsSnapshot().size @@ -582,14 +584,14 @@ class MarmotMipBehaviorTest { val result = alice.decrypt(wireBytes) assertEquals( - com.vitorpamplona.quartz.marmot.mls.framing.ContentType.PROPOSAL, + com.vitorpamplona.quartz.mls.framing.ContentType.PROPOSAL, result.contentType, ) assertEquals(bob.leafIndex, result.senderLeafIndex) val after = alice.pendingProposalsSnapshot() assertEquals(before + 1, after.size, "decrypt must stage the proposal in pending pool") val staged = after.last() - assertIs(staged.proposal) + assertIs(staged.proposal) assertEquals(bob.leafIndex, staged.senderLeafIndex) assertNotNull( staged.authenticatedContentBytes, @@ -610,7 +612,7 @@ class MarmotMipBehaviorTest { val (alice, bob) = build2MemberGroupWithBobJoined() val proposal = - com.vitorpamplona.quartz.marmot.mls.messages + com.vitorpamplona.quartz.mls.messages .Proposal .Psk(pskType = 1, pskId = ByteArray(16) { 0xAB.toByte() }, pskNonce = ByteArray(16)) val wireBytes = bob.encryptProposalAsPrivateMessage(proposal) @@ -639,7 +641,7 @@ class MarmotMipBehaviorTest { val alice = manager.getGroup(groupId)!! val welcomeBytes = requireNotNull(commitResult.welcomeBytes) { "addMember must produce a Welcome" } - val bob = MlsGroup.processWelcome(welcomeBytes, bobBundle) + val bob = MlsGroup.processWelcome(welcomeBytes, bobBundle, policy = MarmotGroupPolicy) return alice to bob } @@ -658,7 +660,7 @@ class MarmotMipBehaviorTest { @Test fun secretTree_rejectsRatchetJumpsBeyondCap() { val st = - com.vitorpamplona.quartz.marmot.mls.schedule.SecretTree( + com.vitorpamplona.quartz.mls.schedule.SecretTree( encryptionSecret = ByteArray(32), leafCount = 1, ) @@ -682,7 +684,7 @@ class MarmotMipBehaviorTest { @Test fun privateMessage_rejectsOversizedAuthenticatedData() { val w = - com.vitorpamplona.quartz.marmot.mls.codec + com.vitorpamplona.quartz.mls.codec .TlsWriter() w.putOpaqueVarInt(ByteArray(32)) // group_id w.putUint64(0L) // epoch @@ -692,9 +694,9 @@ class MarmotMipBehaviorTest { w.putOpaqueVarInt(ByteArray(64)) // ciphertext (small) val ex = assertFailsWith { - com.vitorpamplona.quartz.marmot.mls.framing.PrivateMessage + com.vitorpamplona.quartz.mls.framing.PrivateMessage .decodeTls( - com.vitorpamplona.quartz.marmot.mls.codec + com.vitorpamplona.quartz.mls.codec .TlsReader(w.toByteArray()), ) } @@ -715,11 +717,11 @@ class MarmotMipBehaviorTest { */ @Test fun verifyTreeParentHashesForJoin_acceptsSingleMemberTree() { - val alice = MlsGroup.create(aliceId.hexToByteArray()) + val alice = MlsGroup.create(aliceId.hexToByteArray(), policy = MarmotGroupPolicy) val tree = - com.vitorpamplona.quartz.marmot.mls.tree.RatchetTree + com.vitorpamplona.quartz.mls.tree.RatchetTree .decodeTls( - com.vitorpamplona.quartz.marmot.mls.codec + com.vitorpamplona.quartz.mls.codec .TlsReader(alice.exportTreeBytes()), ) assertNull(MlsGroup.verifyTreeParentHashesForJoin(tree)) @@ -749,9 +751,9 @@ class MarmotMipBehaviorTest { val alice = manager.getGroup(groupId)!! val originalTree = - com.vitorpamplona.quartz.marmot.mls.tree.RatchetTree + com.vitorpamplona.quartz.mls.tree.RatchetTree .decodeTls( - com.vitorpamplona.quartz.marmot.mls.codec + com.vitorpamplona.quartz.mls.codec .TlsReader(alice.exportTreeBytes()), ) @@ -763,7 +765,7 @@ class MarmotMipBehaviorTest { for (i in 0 until originalTree.leafCount) { val leaf = originalTree.getLeaf(i) ?: continue if (leaf.leafNodeSource == - com.vitorpamplona.quartz.marmot.mls.tree.LeafNodeSource.COMMIT + com.vitorpamplona.quartz.mls.tree.LeafNodeSource.COMMIT ) { val tampered = leaf.copy(parentHash = ByteArray(32) { 0x99.toByte() }) originalTree.setLeaf(i, tampered) @@ -798,7 +800,7 @@ class MarmotMipBehaviorTest { */ @Test fun findRequiredCapabilities_decodesMarmotExtensionInstalledByCreate() { - val alice = MlsGroup.create(aliceId.hexToByteArray()) + val alice = MlsGroup.create(aliceId.hexToByteArray(), policy = MarmotGroupPolicy) val req = MlsGroup.findRequiredCapabilities(alice.extensions) ?: error("required_capabilities must be present after create()") @@ -823,7 +825,7 @@ class MarmotMipBehaviorTest { ) // Missing 0xF2EE. val caps = - com.vitorpamplona.quartz.marmot.mls.tree.Capabilities( + com.vitorpamplona.quartz.mls.tree.Capabilities( extensions = emptyList(), proposals = listOf(0x000A), credentials = listOf(0x0001), @@ -847,7 +849,7 @@ class MarmotMipBehaviorTest { credentials = emptyList(), ) val caps = - com.vitorpamplona.quartz.marmot.mls.tree.Capabilities( + com.vitorpamplona.quartz.mls.tree.Capabilities( extensions = emptyList(), proposals = emptyList(), credentials = listOf(0x0001), @@ -866,7 +868,7 @@ class MarmotMipBehaviorTest { credentials = listOf(0x0001), ) val caps = - com.vitorpamplona.quartz.marmot.mls.tree.Capabilities( + com.vitorpamplona.quartz.mls.tree.Capabilities( extensions = listOf(0xF2EE, 0x1234), proposals = listOf(0x000A, 0x000B), credentials = listOf(0x0001, 0x0002), @@ -905,8 +907,8 @@ class MarmotMipBehaviorTest { * (SelfRemove), then re-sign so the KP's outer signature still * validates. Useful for testing the §7.2 gate in isolation. */ - private fun createKeyPackageWithoutSelfRemove(identity: String): com.vitorpamplona.quartz.marmot.mls.messages.MlsKeyPackage { - val tempGroup = MlsGroup.create(identity.hexToByteArray()) + private fun createKeyPackageWithoutSelfRemove(identity: String): com.vitorpamplona.quartz.mls.messages.MlsKeyPackage { + val tempGroup = MlsGroup.create(identity.hexToByteArray(), policy = MarmotGroupPolicy) val bundle = tempGroup.createKeyPackage(identity.hexToByteArray(), ByteArray(0)) val original = bundle.keyPackage val originalLeaf = original.leafNode @@ -923,7 +925,7 @@ class MarmotMipBehaviorTest { originalLeaf.copy(capabilities = tamperedCaps).let { lf -> val tbs = lf.encodeTbs() val sig = - com.vitorpamplona.quartz.marmot.mls.crypto.MlsCryptoProvider + com.vitorpamplona.quartz.mls.crypto.MlsCryptoProvider .signWithLabel(bundle.signaturePrivateKey, "LeafNodeTBS", tbs) lf.copy(signature = sig) } @@ -931,7 +933,7 @@ class MarmotMipBehaviorTest { val unsigned = original.copy(leafNode = tamperedLeaf, signature = ByteArray(0)) return unsigned.copy( signature = - com.vitorpamplona.quartz.marmot.mls.crypto.MlsCryptoProvider + com.vitorpamplona.quartz.mls.crypto.MlsCryptoProvider .signWithLabel(bundle.signaturePrivateKey, "KeyPackageTBS", unsigned.encodeTbs()), ) } @@ -947,7 +949,7 @@ class MarmotMipBehaviorTest { */ @Test fun computePskSecret_emptyListReturnsAllZeros() { - val alice = MlsGroup.create(aliceId.hexToByteArray()) + val alice = MlsGroup.create(aliceId.hexToByteArray(), policy = MarmotGroupPolicy) val out = alice.computePskSecret(emptyList()) assertEquals(32, out.size, "psk_secret length must be Nh = 32 for SHA-256") assertTrue(out.all { it == 0.toByte() }, "default_psk_secret is all zeros") @@ -970,14 +972,14 @@ class MarmotMipBehaviorTest { */ @Test fun computePskSecret_singleExternalPsk_matchesSpecDerivation() { - val alice = MlsGroup.create(aliceId.hexToByteArray()) + val alice = MlsGroup.create(aliceId.hexToByteArray(), policy = MarmotGroupPolicy) val pskId = ByteArray(16) { (it + 1).toByte() } val pskNonce = ByteArray(16) { (0x80 or it).toByte() } val pskValue = ByteArray(32) { (0xA0 or (it and 0x0F)).toByte() } alice.registerPsk(pskId, pskValue) val proposal = - com.vitorpamplona.quartz.marmot.mls.messages + com.vitorpamplona.quartz.mls.messages .Proposal .Psk(pskType = 1, pskId = pskId, pskNonce = pskNonce) @@ -985,11 +987,11 @@ class MarmotMipBehaviorTest { // Reference computation per §5.3 (PSKType=1, no usage/group/epoch). val zero = ByteArray(32) - val crypto = com.vitorpamplona.quartz.marmot.mls.crypto.MlsCryptoProvider + val crypto = com.vitorpamplona.quartz.mls.crypto.MlsCryptoProvider val pskExtracted = crypto.hkdfExtract(salt = zero, ikm = pskValue) val labelWriter = - com.vitorpamplona.quartz.marmot.mls.codec + com.vitorpamplona.quartz.mls.codec .TlsWriter() labelWriter.putUint8(1) // PSKType external labelWriter.putOpaqueVarInt(pskId) @@ -1017,12 +1019,12 @@ class MarmotMipBehaviorTest { */ @Test fun computePskSecret_resumptionPskRejectsUntilProposalWidened() { - val alice = MlsGroup.create(aliceId.hexToByteArray()) + val alice = MlsGroup.create(aliceId.hexToByteArray(), policy = MarmotGroupPolicy) val pskId = ByteArray(16) { it.toByte() } alice.registerPsk(pskId, ByteArray(32)) val proposal = - com.vitorpamplona.quartz.marmot.mls.messages + com.vitorpamplona.quartz.mls.messages .Proposal .Psk(pskType = 2, pskId = pskId, pskNonce = ByteArray(16)) @@ -1038,18 +1040,18 @@ class MarmotMipBehaviorTest { */ @Test fun computePskSecret_orderingChangesOutput() { - val alice = MlsGroup.create(aliceId.hexToByteArray()) + val alice = MlsGroup.create(aliceId.hexToByteArray(), policy = MarmotGroupPolicy) val idA = ByteArray(16) { 0x11 } val idB = ByteArray(16) { 0x22 } alice.registerPsk(idA, ByteArray(32) { 0x33 }) alice.registerPsk(idB, ByteArray(32) { 0x44 }) val pskA = - com.vitorpamplona.quartz.marmot.mls.messages + com.vitorpamplona.quartz.mls.messages .Proposal .Psk(pskType = 1, pskId = idA, pskNonce = ByteArray(8)) val pskB = - com.vitorpamplona.quartz.marmot.mls.messages + com.vitorpamplona.quartz.mls.messages .Proposal .Psk(pskType = 1, pskId = idB, pskNonce = ByteArray(8)) diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotPipelineTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotPipelineTest.kt index bb5fd83ec0..73eaf4d668 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotPipelineTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotPipelineTest.kt @@ -20,11 +20,11 @@ */ package com.vitorpamplona.quartz.marmot +import com.vitorpamplona.quartz.marmot.groups.MlsGroupManager +import com.vitorpamplona.quartz.marmot.groups.MlsGroupStateStore import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRotationManager import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEventEncryption -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupManager -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore import com.vitorpamplona.quartz.marmot.protocolCore.ConvergenceStatus import com.vitorpamplona.quartz.marmot.protocolCore.GroupLifecycleState import com.vitorpamplona.quartz.nip01Core.core.toHexKey @@ -380,28 +380,28 @@ class MarmotPipelineTest { // The framedCommitBytes must decode as an MlsMessage(PublicMessage(commit)) val framed = commitResult.framedCommitBytes val mlsMessage = - com.vitorpamplona.quartz.marmot.mls.framing.MlsMessage + com.vitorpamplona.quartz.mls.framing.MlsMessage .decodeTls( - com.vitorpamplona.quartz.marmot.mls.codec + com.vitorpamplona.quartz.mls.codec .TlsReader(framed), ) assertEquals( - com.vitorpamplona.quartz.marmot.mls.framing.WireFormat.PUBLIC_MESSAGE, + com.vitorpamplona.quartz.mls.framing.WireFormat.PUBLIC_MESSAGE, mlsMessage.wireFormat, ) val publicMessage = - com.vitorpamplona.quartz.marmot.mls.framing.PublicMessage + com.vitorpamplona.quartz.mls.framing.PublicMessage .decodeTls( - com.vitorpamplona.quartz.marmot.mls.codec + com.vitorpamplona.quartz.mls.codec .TlsReader(mlsMessage.payload), ) assertEquals( - com.vitorpamplona.quartz.marmot.mls.framing.ContentType.COMMIT, + com.vitorpamplona.quartz.mls.framing.ContentType.COMMIT, publicMessage.contentType, ) assertEquals( - com.vitorpamplona.quartz.marmot.mls.framing.SenderType.MEMBER, + com.vitorpamplona.quartz.mls.framing.SenderType.MEMBER, publicMessage.sender.senderType, ) assertNotNull(publicMessage.confirmationTag, "confirmation_tag must be present on a commit") @@ -431,13 +431,13 @@ class MarmotPipelineTest { val exporterKey = manager.exporterSecret(groupId) val mlsBytes = GroupEventEncryption.decrypt(event.content, exporterKey) val mlsMessage = - com.vitorpamplona.quartz.marmot.mls.framing.MlsMessage + com.vitorpamplona.quartz.mls.framing.MlsMessage .decodeTls( - com.vitorpamplona.quartz.marmot.mls.codec + com.vitorpamplona.quartz.mls.codec .TlsReader(mlsBytes), ) assertEquals( - com.vitorpamplona.quartz.marmot.mls.framing.WireFormat.PUBLIC_MESSAGE, + com.vitorpamplona.quartz.mls.framing.WireFormat.PUBLIC_MESSAGE, mlsMessage.wireFormat, ) } diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/CommitPreservesLeafIdentityTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/appComponents/CommitPreservesLeafIdentityTest.kt similarity index 96% rename from quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/CommitPreservesLeafIdentityTest.kt rename to quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/appComponents/CommitPreservesLeafIdentityTest.kt index 101d0048eb..13938086c2 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/CommitPreservesLeafIdentityTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/appComponents/CommitPreservesLeafIdentityTest.kt @@ -18,12 +18,13 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.group +package com.vitorpamplona.quartz.marmot.appComponents import com.vitorpamplona.quartz.marmot.appComponents.AppComponentIds import com.vitorpamplona.quartz.marmot.appComponents.CurrentProfileGroupFactory import com.vitorpamplona.quartz.marmot.appComponents.GroupProfileV1 -import com.vitorpamplona.quartz.marmot.mls.components.AppDataDictionary +import com.vitorpamplona.quartz.mls.components.AppDataDictionary +import com.vitorpamplona.quartz.mls.group.MlsGroup import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import kotlinx.coroutines.runBlocking diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/appComponents/CurrentProfileAuthorizationTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/appComponents/CurrentProfileAuthorizationTest.kt index 5dc2df88e2..20fe82cef6 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/appComponents/CurrentProfileAuthorizationTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/appComponents/CurrentProfileAuthorizationTest.kt @@ -20,8 +20,13 @@ */ package com.vitorpamplona.quartz.marmot.appComponents -import com.vitorpamplona.quartz.marmot.mls.components.AppDataDictionary -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup +import com.vitorpamplona.quartz.marmot.groups.MarmotGroupPolicy +import com.vitorpamplona.quartz.marmot.groups.currentAdminIdentities +import com.vitorpamplona.quartz.marmot.groups.currentGroupState +import com.vitorpamplona.quartz.marmot.groups.currentMarmotData +import com.vitorpamplona.quartz.marmot.groups.isLocalAdmin +import com.vitorpamplona.quartz.mls.components.AppDataDictionary +import com.vitorpamplona.quartz.mls.group.MlsGroup import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey import kotlin.test.Test @@ -47,7 +52,7 @@ class CurrentProfileAuthorizationTest { creator: ByteArray, admins: List, ): MlsGroup { - val group = MlsGroup.create(creator) + val group = MlsGroup.create(creator, policy = MarmotGroupPolicy) val dictionary = MarmotGroupState.buildDictionary( adminPolicy = AdminPolicyV1.of(admins), @@ -79,7 +84,7 @@ class CurrentProfileAuthorizationTest { val alice = currentProfileGroup(aliceAccount, listOf(aliceAccount)) val bobBundle = alice.createKeyPackage(bobAccount, ByteArray(0)) val add = alice.addMember(bobBundle.keyPackage.toTlsBytes()) - val bob = MlsGroup.processWelcome(add.welcomeBytes!!, bobBundle) + val bob = MlsGroup.processWelcome(add.welcomeBytes!!, bobBundle, policy = MarmotGroupPolicy) assertTrue(!bob.isLocalAdmin()) assertEquals(setOf(aliceAccount.toHexKey()), bob.currentAdminIdentities()) diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/appComponents/CurrentProfileGroupFactoryTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/appComponents/CurrentProfileGroupFactoryTest.kt index 6c138d319b..6746a556db 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/appComponents/CurrentProfileGroupFactoryTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/appComponents/CurrentProfileGroupFactoryTest.kt @@ -23,14 +23,17 @@ package com.vitorpamplona.quartz.marmot.appComponents import com.vitorpamplona.quartz.TestResourceLoader import com.vitorpamplona.quartz.marmot.appComponents.accountIdentityProof.AccountIdentityProofV2 import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.AgentTextStreamRoles +import com.vitorpamplona.quartz.marmot.groups.currentAdminIdentities +import com.vitorpamplona.quartz.marmot.groups.currentGroupState +import com.vitorpamplona.quartz.marmot.groups.isLocalAdmin import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData import com.vitorpamplona.quartz.marmot.mip01Groups.MlsCiphersuite -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.components.AppDataDictionary -import com.vitorpamplona.quartz.marmot.mls.components.ComponentsList -import com.vitorpamplona.quartz.marmot.mls.framing.MlsMessage -import com.vitorpamplona.quartz.marmot.mls.messages.MlsKeyPackage -import com.vitorpamplona.quartz.marmot.mls.tree.Credential +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.components.AppDataDictionary +import com.vitorpamplona.quartz.mls.components.ComponentsList +import com.vitorpamplona.quartz.mls.framing.MlsMessage +import com.vitorpamplona.quartz.mls.messages.MlsKeyPackage +import com.vitorpamplona.quartz.mls.tree.Credential import com.vitorpamplona.quartz.nip01Core.core.JsonMapper import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/CurrentProfileWelcomeTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/appComponents/CurrentProfileWelcomeTest.kt similarity index 92% rename from quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/CurrentProfileWelcomeTest.kt rename to quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/appComponents/CurrentProfileWelcomeTest.kt index 5fb12b322d..07f9b46bbf 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/CurrentProfileWelcomeTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/appComponents/CurrentProfileWelcomeTest.kt @@ -18,16 +18,23 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.group +package com.vitorpamplona.quartz.marmot.appComponents import com.vitorpamplona.quartz.marmot.appComponents.CurrentProfileGroupFactory import com.vitorpamplona.quartz.marmot.appComponents.GroupProfileV1 import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.AgentTextStreamQuicPolicyV1 import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.AgentTextStreamRoles -import com.vitorpamplona.quartz.marmot.mls.crypto.Ed25519 -import com.vitorpamplona.quartz.marmot.mls.crypto.Ed25519KeyPair -import com.vitorpamplona.quartz.marmot.mls.messages.KeyPackageBundle -import com.vitorpamplona.quartz.marmot.mls.tree.Capabilities +import com.vitorpamplona.quartz.marmot.groups.MarmotCapabilities +import com.vitorpamplona.quartz.marmot.groups.MarmotGroupPolicy +import com.vitorpamplona.quartz.marmot.groups.agentTextStreamSecret +import com.vitorpamplona.quartz.marmot.groups.currentGroupState +import com.vitorpamplona.quartz.marmot.groups.currentMarmotData +import com.vitorpamplona.quartz.marmot.groups.currentNostrGroupId +import com.vitorpamplona.quartz.mls.crypto.Ed25519 +import com.vitorpamplona.quartz.mls.crypto.Ed25519KeyPair +import com.vitorpamplona.quartz.mls.group.MlsGroup +import com.vitorpamplona.quartz.mls.messages.KeyPackageBundle +import com.vitorpamplona.quartz.mls.tree.Capabilities import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair @@ -87,7 +94,7 @@ class CurrentProfileWelcomeTest { val commit = group.commit() val welcome = assertNotNull(commit.welcomeBytes, "adding a member must produce a Welcome") - val joined = MlsGroup.processWelcome(welcome, invitee) + val joined = MlsGroup.processWelcome(welcome, invitee, policy = MarmotGroupPolicy) assertEquals(nostrGroupId.toHexKey(), joined.currentNostrGroupId()) assertEquals(group.currentGroupState().profile?.name, joined.currentGroupState().profile?.name) } @@ -122,7 +129,7 @@ class CurrentProfileWelcomeTest { group.proposeAdd(invitee.keyPackage.toTlsBytes()) val welcome = assertNotNull(group.commit().welcomeBytes) - val failure = assertFailsWith { MlsGroup.processWelcome(welcome, invitee) } + val failure = assertFailsWith { MlsGroup.processWelcome(welcome, invitee, policy = MarmotGroupPolicy) } assertTrue( failure.message.orEmpty().contains("agent text stream roles"), "expected a role-capability refusal, got: ${failure.message}", @@ -151,7 +158,7 @@ class CurrentProfileWelcomeTest { group.proposeAdd(invitee.keyPackage.toTlsBytes()) val welcome = assertNotNull(group.commit().welcomeBytes) - val joined = MlsGroup.processWelcome(welcome, invitee) + val joined = MlsGroup.processWelcome(welcome, invitee, policy = MarmotGroupPolicy) assertEquals(nostrGroupId.toHexKey(), joined.currentNostrGroupId()) } @@ -180,7 +187,7 @@ class CurrentProfileWelcomeTest { group.proposeAdd(invitee.keyPackage.toTlsBytes()) val welcome = assertNotNull(group.commit().welcomeBytes) - val joined = MlsGroup.processWelcome(welcome, invitee) + val joined = MlsGroup.processWelcome(welcome, invitee, policy = MarmotGroupPolicy) assertEquals(nostrGroupId.toHexKey(), joined.currentNostrGroupId()) } @@ -215,7 +222,7 @@ class CurrentProfileWelcomeTest { group.proposeAdd(invitee.keyPackage.toTlsBytes()) val welcome = assertNotNull(group.commit().welcomeBytes) - val failure = assertFailsWith { MlsGroup.processWelcome(welcome, invitee) } + val failure = assertFailsWith { MlsGroup.processWelcome(welcome, invitee, policy = MarmotGroupPolicy) } assertTrue( failure.message.orEmpty().contains("agent text stream roles"), "expected a role-capability refusal, got: ${failure.message}", @@ -253,7 +260,7 @@ class CurrentProfileWelcomeTest { ): KeyPackageBundle { val full = CurrentProfileGroupFactory.createKeyPackage(signer) val reduced = - MlsGroup.currentProfileLeafCapabilities().let { + MarmotCapabilities.currentProfileLeaf().let { Capabilities( extensions = it.extensions + roles, proposals = it.proposals, @@ -289,7 +296,7 @@ class CurrentProfileWelcomeTest { group.proposeAdd(invitee.keyPackage.toTlsBytes()) val welcome = assertNotNull(group.commit().welcomeBytes) - val joined = MlsGroup.processWelcome(welcome, invitee) + val joined = MlsGroup.processWelcome(welcome, invitee, policy = MarmotGroupPolicy) assertEquals( AgentTextStreamQuicPolicyV1.userToAgentDefault(), joined.currentGroupState().agentTextStream, diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/appComponents/GroupAvatarUrlV1Test.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/appComponents/GroupAvatarUrlV1Test.kt index dab9c56995..c79419f47a 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/appComponents/GroupAvatarUrlV1Test.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/appComponents/GroupAvatarUrlV1Test.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.quartz.marmot.appComponents -import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter +import com.vitorpamplona.quartz.mls.codec.TlsWriter import org.junit.Assert.assertEquals import org.junit.Assert.assertThrows import org.junit.Assert.assertTrue diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/appComponents/MarmotGroupStateVectorTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/appComponents/MarmotGroupStateVectorTest.kt index f385caa7cf..1299edbbf7 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/appComponents/MarmotGroupStateVectorTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/appComponents/MarmotGroupStateVectorTest.kt @@ -21,8 +21,8 @@ package com.vitorpamplona.quartz.marmot.appComponents import com.vitorpamplona.quartz.TestResourceLoader -import com.vitorpamplona.quartz.marmot.mls.components.AppDataDictionary -import com.vitorpamplona.quartz.marmot.mls.components.ComponentData +import com.vitorpamplona.quartz.mls.components.AppDataDictionary +import com.vitorpamplona.quartz.mls.components.ComponentData import com.vitorpamplona.quartz.nip01Core.core.JsonMapper import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/groups/MarmotPolicySeamTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/groups/MarmotPolicySeamTest.kt new file mode 100644 index 0000000000..23b932237b --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/groups/MarmotPolicySeamTest.kt @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.marmot.groups + +import com.vitorpamplona.quartz.marmot.appComponents.AdminPolicyV1 +import com.vitorpamplona.quartz.marmot.appComponents.GroupProfileV1 +import com.vitorpamplona.quartz.marmot.appComponents.MarmotGroupState +import com.vitorpamplona.quartz.marmot.appComponents.NostrRoutingV1 +import com.vitorpamplona.quartz.mls.group.MlsGroup +import com.vitorpamplona.quartz.mls.group.MlsGroupPolicy +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** + * That MIP-03 enforcement now travels with [MarmotGroupPolicy] rather than + * with the engine. + * + * Every other authorization test asserts that Marmot refuses. This one asserts + * the other half, which is what the extraction actually changed: the same + * group, at the same state, accepts the same commit once the policy is gone. + * If someone re-hardcodes the rules into `MlsGroup` these tests fail, and a + * cordn or plain RFC 9420 group would be back to inheriting Marmot's rules + * without asking. + */ +class MarmotPolicySeamTest { + private val alice = "11".repeat(32).hexToByteArray() + private val bob = "22".repeat(32).hexToByteArray() + + /** A group created by [creator] whose admin policy names only [admins]. */ + private fun groupAdminedBy( + creator: ByteArray, + admins: List, + ): MlsGroup { + val group = MlsGroup.create(creator, policy = MarmotGroupPolicy) + val dictionary = + MarmotGroupState.buildDictionary( + adminPolicy = AdminPolicyV1.of(admins), + routing = NostrRoutingV1.of(ByteArray(32) { 0x5a }, listOf("wss://relay.example")), + profile = GroupProfileV1("Seam", ""), + ) + // Bootstrap: installed before any admin is named, which MIP-01 allows. + group.proposeGroupContextExtensions(listOf(dictionary.toExtension())) + group.commit() + return group + } + + @Test + fun marmotsPolicyRefusesANonAdminExtensionChange() { + val group = groupAdminedBy(creator = alice, admins = listOf(bob)) + assertTrue(!group.isLocalAdmin(), "alice must not be an admin for this to test anything") + + group.proposeGroupContextExtensions(group.extensions) + assertFailsWith("a non-admin must not be able to rewrite group state") { + group.commit() + } + } + + @Test + fun theSameCommitIsAcceptedOnceTheGroupCarriesNoPolicy() { + val marmot = groupAdminedBy(creator = alice, admins = listOf(bob)) + val epochBefore = marmot.epoch + + // Same state, same proposal, no binding AUTHORIZATION rules — but the + // same extension types still declared. A Marmot group carries types its + // older leaves do not advertise, and RFC 9420 §13.4 is enforced from + // leaf capabilities, so dropping the declaration too would fail this + // commit for a reason that has nothing to do with who may commit. + val noAuthorizationRules = + object : MlsGroupPolicy { + override val knownExtensionTypes = MarmotGroupPolicy.knownExtensionTypes + } + val plain = MlsGroup.restore(marmot.saveState(), noAuthorizationRules) + plain.proposeGroupContextExtensions(plain.extensions) + plain.commit() + + assertEquals( + epochBefore + 1, + plain.epoch, + "RFC 9420 places no limit on who may commit; only the binding does", + ) + } + + @Test + fun marmotsProfileTravelsWithItsPolicy() { + val plain = MlsGroup.create(alice) + val marmot = MlsGroup.create(alice, policy = MarmotGroupPolicy) + + assertTrue( + plain.extensions.none { it.extensionType == MlsGroup.REQUIRED_CAPABILITIES_EXTENSION_TYPE }, + "the engine's own default must require nothing", + ) + assertTrue( + marmot.extensions.any { it.extensionType == MlsGroup.REQUIRED_CAPABILITIES_EXTENSION_TYPE }, + "naming MarmotGroupPolicy must still install required_capabilities", + ) + assertEquals( + listOf(MarmotCapabilities.MARMOT_GROUP_DATA_EXTENSION_TYPE), + MarmotGroupPolicy.defaultLeafCapabilities.extensions, + "and the MIP-era leaf set, which used to be MlsGroup.create's hardcoded default", + ) + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsGroupManagerTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/groups/MlsGroupManagerTest.kt similarity index 97% rename from quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsGroupManagerTest.kt rename to quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/groups/MlsGroupManagerTest.kt index f516a80421..7226a5fbb6 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsGroupManagerTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/groups/MlsGroupManagerTest.kt @@ -18,12 +18,13 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls +package com.vitorpamplona.quartz.marmot.groups +import com.vitorpamplona.quartz.marmot.groups.MarmotGroupPolicy +import com.vitorpamplona.quartz.marmot.groups.MlsGroupManager +import com.vitorpamplona.quartz.marmot.groups.MlsGroupStateStore import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupManager -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore +import com.vitorpamplona.quartz.mls.group.MlsGroup import kotlinx.coroutines.runBlocking import kotlin.test.Test import kotlin.test.assertContentEquals @@ -147,7 +148,7 @@ class MlsGroupManagerTest { // group is enough to observe the ratchet behavior.) val bobBundle = aliceGroup.createKeyPackage("bob".encodeToByteArray(), ByteArray(0)) val addResult = alice.addMember(groupId, bobBundle.keyPackage.toTlsBytes()) - val bob = MlsGroup.processWelcome(addResult.welcomeBytes!!, bobBundle) + val bob = MlsGroup.processWelcome(addResult.welcomeBytes!!, bobBundle, policy = MarmotGroupPolicy) // Alice sends generation 0 (no commit); Bob consumes it. val ct0 = alice.encrypt(groupId, "msg0".encodeToByteArray()) @@ -314,7 +315,7 @@ class MlsGroupManagerTest { // production before a Welcome has ever been seen). val bobBundle1 = MlsGroup - .create("bob".encodeToByteArray()) + .create("bob".encodeToByteArray(), policy = MarmotGroupPolicy) .createKeyPackage("bob".encodeToByteArray(), ByteArray(0)) val firstAdd = alice.addMember(groupId, bobBundle1.keyPackage.toTlsBytes()) val firstWelcome = firstAdd.welcomeBytes ?: fail("Alice's first add must produce a Welcome") @@ -371,7 +372,7 @@ class MlsGroupManagerTest { // --- Rejoin: fresh KeyPackage + fresh Welcome, SAME groupId. - val bobBundle2 = MlsGroup - .create("bob".encodeToByteArray()) + .create("bob".encodeToByteArray(), policy = MarmotGroupPolicy) .createKeyPackage("bob".encodeToByteArray(), ByteArray(0)) val secondAdd = alice.addMember(groupId, bobBundle2.keyPackage.toTlsBytes()) val secondWelcome = diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageFramingTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageFramingTest.kt index 26619fe6f9..5509b2a4ef 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageFramingTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageFramingTest.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.quartz.marmot.mip00KeyPackages -import com.vitorpamplona.quartz.marmot.mls.framing.MlsMessage -import com.vitorpamplona.quartz.marmot.mls.framing.WireFormat -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup +import com.vitorpamplona.quartz.mls.framing.MlsMessage +import com.vitorpamplona.quartz.mls.framing.WireFormat +import com.vitorpamplona.quartz.mls.group.MlsGroup import kotlin.test.Test import kotlin.test.assertContentEquals import kotlin.test.assertEquals diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/LastResortKeyPackageReuseTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/LastResortKeyPackageReuseTest.kt index 403aa6e217..ed80e026f0 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/LastResortKeyPackageReuseTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/LastResortKeyPackageReuseTest.kt @@ -21,7 +21,7 @@ package com.vitorpamplona.quartz.marmot.mip00KeyPackages import com.vitorpamplona.quartz.marmot.appComponents.CurrentProfileGroupFactory -import com.vitorpamplona.quartz.marmot.mls.tree.Extension +import com.vitorpamplona.quartz.mls.tree.Extension import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import kotlinx.coroutines.runBlocking diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/transport/CurrentProfileKeyPackageEventTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/transport/CurrentProfileKeyPackageEventTest.kt index 2d18dbd279..b0a31075ff 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/transport/CurrentProfileKeyPackageEventTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/transport/CurrentProfileKeyPackageEventTest.kt @@ -25,10 +25,10 @@ import com.vitorpamplona.quartz.marmot.appComponents.AppComponentIds import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageUtils -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.framing.MlsMessage -import com.vitorpamplona.quartz.marmot.mls.messages.MlsKeyPackage -import com.vitorpamplona.quartz.marmot.mls.tree.Credential +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.framing.MlsMessage +import com.vitorpamplona.quartz.mls.messages.MlsKeyPackage +import com.vitorpamplona.quartz.mls.tree.Credential import com.vitorpamplona.quartz.nip01Core.core.JsonMapper import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/AmethystAuthoredVectorGen.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/AmethystAuthoredVectorGen.kt similarity index 94% rename from quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/AmethystAuthoredVectorGen.kt rename to quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/AmethystAuthoredVectorGen.kt index 1bf2935a0f..865968db72 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/AmethystAuthoredVectorGen.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/AmethystAuthoredVectorGen.kt @@ -18,14 +18,14 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls +package com.vitorpamplona.quartz.mls -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.crypto.MlsCryptoProvider -import com.vitorpamplona.quartz.marmot.mls.framing.MlsMessage -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup -import com.vitorpamplona.quartz.marmot.mls.messages.MlsKeyPackage -import com.vitorpamplona.quartz.marmot.mls.messages.Welcome +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.crypto.MlsCryptoProvider +import com.vitorpamplona.quartz.mls.framing.MlsMessage +import com.vitorpamplona.quartz.mls.group.MlsGroup +import com.vitorpamplona.quartz.mls.messages.MlsKeyPackage +import com.vitorpamplona.quartz.mls.messages.Welcome import com.vitorpamplona.quartz.nip01Core.core.JsonMapper import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey @@ -44,7 +44,7 @@ import kotlin.test.Test * JOINER_HANDOFF_JSON=/tmp/joiner-handoff.json \ * AMETHYST_FIXTURE_JSON=/tmp/amethyst-fixture.json \ * ./gradlew :quartz:jvmTest \ - * --tests com.vitorpamplona.quartz.marmot.mls.AmethystAuthoredVectorGen + * --tests com.vitorpamplona.quartz.mls.AmethystAuthoredVectorGen * * The handoff file comes from the foreign backend's * `emit-joiner-kp` helper (it holds Bob's public KP bytes and Bob's diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/Ed25519Test.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/Ed25519Test.kt similarity index 97% rename from quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/Ed25519Test.kt rename to quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/Ed25519Test.kt index 1c8822924f..1acb27e465 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/Ed25519Test.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/Ed25519Test.kt @@ -18,9 +18,9 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls +package com.vitorpamplona.quartz.mls -import com.vitorpamplona.quartz.marmot.mls.crypto.Ed25519 +import com.vitorpamplona.quartz.mls.crypto.Ed25519 import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/HpkeTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/HpkeTest.kt similarity index 97% rename from quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/HpkeTest.kt rename to quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/HpkeTest.kt index e740eb7a50..75cba09cf8 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/HpkeTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/HpkeTest.kt @@ -18,10 +18,10 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls +package com.vitorpamplona.quartz.mls -import com.vitorpamplona.quartz.marmot.mls.crypto.Hpke -import com.vitorpamplona.quartz.marmot.mls.crypto.X25519 +import com.vitorpamplona.quartz.mls.crypto.Hpke +import com.vitorpamplona.quartz.mls.crypto.X25519 import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MdkWelcomeInteropTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/MdkWelcomeInteropTest.kt similarity index 95% rename from quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MdkWelcomeInteropTest.kt rename to quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/MdkWelcomeInteropTest.kt index 38eb366374..ce9b52f194 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MdkWelcomeInteropTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/MdkWelcomeInteropTest.kt @@ -18,15 +18,15 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls +package com.vitorpamplona.quartz.mls import com.vitorpamplona.quartz.TestResourceLoader -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.framing.MlsMessage -import com.vitorpamplona.quartz.marmot.mls.framing.WireFormat -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup -import com.vitorpamplona.quartz.marmot.mls.messages.KeyPackageBundle -import com.vitorpamplona.quartz.marmot.mls.messages.MlsKeyPackage +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.framing.MlsMessage +import com.vitorpamplona.quartz.mls.framing.WireFormat +import com.vitorpamplona.quartz.mls.group.MlsGroup +import com.vitorpamplona.quartz.mls.messages.KeyPackageBundle +import com.vitorpamplona.quartz.mls.messages.MlsKeyPackage import com.vitorpamplona.quartz.nip01Core.core.JsonMapper import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsConformanceTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/MlsConformanceTest.kt similarity index 95% rename from quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsConformanceTest.kt rename to quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/MlsConformanceTest.kt index 86459eb11e..05cf503773 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsConformanceTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/MlsConformanceTest.kt @@ -18,19 +18,19 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls +package com.vitorpamplona.quartz.mls -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.crypto.Ed25519 -import com.vitorpamplona.quartz.marmot.mls.crypto.Hpke -import com.vitorpamplona.quartz.marmot.mls.crypto.MlsCryptoProvider -import com.vitorpamplona.quartz.marmot.mls.crypto.X25519 -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup -import com.vitorpamplona.quartz.marmot.mls.messages.GroupInfo -import com.vitorpamplona.quartz.marmot.mls.messages.MlsKeyPackage -import com.vitorpamplona.quartz.marmot.mls.messages.Welcome -import com.vitorpamplona.quartz.marmot.mls.schedule.KeySchedule -import com.vitorpamplona.quartz.marmot.mls.tree.LeafNode +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.crypto.Ed25519 +import com.vitorpamplona.quartz.mls.crypto.Hpke +import com.vitorpamplona.quartz.mls.crypto.MlsCryptoProvider +import com.vitorpamplona.quartz.mls.crypto.X25519 +import com.vitorpamplona.quartz.mls.group.MlsGroup +import com.vitorpamplona.quartz.mls.messages.GroupInfo +import com.vitorpamplona.quartz.mls.messages.MlsKeyPackage +import com.vitorpamplona.quartz.mls.messages.Welcome +import com.vitorpamplona.quartz.mls.schedule.KeySchedule +import com.vitorpamplona.quartz.mls.tree.LeafNode import kotlin.test.Test import kotlin.test.assertContentEquals import kotlin.test.assertEquals @@ -185,10 +185,10 @@ class MlsConformanceTest { // Deserialize the MlsMessage wrapping the Welcome val mlsMsg = - com.vitorpamplona.quartz.marmot.mls.framing.MlsMessage + com.vitorpamplona.quartz.mls.framing.MlsMessage .decodeTls(TlsReader(welcomeBytes)) assertEquals( - com.vitorpamplona.quartz.marmot.mls.framing.WireFormat.WELCOME, + com.vitorpamplona.quartz.mls.framing.WireFormat.WELCOME, mlsMsg.wireFormat, "Wire format must be WELCOME (3)", ) @@ -415,7 +415,7 @@ class MlsConformanceTest { // Commit should be deserializable val commit = - com.vitorpamplona.quartz.marmot.mls.messages.Commit + com.vitorpamplona.quartz.mls.messages.Commit .decodeTls(TlsReader(result.commitBytes)) assertTrue(commit.proposals.isNotEmpty(), "Commit must contain proposals") assertTrue(commit.updatePath != null, "Add commit should have UpdatePath") @@ -431,7 +431,7 @@ class MlsConformanceTest { assertTrue(result.commitBytes.isNotEmpty()) val commit = - com.vitorpamplona.quartz.marmot.mls.messages.Commit + com.vitorpamplona.quartz.mls.messages.Commit .decodeTls(TlsReader(result.commitBytes)) assertTrue(commit.proposals.isNotEmpty(), "Remove commit must contain proposals") } diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsGroupEdgeCaseTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/MlsGroupEdgeCaseTest.kt similarity index 98% rename from quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsGroupEdgeCaseTest.kt rename to quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/MlsGroupEdgeCaseTest.kt index a9750d7b5f..b1cd2df3a8 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsGroupEdgeCaseTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/MlsGroupEdgeCaseTest.kt @@ -18,10 +18,10 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls +package com.vitorpamplona.quartz.mls -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup -import com.vitorpamplona.quartz.marmot.mls.messages.KeyPackageBundle +import com.vitorpamplona.quartz.mls.group.MlsGroup +import com.vitorpamplona.quartz.mls.messages.KeyPackageBundle import kotlin.test.Test import kotlin.test.assertContentEquals import kotlin.test.assertEquals diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsGroupLifecycleTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/MlsGroupLifecycleTest.kt similarity index 99% rename from quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsGroupLifecycleTest.kt rename to quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/MlsGroupLifecycleTest.kt index 6609561c90..d8d82eaff0 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsGroupLifecycleTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/MlsGroupLifecycleTest.kt @@ -18,10 +18,10 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls +package com.vitorpamplona.quartz.mls -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup -import com.vitorpamplona.quartz.marmot.mls.messages.KeyPackageBundle +import com.vitorpamplona.quartz.mls.group.MlsGroup +import com.vitorpamplona.quartz.mls.messages.KeyPackageBundle import kotlin.test.Test import kotlin.test.assertContentEquals import kotlin.test.assertEquals diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsGroupStateTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/MlsGroupStateTest.kt similarity index 97% rename from quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsGroupStateTest.kt rename to quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/MlsGroupStateTest.kt index 81cba67a8a..d9b2f0e138 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsGroupStateTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/MlsGroupStateTest.kt @@ -18,11 +18,11 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls +package com.vitorpamplona.quartz.mls -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupState -import com.vitorpamplona.quartz.marmot.mls.group.RetainedEpochSecrets +import com.vitorpamplona.quartz.mls.group.MlsGroup +import com.vitorpamplona.quartz.mls.group.MlsGroupState +import com.vitorpamplona.quartz.mls.group.RetainedEpochSecrets import kotlin.test.Test import kotlin.test.assertContentEquals import kotlin.test.assertEquals @@ -123,14 +123,14 @@ class MlsGroupStateTest { // Serialize and deserialize val writer = - com.vitorpamplona.quartz.marmot.mls.codec + com.vitorpamplona.quartz.mls.codec .TlsWriter() retained.encodeTls(writer) val bytes = writer.toByteArray() val restored = RetainedEpochSecrets.decodeTls( - com.vitorpamplona.quartz.marmot.mls.codec + com.vitorpamplona.quartz.mls.codec .TlsReader(bytes), ) @@ -177,7 +177,7 @@ class MlsGroupStateTest { // staged-proposal pool; older blobs still decode, so the version only // ever moves forward when the layout gains a field. val reader = - com.vitorpamplona.quartz.marmot.mls.codec + com.vitorpamplona.quartz.mls.codec .TlsReader(bytes) val version = reader.readUint16() assertEquals(4, version) @@ -347,7 +347,7 @@ class MlsGroupStateTest { */ private fun encodeAsV1(state: MlsGroupState): ByteArray { val writer = - com.vitorpamplona.quartz.marmot.mls.codec + com.vitorpamplona.quartz.mls.codec .TlsWriter() writer.putUint16(1) state.groupContext.encodeTls(writer) diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsGroupTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/MlsGroupTest.kt similarity index 98% rename from quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsGroupTest.kt rename to quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/MlsGroupTest.kt index e01677c711..74893e3e99 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsGroupTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/MlsGroupTest.kt @@ -18,9 +18,9 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls +package com.vitorpamplona.quartz.mls -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup +import com.vitorpamplona.quartz.mls.group.MlsGroup import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull @@ -48,7 +48,7 @@ class MlsGroupTest { fun testCreateGroupWithSigningKey() { val identity = "alice@nostr".encodeToByteArray() val sigKp = - com.vitorpamplona.quartz.marmot.mls.crypto.Ed25519 + com.vitorpamplona.quartz.mls.crypto.Ed25519 .generateKeyPair() val group = MlsGroup.create(identity, sigKp.privateKey) diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/TsMlsWelcomeInteropTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/TsMlsWelcomeInteropTest.kt similarity index 94% rename from quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/TsMlsWelcomeInteropTest.kt rename to quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/TsMlsWelcomeInteropTest.kt index 244b6c0c00..2324d980ae 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/TsMlsWelcomeInteropTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/TsMlsWelcomeInteropTest.kt @@ -18,15 +18,15 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls +package com.vitorpamplona.quartz.mls import com.vitorpamplona.quartz.TestResourceLoader -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.framing.MlsMessage -import com.vitorpamplona.quartz.marmot.mls.framing.WireFormat -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup -import com.vitorpamplona.quartz.marmot.mls.messages.KeyPackageBundle -import com.vitorpamplona.quartz.marmot.mls.messages.MlsKeyPackage +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.framing.MlsMessage +import com.vitorpamplona.quartz.mls.framing.WireFormat +import com.vitorpamplona.quartz.mls.group.MlsGroup +import com.vitorpamplona.quartz.mls.messages.KeyPackageBundle +import com.vitorpamplona.quartz.mls.messages.MlsKeyPackage import com.vitorpamplona.quartz.nip01Core.core.JsonMapper import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/X25519Test.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/X25519Test.kt similarity index 97% rename from quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/X25519Test.kt rename to quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/X25519Test.kt index f323e8d342..38bc2a755d 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/X25519Test.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/X25519Test.kt @@ -18,9 +18,9 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls +package com.vitorpamplona.quartz.mls -import com.vitorpamplona.quartz.marmot.mls.crypto.X25519 +import com.vitorpamplona.quartz.mls.crypto.X25519 import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/components/AppDataDictionaryInteropTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/components/AppDataDictionaryInteropTest.kt similarity index 96% rename from quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/components/AppDataDictionaryInteropTest.kt rename to quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/components/AppDataDictionaryInteropTest.kt index 6362457ad0..a558d8c37e 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/components/AppDataDictionaryInteropTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/components/AppDataDictionaryInteropTest.kt @@ -18,18 +18,18 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.components +package com.vitorpamplona.quartz.mls.components import com.vitorpamplona.quartz.TestResourceLoader import com.vitorpamplona.quartz.marmot.appComponents.AppComponentIds import com.vitorpamplona.quartz.marmot.appComponents.accountIdentityProof.AccountIdentityProofV2 import com.vitorpamplona.quartz.marmot.mip01Groups.MlsCiphersuite -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter -import com.vitorpamplona.quartz.marmot.mls.framing.MlsMessage -import com.vitorpamplona.quartz.marmot.mls.framing.WireFormat -import com.vitorpamplona.quartz.marmot.mls.messages.MlsKeyPackage -import com.vitorpamplona.quartz.marmot.mls.tree.Credential +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.codec.TlsWriter +import com.vitorpamplona.quartz.mls.framing.MlsMessage +import com.vitorpamplona.quartz.mls.framing.WireFormat +import com.vitorpamplona.quartz.mls.messages.MlsKeyPackage +import com.vitorpamplona.quartz.mls.tree.Credential import com.vitorpamplona.quartz.nip01Core.core.JsonMapper import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/components/AppDataUpdateProposalTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/components/AppDataUpdateProposalTest.kt similarity index 93% rename from quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/components/AppDataUpdateProposalTest.kt rename to quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/components/AppDataUpdateProposalTest.kt index f5e69864d3..da09c97ff1 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/components/AppDataUpdateProposalTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/components/AppDataUpdateProposalTest.kt @@ -18,17 +18,18 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.components +package com.vitorpamplona.quartz.mls.components import com.vitorpamplona.quartz.marmot.appComponents.AdminPolicyV1 import com.vitorpamplona.quartz.marmot.appComponents.AppComponentIds import com.vitorpamplona.quartz.marmot.appComponents.GroupLifecycleV1 import com.vitorpamplona.quartz.marmot.appComponents.GroupProfileV1 -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter -import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup -import com.vitorpamplona.quartz.marmot.mls.messages.Proposal -import com.vitorpamplona.quartz.marmot.mls.messages.ProposalType +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.codec.TlsWriter +import com.vitorpamplona.quartz.mls.group.MlsGroup +import com.vitorpamplona.quartz.mls.messages.Proposal +import com.vitorpamplona.quartz.mls.messages.ProposalType +import com.vitorpamplona.quartz.mls.tree.Capabilities import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey import kotlin.test.Test @@ -181,7 +182,9 @@ class AppDataUpdateProposalTest { // GroupContextExtensions proposal already produced, regardless of the // order the two appear in the proposal list. Propose them in the // "wrong" order to prove we do not simply follow list order. - val group = MlsGroup.create(creator) + // Advertised, because installing the dictionary as a GroupContext + // extension puts it under RFC 9420 §13.4: every member must support it. + val group = MlsGroup.create(creator, capabilities = Capabilities(extensions = listOf(AppDataDictionary.EXTENSION_TYPE))) group.proposeAppDataUpdate(AppComponentIds.GROUP_PROFILE_V1, profileA) group.proposeGroupContextExtensions( listOf(AppDataDictionary(listOf(ComponentData(AppComponentIds.ADMIN_POLICY_V1, adminPolicy))).toExtension()), diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/AuthenticatedDataTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/group/AuthenticatedDataTest.kt similarity index 92% rename from quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/AuthenticatedDataTest.kt rename to quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/group/AuthenticatedDataTest.kt index 7a3385da12..a30d1e0810 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/AuthenticatedDataTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/group/AuthenticatedDataTest.kt @@ -18,11 +18,11 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.group +package com.vitorpamplona.quartz.mls.group -import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader -import com.vitorpamplona.quartz.marmot.mls.framing.MlsMessage -import com.vitorpamplona.quartz.marmot.mls.framing.PrivateMessage +import com.vitorpamplona.quartz.mls.codec.TlsReader +import com.vitorpamplona.quartz.mls.framing.MlsMessage +import com.vitorpamplona.quartz.mls.framing.PrivateMessage import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import kotlin.test.Test import kotlin.test.assertContentEquals diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/group/GroupContextExtensionsRuleTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/group/GroupContextExtensionsRuleTest.kt new file mode 100644 index 0000000000..773d08ca1d --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/group/GroupContextExtensionsRuleTest.kt @@ -0,0 +1,141 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.mls.group + +import com.vitorpamplona.quartz.mls.codec.TlsWriter +import com.vitorpamplona.quartz.mls.tree.Capabilities +import com.vitorpamplona.quartz.mls.tree.Credential +import com.vitorpamplona.quartz.mls.tree.Extension +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** + * RFC 9420 §12.1.7, which the engine used to get wrong in both directions. + * + * It rejected any GroupContextExtensions proposal carrying an extension type + * outside a hardcoded list — a rule the RFC does not have, and one that made + * a whole class of valid group un-joinable. Meanwhile the rule the RFC DOES + * state, that the resulting group must not require capabilities some member + * lacks, was not checked at all. + * + * Found while building the cordn binding: cordn's `0xC04D` metadata extension + * is exactly the kind of application extension the old check refused. + */ +class GroupContextExtensionsRuleTest { + private val alice = "alice".encodeToByteArray() + + /** An arbitrary application extension type, in the private-use range. */ + private val appExtension = 0xC04D + + private fun requiredCapabilities(extensions: List): Extension { + val writer = TlsWriter() + val exts = TlsWriter() + extensions.forEach { exts.putUint16(it) } + writer.putOpaqueVarInt(exts.toByteArray()) + writer.putOpaqueVarInt(ByteArray(0)) + val creds = TlsWriter() + creds.putUint16(Credential.CREDENTIAL_TYPE_BASIC) + writer.putOpaqueVarInt(creds.toByteArray()) + return Extension(MlsGroup.REQUIRED_CAPABILITIES_EXTENSION_TYPE, writer.toByteArray()) + } + + @Test + fun anUnknownExtensionTypeIsInstalledWhenEveryMemberAdvertisesIt() { + // "We recognise the type" is not the rule. RFC 9420 §13.4 makes the + // test membership: "an extension in use by the group MUST be supported + // by all members of the group", read off leaf capabilities. So a type + // this code has never heard of installs fine, provided the leaves say + // they support it. + // + // An earlier version of this test asserted the opposite - that an + // unknown type installs unconditionally - on the reading that §12.1.7 + // states the only rule. §12.1.7 does state the only rule *it* has; + // §13.4 is where the membership requirement lives. + val group = MlsGroup.create(alice, capabilities = Capabilities(extensions = listOf(appExtension))) + group.proposeGroupContextExtensions(listOf(Extension(appExtension, byteArrayOf(1, 2, 3)))) + group.commit() + + assertTrue( + group.extensions.any { it.extensionType == appExtension }, + "the extension must be installed, not rejected", + ) + } + + @Test + fun anExtensionThisMemberDoesNotAdvertiseIsRejected() { + // The same proposal, from a leaf that never claimed the capability. + // Accepting it would put the group in a state §13.4 forbids. + val group = MlsGroup.create(alice) + group.proposeGroupContextExtensions(listOf(Extension(appExtension, byteArrayOf(1, 2, 3)))) + + val error = assertFailsWith { group.commit() } + assertTrue( + error.message?.contains("Unsupported extension type") == true, + "unexpected message: ${error.message}", + ) + } + + @Test + fun aRequiredCapabilityNoMemberAdvertisesIsRejected() { + // The rule the RFC actually states. Accepting this splits the group: + // every peer that checks refuses the commit, every peer that does not + // applies it, and the two halves diverge at the next epoch. + val group = MlsGroup.create(alice, capabilities = Capabilities()) + group.proposeGroupContextExtensions(listOf(requiredCapabilities(listOf(appExtension)))) + + val error = assertFailsWith { group.commit() } + assertTrue( + error.message?.contains("required_capabilities") == true, + "must fail on the capability rule specifically: got '${error.message}'", + ) + } + + @Test + fun aRequiredCapabilityEveryMemberAdvertisesIsAccepted() { + val group = MlsGroup.create(alice, capabilities = Capabilities(extensions = listOf(appExtension))) + val epochBefore = group.epoch + + group.proposeGroupContextExtensions(listOf(requiredCapabilities(listOf(appExtension)))) + group.commit() + + assertEquals(epochBefore + 1, group.epoch) + } + + @Test + fun theReplacementIsWholesaleNotAMerge() { + // §12.1.7: "This is a wholesale replacement, not a merge. An extension + // is only carried over if the sender of the proposal includes it." + // + // Both types are advertised so that §13.4 is satisfied throughout and + // this test fails only on the replacement rule it is about. + val group = MlsGroup.create(alice, capabilities = Capabilities(extensions = listOf(appExtension, 0xC04E))) + group.proposeGroupContextExtensions(listOf(Extension(appExtension, byteArrayOf(1)))) + group.commit() + + group.proposeGroupContextExtensions(listOf(Extension(0xC04E, byteArrayOf(2)))) + group.commit() + + assertTrue(group.extensions.none { it.extensionType == appExtension }, "the old extension is gone") + assertTrue(group.extensions.any { it.extensionType == 0xC04E }) + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/GroupContextExtensionsSupportTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/group/GroupContextExtensionsSupportTest.kt similarity index 95% rename from quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/GroupContextExtensionsSupportTest.kt rename to quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/group/GroupContextExtensionsSupportTest.kt index ada9e0375c..b2c0be0f7a 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/GroupContextExtensionsSupportTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/group/GroupContextExtensionsSupportTest.kt @@ -18,10 +18,10 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.group +package com.vitorpamplona.quartz.mls.group -import com.vitorpamplona.quartz.marmot.mls.tree.Capabilities -import com.vitorpamplona.quartz.marmot.mls.tree.Extension +import com.vitorpamplona.quartz.mls.tree.Capabilities +import com.vitorpamplona.quartz.mls.tree.Extension import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import kotlin.test.Test import kotlin.test.assertContentEquals diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/GroupCreateOptionsTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/group/GroupCreateOptionsTest.kt similarity index 98% rename from quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/GroupCreateOptionsTest.kt rename to quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/group/GroupCreateOptionsTest.kt index 4e71e5f0c4..d95a39a1e3 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/GroupCreateOptionsTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/group/GroupCreateOptionsTest.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.group +package com.vitorpamplona.quartz.mls.group import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import kotlin.test.Test diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/KeyPackageLifetimeTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/group/KeyPackageLifetimeTest.kt similarity index 95% rename from quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/KeyPackageLifetimeTest.kt rename to quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/group/KeyPackageLifetimeTest.kt index 89472471a3..30d78c0b02 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/KeyPackageLifetimeTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/group/KeyPackageLifetimeTest.kt @@ -18,9 +18,9 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.group +package com.vitorpamplona.quartz.mls.group -import com.vitorpamplona.quartz.marmot.mls.tree.Lifetime +import com.vitorpamplona.quartz.mls.tree.Lifetime import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.utils.TimeUtils import kotlin.test.Test diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/group/MlsGroupPolicySeamTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/group/MlsGroupPolicySeamTest.kt new file mode 100644 index 0000000000..34e9ac85d4 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/group/MlsGroupPolicySeamTest.kt @@ -0,0 +1,145 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.mls.group + +import com.vitorpamplona.quartz.mls.tree.Capabilities +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * The [MlsGroupPolicy] seam itself, with no binding involved. + * + * The engine used to carry Marmot's authorization rules and capability + * defaults inline, so a plain RFC 9420 group could not be created at all — + * every group came out requiring `marmot_group_data`. These tests pin the two + * halves of the fix: the default really is RFC 9420 as written, and a policy + * that refuses really is consulted rather than advisory. + */ +class MlsGroupPolicySeamTest { + private val alice = "alice".encodeToByteArray() + + /** Records what the engine asked, and refuses on demand. */ + private class RecordingPolicy( + val refuseCommit: Boolean = false, + val refuseSelfRemove: Boolean = false, + override val commitExporter: MlsExporterLabel? = null, + ) : MlsGroupPolicy { + var commitsSeen = 0 + var selfRemovesSeen = 0 + var lastCommitter: Int? = null + + override fun authorizeCommit( + group: GroupView, + proposals: List, + committerLeafIndex: Int, + ) { + commitsSeen++ + lastCommitter = committerLeafIndex + if (refuseCommit) throw IllegalStateException("refused by policy") + } + + override fun authorizeSelfRemove(group: GroupView) { + selfRemovesSeen++ + if (refuseSelfRemove) throw IllegalStateException("no leaving") + } + } + + @Test + fun aDefaultGroupRequiresNothingBeyondRfc9420() { + val group = MlsGroup.create(alice) + + assertFalse( + group.extensions.any { it.extensionType == MlsGroup.REQUIRED_CAPABILITIES_EXTENSION_TYPE }, + "the default profile must not install required_capabilities — that was Marmot's, not RFC 9420's", + ) + assertEquals( + Capabilities(), + MlsGroupPolicy.Permissive.defaultLeafCapabilities, + "RFC 9420 §7.2 forbids advertising DEFAULT types, so the neutral leaf advertises nothing", + ) + } + + @Test + fun authorizeCommitIsConsultedWithTheLocalCommitter() { + val policy = RecordingPolicy() + val group = MlsGroup.create(alice, policy = policy) + + group.proposeGroupContextExtensions(group.extensions) + group.commit() + + assertEquals(1, policy.commitsSeen) + assertEquals(group.leafIndex, policy.lastCommitter) + } + + @Test + fun aRefusedCommitDoesNotAdvanceTheEpoch() { + val policy = RecordingPolicy(refuseCommit = true) + val group = MlsGroup.create(alice, policy = policy) + val epochBefore = group.epoch + + group.proposeGroupContextExtensions(group.extensions) + assertFailsWith { group.commit() } + + assertEquals( + epochBefore, + group.epoch, + "a policy refusal must abort before any mutation, or the group diverges from every peer", + ) + } + + @Test + fun authorizeSelfRemoveGatesTheProposalAtItsSender() { + val allowed = RecordingPolicy() + MlsGroup.create(alice, policy = allowed).proposeSelfRemove() + assertEquals(1, allowed.selfRemovesSeen) + + val refused = RecordingPolicy(refuseSelfRemove = true) + val group = MlsGroup.create(alice, policy = refused) + assertFailsWith { group.proposeSelfRemove() } + assertTrue(group.pendingProposalsSnapshot().isEmpty(), "a refused proposal must not be staged") + } + + @Test + fun theCommitExporterSecretComesFromThePolicy() { + val none = MlsGroup.create(alice, policy = RecordingPolicy()) + none.proposeGroupContextExtensions(none.extensions) + assertEquals( + 0, + none.commit().preCommitExporterSecret.size, + "a binding that seals nothing outside MLS gets no secret", + ) + + val label = MlsExporterLabel("myapp", "group-event".encodeToByteArray(), 32) + val bound = MlsGroup.create(alice, policy = RecordingPolicy(commitExporter = label)) + val expected = bound.exporterSecret(label.label, label.context, label.length) + bound.proposeGroupContextExtensions(bound.extensions) + + assertContentEquals( + expected, + bound.commit().preCommitExporterSecret, + "the engine must derive it under the policy's label, not one of its own", + ) + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/UpdatePathAncestorTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/group/UpdatePathAncestorTest.kt similarity index 98% rename from quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/UpdatePathAncestorTest.kt rename to quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/group/UpdatePathAncestorTest.kt index 8aa92dabbc..90fc240276 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/UpdatePathAncestorTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/mls/group/UpdatePathAncestorTest.kt @@ -18,9 +18,9 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.group +package com.vitorpamplona.quartz.mls.group -import com.vitorpamplona.quartz.marmot.mls.messages.KeyPackageBundle +import com.vitorpamplona.quartz.mls.messages.KeyPackageBundle import kotlin.test.Test import kotlin.test.assertContentEquals import kotlin.test.assertEquals diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/JacksonMapperTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/JacksonMapperTest.kt index fb949ca47d..6460a74a65 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/JacksonMapperTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/JacksonMapperTest.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.quartz.nip01Core.jackson import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate -import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import com.vitorpamplona.quartz.nip51Lists.starterPack.StarterPackEvent import com.vitorpamplona.quartz.nip59Giftwrap.rumors.Rumor import kotlin.test.Test import kotlin.test.assertContentEquals @@ -46,7 +46,7 @@ class JacksonMapperTest { ) val followCard = - FollowListEvent( + StarterPackEvent( id = "eca31634fce7c9068b56fa8db9f387da70bdcceb3986a77ca1a9844f3128eb5f", pubKey = "3c39a7b53dec9ac85acf08b267637a9841e6df7b7b0f5e2ac56a8cf107de37da", createdAt = 1761736286, diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/KotlinSerializationMapperTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/KotlinSerializationMapperTest.kt index 40e7aa5884..f33431cd8f 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/KotlinSerializationMapperTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/KotlinSerializationMapperTest.kt @@ -39,7 +39,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse -import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import com.vitorpamplona.quartz.nip51Lists.starterPack.StarterPackEvent import com.vitorpamplona.quartz.nip59Giftwrap.rumors.Rumor import kotlin.test.Test import kotlin.test.assertContentEquals @@ -65,7 +65,7 @@ class KotlinSerializationMapperTest { ) val followCard = - FollowListEvent( + StarterPackEvent( id = "eca31634fce7c9068b56fa8db9f387da70bdcceb3986a77ca1a9844f3128eb5f", pubKey = "3c39a7b53dec9ac85acf08b267637a9841e6df7b7b0f5e2ac56a8cf107de37da", createdAt = 1761736286, @@ -194,7 +194,7 @@ class KotlinSerializationMapperTest { fun deserializeEventWithSpecialCharactersInContent() { val content = "Hello \"world\" \n\ttab\\backslash" val event = - FollowListEvent( + StarterPackEvent( id = "abc", pubKey = "def", createdAt = 1000, @@ -769,7 +769,7 @@ class KotlinSerializationMapperTest { @Test fun emptyContentEvent() { val event = - FollowListEvent( + StarterPackEvent( id = "a".repeat(64), pubKey = "b".repeat(64), createdAt = 0, @@ -796,7 +796,7 @@ class KotlinSerializationMapperTest { fun eventWithUnicodeContent() { val content = "Hello \uD83D\uDE00 world \u00E9\u00E8\u00EA" val event = - FollowListEvent( + StarterPackEvent( id = "a".repeat(64), pubKey = "b".repeat(64), createdAt = 1000, diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/RelayGroupFilterServingRelayTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/RelayGroupFilterServingRelayTest.kt index 1bf3edf89c..bd1395c3df 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/RelayGroupFilterServingRelayTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/RelayGroupFilterServingRelayTest.kt @@ -31,7 +31,7 @@ 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 +import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupRolesEvent import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent @@ -72,7 +72,7 @@ class RelayGroupFilterServingRelayTest : RelayClientTest() { GroupMetadataEvent.KIND -> GroupMetadataEvent(id, relayKey, 100L, tags, "", sig) GroupAdminsEvent.KIND -> GroupAdminsEvent(id, relayKey, 100L, tags, "", sig) GroupMembersEvent.KIND -> GroupMembersEvent(id, relayKey, 100L, tags, "", sig) - SupportedRolesEvent.KIND -> SupportedRolesEvent(id, relayKey, 100L, tags, "", sig) + GroupRolesEvent.KIND -> GroupRolesEvent(id, relayKey, 100L, tags, "", sig) GroupPinnedEvent.KIND -> GroupPinnedEvent(id, relayKey, 100L, tags, "", sig) else -> error("unexpected state kind $kind") } @@ -105,7 +105,7 @@ class RelayGroupFilterServingRelayTest : RelayClientTest() { state(1, GroupMetadataEvent.KIND), state(2, GroupAdminsEvent.KIND), state(3, GroupMembersEvent.KIND), - state(4, SupportedRolesEvent.KIND), + state(4, GroupRolesEvent.KIND), state(5, GroupPinnedEvent.KIND), ), ) @@ -119,7 +119,7 @@ class RelayGroupFilterServingRelayTest : RelayClientTest() { GroupMetadataEvent.KIND, GroupAdminsEvent.KIND, GroupMembersEvent.KIND, - SupportedRolesEvent.KIND, + GroupRolesEvent.KIND, GroupPinnedEvent.KIND, ), tags = mapOf("d" to listOf("g1")), @@ -268,7 +268,7 @@ class RelayGroupFilterServingRelayTest : RelayClientTest() { GroupMetadataEvent.KIND, GroupAdminsEvent.KIND, GroupMembersEvent.KIND, - SupportedRolesEvent.KIND, + GroupRolesEvent.KIND, ), limit = 500, ), diff --git a/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsEventStore.kt b/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsEventStore.kt index 38cc465830..62616dd7ef 100644 --- a/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsEventStore.kt +++ b/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsEventStore.kt @@ -33,7 +33,7 @@ import com.vitorpamplona.quartz.nip01Core.store.IEventStore import com.vitorpamplona.quartz.nip01Core.store.sqlite.DefaultIndexingStrategy import com.vitorpamplona.quartz.nip01Core.store.sqlite.IndexingStrategy import com.vitorpamplona.quartz.nip01Core.store.sqlite.TagNameValueHasher -import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent import com.vitorpamplona.quartz.nip40Expiration.expiration import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.nip50Search.strippingSearchExtensions @@ -136,7 +136,7 @@ open class FsEventStore( if (slot != null && existingSlot?.id != event.id) { slots.install(slot, canonical, event, existingSlot) } - if (event is DeletionEvent) processDeletion(event, canonical) + if (event is DeletionRequestEvent) processDeletion(event, canonical) if (event is RequestToVanishEvent) processVanish(event, canonical) return } @@ -158,7 +158,7 @@ open class FsEventStore( if (slot != null) { slots.install(slot, canonical, event, existingSlot) } - if (event is DeletionEvent) processDeletion(event, canonical) + if (event is DeletionRequestEvent) processDeletion(event, canonical) if (event is RequestToVanishEvent) processVanish(event, canonical) } catch (t: Throwable) { Files.deleteIfExists(tmp) @@ -223,7 +223,7 @@ open class FsEventStore( * future re-inserts are blocked. */ private fun processDeletion( - deletion: DeletionEvent, + deletion: DeletionRequestEvent, deletionCanonical: java.nio.file.Path, ) { val ownerHashOfDeletion = hasher.hash(deletion.pubKey) diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/BindingIsolationTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/BindingIsolationTest.kt new file mode 100644 index 0000000000..5aff11f31d --- /dev/null +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/BindingIsolationTest.kt @@ -0,0 +1,146 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertTrue +import kotlin.test.fail + +/** + * Marmot and cordn must be able to walk away from each other. + * + * They are two bindings of RFC 9420 onto two different delivery models, they do + * not interoperate, and neither is a layer of the other. So a change to one must + * never force a change to the other — which holds only while neither imports the + * other, and while the engine under both imports neither. + * + * `mls/README.md` has stated the engine half of this as a grep a human is + * supposed to run. A grep in a README is a wish. This is the same invariant as a + * test, so the build says no instead of a reviewer noticing. + * + * ## Scope: shipped code only + * + * Main source sets, never tests, and deliberately: an interop test that drives + * both profiles through one engine — `cordn/interop/BothCredentialProfilesTest` + * is exactly that — is how we *demonstrate* the two cannot collide, and the + * `mls/components` tests decode real Marmot payloads as fixtures because the + * point of an interop test is to run against bytes that exist. A fixture is + * data; an import in shipped code is a dependency. + */ +class BindingIsolationTest { + private val quartzRoot: File by lazy { + // Gradle runs tests with the module directory as the working directory, + // but resolve it rather than trust it: a wrong root would make every + // assertion below vacuously pass, which is the one outcome an + // architecture test must never have. + generateSequence(File(".").absoluteFile) { it.parentFile } + .map { if (it.name == "quartz") it else File(it, "quartz") } + .firstOrNull { File(it, "src/commonMain/kotlin/com/vitorpamplona/quartz").isDirectory } + ?: fail("cannot locate the quartz module from ${File(".").absolutePath}") + } + + /** Main source sets only — see the class KDoc. */ + private fun sourceSets(pkg: String): List = + File(quartzRoot, "src") + .listFiles() + .orEmpty() + .filter { it.isDirectory && !it.name.endsWith("Test") } + .map { File(it, "kotlin/com/vitorpamplona/quartz/$pkg") } + .filter { it.isDirectory } + + /** Every `import ` under [pkg], as `path:line import…`. */ + private fun offendingImports( + pkg: String, + forbidden: String, + ): List = + sourceSets(pkg).flatMap { dir -> + dir + .walkTopDown() + .filter { it.isFile && it.extension == "kt" } + .flatMap { file -> + file.readLines().withIndex().mapNotNull { (i, line) -> + if (line.trimStart().startsWith("import $forbidden")) { + "${file.relativeTo(quartzRoot)}:${i + 1} ${line.trim()}" + } else { + null + } + } + } + } + + private fun assertNoImports( + pkg: String, + forbidden: String, + why: String, + ) { + val offences = offendingImports(pkg, forbidden) + assertTrue( + offences.isEmpty(), + "$pkg must not import $forbidden — $why\n " + offences.joinToString("\n "), + ) + } + + @Test + fun `the engine knows nothing about either binding`() { + // The reason Stage 1 of the cordn interop plan existed at all. An engine + // that imports a binding is an engine only that binding can use, and + // :quic was already reaching into it for X25519 before the split. + assertNoImports("mls", "com.vitorpamplona.quartz.marmot", "it is RFC 9420, not Marmot") + assertNoImports("mls", "com.vitorpamplona.quartz.cordn", "it is RFC 9420, not cordn") + assertNoImports("mls", "com.vitorpamplona.quartz.nip01Core", "it is RFC 9420, not Nostr") + } + + @Test + fun `cordn does not depend on Marmot`() { + // Everything named Marmot*, Mip* or mip0* is the wrong layer for cordn: + // a different delivery service, a different credential encoding, a + // different capability profile. Reuse there would couple two protocols + // that have no reason to move together. + assertNoImports("cordn", "com.vitorpamplona.quartz.marmot", "they are independent bindings") + } + + @Test + fun `Marmot does not depend on cordn`() { + // The direction that matters most in practice: Marmot ships to users + // today, and cordn is the newer, less settled of the two. Marmot must + // not acquire a reason to care when cordn changes. + assertNoImports("marmot", "com.vitorpamplona.quartz.cordn", "they are independent bindings") + } + + @Test + fun `the check is actually looking at files`() { + // Guards the failure mode that would make every assertion above pass + // for the wrong reason: a bad root, a renamed package, an empty walk. + listOf("mls", "marmot", "cordn").forEach { pkg -> + val files = + sourceSets(pkg).flatMap { + it.walkTopDown().filter { f -> f.isFile && f.extension == "kt" }.toList() + } + assertTrue(files.size > 5, "found only ${files.size} Kotlin files under $pkg — the scan is broken, not the code") + } + // And that it can see an import at all, using one every binding has. + assertTrue( + offendingImports("cordn", "com.vitorpamplona.quartz.mls").isNotEmpty(), + "the import scanner found no mls imports in cordn, so it would not find a marmot one either", + ) + } +} diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/buzz/interop/BuzzRelayLiveInteropTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/buzz/interop/BuzzRelayLiveInteropTest.kt index 429936584d..fd513e3b49 100644 --- a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/buzz/interop/BuzzRelayLiveInteropTest.kt +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/buzz/interop/BuzzRelayLiveInteropTest.kt @@ -29,8 +29,8 @@ import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateGroupEvent -import com.vitorpamplona.quartz.nip29RelayGroups.request.JoinRequestEvent -import com.vitorpamplona.quartz.nip29RelayGroups.request.LeaveRequestEvent +import com.vitorpamplona.quartz.nip29RelayGroups.request.GroupJoinRequestEvent +import com.vitorpamplona.quartz.nip29RelayGroups.request.GroupLeaveRequestEvent import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent import kotlinx.coroutines.runBlocking import okhttp3.OkHttpClient @@ -238,7 +238,7 @@ class BuzzRelayLiveInteropTest { val (ws2, frames2) = authedSocket(joiner) val join = joiner.sign( - JoinRequestEvent.build(channelId), + GroupJoinRequestEvent.build(channelId), ) ws2.send("""["EVENT",${join.toJson()}]""") val joinOk = frames2.nextOf("OK") @@ -250,7 +250,7 @@ class BuzzRelayLiveInteropTest { assertTrue("true" in frames2.nextOf("OK"), "post after join should be accepted") // LEAVE: kind 9022, the NIP-29 leave request. - val leave = joiner.sign(LeaveRequestEvent.build(channelId)) + val leave = joiner.sign(GroupLeaveRequestEvent.build(channelId)) ws2.send("""["EVENT",${leave.toJson()}]""") assertTrue("true" in frames2.nextOf("OK"), "leave (9022) should be accepted") diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/contextvm/cep06Announcements/ServerAnnouncementLiveVectorTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/contextvm/cep06Announcements/ServerAnnouncementLiveVectorTest.kt new file mode 100644 index 0000000000..e1251516f4 --- /dev/null +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/contextvm/cep06Announcements/ServerAnnouncementLiveVectorTest.kt @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.contextvm.cep06Announcements + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.verify +import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * CEP-6 announcements captured off the public relays on 2026-09-22, from + * coordinators that serve the eleven cordn tools. + * + * These are real, signature-valid events, so they pin the parser to what other + * implementations actually emit rather than to what we would emit ourselves. + */ +class ServerAnnouncementLiveVectorTest { + /** A named, hosted coordinator — the only one on the network carrying a website. */ + private val dojopop = + """{"id":"1a65d58ffbaae66cd14d15e275d57e08dea50bbd06ac4a606f2a7312d1cb29f8","pubkey":"d969813a5c0e3e65dad03fc9e1d2db5933dda8b307ddce474e8da60b4e288259","created_at":1786946186,"kind":11316,"tags":[["name","dojopop-cordn"],["about","DojoPop MLS group messaging coordinator"],["website","https://dojopop.live"],["support_encryption"],["support_encryption_ephemeral"],["support_oversized_transfer"],["support_open_stream"]],"content":"{\"protocolVersion\":\"2025-11-25\",\"capabilities\":{\"tools\":{\"listChanged\":true}},\"serverInfo\":{\"name\":\"cordn-server\",\"version\":\"0.1.0\"}}","sig":"33ad89ba030b935bbcd08e98ed7b952dfe3d072e165ab96b9f98635cb81a2987e66d7d75a391aa904f824e9dab090b3edd16f0002f9522f5dfb9a3fbc15db88e"}""" + + /** A browser-tab coordinator — same shape, no website tag. */ + private val browserTab = + """{"id":"acead902457220f407bcb7857020c034089d0c35e4c29d18066ac17fab055aa7","pubkey":"35e2a4020dd6b9f5f45cf27d3e65f4a286a8e89d75c8ae141d9a794c1236dc26","created_at":1786113384,"kind":11316,"tags":[["name","My coordinator"],["about","Cordn coordinator running in a browser tab; key package quota 33 per identity"],["support_encryption"],["support_encryption_ephemeral"],["support_oversized_transfer"],["support_open_stream"]],"content":"{\"protocolVersion\":\"2025-11-25\",\"capabilities\":{\"tools\":{\"listChanged\":true}},\"serverInfo\":{\"name\":\"My coordinator\",\"version\":\"0.1.0\"}}","sig":"a0f29d13955636d407635eaa59ee307fa9b39d54466189e247f0ff9c61c17070084d0bd604efd29287a19c4e841a21c7c69dd1a500b245c98561dacc09b550a7"}""" + + private fun parse(json: String): ServerAnnouncement { + val event: Event = JacksonMapper.fromJson(json) + assertTrue("captured vector must still verify", event.verify()) + return ServerAnnouncement.parseOrNull(event)!! + } + + @Test + fun readsANamedCoordinatorAnnouncement() { + val ann = parse(dojopop) + + assertEquals(11316, ann.kind) + assertEquals("d969813a5c0e3e65dad03fc9e1d2db5933dda8b307ddce474e8da60b4e288259", ann.pubKey) + assertEquals("dojopop-cordn", ann.discovery.name) + assertEquals("DojoPop MLS group messaging coordinator", ann.discovery.about) + assertEquals("https://dojopop.live", ann.discovery.website) + assertNull(ann.discovery.picture) + assertTrue(ann.discovery.supportsEncryption) + assertTrue(ann.discovery.supportsEphemeralEncryption) + assertTrue(ann.discovery.supportsOversizedTransfer) + assertTrue(ann.discovery.supportsOpenStream) + } + + @Test + fun readsACoordinatorThatOmitsTheOptionalTags() { + val ann = parse(browserTab) + + assertEquals("My coordinator", ann.discovery.name) + assertNull(ann.discovery.website) + assertNull(ann.discovery.picture) + assertTrue(ann.discovery.supportsEncryption) + } + + /** + * Nothing in a CEP-6 announcement says which relays reach the coordinator. + * + * A client that learns a coordinator this way only has its pubkey, so it has + * to keep talking on the relay it heard the announcement on. Asserted so the + * day a routing tag appears in the wild, this test is what flags it. + */ + @Test + fun carriesNoRelayHint() { + listOf(dojopop, browserTab).forEach { + assertEquals(emptyList(), parse(it).discovery.unknownTags) + } + } + + /** Replaceable kinds: a lagging relay's older copy must not win. */ + @Test + fun keepsTheNewestAnnouncementPerKind() { + val events = listOf(JacksonMapper.fromJson(browserTab), JacksonMapper.fromJson(dojopop)) + + val latest = ServerAnnouncement.latestPerKind(events) + + assertEquals(1, latest.size) + assertEquals("dojopop-cordn", latest[11316]!!.discovery.name) + } +} diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/cordn/appMultiDevice/CordnDeviceDocumentTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/cordn/appMultiDevice/CordnDeviceDocumentTest.kt new file mode 100644 index 0000000000..8ad4339a3c --- /dev/null +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/cordn/appMultiDevice/CordnDeviceDocumentTest.kt @@ -0,0 +1,194 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.appMultiDevice + +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The §4/§5/§6/§7 document layer, on its own. + * + * These codecs decide what survives a phone swap, so the cases worth pinning + * are the ones that fail quietly: a field dropped in the round trip is data the + * user loses, and an address check that passes when it should not is a blob + * nobody authorised being handed to the MLS engine. + */ +class CordnDeviceDocumentTest { + private val group = + CordnGroupDocument( + gid = "gid-1", + coordinator = "aa".repeat(32), + clientState = "c3RhdGU=", + cursor = 42, + issuedAt = 1_700_000_000_000, + roomState = "cm9vbQ==", + echoState = "ZWNobw==", + joinedViaRequest = true, + ) + + @Test + fun `a group document round-trips every field`() { + val decoded = CordnDeviceDocument.decode(CordnDeviceDocument.encode(group)) as CordnGroupDocument + + assertEquals(group, decoded) + } + + @Test + fun `a meta document round-trips every field`() { + val meta = + CordnMetaDocument( + removed = listOf(CordnTombstone("gone", 7)), + lastResortKeyPackage = CordnLastResortKeyPackage("a2s=", "cHJpdg=="), + issuedAt = 99, + ) + + assertEquals(meta, CordnDeviceDocument.decode(CordnDeviceDocument.encode(meta))) + } + + @Test + fun `an empty meta document round-trips`() { + // The normal migration case for an account with no tombstones and no + // published last-resort key package. + assertEquals(CordnMetaDocument(), CordnDeviceDocument.decode(CordnDeviceDocument.encode(CordnMetaDocument()))) + } + + @Test + fun `the draft, the read position and the via-request flag survive`() { + // CordnBackup's Archive carries none of these three, so a restore there + // silently returns every room unread with the half-typed message gone. + // Migration must not repeat that, which is why they are on the document. + val decoded = CordnDeviceDocument.decode(CordnDeviceDocument.encode(group)) as CordnGroupDocument + + assertEquals("cm9vbQ==", decoded.roomState) + assertEquals("ZWNobw==", decoded.echoState) + assertTrue(decoded.joinedViaRequest) + } + + @Test + fun `an unknown schema version is rejected`() { + val bumped = CordnDeviceDocument.encode(group).replace("\"schemaVersion\":1", "\"schemaVersion\":2") + + val thrown = assertThrows(CordnDocumentException::class.java) { CordnDeviceDocument.decode(bumped) } + assertTrue(thrown.message!!.contains("schemaVersion")) + } + + @Test + fun `an unknown document type is rejected`() { + val retyped = CordnDeviceDocument.encode(group).replace("\"type\":\"group\"", "\"type\":\"ledger\"") + + assertThrows(CordnDocumentException::class.java) { CordnDeviceDocument.decode(retyped) } + } + + @Test + fun `a half lastResortKeyPackage is rejected rather than half-loaded`() { + // Both halves are needed at join time. Keeping the public half alone + // would resolve an incoming Welcome and then fail to open it. + val half = """{"schemaVersion":1,"type":"meta","issuedAt":0,"lastResortKeyPackage":{"keyPackage":"a2s="}}""" + + assertThrows(CordnDocumentException::class.java) { CordnDeviceDocument.decode(half) } + } + + @Test + fun `unknown fields are ignored, so a newer writer does not break this reader`() { + val extended = CordnDeviceDocument.encode(group).dropLast(1) + ""","somethingNewer":{"a":1}}""" + + assertEquals(group, CordnDeviceDocument.decode(extended)) + } + + @Test + fun `a foreign clientState is flagged rather than handed to the engine`() { + // The spec leaves clientState library-private on purpose, so a ts-mls + // document is unreadable here. Without the marker that surfaces as a + // crash inside MLS; with it, a caller can refuse with a reason. + val theirs = CordnDeviceDocument.encode(group).replace(CordnDeviceDocument.CLIENT_STATE_FORMAT, "ts-mls") + + val decoded = CordnDeviceDocument.decode(theirs) as CordnGroupDocument + assertFalse(decoded.isReadableHere) + assertTrue((CordnDeviceDocument.decode(CordnDeviceDocument.encode(group)) as CordnGroupDocument).isReadableHere) + } + + @Test + fun `a document that predates the marker is not assumed to be ours`() { + val unmarked = CordnDeviceDocument.encode(group.copy(clientStateFormat = null)) + + val decoded = CordnDeviceDocument.decode(unmarked) as CordnGroupDocument + assertNull(decoded.clientStateFormat) + assertFalse(decoded.isReadableHere) + } + + @Test + fun `a sealed document round-trips and the address is over the ciphertext`() { + val dek = KeyPair() + + val blob = CordnDocumentSeal.seal(group, dek) + + assertEquals(group, CordnDocumentSeal.open(blob, dek)) + assertTrue(CordnDocumentSeal.verifyAddress(blob, CordnDocumentSeal.address(blob))) + } + + @Test + fun `the same document seals to a different address every time`() { + // NIP-44 salts randomly, so the address cannot be used for dedup — §5 + // says so, and a caller that assumed otherwise would skip republishing + // a document whose content had genuinely changed. + val dek = KeyPair() + + val first = CordnDocumentSeal.seal(group, dek) + val second = CordnDocumentSeal.seal(group, dek) + + assertNotEquals(CordnDocumentSeal.address(first), CordnDocumentSeal.address(second)) + assertEquals(CordnDocumentSeal.open(first, dek), CordnDocumentSeal.open(second, dek)) + } + + @Test + fun `another DEK does not open the document`() { + val blob = CordnDocumentSeal.seal(group, KeyPair()) + + assertThrows(CordnDocumentException::class.java) { CordnDocumentSeal.open(blob, KeyPair()) } + } + + @Test + fun `a blob that does not match its advertised address is refused`() { + // The §6 check. It is what stops a content store from substituting a + // blob the tip never named. + val dek = KeyPair() + val blob = CordnDocumentSeal.seal(group, dek) + val other = CordnDocumentSeal.seal(group.copy(gid = "gid-2"), dek) + + assertFalse(CordnDocumentSeal.verifyAddress(other, CordnDocumentSeal.address(blob))) + } + + @Test + fun `the plaintext never contains the state in the clear`() { + val dek = KeyPair() + + val blob = CordnDocumentSeal.seal(group, dek).decodeToString() + + assertFalse(blob.contains("c3RhdGU=")) + assertFalse(blob.contains("gid-1")) + } +} diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/cordn/appMultiDevice/CordnDeviceTipTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/cordn/appMultiDevice/CordnDeviceTipTest.kt new file mode 100644 index 0000000000..bf1f7f3bab --- /dev/null +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/cordn/appMultiDevice/CordnDeviceTipTest.kt @@ -0,0 +1,150 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.appMultiDevice + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.Tag +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The §6 tip inventory. + * + * The tip is the only authenticity root in this design — documents carry no + * signature — so the cases here are about refusing an inventory a device + * cannot act on, rather than salvaging part of it. + */ +class CordnDeviceTipTest { + private val dek = "ab".repeat(32) + + private val inventory = + CordnTipInventory( + groups = + listOf( + CordnTipEntry(address = "11".repeat(32), gid = "gid-1"), + CordnTipEntry(address = "22".repeat(32), gid = "gid-2"), + ), + meta = "33".repeat(32), + dekPrivateKey = dek, + servers = listOf("https://first.example", "https://second.example"), + ) + + @Test + fun `an inventory round-trips through the inner event tags`() { + assertEquals(inventory, CordnDeviceTip.parse(innerEvent(CordnDeviceTip.tags(inventory)))) + } + + @Test + fun `server order is preserved, because a reader tries them in order`() { + val parsed = CordnDeviceTip.parse(innerEvent(CordnDeviceTip.tags(inventory))) + + assertEquals(listOf("https://first.example", "https://second.example"), parsed.servers) + } + + @Test + fun `an inventory with no groups is legal`() { + // An account that holds no cordn groups still has a meta document and + // a DEK, and a migration of it should succeed with nothing to seed. + val empty = inventory.copy(groups = emptyList()) + + assertEquals(empty, CordnDeviceTip.parse(innerEvent(CordnDeviceTip.tags(empty)))) + } + + @Test + fun `a missing dek is refused`() { + // Without it every listed document is unreadable, so there is no + // partial success to fall back to. + val tags = CordnDeviceTip.tags(inventory).filterNot { it[0] == CordnDeviceTip.TAG_DEK }.toTypedArray() + + assertThrows(CordnDocumentException::class.java) { CordnDeviceTip.parse(innerEvent(tags)) } + } + + @Test + fun `a malformed dek is refused`() { + val tags = arrayOf(arrayOf(CordnDeviceTip.TAG_DEK, "nothex")) + + val thrown = assertThrows(CordnDocumentException::class.java) { CordnDeviceTip.parse(innerEvent(tags)) } + assertTrue(thrown.message!!.contains("64 hex")) + } + + @Test + fun `a group entry with no gid is refused rather than skipped`() { + // Skipping it would silently drop a group from the migration, which + // the user discovers as a missing conversation on the new phone. + val tags = + arrayOf( + arrayOf(CordnDeviceTip.TAG_X, "11".repeat(32), CordnDeviceTip.KIND_GROUP), + arrayOf(CordnDeviceTip.TAG_DEK, dek), + ) + + assertThrows(CordnDocumentException::class.java) { CordnDeviceTip.parse(innerEvent(tags)) } + } + + @Test + fun `the wrong inner kind is refused`() { + val wrong = innerEvent(CordnDeviceTip.tags(inventory)).let { Event(it.id, it.pubKey, it.createdAt, 1, it.tags, it.content, it.sig) } + + assertThrows(CordnDocumentException::class.java) { CordnDeviceTip.parse(wrong) } + } + + @Test + fun `unknown tags are ignored`() { + val tags = CordnDeviceTip.tags(inventory) + arrayOf(arrayOf("future", "value")) + + assertEquals(inventory, CordnDeviceTip.parse(innerEvent(tags))) + } + + @Test + fun `a second meta entry does not displace the first`() { + val tags = + CordnDeviceTip.tags(inventory) + arrayOf(arrayOf(CordnDeviceTip.TAG_X, "99".repeat(32), CordnDeviceTip.KIND_META)) + + assertEquals("33".repeat(32), CordnDeviceTip.parse(innerEvent(tags)).meta) + } + + @Test + fun `an inventory with no meta entry parses`() { + val noMeta = inventory.copy(meta = null) + + assertNull(CordnDeviceTip.parse(innerEvent(CordnDeviceTip.tags(noMeta))).meta) + } + + @Test + fun `the dek is normalised to lowercase`() { + val tags = arrayOf(arrayOf(CordnDeviceTip.TAG_DEK, dek.uppercase())) + + assertEquals(dek, CordnDeviceTip.parse(innerEvent(tags)).dekPrivateKey) + } + + private fun innerEvent(tags: Array) = + Event( + id = "00".repeat(32), + pubKey = "cc".repeat(32), + createdAt = 1, + kind = CordnDeviceTip.INNER_KIND, + tags = tags, + content = "", + sig = "00".repeat(32), + ) +} diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/cordn/appMultiDevice/CordnHandoffCodeTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/cordn/appMultiDevice/CordnHandoffCodeTest.kt new file mode 100644 index 0000000000..bf5c30d05a --- /dev/null +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/cordn/appMultiDevice/CordnHandoffCodeTest.kt @@ -0,0 +1,183 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.cordn.appMultiDevice + +import com.vitorpamplona.quartz.nip19Bech32.bech32.Bech32 +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The scannable code that points a new phone at the old phone's tip. + * + * This is the one artefact a user physically handles, so the cases worth + * pinning are about what a photographed or mis-scanned code can do: it must + * carry no owner key material, it must not silently lose a relay to + * truncation, and by default it must not grant the ability to move the tip. + */ +class CordnHandoffCodeTest { + private fun tlv( + type: Byte, + value: ByteArray, + ) = byteArrayOf(type, value.size.toByte()) + value + + private fun bech32(payload: ByteArray) = Bech32.encodeBytes(CordnHandoffCode.HRP, payload, Bech32.Encoding.Bech32) + + private val code = + CordnHandoffCode( + ephemeralPubKey = "aa".repeat(32), + dTag = "opaque-d", + relays = listOf("wss://one.example", "wss://two.example"), + ) + + @Test + fun `a code round-trips`() { + assertEquals(code, CordnHandoffCode.decode(code.encode())) + } + + @Test + fun `a code is a cordndev string`() { + assertTrue(code.encode().startsWith("cordndev1")) + } + + @Test + fun `relay order survives`() { + assertEquals(listOf("wss://one.example", "wss://two.example"), CordnHandoffCode.decode(code.encode()).relays) + } + + @Test + fun `a code grants no write by default`() { + // A photographed QR should be worth nothing. Without the write key the + // code names an event anyone could already fetch and only the owner can + // decrypt, so a leak reveals nothing and cannot move the tip. + assertFalse(code.grantsWrite) + assertNull(CordnHandoffCode.decode(code.encode()).writeKey) + } + + @Test + fun `a write-granting code round-trips and says so`() { + val writable = code.copy(writeKey = "bb".repeat(32)) + + val decoded = CordnHandoffCode.decode(writable.encode()) + assertTrue(decoded.grantsWrite) + assertEquals("bb".repeat(32), decoded.writeKey) + } + + @Test + fun `readOnly strips the write key and keeps everything else`() { + val writable = code.copy(writeKey = "bb".repeat(32)) + + assertEquals(code, writable.readOnly()) + assertFalse(writable.readOnly().grantsWrite) + } + + @Test + fun `readOnly on an already read-only code is the same code`() { + assertEquals(code, code.readOnly()) + } + + @Test + fun `the kind travels, so changing it later cannot strand old codes`() { + val odd = code.copy(kind = 31078) + + assertEquals(31078, CordnHandoffCode.decode(odd.encode()).kind) + } + + @Test + fun `a code with no relays is refused at construction`() { + assertThrows(IllegalArgumentException::class.java) { code.copy(relays = emptyList()) } + } + + @Test + fun `a truncated trailing relay is refused, not silently dropped`() { + // The failure this strictness exists to prevent. The payload is hand + // built so the truncation lands on an OPTIONAL tuple: chopping the + // encoder's own output instead removes the pubkey, which both a strict + // and a lenient parser reject, so it discriminates nothing. (The first + // two versions of this test made exactly that mistake and passed + // against a deliberately lenient parser.) + val relay = "wss://two.example".encodeToByteArray() + val full = + tlv(CordnHandoffCode.TLV_PUBKEY, ByteArray(32) { 0xAA.toByte() }) + + tlv(CordnHandoffCode.TLV_D, "opaque-d".encodeToByteArray()) + + tlv(CordnHandoffCode.TLV_RELAY, "wss://one.example".encodeToByteArray()) + + tlv(CordnHandoffCode.TLV_RELAY, relay) + + // Sanity: intact, it decodes with both relays. + assertEquals(2, CordnHandoffCode.decode(bech32(full)).relays.size) + + val truncated = full.copyOfRange(0, full.size - 4) + + assertNull(CordnHandoffCode.decodeOrNull(bech32(truncated))) + } + + @Test + fun `a relay whose declared length overruns the payload is refused`() { + val full = + tlv(CordnHandoffCode.TLV_PUBKEY, ByteArray(32) { 0xAA.toByte() }) + + tlv(CordnHandoffCode.TLV_D, "opaque-d".encodeToByteArray()) + + tlv(CordnHandoffCode.TLV_RELAY, "wss://one.example".encodeToByteArray()) + // Inflate the relay tuple's declared length past the end of the buffer. + val lying = full.copyOf().also { it[full.size - "wss://one.example".length - 1] = 0xFF.toByte() } + + assertNull(CordnHandoffCode.decodeOrNull(bech32(lying))) + } + + @Test + fun `a mixed-case code is refused`() { + val mixed = code.encode().replaceFirst("cordndev1", "CordnDev1") + + assertThrows(IllegalArgumentException::class.java) { CordnHandoffCode.decode(mixed) } + } + + @Test + fun `an uppercase code is accepted, because bech32 allows it`() { + assertEquals(code, CordnHandoffCode.decode(code.encode().uppercase())) + } + + @Test + fun `a cordn group ref is not a handoff code`() { + assertNull(CordnHandoffCode.decodeOrNull("cordn1qqqqqq")) + } + + @Test + fun `a bad pubkey length is refused at construction`() { + assertThrows(IllegalArgumentException::class.java) { code.copy(ephemeralPubKey = "aa") } + } + + @Test + fun `an empty d tag is refused`() { + assertThrows(IllegalArgumentException::class.java) { code.copy(dTag = "") } + } + + @Test + fun `the code carries nothing that could be an owner key`() { + // §4.3: the documents do not provision identity, and neither does this. + // The user signs in on the new phone by their usual means first. + val writable = code.copy(writeKey = "bb".repeat(32)) + + val fields = listOf(writable.ephemeralPubKey, writable.dTag, writable.writeKey!!) + writable.relays + assertEquals(setOf("aa".repeat(32), "opaque-d", "bb".repeat(32), "wss://one.example", "wss://two.example"), fields.toSet()) + } +} diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsAuthorsMissingOutboxTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsAuthorsMissingOutboxTest.kt index 09238d03b2..1d57a7d042 100644 --- a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsAuthorsMissingOutboxTest.kt +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsAuthorsMissingOutboxTest.kt @@ -21,7 +21,7 @@ package com.vitorpamplona.quartz.nip01Core.store.fs import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync -import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent @@ -95,7 +95,7 @@ class FsAuthorsMissingOutboxTest { store.insert(relayList) assertEquals(emptySet(), store.authorsMissingOutbox().toSet()) - store.insert(signer.sign(DeletionEvent.build(listOf(relayList)))) + store.insert(signer.sign(DeletionRequestEvent.build(listOf(relayList)))) assertEquals(setOf(signer.pubKey), store.authorsMissingOutbox().toSet()) } } diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsDeletionTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsDeletionTest.kt index 88cc2d05b5..cbb23808b2 100644 --- a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsDeletionTest.kt +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsDeletionTest.kt @@ -22,9 +22,9 @@ package com.vitorpamplona.quartz.nip01Core.store.fs import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync -import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent import kotlinx.coroutines.runBlocking import java.nio.file.Files import java.nio.file.Path @@ -67,9 +67,9 @@ class FsDeletionTest { body: String, ts: Long, signer: NostrSignerSync = this.signer, - ) = signer.sign( + ) = signer.sign( createdAt = ts, - kind = LongTextNoteEvent.KIND, + kind = LongFormContentEvent.KIND, tags = arrayOf(arrayOf("d", slug)), content = body, ) @@ -92,12 +92,12 @@ class FsDeletionTest { store.insert(n1) store.insert(n2) - val del = signer.sign(DeletionEvent.build(listOf(n1), createdAt = 30)) + val del = signer.sign(DeletionRequestEvent.build(listOf(n1), createdAt = 30)) store.insert(del) assertEquals(emptyList(), store.query(Filter(ids = listOf(n1.id))).map { it.id }) assertEquals(listOf(n2.id), store.query(Filter(ids = listOf(n2.id))).map { it.id }) - assertEquals(listOf(del.id), store.query(Filter(ids = listOf(del.id))).map { it.id }) + assertEquals(listOf(del.id), store.query(Filter(ids = listOf(del.id))).map { it.id }) } @Test @@ -106,7 +106,7 @@ class FsDeletionTest { val n1 = note("one", 10) store.insert(n1) - val del = signer.sign(DeletionEvent.build(listOf(n1), createdAt = 30)) + val del = signer.sign(DeletionRequestEvent.build(listOf(n1), createdAt = 30)) store.insert(del) store.insert(n1) // should be blocked @@ -121,7 +121,7 @@ class FsDeletionTest { store.insert(theirs) // Our signer attempts to delete it. - val del = signer.sign(DeletionEvent.build(listOf(theirs), createdAt = 30)) + val del = signer.sign(DeletionRequestEvent.build(listOf(theirs), createdAt = 30)) store.insert(del) // Cascade did NOT run — not our author. @@ -150,14 +150,14 @@ class FsDeletionTest { store.insert(v1) store.insert(v2) - val del = signer.sign(DeletionEvent.build(listOf(v2), createdAt = 30)) + val del = signer.sign(DeletionRequestEvent.build(listOf(v2), createdAt = 30)) store.insert(del) // Slot cleared, canonical removed, indexes gone. val dHash = FsLayout.sha256Hex("intro") - val slot = root.resolve("addressable/${LongTextNoteEvent.KIND}/${signer.pubKey}/$dHash.json") + val slot = root.resolve("addressable/${LongFormContentEvent.KIND}/${signer.pubKey}/$dHash.json") assertFalse(slot.exists(), "addressable slot should be cleared") - assertEquals(emptyList(), store.query(Filter(authors = listOf(signer.pubKey), kinds = listOf(LongTextNoteEvent.KIND))).map { it.id }) + assertEquals(emptyList(), store.query(Filter(authors = listOf(signer.pubKey), kinds = listOf(LongFormContentEvent.KIND))).map { it.id }) } @Test @@ -166,14 +166,14 @@ class FsDeletionTest { val v1 = article("intro", "draft 1", 10) store.insert(v1) - val del = signer.sign(DeletionEvent.build(listOf(v1), createdAt = 20)) + val del = signer.sign(DeletionRequestEvent.build(listOf(v1), createdAt = 20)) store.insert(del) // A newer addressable at the same address should still be accepted. val v3 = article("intro", "draft 3", 30) store.insert(v3) - val got = store.query(Filter(authors = listOf(signer.pubKey), kinds = listOf(LongTextNoteEvent.KIND))) + val got = store.query(Filter(authors = listOf(signer.pubKey), kinds = listOf(LongFormContentEvent.KIND))) assertEquals(listOf(v3.id), got.map { it.id }) } @@ -183,13 +183,13 @@ class FsDeletionTest { val v1 = article("intro", "draft 1", 10) store.insert(v1) - val del = signer.sign(DeletionEvent.build(listOf(v1), createdAt = 20)) + val del = signer.sign(DeletionRequestEvent.build(listOf(v1), createdAt = 20)) store.insert(del) // Attempting to re-insert an event authored earlier than the deletion should fail. val older = article("intro", "even-older", 5) store.insert(older) - assertEquals(emptyList(), store.query(Filter(ids = listOf(older.id))).map { it.id }) + assertEquals(emptyList(), store.query(Filter(ids = listOf(older.id))).map { it.id }) } @Test @@ -198,12 +198,12 @@ class FsDeletionTest { val v = article("intro", "v", 10) store.insert(v) - val del = signer.sign(DeletionEvent.build(listOf(v), createdAt = 15)) + val del = signer.sign(DeletionRequestEvent.build(listOf(v), createdAt = 15)) store.insert(del) val equal = article("intro", "equal", 15) store.insert(equal) - assertEquals(emptyList(), store.query(Filter(ids = listOf(equal.id))).map { it.id }) + assertEquals(emptyList(), store.query(Filter(ids = listOf(equal.id))).map { it.id }) } // ------------------------------------------------------------------ @@ -216,18 +216,18 @@ class FsDeletionTest { val v = article("intro", "v", 10) store.insert(v) - val del1 = signer.sign(DeletionEvent.build(listOf(v), createdAt = 20)) + val del1 = signer.sign(DeletionRequestEvent.build(listOf(v), createdAt = 20)) store.insert(del1) val del2Target = article("intro", "v2", 30) // inserted only to give del2 a target store.insert(del2Target) - val del2 = signer.sign(DeletionEvent.build(listOf(del2Target), createdAt = 40)) + val del2 = signer.sign(DeletionRequestEvent.build(listOf(del2Target), createdAt = 40)) store.insert(del2) // Cutoff should now be 40, so an event at createdAt=35 is blocked. val mid = article("intro", "mid", 35) store.insert(mid) - assertEquals(emptyList(), store.query(Filter(ids = listOf(mid.id))).map { it.id }) + assertEquals(emptyList(), store.query(Filter(ids = listOf(mid.id))).map { it.id }) } @Test @@ -238,23 +238,23 @@ class FsDeletionTest { store.insert(v1) store.insert(v2) - val strongDel = signer.sign(DeletionEvent.build(listOf(v2), createdAt = 100)) + val strongDel = signer.sign(DeletionRequestEvent.build(listOf(v2), createdAt = 100)) store.insert(strongDel) // Now insert a weaker (earlier) deletion for the same address. val weakTarget = article("slug", "target-for-weak", 30) store.insert(weakTarget) // this passes? No: cutoff=100, target@30 is blocked. Actually we want to - // construct a DeletionEvent that targets the slug address directly. The simplest way: + // construct a DeletionRequestEvent that targets the slug address directly. The simplest way: val weakDel = - signer.sign( - DeletionEvent.buildAddressOnly(listOf(v1), createdAt = 50), + signer.sign( + DeletionRequestEvent.buildAddressOnly(listOf(v1), createdAt = 50), ) store.insert(weakDel) // Cutoff should still be 100 — an event at 60 must still be blocked. val blocked = article("slug", "should-be-blocked", 60) store.insert(blocked) - assertEquals(emptyList(), store.query(Filter(ids = listOf(blocked.id))).map { it.id }) + assertEquals(emptyList(), store.query(Filter(ids = listOf(blocked.id))).map { it.id }) } // ------------------------------------------------------------------ @@ -266,10 +266,10 @@ class FsDeletionTest { runBlocking { val n = note("x", 10) store.insert(n) - val del = signer.sign(DeletionEvent.build(listOf(n), createdAt = 20)) + val del = signer.sign(DeletionRequestEvent.build(listOf(n), createdAt = 20)) store.insert(del) - val byKind = store.query(Filter(kinds = listOf(DeletionEvent.KIND))) + val byKind = store.query(Filter(kinds = listOf(DeletionRequestEvent.KIND))) assertEquals(listOf(del.id), byKind.map { it.id }) } @@ -289,10 +289,10 @@ class FsDeletionTest { // `event_tags.pubkey_hash = NEW.pubkey_owner_hash` guard. val v1 = otherArticle("shared", "v1", 10) store.insert(v1) - assertEquals(listOf(v1.id), store.query(Filter(ids = listOf(v1.id))).map { it.id }) + assertEquals(listOf(v1.id), store.query(Filter(ids = listOf(v1.id))).map { it.id }) val strangerDel = - signer.sign(DeletionEvent.build(listOf(v1), createdAt = 20)) + signer.sign(DeletionRequestEvent.build(listOf(v1), createdAt = 20)) store.insert(strangerDel) // Bob can still publish a newer version at the same address. Since @@ -300,7 +300,7 @@ class FsDeletionTest { // exists to block. val v2 = otherArticle("shared", "v2", 30) store.insert(v2) - assertEquals(listOf(v2.id), store.query(Filter(ids = listOf(v2.id))).map { it.id }) + assertEquals(listOf(v2.id), store.query(Filter(ids = listOf(v2.id))).map { it.id }) } @Test @@ -308,7 +308,7 @@ class FsDeletionTest { runBlocking { val n = note("x", 10) store.insert(n) - val del = signer.sign(DeletionEvent.build(listOf(n), createdAt = 20)) + val del = signer.sign(DeletionRequestEvent.build(listOf(n), createdAt = 20)) store.insert(del) val tomb = root.resolve("tombstones/id/${n.id}.json") diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsParityTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsParityTest.kt index a9603f8996..0a79e052d5 100644 --- a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsParityTest.kt +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsParityTest.kt @@ -25,9 +25,9 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore -import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent import kotlinx.coroutines.runBlocking import java.nio.file.Files import java.nio.file.Path @@ -121,10 +121,10 @@ class FsParityTest { slug: String, body: String, ts: Long, - ): LongTextNoteEvent = + ): LongFormContentEvent = signer.sign( createdAt = ts, - kind = LongTextNoteEvent.KIND, + kind = LongFormContentEvent.KIND, tags = arrayOf(arrayOf("d", slug)), content = body, ) @@ -234,7 +234,7 @@ class FsParityTest { insertBoth(v2) insertBoth(v3) - assertParity(Filter(authors = listOf(signer.pubKey), kinds = listOf(LongTextNoteEvent.KIND))) + assertParity(Filter(authors = listOf(signer.pubKey), kinds = listOf(LongFormContentEvent.KIND))) } @Test @@ -284,7 +284,7 @@ class FsParityTest { assertParity( Filter( authors = listOf(signer.pubKey), - kinds = listOf(LongTextNoteEvent.KIND), + kinds = listOf(LongFormContentEvent.KIND), tags = mapOf("d" to listOf("tie")), ), ) @@ -303,12 +303,12 @@ class FsParityTest { insertBoth(a) insertBoth(b) - val del = signer.sign(DeletionEvent.build(listOf(a), createdAt = 30)) + val del = signer.sign(DeletionRequestEvent.build(listOf(a), createdAt = 30)) insertBoth(del) assertParity(Filter(ids = listOf(a.id))) assertParity(Filter(ids = listOf(b.id))) - assertParity(Filter(kinds = listOf(DeletionEvent.KIND))) + assertParity(Filter(kinds = listOf(DeletionRequestEvent.KIND))) // Re-insert blocked. insertBoth(a) @@ -321,16 +321,16 @@ class FsParityTest { val v = article("intro", "v1", 10) insertBoth(v) - val del = signer.sign(DeletionEvent.build(listOf(v), createdAt = 20)) + val del = signer.sign(DeletionRequestEvent.build(listOf(v), createdAt = 20)) insertBoth(del) - assertParity(Filter(authors = listOf(signer.pubKey), kinds = listOf(LongTextNoteEvent.KIND))) + assertParity(Filter(authors = listOf(signer.pubKey), kinds = listOf(LongFormContentEvent.KIND))) // Older event at this address must be blocked, newer must pass. insertBoth(article("intro", "older", 5)) insertBoth(article("intro", "newer", 100)) - assertParity(Filter(authors = listOf(signer.pubKey), kinds = listOf(LongTextNoteEvent.KIND))) + assertParity(Filter(authors = listOf(signer.pubKey), kinds = listOf(LongFormContentEvent.KIND))) } // ------------------------------------------------------------------ @@ -429,7 +429,7 @@ class FsParityTest { val artB = article("b", "B v1", 30) val artBv2 = article("b", "B v2", 50) // Deletion of n1 - val del = signer.sign(DeletionEvent.build(listOf(n1), createdAt = 40)) + val del = signer.sign(DeletionRequestEvent.build(listOf(n1), createdAt = 40)) listOf(n1, n2, meta1, meta2, artA, artB, artBv2, del).forEach { insertBoth(it) } @@ -437,8 +437,8 @@ class FsParityTest { assertParity(Filter(ids = listOf(n1.id)), "n1 deleted") assertParity(Filter(ids = listOf(n2.id)), "n2 alive") assertParity(Filter(authors = listOf(signer.pubKey), kinds = listOf(0)), "metadata winner") - assertParity(Filter(authors = listOf(signer.pubKey), kinds = listOf(LongTextNoteEvent.KIND)), "articles set") - assertParity(Filter(authors = listOf(signer.pubKey), kinds = listOf(DeletionEvent.KIND)), "deletion present") + assertParity(Filter(authors = listOf(signer.pubKey), kinds = listOf(LongFormContentEvent.KIND)), "articles set") + assertParity(Filter(authors = listOf(signer.pubKey), kinds = listOf(DeletionRequestEvent.KIND)), "deletion present") } // ------------------------------------------------------------------ diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsSearchTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsSearchTest.kt index ccd9400ae9..ddfdadacb0 100644 --- a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsSearchTest.kt +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsSearchTest.kt @@ -24,7 +24,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent import kotlinx.coroutines.runBlocking import java.nio.file.Files import java.nio.file.Path @@ -166,7 +166,7 @@ class FsSearchTest { val n = note("uniqnote bitcoin", ts = 100) val long = signer.sign( - LongTextNoteEvent.build( + LongFormContentEvent.build( "uniqlong body", title = "title", dTag = "d1", diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsSlotsTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsSlotsTest.kt index 26ac6f6463..4ebda49500 100644 --- a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsSlotsTest.kt +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsSlotsTest.kt @@ -24,7 +24,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent import kotlinx.coroutines.runBlocking import java.nio.file.Files import java.nio.file.Path @@ -224,10 +224,10 @@ class FsSlotsTest { } val got = - store.query( + store.query( Filter( authors = listOf(signer.pubKey), - kinds = listOf(LongTextNoteEvent.KIND), + kinds = listOf(LongFormContentEvent.KIND), tags = mapOf("d" to listOf("intro")), ), ) @@ -255,10 +255,10 @@ class FsSlotsTest { slug: String, body: String, createdAt: Long, - ): LongTextNoteEvent = + ): LongFormContentEvent = signer.sign( createdAt = createdAt, - kind = LongTextNoteEvent.KIND, + kind = LongFormContentEvent.KIND, tags = arrayOf(arrayOf("d", slug)), content = body, ) @@ -271,7 +271,7 @@ class FsSlotsTest { store.insert(v1) store.insert(v2) - val got = store.query(Filter(authors = listOf(signer.pubKey), kinds = listOf(LongTextNoteEvent.KIND))) + val got = store.query(Filter(authors = listOf(signer.pubKey), kinds = listOf(LongFormContentEvent.KIND))) assertEquals(listOf(v2.id), got.map { it.id }) assertFalse(store.hasCanonical(v1.id), "older draft canonical should be removed") } @@ -284,7 +284,7 @@ class FsSlotsTest { store.insert(intro) store.insert(about) - val got = store.query(Filter(authors = listOf(signer.pubKey), kinds = listOf(LongTextNoteEvent.KIND))) + val got = store.query(Filter(authors = listOf(signer.pubKey), kinds = listOf(LongFormContentEvent.KIND))) assertEquals(setOf(intro.id, about.id), got.map { it.id }.toSet()) } @@ -296,7 +296,7 @@ class FsSlotsTest { store.insert(newer) store.insert(older) - val got = store.query(Filter(authors = listOf(signer.pubKey))) + val got = store.query(Filter(authors = listOf(signer.pubKey))) assertEquals(listOf(newer.id), got.map { it.id }) } @@ -307,7 +307,7 @@ class FsSlotsTest { store.insert(v) val dHash = FsLayout.sha256Hex("intro") - val slot = root.resolve("addressable/${LongTextNoteEvent.KIND}/${signer.pubKey}/$dHash.json") + val slot = root.resolve("addressable/${LongFormContentEvent.KIND}/${signer.pubKey}/$dHash.json") assertTrue(slot.exists()) val parsed = Event.fromJson(slot.readText()) assertEquals(v.id, parsed.id) @@ -320,7 +320,7 @@ class FsSlotsTest { store.insert(v) val dHash = FsLayout.sha256Hex("") - val slot = root.resolve("addressable/${LongTextNoteEvent.KIND}/${signer.pubKey}/$dHash.json") + val slot = root.resolve("addressable/${LongFormContentEvent.KIND}/${signer.pubKey}/$dHash.json") assertTrue(slot.exists()) } @@ -330,7 +330,7 @@ class FsSlotsTest { val v = article("intro", "hello", 10) store.insert(v) val dHash = FsLayout.sha256Hex("intro") - val slot = root.resolve("addressable/${LongTextNoteEvent.KIND}/${signer.pubKey}/$dHash.json") + val slot = root.resolve("addressable/${LongFormContentEvent.KIND}/${signer.pubKey}/$dHash.json") assertTrue(slot.exists()) store.delete(v.id) diff --git a/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/Ed25519.linux.kt b/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/mls/crypto/Ed25519.linux.kt similarity index 98% rename from quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/Ed25519.linux.kt rename to quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/mls/crypto/Ed25519.linux.kt index efc7e36298..bf9061c8f6 100644 --- a/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/Ed25519.linux.kt +++ b/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/mls/crypto/Ed25519.linux.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.crypto +package com.vitorpamplona.quartz.mls.crypto import com.vitorpamplona.quartz.utils.RandomInstance import io.github.andreypfau.kotlinx.crypto.Sha512 @@ -115,7 +115,7 @@ actual object Ed25519 { } actual fun keyPairFromSeed(seed: ByteArray): Ed25519KeyPair { - require(seed.size == SEED_LENGTH) { "Seed must be 32 bytes" } + require(seed.size == SEED_LENGTH) { "Ed25519 seed must be $SEED_LENGTH bytes, was ${seed.size}" } val publicKey = derivePublicKey(seed) return Ed25519KeyPair(seed + publicKey, publicKey) } diff --git a/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/X25519.linux.kt b/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/mls/crypto/X25519.linux.kt similarity index 99% rename from quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/X25519.linux.kt rename to quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/mls/crypto/X25519.linux.kt index 413eb64faa..a8a4188344 100644 --- a/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/X25519.linux.kt +++ b/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/mls/crypto/X25519.linux.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.marmot.mls.crypto +package com.vitorpamplona.quartz.mls.crypto import com.vitorpamplona.quartz.utils.RandomInstance diff --git a/quartz/tools/cordn-vector-gen/.gitignore b/quartz/tools/cordn-vector-gen/.gitignore new file mode 100644 index 0000000000..504afef81f --- /dev/null +++ b/quartz/tools/cordn-vector-gen/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +package-lock.json diff --git a/quartz/tools/cordn-vector-gen/README.md b/quartz/tools/cordn-vector-gen/README.md new file mode 100644 index 0000000000..7ff28db5d3 --- /dev/null +++ b/quartz/tools/cordn-vector-gen/README.md @@ -0,0 +1,50 @@ +# cordn-vector-gen + +Emits `quartz/src/commonTest/resources/cordn/coordinator-contracts.json` from +**`@cordn/core`** — the package cordn's own reference coordinator and client +both import. Consumed by `CoordinatorContractVectorTest` and +`CordnGroupRefVectorTest`. + +## Why this exists + +Our ts-mls fixtures (`resources/tsmls/`) cover the *crypto* layer: KeyPackages, +Welcomes, exporters, sealed payloads. They say nothing about the layer above — +the eleven coordinator tools, their argument names, which fields are optional, +and the `cordn1…` group ref. That layer had been verified only against our +reading of the spec, which is exactly the kind of agreement that holds right up +until someone runs a real coordinator. + +So this generator goes the other way: it takes the payloads **we** put on the +wire and hands each one to **their** zod schema. A field we named wrong fails at +generation time. Each method also carries `rejects` — payloads their schema must +refuse — because a positive result only means something if the schema is strict +where we assume it is. + +Group refs are round-tripped through their bech32 codec, and the vectors pin +both directions: we must decode what they encode, *and* encode byte-identically. +Only the second half catches TLV ordering, since the spec lets decoders accept +any order. + +## Regenerating + +``` +cd quartz/tools/cordn-vector-gen +npm install +node generate.mjs > ../../src/commonTest/resources/cordn/coordinator-contracts.json +``` + +Output is deterministic — fixed actors, fixed timestamps, no randomness — so a +regeneration that changes the file means cordn changed something. Commit the +result with the reason. + +## Licensing + +`@cordn/core` and `@cordn/cli` are **MIT** and each ship a LICENSE file; this +generator is a dev-only dependency on the first and ships in nothing. + +The rest of the `Cordn-msg/cordn` repository — `packages/coordinator`, +`packages/server`, `packages/test-utils`, and the `ghcr.io/cordn-msg/cordn` +image built from them — carries **no license file and no `license` field**, so +default copyright applies. That is why Tier B (a live coordinator round-trip) +is not wired up here and why these vectors are generated from the two licensed +packages instead. See `quartz/plans/2026-09-17-cordn-interop.md` §7. diff --git a/quartz/tools/cordn-vector-gen/generate.mjs b/quartz/tools/cordn-vector-gen/generate.mjs new file mode 100644 index 0000000000..959a17ca14 --- /dev/null +++ b/quartz/tools/cordn-vector-gen/generate.mjs @@ -0,0 +1,334 @@ +// Emits cordn coordinator-contract + group-ref vectors, validated by cordn's +// OWN schemas and codecs (`@cordn/core`, MIT). +// +// The point is direction: every sample below is what OUR Kotlin client puts on +// the wire, handed to THEIR zod schema. A shape we invented that their +// coordinator would reject fails here, at generation time, instead of silently +// in production. The negative samples do the converse — they prove the schema +// is actually strict where we assume it is, so a passing positive means +// something. +// +// Usage: npm install && node generate.mjs > ../../src/commonTest/resources/cordn/coordinator-contracts.json + +import { + COORDINATOR_METHODS, + publishKeyPackageInputSchema, + publishKeyPackageOutputSchema, + listAvailableKeyPackagesInputSchema, + listAvailableKeyPackagesOutputSchema, + consumeKeyPackageInputSchema, + consumeKeyPackageOutputSchema, + removeKeyPackagesInputSchema, + removeKeyPackagesOutputSchema, + fetchPendingWelcomesInputSchema, + fetchPendingWelcomesOutputSchema, + storeWelcomeInputSchema, + storeWelcomeOutputSchema, + storeJoinRequestInputSchema, + storeJoinRequestOutputSchema, + fetchManyPendingJoinRequestsInputSchema, + fetchManyPendingJoinRequestsOutputSchema, + postGroupMessageInputSchema, + postGroupMessageOutputSchema, + fetchManyGroupMessagesInputSchema, + fetchManyGroupMessagesOutputSchema, + subscribeManyGroupMessagesInputSchema, + subscribeManyGroupMessagesOutputSchema, + groupMessageSchema, + encodeGroupRef, + decodeGroupRef, + isGroupRef, +} from "@cordn/core"; +import { readFileSync } from "node:fs"; + +const coreVersion = JSON.parse( + readFileSync(new URL("./node_modules/@cordn/core/package.json", import.meta.url), "utf8"), +).version; + +// Deterministic actors. 64 lowercase hex, because that is what a cordn +// credential carries as 64 ASCII bytes (spec/00.md §13). +const ALICE = "a".repeat(64); +const BOB = "b".repeat(64); +const COORDINATOR = "c".repeat(64); +const GID = "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21"; +const KP_REF = "1f".repeat(16); +const KP_REF_2 = "2e".repeat(16); + +const failures = []; + +/** Validates `value` against `schema`, recording (not throwing) on mismatch. */ +function accepts(label, schema, value) { + const parsed = schema.safeParse(value); + if (!parsed.success) { + failures.push(`${label}: cordn's schema REJECTED a payload we send/expect — ${JSON.stringify(parsed.error.issues)}`); + } + return value; +} + +/** The converse: proves the schema really is strict about `label`. */ +function rejects(label, schema, value) { + const parsed = schema.safeParse(value); + if (parsed.success) { + failures.push(`${label}: cordn's schema ACCEPTED a payload we assume it rejects`); + } + return value; +} + +const contracts = { + [COORDINATOR_METHODS.publishKeyPackage]: { + input: accepts("kp_publish.input", publishKeyPackageInputSchema, { + kp_ref: KP_REF, + kp_64: "AAECAwQFBgc=", + }), + output: accepts("kp_publish.output", publishKeyPackageOutputSchema, { + kp_ref: KP_REF, + last_resort: false, + at: 1757000000, + }), + rejects: [ + rejects("kp_publish.input/no-ref", publishKeyPackageInputSchema, { kp_64: "AAECAwQFBgc=" }), + rejects("kp_publish.input/legacy-field", publishKeyPackageInputSchema, { + kp_ref: KP_REF, + keyPackageBase64: "AAECAwQFBgc=", + }), + ], + }, + [COORDINATOR_METHODS.listAvailableKeyPackages]: { + input: accepts("kp_list.input", listAvailableKeyPackagesInputSchema, {}), + output: accepts("kp_list.output", listAvailableKeyPackagesOutputSchema, { + keyPackages: [ + { pk: ALICE, kp_ref: KP_REF, last_resort: false, at: 1757000000 }, + { pk: BOB, kp_ref: KP_REF_2, last_resort: true, at: 1757000100 }, + ], + }), + rejects: [ + rejects("kp_list.output/missing-last_resort", listAvailableKeyPackagesOutputSchema, { + keyPackages: [{ pk: ALICE, kp_ref: KP_REF, at: 1757000000 }], + }), + ], + }, + [COORDINATOR_METHODS.consumeKeyPackage]: { + input: accepts("kp_take.input", consumeKeyPackageInputSchema, { id: KP_REF }), + output: accepts("kp_take.output", consumeKeyPackageOutputSchema, { + keyPackage: { + pk: BOB, + kp_ref: KP_REF_2, + last_resort: true, + at: 1757000100, + event: { + id: "0".repeat(64), + pubkey: BOB, + created_at: 1757000100, + kind: 25910, + tags: [["p", COORDINATOR]], + content: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { name: "kp_publish", arguments: { kp_ref: KP_REF_2, kp_64: "AAECAwQFBgc=" } }, + }), + sig: "0".repeat(128), + }, + }, + }), + // `keyPackage: null` is how the coordinator says "nothing matching" — our + // client returns null rather than raising, so pin that it is legal. + emptyOutput: accepts("kp_take.output/null", consumeKeyPackageOutputSchema, { keyPackage: null }), + rejects: [ + rejects("kp_take.output/no-event", consumeKeyPackageOutputSchema, { + keyPackage: { pk: BOB, kp_ref: KP_REF_2, last_resort: true, at: 1757000100 }, + }), + ], + }, + [COORDINATOR_METHODS.removeKeyPackages]: { + input: accepts("kp_remove.input", removeKeyPackagesInputSchema, { kp_refs: [KP_REF, KP_REF_2] }), + output: accepts("kp_remove.output", removeKeyPackagesOutputSchema, { kp_refs: [KP_REF] }), + rejects: [rejects("kp_remove.input/scalar", removeKeyPackagesInputSchema, { kp_refs: KP_REF })], + }, + [COORDINATOR_METHODS.fetchPendingWelcomes]: { + // Our client omits `consumed` entirely when it has nothing to retire, + // rather than sending an empty array. + input: accepts("welcome_take.input/empty", fetchPendingWelcomesInputSchema, {}), + inputWithConsumed: accepts("welcome_take.input/consumed", fetchPendingWelcomesInputSchema, { + consumed: [{ kp_ref: KP_REF, at: 1757000000 }], + }), + output: accepts("welcome_take.output", fetchPendingWelcomesOutputSchema, { + welcomes: [ + { kp_ref: KP_REF, welcome_64: "V2VsY29tZQ==", at: 1757000200, after: 12 }, + { kp_ref: KP_REF_2, welcome_64: "V2VsY29tZTI=", at: 1757000300 }, + ], + }), + rejects: [ + rejects("welcome_take.input/consumed-without-at", fetchPendingWelcomesInputSchema, { + consumed: [{ kp_ref: KP_REF }], + }), + ], + }, + [COORDINATOR_METHODS.storeWelcome]: { + input: accepts("welcome_store.input", storeWelcomeInputSchema, { + target_pk: BOB, + kp_ref: KP_REF_2, + welcome_64: "V2VsY29tZQ==", + after: 12, + }), + inputWithoutAfter: accepts("welcome_store.input/no-after", storeWelcomeInputSchema, { + target_pk: BOB, + kp_ref: KP_REF_2, + welcome_64: "V2VsY29tZQ==", + }), + output: accepts("welcome_store.output", storeWelcomeOutputSchema, { at: 1757000200 }), + rejects: [ + rejects("welcome_store.input/no-target", storeWelcomeInputSchema, { + kp_ref: KP_REF_2, + welcome_64: "V2VsY29tZQ==", + }), + ], + }, + [COORDINATOR_METHODS.storeJoinRequest]: { + input: accepts("join_request_store.input", storeJoinRequestInputSchema, { gid: GID, kp_ref: KP_REF }), + output: accepts("join_request_store.output", storeJoinRequestOutputSchema, { at: 1757000400 }), + rejects: [rejects("join_request_store.input/no-gid", storeJoinRequestInputSchema, { kp_ref: KP_REF })], + }, + [COORDINATOR_METHODS.fetchManyPendingJoinRequests]: { + input: accepts("join_request_take_many.input", fetchManyPendingJoinRequestsInputSchema, { + groups: [{ gid: GID }], + }), + inputWithConsumed: accepts("join_request_take_many.input/consumed", fetchManyPendingJoinRequestsInputSchema, { + groups: [{ gid: GID }], + consumed: [{ gid: GID, pk: BOB, at: 1757000400 }], + }), + output: accepts("join_request_take_many.output", fetchManyPendingJoinRequestsOutputSchema, { + requests: [{ pk: BOB, kp_ref: KP_REF_2, at: 1757000400, gid: GID }], + }), + rejects: [ + // The many-variant carries `gid` on every request; the single-group + // shape does not, and mixing them is the easy mistake. + rejects("join_request_take_many.output/no-gid", fetchManyPendingJoinRequestsOutputSchema, { + requests: [{ pk: BOB, kp_ref: KP_REF_2, at: 1757000400 }], + }), + rejects("join_request_take_many.input/bare-gids", fetchManyPendingJoinRequestsInputSchema, { groups: [GID] }), + ], + }, + [COORDINATOR_METHODS.postGroupMessage]: { + input: accepts("msg_post.input", postGroupMessageInputSchema, { gid: GID, msg_64: "c2VhbGVk" }), + output: accepts("msg_post.output", postGroupMessageOutputSchema, { gid: GID, cursor: 42, at: 1757000500 }), + rejects: [rejects("msg_post.input/no-msg", postGroupMessageInputSchema, { gid: GID })], + }, + [COORDINATOR_METHODS.fetchManyGroupMessages]: { + // A first-ever fetch omits `after` rather than sending 0 — the schema + // types it as a number, and 0 would be a real cursor value. + input: accepts("msg_fetch_many.input/first", fetchManyGroupMessagesInputSchema, { groups: [{ gid: GID }] }), + inputWithCursor: accepts("msg_fetch_many.input/resume", fetchManyGroupMessagesInputSchema, { + groups: [{ gid: GID, after: 42 }], + }), + output: accepts("msg_fetch_many.output", fetchManyGroupMessagesOutputSchema, { + messages: [ + { gid: GID, cursor: 43, msg_64: "c2VhbGVkLTE=", at: 1757000600 }, + { gid: GID, cursor: 44, msg_64: "c2VhbGVkLTI=", at: 1757000700 }, + ], + }), + rejects: [ + rejects("msg_fetch_many.input/string-cursor", fetchManyGroupMessagesInputSchema, { + groups: [{ gid: GID, after: "42" }], + }), + ], + }, + [COORDINATOR_METHODS.subscribeManyGroupMessages]: { + input: accepts("msg_sub_many.input", subscribeManyGroupMessagesInputSchema, { + groups: [{ gid: GID, after: 42 }], + }), + output: accepts("msg_sub_many.output", subscribeManyGroupMessagesOutputSchema, { + subscribed: true, + groups: [GID], + }), + // Each CEP-41 stream fragment is one of these, NOT a {messages:[…]} page. + streamFragment: accepts("msg_sub_many.fragment", groupMessageSchema, { + gid: GID, + cursor: 45, + msg_64: "c2VhbGVkLTM=", + at: 1757000800, + }), + rejects: [ + rejects("msg_sub_many.output/subscribed-false", subscribeManyGroupMessagesOutputSchema, { + subscribed: false, + groups: [GID], + }), + ], + }, +}; + +// Group refs, round-tripped through THEIR bech32 codec. +const groupRefCases = [ + { gid: GID }, + { gid: GID, coordinatorPubkey: COORDINATOR }, + { gid: GID, coordinatorPubkey: COORDINATOR, relays: ["wss://relay.example.com/"] }, + { + gid: GID, + coordinatorPubkey: COORDINATOR, + relays: ["wss://relay.example.com/", "wss://relay2.example.com/"], + }, + { gid: "a" }, + { gid: "grupo-café-éàü" }, +]; + +const groupRefs = groupRefCases.map((ref) => { + const encoded = encodeGroupRef(ref); + const decoded = decodeGroupRef(encoded); + if (JSON.stringify(decoded) !== JSON.stringify(ref)) { + failures.push(`groupRef: their own round-trip changed ${JSON.stringify(ref)} into ${JSON.stringify(decoded)}`); + } + if (!isGroupRef(encoded)) failures.push(`groupRef: isGroupRef rejected ${encoded}`); + return { ...ref, encoded }; +}); + +// Bech32 forbids mixed case but allows an all-uppercase form. Their +// `isGroupRef` screen rejects uppercase while `decodeGroupRef` accepts it, so +// a ref pasted in caps decodes on both sides — pin that, it is easy to get +// wrong in either direction. +const uppercaseRef = encodeGroupRef({ gid: GID, coordinatorPubkey: COORDINATOR }).toUpperCase(); +if (JSON.stringify(decodeGroupRef(uppercaseRef)) !== JSON.stringify({ gid: GID, coordinatorPubkey: COORDINATOR })) { + failures.push("groupRef: their decoder did not round-trip the uppercase form"); +} +const groupRefUppercase = { gid: GID, coordinatorPubkey: COORDINATOR, encoded: uppercaseRef }; + +// Strings their decoder must refuse. Ours has to refuse them too, or we accept +// group coordinates nobody else would. +const groupRefRejects = [ + "cordn1qqqqq", + "nostr1qqqqq", + "CORDN1QQQQQ", + "cordn", + "", +]; +for (const bad of groupRefRejects) { + let threw = false; + try { + decodeGroupRef(bad); + } catch { + threw = true; + } + if (!threw) failures.push(`groupRef: their decoder ACCEPTED ${JSON.stringify(bad)}, which we treat as invalid`); +} + +if (failures.length > 0) { + console.error("cordn-vector-gen: refusing to emit vectors\n " + failures.join("\n ")); + process.exit(1); +} + +process.stdout.write( + JSON.stringify( + { + _comment: + "Generated by quartz/tools/cordn-vector-gen from @cordn/core (MIT). " + + "Every payload here was validated by cordn's own zod schemas / bech32 codec. Do not hand-edit.", + generator: { cordnCore: coreVersion }, + methods: COORDINATOR_METHODS, + contracts, + groupRefs, + groupRefUppercase, + groupRefRejects, + }, + null, + 2, + ) + "\n", +); diff --git a/quartz/tools/cordn-vector-gen/package.json b/quartz/tools/cordn-vector-gen/package.json new file mode 100644 index 0000000000..73145858fa --- /dev/null +++ b/quartz/tools/cordn-vector-gen/package.json @@ -0,0 +1,9 @@ +{ + "name": "cordn-vector-gen", + "version": "0.1.0", + "type": "module", + "private": true, + "dependencies": { + "@cordn/core": "0.5.5" + } +} diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsClient.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsClient.kt index 5aeba8fc6f..0ded197347 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsClient.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsClient.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.quic.tls -import com.vitorpamplona.quartz.marmot.mls.crypto.X25519 -import com.vitorpamplona.quartz.marmot.mls.crypto.X25519KeyPair +import com.vitorpamplona.quartz.mls.crypto.X25519 +import com.vitorpamplona.quartz.mls.crypto.X25519KeyPair import com.vitorpamplona.quic.QuicCodecException import com.vitorpamplona.quic.QuicReader import com.vitorpamplona.quic.QuicWriter diff --git a/quic/src/commonTest/kotlin/com/vitorpamplona/quic/tls/InProcessTlsServer.kt b/quic/src/commonTest/kotlin/com/vitorpamplona/quic/tls/InProcessTlsServer.kt index 356b8b2527..488f090c75 100644 --- a/quic/src/commonTest/kotlin/com/vitorpamplona/quic/tls/InProcessTlsServer.kt +++ b/quic/src/commonTest/kotlin/com/vitorpamplona/quic/tls/InProcessTlsServer.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.quic.tls -import com.vitorpamplona.quartz.marmot.mls.crypto.X25519 -import com.vitorpamplona.quartz.marmot.mls.crypto.X25519KeyPair +import com.vitorpamplona.quartz.mls.crypto.X25519 +import com.vitorpamplona.quartz.mls.crypto.X25519KeyPair import com.vitorpamplona.quartz.utils.RandomInstance import com.vitorpamplona.quic.QuicReader import com.vitorpamplona.quic.QuicWriter diff --git a/tools/strings-migrate/fix_escapes.py b/tools/strings-migrate/fix_escapes.py index 34a48089f8..65c7a7c566 100755 --- a/tools/strings-migrate/fix_escapes.py +++ b/tools/strings-migrate/fix_escapes.py @@ -19,6 +19,17 @@ It does not handle these Android conventions, so this script applies them itself \\n, \\t, \\uXXXX and \\\\ are deliberately left alone -- Compose already resolves them, and converting them here would double-process. +Whitespace is the other half. aapt collapses every whitespace run in an unquoted +value to one space and trims the ends; Compose keeps the XML text verbatim. A value +wrapped over several indented lines therefore showed on Android as one line, and in +Compose as a leading line break plus eight spaces -- which CommonMark renders as a +code block (`account_backup_tips2_md`). This script applies aapt's rule to runs that +contain a line break or tab, which only XML layout produces (a deliberate newline is +written \\n). In a translation it also trims a leading/trailing space the source +string does not have: Crowdin exports a translator's stray edge space, aapt used to +drop it, Compose would draw it (" miejsca zniknęły" beside "tematy zniknęły"). +Deliberate edge spaces (" and ") exist in the source too, and are kept. + Usage: fix_escapes.py [...] (idempotent; safe to re-run) """ import re @@ -35,9 +46,28 @@ TOOLS_ATTR = re.compile(r'\s+tools:[\w.-]+="[^"]*"') TOOLS_NS = re.compile(r'\s+xmlns:tools="[^"]*"') # a backslash escape NOT itself preceded by a backslash ANDROID_ONLY = re.compile(r"(?]*\bname="([^"]+)"') -def fix_text(text: str, unwrap_quotes: bool = True) -> str: +def normalize_whitespace(text: str, source: str = None) -> str: + """Apply the aapt whitespace handling that Compose lacks. Idempotent. + + [source] is the default-locale value of the same key when [text] is a translation. + """ + if " str: """Convert one element's text. NOTE: quote-unwrapping is NOT idempotent and must run exactly once per file, at @@ -47,8 +77,9 @@ def fix_text(text: str, unwrap_quotes: bool = True) -> str: already-migrated files must therefore pass unwrap_quotes=False. """ if unwrap_quotes and len(text) >= 2 and text.startswith('"') and text.endswith('"'): - text = text[1:-1] - return ANDROID_ONLY.sub(r"\1", text) + # Quoted: aapt kept this whitespace exactly, so Compose keeps it too. + return ANDROID_ONLY.sub(r"\1", text[1:-1]) + return normalize_whitespace(ANDROID_ONLY.sub(r"\1", text), source) def strip_android_only_attrs(src: str) -> tuple: @@ -58,13 +89,31 @@ def strip_android_only_attrs(src: str) -> tuple: return out, n + m +def source_values(path: str) -> dict: + """The default-locale values beside a `values-/strings.xml`, else {}.""" + parent = os.path.dirname(os.path.abspath(path)) + if not os.path.basename(parent).startswith("values-"): + return {} + default = os.path.join(os.path.dirname(parent), "values", os.path.basename(path)) + if not os.path.exists(default): + return {} + out = {} + for m in STRING_EL.finditer(open(default, encoding="utf-8").read()): + name = STRING_NAME.match(m.group(1)) + if name: + out[name.group(1)] = m.group(2) + return out + + def fix_file(path: str, unwrap_quotes: bool = True) -> int: src = open(path, encoding="utf-8").read() + sources = source_values(path) changed = 0 def repl(m): nonlocal changed - fixed = fix_text(m.group(2), unwrap_quotes) + name = STRING_NAME.match(m.group(1)) + fixed = fix_text(m.group(2), unwrap_quotes, sources.get(name.group(1)) if name else None) if fixed != m.group(2): changed += 1 return m.group(1) + fixed + m.group(3)