Merge branch 'claude/serene-lamport-3eyza2' into worktree-agent-a92c46ad074753a5d

Resolve conflicts with the NIP event-class renames (Group*Event prefixes,
GroupRolesEvent/groupRoles): keep the e/a pin-list, banner, invite and
CLI pin/unpin changes, using the renamed classes throughout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGR1u8SyzcUuekub39SBsc
This commit is contained in:
Claude
2026-09-27 17:29:10 +00:00
1289 changed files with 31016 additions and 12318 deletions
+42
View File
@@ -285,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 <keys…>`. 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
+14
View File
@@ -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"(?<!\\)\\(['\"?@])")
TOOLS_ATTR = re.compile(r'tools:[\w.-]+="')
# A value whose text holds a raw line break or tab. aapt collapsed that XML layout to
# one space; Compose draws it, and a markdown value indented by it becomes a code block.
STRING_TEXT = re.compile(r"<(string|item)\b[^>]*>(.*?)</\1>", 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 "<![CDATA[" not in m.group(2) and LAYOUT_WS.search(m.group(2))
)
if n:
found[path]["raw line break/tab in a value"] += n
return found
+2 -2
View File
@@ -43,10 +43,10 @@ Relay frame ──► LocalCache.insertOrUpdateNote() ──► LocalCacheFlow e
- `kind3FollowList = Kind3FollowListState(...)` ← NIP-02 ContactList (kind 3)
- `nip65RelayList = Nip65RelayListState(...)` ← NIP-65 RelayList (kind 10002), plus siblings `dmRelayList`, `searchRelayList`, `blockedRelayList`, `trustedRelayList`, `proxyRelayList`, `broadcastRelayList`, `indexerRelayList`, …
- `muteList = MuteListState(...)` ← NIP-51 MuteList (kind 10000)
- `bookmarkState = BookmarkListState(...)` ← NIP-51 Bookmarks (kind 10003), plus `labeledBookmarkLists`, `pinState`, `interestSets`, `peopleLists`, `followLists`, `hashtagList`, `geohashList`, `communityList`, `emoji`, `blossomServers`, …
- `bookmarkState = BookmarkListState(...)` ← NIP-51 Bookmarks (kind 10003), plus `bookmarkSets`, `pinState`, `interestSets`, `peopleLists`, `followLists`, `hashtagList`, `geohashList`, `communityList`, `emoji`, `blossomServers`, …
- Derived/merged views: `hiddenUsers`, `allFollows`, `homeRelays`, `outboxRelays`, `dmRelays`, `notificationRelays`, `trustedRelays`, and the `live*FollowListsPerRelay` outbox loaders.
- **The pattern:** each `XState` class pins its addressable note via `cache.getOrCreateAddressableNote(address)` (a long-term reference so GC/eviction can't drop it), exposes `val flow: StateFlow<…>` 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.
@@ -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/` |
+18 -3
View File
@@ -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))
}
}
```
+1 -1
View File
@@ -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)
}
```
+15 -10
View File
@@ -143,11 +143,13 @@ messages quoted below (they surface as the NIP-01 `OK false` reason).
kinds. A `BEFORE INSERT` trigger deletes any stored version that is *older* — meaning
`created_at` smaller, **or equal `created_at` with lexicographically larger id** (NIP-01
lowest-id-wins). Inserting a version that is *not* newer under that ordering leaves the stored
row in place and fails the unique index → rejected with `RejectionReason.SUPERSEDED`
(`duplicate: a newer version of this replaceable event is already stored`), which the relay
session answers with `OK true` exactly like an id duplicate (NIP-01 `duplicate:` prefix; same
reply nostr-rs-relay gives). Net contract: exactly one version stored; newest wins; ties broken
by lowest id; older re-inserts blocked but acknowledged as already covered.
row in place and fails the unique index → rejected with `RejectionReason.REPLACED`
(`replaced: a newer version exists`), which the relay session answers with `OK false`: the
event was not written, and NIP-01's `true` means accepted. Same reply strfry gives
(`false, "replaced: have newer event"`); nostr-rs-relay answers `true, "duplicate:"` instead,
and `RejectionReason.SUPERSEDED`, which did the same, is deprecated. Net contract: exactly one
version stored; newest wins; ties broken by lowest id; older re-inserts blocked and reported as
such.
**STORE-W02 — addressable supersession.** Same as W01 with unique index
`(kind, pubkey, d_tag)` over `30000 ≤ kind < 40000`. Nuance: `d_tag` is populated from the
@@ -193,16 +195,15 @@ in input order; OK frames pair by event id, not order.
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`.
complement of the supersession predicate in W01/W02) → `REPLACED`; 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.
replaceable/addressable event **byte-for-byte** is `DUPLICATE` (`OK true`: it is stored), not
`REPLACED` (it violates both indexes and only the id answer is driver-independent); a stale
*different* version is `REPLACED` (`OK false`: it is not).
---
@@ -338,6 +339,10 @@ non-itemizable cases).
Add one line per behavior change, newest first: `YYYY-MM-DD <short sha> <rule id> — what changed`.
- 2026-09-27 (pending) W01/W02, W09 — a stale replaceable/addressable version is `REPLACED`
(`replaced:` → `OK false`) again, not `SUPERSEDED` (`duplicate:` → `OK true`): it is not
written, and `OK true` told the client it was. `SUPERSEDED` is deprecated. Byte-for-byte
re-offers stay `DUPLICATE`.
- 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
+2 -2
View File
@@ -1,6 +1,6 @@
---
name: feed-patterns
description: Feed composition and data-access layer patterns in Amethyst. Use when adding or modifying a feed (home, profile, hashtag, bookmarks, notifications, DMs, communities), working with the shared `FeedFilter` / `AdditiveFeedFilter` / `ChangesFlowFilter` / `FeedContentState` in `commons/.../feeds/`, the Android-only `AdditiveComplexFeedFilter` / `FilterByListParams` in `amethyst/.../ui/dal/`, or extending the `FeedViewModel` family in `commons/.../viewmodels/`. Covers how feeds scan `LocalCache`, react to changes, apply ordering, and render through Compose.
description: Feed composition and data-access layer patterns in Amethyst. Use when adding or modifying a feed (home, profile, hashtag, bookmarks, notifications, DMs, communities), working with the shared `FeedFilter` / `AdditiveFeedFilter` / `ChangesFlowFilter` / `FeedContentState` in `commons/.../feeds/`, `FilterByListParams` in `commons/.../feeds/`, the Android-only `AdditiveComplexFeedFilter` in `amethyst/.../ui/dal/`, or extending the `FeedViewModel` family in `commons/.../viewmodels/`. Covers how feeds scan `LocalCache`, react to changes, apply ordering, and render through Compose.
---
# Feed Patterns
@@ -101,7 +101,7 @@ Concrete filters (Home, Hashtag, Profile, Bookmark, Notifications, Communities,
## Filter Sharing (Android vs Desktop)
- The filter **base classes** (`FeedFilter`, `AdditiveFeedFilter`, `ChangesFlowFilter`) and feed state (`FeedContentState`) are in `commons/.../feeds/` — **shared**. ViewModels are in `commons/.../viewmodels/` — **shared**.
- The **concrete** filters are platform-local: Android's in `amethyst/.../ui/screen/loggedIn/*/dal/`, Desktop's in `desktopApp/.../feeds/`. `amethyst/.../ui/dal/` keeps Android-only helpers (`AdditiveComplexFeedFilter`, `FilterByListParams`, `DefaultFeedOrder`) plus back-compat typealiases.
- The **concrete** filters are platform-local: Android's in `amethyst/.../ui/screen/loggedIn/*/dal/`, Desktop's in `desktopApp/.../feeds/`. `amethyst/.../ui/dal/` keeps Android-only helpers (`AdditiveComplexFeedFilter`, `DefaultFeedOrder`) plus back-compat typealiases; `FilterByListParams` is shared, in `commons/.../feeds/`.
- When porting a feed, share the concrete filter only if both platforms need identical inclusion rules.
## Gotchas
@@ -12,7 +12,7 @@ Step-by-step recipe for composing a new feed. Assume the feed shows `Note`s filt
| The feed is a simple list that changes frequently (e.g. bookmarks, lists) | `FeedFilter<Note>` + `ListChangeFeedViewModel` |
| The feed is a DM thread | `ChatroomFeedViewModel` (already provides filter machinery) |
The bases live in `commons/src/commonMain/.../commons/ui/feeds/`; `AdditiveComplexFeedFilter` and the `FilterByListParams` / `DefaultFeedOrder` helpers in `amethyst/src/main/java/.../ui/dal/`.
The bases live in `commons/src/commonMain/.../commons/ui/feeds/`; `AdditiveComplexFeedFilter` and the `DefaultFeedOrder` helper in `amethyst/src/main/java/.../ui/dal/`; `FilterByListParams` in `commons/src/commonMain/.../commons/feeds/`.
## 2. Write the Filter
@@ -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-<locale>/strings.xml` |
| **commonsUI** (KMP Compose resources, shared by Android + Desktop) | `commonsUI/src/commonMain/composeResources/values/strings.xml` | `commonsUI/src/commonMain/composeResources/values-<locale>/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.
@@ -253,13 +253,13 @@ fun String.toDisplayHexKey(): String {
**After:**
```kotlin
// commons/commonMain/formatters/PubKeyFormatter.kt
// commons/src/commonMain/.../commons/util/PubKeyFormatter.kt
fun String.toDisplayHexKey(): String {
return "${take(8)}:${takeLast(8)}"
}
// Both apps use it
import com.vitorpamplona.amethyst.commons.formatters.toDisplayHexKey
import com.vitorpamplona.amethyst.commons.util.toDisplayHexKey
```
**Why successful:**
@@ -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<ServiceProviderTag> = 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:<provider>:<target>`).
- **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`.
@@ -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
@@ -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 |
+11 -11
View File
@@ -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
+3 -4
View File
@@ -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
@@ -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,9 +138,9 @@ 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) |
| 39307 | TextTrackEvent | nip71Video/textTrack | `WebVttText.cueText(content)` NL — each cue's payload lines with tags removed and character references decoded; no header, timings, cue settings, ids, `NOTE`/`STYLE`/`REGION` blocks. Content with no `-->` timing line is not a caption file and is indexed as written |
| 39701 | WebBookmarkEvent | nipB0WebBookmarks | `listOfNotNull(title(), description())` NL |
| 39998 | AddressableListHeaderEvent | experimental/decentralizedLists/header | same as 9998 |
| 39999 | AddressableListItemEvent | experimental/decentralizedLists/item | same as 9998 |
@@ -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)
@@ -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<AuthPurpose>)
- `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).
@@ -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:
@@ -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
@@ -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
@@ -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 |
+1 -1
View File
@@ -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`
@@ -41,9 +41,9 @@ storm.
|---|---|
| The chip row + "Add" chip | `ui/note/creators/notify/Notifying.kt` (`Notifying`, `NotifyUserChip`, `AddUserChip`) |
| Audience state | `ShortNotePostViewModel.pTags: List<User>?`, `mutedNotifies: Set<HexKey>`, `activeNotifies()`, `addToReplyList(user)` |
| My NIP-51 people lists (kind 30000, public **and** decrypted private members) | `account.peopleLists.uiListFlow: StateFlow<List<PeopleList>>` (`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<User>`) | `model/nip51Lists/peopleList/PeopleList.kt` |
| My NIP-51 people lists (kind 30000, public **and** decrypted private members) | `account.peopleLists.uiListFlow: StateFlow<List<PeopleList>>` (`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<User>`) | `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` |
@@ -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**
@@ -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)`.
@@ -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 |
@@ -0,0 +1,361 @@
# Browser surfaces → PWA parity review
Status: **implemented** (phases 0–5, see §7), decisions recorded in §6. Not yet verified on a device. Scope: every surface that renders a
plain web client — the **embedded** bottom-bar tab and the **external** full-screen browser —
plus their shared top pull-down "pill" and bottom console "pill". The nsite/napplet hosts
reuse the same chrome and are covered where the change is shared.
Benchmark: an installed PWA in Chrome for Android (WebAPK, `display: standalone` /
`minimal-ui`), including the Custom-Tab-style bar Chrome shows when a PWA navigates out of
its scope.
## 1. Surfaces today
| Surface | Code | Process / rendering | Chrome |
|---|---|---|---|
| **Embedded web tab** (bottom-bar favorite) | `WebAppScreen` → `EmbeddedWebAppController` → `NappletBrowserService` | `:napplet` WebView streamed via SurfaceControlViewHost (API 30+) | Compose `TopControlSheet` + `BottomConsoleSheet`, drawn by `EmbeddedTabLayer` |
| **External browser** (Browser tab launcher, "Open full") | `BrowserScreen` → `FavoriteAppLauncher.launchUrl` → `NappletBrowserActivity` | `:napplet` WebView hosted directly; own task (`documentLaunchMode=intoExisting`) | native-View `NappletControlSheet` + `NappletConsolePanel` |
| Embedded / full nsite + napplet | `NostrAppScreen` / `NappletHostService` / `NappletHostActivity` | same split, verified-blob shell | same two chrome implementations, `isSandbox = true` |
So there are **two implementations of the same chrome** (Compose + plain Views), and they have
drifted.
### Top pill — current contents
| Row | Embedded (Compose) | External (Views) |
|---|---|---|
| Header | icon + host title | emoji 🌐/🛡 + **launch-time** title (never updates) |
| Address | — | **editable** URL field + 🔒/🧅/🌐 glyph |
| Tor | switch, `Lock` icon, `favorite_app_network_*` strings | switch, `ic_tor` icon, `napplet_net_*` strings |
| Reload | ✓ | ✓ (glyph `↻`) |
| Access info | sandbox only | sandbox only (glyph `ⓘ`) |
| Manage permissions | ✓ (navigates in-app) | ✓ (glyph `⚙`, IPC → broker) |
| Open full screen | ✓ | — (no way back to the tab) |
| Favorite | ✓, reflects registry live | ✓, but **resets to "Add" on every navigation** |
| Console | switch | switch (glyph `>`) |
### Bottom pill
Console only: hidden until the top-pill Console switch turns it on, then a grabber + log panel
(max 40 % height in Compose, a fixed 220 dp in Views). Neither surface has a bottom
navigation/actions pill.
## 2. What a Chrome PWA gives the user
**Window / OS integration**
- Its own launcher icon (Add to Home screen / Install), its own task in Recents with the
**app's name, icon and `theme_color`**, and a splash screen built from the manifest.
- Status bar (and nav bar) tinted with `theme_color` / `<meta name="theme-color">`, updated
live when the page changes it.
- `display_override` / `display` modes: `standalone` (no UI), `minimal-ui` (back + reload),
`fullscreen`.
- Launch handling: `start_url`, `scope`, `launch_handler`; deep links into the scope open the
PWA; app shortcuts (manifest `shortcuts`, shown on launcher long-press); `share_target`
(appears in the Android share sheet).
**Navigation**
- System back = history back; at the root, back leaves the app.
- **Out-of-scope** navigation (e.g. an OAuth provider) opens a Custom-Tab-like bar showing the
**origin + security state + ✕ close**, and returns to the app when closed.
- `target=_blank` / `window.open()` open a new Chrome tab (or a popup window for OAuth) and
`window.opener`/`postMessage` works.
- Non-web schemes (`mailto:`, `tel:`, `intent:`, `geo:`, `nostr:`…) hand off to apps.
**App menu** (minimal-ui ⋮, or the out-of-scope bar ⋮) — top icon row
`Forward · Reload/Stop`, then: Share…, Copy link, Open in Chrome, Find in page, Desktop site,
Zoom/text size, Page info (connection, certificate, cookies, site settings / permissions),
Clear site data, App info.
**Web platform APIs that "just work"**
- JS dialogs (`alert`/`confirm`/`prompt`, `beforeunload`), labelled with the origin.
- Permission prompts: camera/mic (`getUserMedia`), geolocation, notifications + Push,
clipboard read, MIDI, protected media — with a per-origin site-settings page.
- Downloads (`<a download>`, `Content-Disposition`, blob: URLs) to the Downloads folder.
- HTML fullscreen (`requestFullscreen`, the video player's fullscreen button), screen
orientation lock, Wake Lock.
- Web Share (`navigator.share`) and Share Target, Badging API, Contact Picker, File System
Access (open/save pickers), `<input type=file capture>`.
- Long-press context menu on links/images (open in new tab, copy link, share, download
image), text selection toolbar with Share/Translate/Search.
- Pull-to-refresh (unless the page sets `overscroll-behavior`).
- Service worker offline support, a proper offline/error page with Retry.
## 3. Gap analysis
Legend: ✅ parity · ⚠️ partial · ❌ missing · 🔒 intentionally blocked (sandbox/Tor/keyless — keep it or gate it behind consent).
| Capability | Embedded | External | Notes |
|---|---|---|---|
| Own task/Recents entry | n/a (it's a tab) | ⚠️ | Own task exists, but Recents shows the Amethyst label/icon — no `setTaskDescription(title, favicon, themeColor)`. |
| Add to Home screen / install | ❌ | ❌ | No `ShortcutManagerCompat.requestPinShortcut`. The launcher intent must route through the main process (the `:napplet` activity isn't exported). |
| Theme-color system bars | ❌ | ❌ | External pads the window with the app's `colorBackground`; `theme-color` is never read. |
| Page title in chrome | ⚠️ host only | ❌ fixed at launch | Neither listens to `onReceivedTitle`. |
| System back = history back | ✅ | ✅ | |
| Forward | ❌ | ❌ | Chrome's menu icon row starts with Forward. |
| Stop loading | ❌ | ❌ | Reload stays Reload while loading. |
| Out-of-scope bar (origin + ✕ close) | ❌ | ❌ | Navigating off-site silently replaces the app. There's no concept of the app's "scope" or "home". |
| `target=_blank` / `window.open` | ❌ | ❌ | `setSupportMultipleWindows(false)` + `javaScriptCanOpenWindowsAutomatically=false`: `_blank` loads in place, `window.open()` returns `null`, so OAuth popups break. |
| Non-http schemes | ⚠️ | ⚠️ | Handed to `ACTION_VIEW` on a gesture, but `intent:` URIs aren't parsed (`Intent.parseUri` + `browser_fallback_url`), so they fail. |
| JS dialogs | ❌ | ❌ | **Confirmed from source:** the framework `JsDialogHelper` only shows a dialog when `webView.context is Activity`. The embed runs on a Service context, and the external browser's WebView uses `nightThemedContext()` (a `ContextThemeWrapper` over `createConfigurationContext`, not an Activity) whenever the theme is DARK/LIGHT, which `FavoriteAppLauncher` always resolves it to. So `confirm()` returns `false` and `prompt()` returns `null` in both. |
| Permission prompts (camera/mic) | ❌ | ❌ | `onPermissionRequest` isn't overridden, so the default denies. Video calls, QR scanners and voice notes on the web all fail. |
| Geolocation | ❌ | ❌ | `setGeolocationEnabled(false)`. To be offered per origin behind consent, on Tor and open web alike (§6). |
| Notifications / Push | ❌ | ❌ | Not available in WebView at all. Only a bridge polyfill could provide it; out of scope for v1. |
| Downloads | ❌ | ❌ | No `DownloadListener`, so download links do nothing. |
| HTML fullscreen video | ❌ | ❌ | `onShowCustomView` isn't implemented, so the fullscreen button is dead. |
| Web Share (`navigator.share`) | ❌ | ❌ | Not in WebView. Can be polyfilled through the existing document-start shim to an `ACTION_SEND` chooser. |
| Share page / Copy link | ❌ | ❌ | Not in either top pill. |
| Open in external browser | ❌ | ❌ | Useful escape hatch (Google sign-in blocks WebView user agents). |
| Find in page | ❌ | ❌ | `WebView.findAllAsync` / `findNext`. |
| Desktop site | ❌ | ❌ | UA override + `useWideViewPort`. |
| Text zoom | ❌ | ❌ | `settings.textZoom`. |
| Page info / site settings | ⚠️ | ⚠️ | "Manage permissions" covers NIP-07 grants only. No connection/cert state, no clear-site-data. |
| Long-press link/image menu | ❌ | ❌ | External can use `hitTestResult` in `onCreateContextMenu`. For the embed, the tap forwarder would need a long-press path. |
| Pull-to-refresh | ❌ | ❌ | Optional. Should respect the page's overscroll (only fire at `scrollY == 0`). |
| Error / offline page | ✅ overlay + Retry | ⚠️ | External only logs to the console and shows the raw WebView error page. |
| Renderer crash recovery | ❌ | ❌ | No `onRenderProcessGone`. A renderer crash in `:napplet` kills every WebView in that process, including every warm tab. |
| File input | ✅ | ✅ | Recently added. |
| NIP-07 `window.nostr` | ✅ | ✅ | Amethyst-only advantage. Keep it. |
| Tor per host | ✅ | ✅ | Amethyst-only advantage. |
### Bugs found along the way (fix regardless of the redesign)
1. **"Open full screen" opens the launch URL, not the current page.** In `WebAppScreen` it
calls `FavoriteAppLauncher.launchUrl(context, url)` with the original `url`, not
`currentUrl`.
2. **External favorite state is wrong after any navigation.** `NappletControlSheet.updateUrl`
forces `isFavorite = false`, so an already-pinned site shows "Add to favorites" and tapping
it sends a toggle that *removes* the pin. The broker knows the truth: it should push the
state back (or the toggle should be an explicit add/remove, not a blind flip).
3. **External header title never updates.** It shows the launch host even after navigating to
another site. That's misleading when combined with NIP-07 prompts.
4. **The two sheets disagree.** They use different icons (emoji vs Material symbols, `Lock` vs
`ic_tor`), different strings for the same row, a different row order, and only one side has
"Open full" and the address field. There's also a doc contradiction: `BrowserScreen` says "a
running app never carries an editable address bar", but `NappletControlSheet` renders one
in the external browser.
## 4. Proposal
### 4.1 One chrome spec, two renderers
Promote `EmbeddedTabChrome` into a surface-neutral **`WebChromeSpec`** in `commons`: title,
origin, security state (https / http / onion-via-Tor / sandbox), canGoBack/Forward,
isLoading, isFavorite, theme color, and a list of typed actions. Both renderers draw from
it with the same order, icons (Material Symbols font; the Views side can draw the same glyphs
from `material_symbols_outlined.ttf` with a `Typeface`) and strings. Add a unit test that pins
the row order and visibility for each surface type (web / nsite / napplet × embed / full).
### 4.2 Top pill (Chrome-style app menu)
Keep the top-center grabber (it stays out of the site's avatar corner and can't be spoofed
by the page). Expanded:
```
┌──────────────────────────────────────────────┐
│ [favicon] Page title [✕] │ ✕ only in external = close task
│ 🔒 example.com · via Tor │ read-only origin chip; tap → Page info
├──────────────────────────────────────────────┤
│ ← → ↻/✕ ★ ⇪ │ back · forward · reload/stop · favorite · share
├──────────────────────────────────────────────┤
│ ⧉ Copy link │
│ ✎ Edit address │ rare: reveals the editable URL field
│ ⬈ Open in browser app │ external browser escape hatch
│ ⛶ Open full screen / ⤓ Return to tab │ embed ↔ external
│ ⌂ Add to Home screen │
│ 🔍 Find in page │
│ 🖥 Desktop site [ ] │
│ 🧅 Route over Tor [●] │
│ ⚙ Site settings & permissions │ NIP-07 grants + camera/mic/location + clear data
│ >_ Console (N) [ ] │ under a "Developer" divider
└──────────────────────────────────────────────┘
```
- **Hide the editable address field by default** (decision 1). A PWA never shows one. The
origin chip is read-only (tap = page info, long-press = copy). An **Edit address** row, in
both the embed and the external browser, swaps the chip for the editable field with
the URL pre-selected; Go navigates and collapses it back to the chip. Expected to be used
rarely; the Browser tab launcher stays the main place to type a URL.
- Header title comes from `onReceivedTitle`, falling back to the host.
- The icon row mirrors Chrome's top row. Reload turns into Stop while `isLoading`.
- A **scope indicator**: when the current origin differs from the app's start origin, tint the
origin chip and show a "Back to <app>" action (the in-app version of Chrome's out-of-scope
bar).
### 4.3 Bottom pill
- Keep it **developer-only** (Console), but move the toggle under a *Developer* divider in the
top pill, and let the grabber show an error-count badge so the user knows why to open it.
- Unify height: 40 % of the surface in both renderers (Views currently hard-code 220 dp).
- **Find-in-page** reuses the bottom slot: a bar with the query field, `n/m`, ↑ ↓ and ✕ docked
where the console grabber sits (Chrome puts find-in-page at the top, but the top edge here
belongs to the grabber, and the bottom avoids covering the page's own header). Only one
bottom panel is shown at a time.
- Out-of-scope navigation can also surface a slim bottom "← Back to <app>" pill. Pick either
this or the chip in 4.2 after trying both on a device.
### 4.4 Behaviours (WebView plumbing)
Fix these in `NappletBrowserActivity`, `NappletBrowserService`, and where applicable the
nsite/napplet hosts (sandbox profile permitting).
1. **JS dialogs:** implement `onJsAlert`/`onJsConfirm`/`onJsPrompt`/`onJsBeforeUnload` ourselves.
- External: show an Activity-owned dialog titled "*origin* says" (the framework helper
can't, because the WebView's themed context isn't an Activity).
- Embed: IPC to the main process and show a Compose dialog over the tab.
- Sandboxed napplets keep today's auto-cancel.
2. **Popups / `_blank` — follow Chrome** (decision 2): enable `setSupportMultipleWindows(true)`
and handle `onCreateWindow`, from both the embed and the external browser.
- Every new window (a `_blank` link or a user-gesture `window.open`) opens as a **new
full-screen Amethyst browser window** (a new `NappletBrowserActivity` task), the way
Chrome opens a new tab.
- `opener` must survive for OAuth popups. The child WebView is created in
`onCreateWindow` (same `:napplet` process as both parents) and handed to the new
activity through an in-process registry keyed by a one-shot token passed in the
intent. `window.close()` from the child (`onCloseWindow`) finishes that task and
returns the user to the opener.
- Keep auto-open without a user gesture blocked (Chrome's popup blocker).
3. **`intent:` URIs:** parse them with `Intent.parseUri(..., URI_INTENT_SCHEME)`, strip the
component/selector, and fall back to `browser_fallback_url`. Keep the gesture requirement.
4. **Downloads:** add a `DownloadListener`. Hand off to the main process, which runs
`DownloadManager` (through the Tor proxy when the host is on Tor, or else refuses rather
than leaking the download). Handle `blob:`/`data:` via the shim.
5. **Permissions:** handle `onPermissionRequest` (camera/mic) and
`onGeolocationPermissionsShowPrompt` (enable geolocation). The consent prompt runs in the
main process (same pattern as NIP-07 consent), is stored per origin in the existing
Connected Apps ledger, and requests the Android runtime permission from the main
activity. Works the same on **Tor and the open web** (decision 3); nothing is
auto-denied because of routing.
6. **Fullscreen:** implement `onShowCustomView`/`onHideCustomView`.
- External: swap in the custom view with immersive system bars.
- Embed: open the external browser in fullscreen, since a streamed surface can't take over
the window.
7. **Web Share polyfill:** have the document-start shim define `navigator.share`/`canShare`
routed over the existing bridge to an `ACTION_SEND` chooser (text/url, files later).
Require a user activation.
8. **Theme color:** a tiny shim observer reports `<meta name="theme-color">` (and changes to
it) over the bridge. External tints the status/nav bar padding and uses it in
`setTaskDescription`. Embed tints the top grabber.
9. **Task description (external):** call `setTaskDescription(title, favicon, themeColor)` on
title, icon and theme changes, so Recents looks like an installed app.
10. **Add to Home screen** (decision 4): `ShortcutManagerCompat.requestPinShortcut`, with the
favicon (from `BrowserIconRegistry`) as the icon. The shortcut always opens the page
**full screen in Amethyst's own browser** (`NappletBrowserActivity`), never the system
browser, so the NIP-07 signer is there. Its intent targets an exported **main-process**
trampoline activity (the `:napplet` activities stay unexported). The trampoline
brings up the broker, the Tor port and the account's WebView profile, then calls
`FavoriteAppLauncher.launchUrl` and finishes. Also add dynamic shortcuts for the top
favorites on launcher long-press, with the same target.
11. **Renderer crash:** handle `onRenderProcessGone`. Destroy that WebView, return `true`, and
show the error overlay with Retry (for the embed, rebuild the session on Retry). All
WebViews in `:napplet` share one renderer, and the process is still killed if **any** of
them leaves the callback unhandled. So `NappletBrowserActivity`, `NappletBrowserService`,
`NappletHostActivity` and `NappletHostService` must all ship the handler in one change.
12. **Context menu (external first):** on long-press over a link or image, offer Open in new
window, Copy link, Share link, Download image.
13. **Desktop site / text zoom:** per-host settings persisted next to `WebAppNetworkRegistry`.
14. **Pull-to-refresh (optional):** only when the page is at `scrollY == 0` and hasn't claimed
overscroll.
### 4.5 Deliberately different from Chrome (keep)
- Keyless `:napplet` process, per-origin NIP-07 consent, per-account WebView profile, Tor
routing per host. These are Amethyst's reason to exist and none of the above weakens them.
Every new capability goes through the broker with the same consent + ledger pattern.
- Notifications/Push: not feasible in WebView without a service-worker bridge. Out of scope.
- Service-worker offline: WebView already supports SW. Nothing to do beyond not clearing the
profile.
## 5. Phasing
| Phase | Contents | Size |
|---|---|---|
| **0: bugs** ✅ | §3 bugs 1–3; live page titles (`onReceivedTitle`) in both surfaces; JS dialogs in the external browser (origin-labelled, with "Block dialogs from this page") | S |
| **1: chrome unification** | `WebChromeSpec` in commons; both renderers; icon row (back/forward/reload-stop/star/share); Copy link; Open in browser app; Return to tab; address field hidden behind "Edit address"; row-order test | M |
| **2: dead web APIs** | `onRenderProcessGone` (all four WebView owners at once — see 4.4 #11), JS dialogs in the embed, `_blank`/`window.open` → new browser window, `intent:` URIs, downloads, fullscreen video, Web Share polyfill | M–L |
| **3: OS integration** | theme-color bars, `setTaskDescription`, Add to Home screen + trampoline, dynamic shortcuts | M |
| **4: permissions & page info** | camera/mic/geo consent via the broker, site settings page (grants + clear data + connection state), Find in page, Desktop site, text zoom | L |
| **5: polish** | long-press context menu, pull-to-refresh, out-of-scope indicator | M |
## 6. Decisions (2026-09-26)
1. **Address bar:** hidden by default in running apps; an "Edit address" row reveals the
editable field. Rarely used.
2. **New windows (`_blank`, `window.open`):** follow Chrome. They open a new full-screen
Amethyst browser window, keeping `opener` for OAuth popups.
3. **Camera / mic / location:** consent-gated per origin, and they work on **both Tor and
the open web**. Routing never auto-denies them.
4. **Add to Home screen:** the shortcut opens the page full screen in **Amethyst's browser**
(not the system browser), so the signer is present. It launches through a main-process
trampoline.
## 7. Implementation (2026-09-26)
What shipped, where it lives, and what was deliberately left out.
> The two renderers described below (`TopControlSheet`, `NappletControlSheet` and their find, console and
> dialog views) were later replaced by one set of Compose components. See
> `2026-09-26-browser-ui-review.md` §5.
**Shared layout.** `commons/…/browser/BrowserChrome.kt` decides which actions the top pill shows, and in
what order, for every surface (web / nsite / napplet × embedded / full screen), plus the security badge,
the scope check, text-zoom steps, the desktop user agent and the theme-colour parser. It is covered by
`BrowserChromeTest`. `nappletHost/…/BrowserChromeLabels.kt` maps each action to one Material Symbol and
one label (shared strings in `commons` Android resources). Both renderers draw from these two:
- `TopControlSheet` (Compose, embedded tabs).
- `NappletControlSheet` (plain Views, full screen). It loads the same Material Symbols font from the
`commonsUI` assets, so the icons match. Three glyphs were added to the subset font: `FormatSize`,
`DesktopWindows` and `AddToHomeScreen`.
**Top pill.** Header: security icon, page title, and `host · connection`. Tapping it opens page info;
long-pressing copies the link. The full-screen header also has ✕. Below it:
- The icon row: back · forward · reload/stop · star (filled when pinned) · share.
- Menu rows: back to app, copy link, edit address, find in page, text size, desktop site, add to Home
screen, open in another browser, open full screen.
- Privacy: Tor, what it can access, site settings.
- Developer: console.
**Bottom pill.** Console as before; find in page docks in the same slot, one panel at a time.
**Web platform** (`BrowserWebTools`, `BrowserDownloads`, `BrowserPopups`, `BrowserExtrasScript`,
`BrowserJsDialogs`; used by both `NappletBrowserActivity` and `NappletBrowserService`):
- JS dialogs: native in full screen; relayed to Compose for the embed.
- `_blank` / `window.open` open as a new full-screen window, with `opener` kept through a parked popup
WebView.
- `intent:` URIs are parsed, hardened, and fall back to `browser_fallback_url`.
- Downloads follow the page's route (Tor through SOCKS, remote DNS), carry the page's cookies, and land
in Downloads. `blob:` and `data:` downloads come through the page script.
- HTML fullscreen: the whole window in full screen; inside the surface for the embed.
- `navigator.share` polyfill (text/url).
- Renderer-crash recovery in all four WebView owners.
**OS integration.**
- `theme-color` tints the full-screen window's system-bar areas.
- `setTaskDescription` sets the title, favicon and colour in Recents.
- Add to Home screen (`WebShortcuts` + `WebShortcutActivity`, main process, waits for Tor).
- Dynamic launcher shortcuts for the first four web favorites.
**Permissions and page info.**
- Camera, microphone and location go through a per-origin prompt. Answers are kept in
`WebSitePermissionRegistry` (main process, same on Tor and open web), then Android's runtime permission
is requested.
- The Connected Apps detail screen lists and resets those answers.
- Page info shows the connection, Tor and certificate, with Clear site data (this profile only).
**Left out, and why.**
- **Pull-to-refresh:** WebView exposes no overscroll signal, so on pages that scroll an inner element
(most SPAs) `scrollY == 0` is always true. A pull there would reload mid-scroll.
- **Long-press menu in the embedded tab:** the SurfaceControlViewHost surface doesn't deliver long-press
context menus. It is available in the full-screen browser.
- **Theme colour in the embedded tab:** the tab owns no system bars. The script's message is ignored
there.
- **Find / text size for embedded nsites and napplets:** that host has no IPC for them yet. The rows are
hidden via `BrowserChrome.State.hasFind` / `hasTextSize`. The full-screen nsite/napplet host has both.
- **Notifications / Push, file sharing through Web Share:** unchanged, see §4.5.
**Needs on-device verification.**
- Popup `opener` handoff across activities.
- Renderer-crash recovery. Trigger it with `chrome://crash` in a debug build, or by killing the renderer.
- Downloads over Tor.
- The pinned-shortcut cold start with Tor enabled.
@@ -0,0 +1,261 @@
# Browser surfaces: UI review and redesign
Status: **shipped** (see §5). The components live in
`commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/`. They are rendered
offscreen by `BrowserPillRenderTest` (commonsUI jvmTest), which writes PNGs to
`commonsUI/build/browser-pill/`.
Follows `2026-09-26-browser-pwa-parity.md`. That plan settled *what* the browser does. This one is about
how it looks and feels.
## 1. What's wrong with what shipped
Reviewed against the code as merged: `TopControlSheet`, `NappletControlSheet`, `BottomConsoleSheet`,
`NappletConsolePanel`, `BrowserFindBar`, `EmbeddedFindBar`, `EmbeddedPageDialogs`, `BrowserJsDialogs`
and the full-screen permission / page-info `AlertDialog`s.
**Top pill**
1. **It's a settings list, not a menu.** Up to 15 rows of equal weight in one scrolling column. Share,
copy and find (used daily) look exactly like desktop site and open-in-another-browser (used rarely).
Chrome solves this with an icon strip plus a short list; we copied the list and made it longer.
2. **No visual hierarchy.** Section labels are 12sp caps you don't notice. Every row is the same 44dp
icon + label. Nothing groups.
3. **Security state is a word, not a signal.** "Not secure" and "Onion-routed over Tor" are the same
grey 13sp text. Plain HTTP isn't warned about, and Tor, the thing Amethyst does that Chrome doesn't,
isn't celebrated.
4. **Three rows are just switches** (Tor, desktop site, console). Each is a full-width row whose only
content is the switch. They say what they are, not what they mean ("Loads over Tor" vs "the site
can't see your IP").
5. **The text-size stepper** is squeezed into a list row: two 24dp buttons and a percentage, with no
preview and no way back to 100%.
6. **The collapsed grabber is mute.** It never shows loading, an insecure page, or console errors, so
there's nothing to invite the pull.
**Inputs**
7. **Edit address is a bare text field** dropped into the header. It has no clear button, no go button,
no suggestions (the launcher's omnibox has them), and no paste-and-go.
8. **Find in page** is a full-width sheet with a default `TextField`. The count is loose text, and "no
matches" has no state of its own. The native version draws a different bar.
9. **The JS prompt field** has no label. "Block dialogs from this page" is a third button squeezed in
beside Cancel/OK. Chrome uses a checkbox, because it's an option, not an answer.
**Dialogs and sheets**
10. **The permission prompt** is a stock `AlertDialog` with bullet text and only Allow / Block. There's
no "just this time", no icon for what's being asked, and nothing about the connection.
11. **Page info** is a paragraph in an `AlertDialog` with three crowded buttons. The site's permissions
aren't there, and Clear site data runs immediately, with no confirmation.
12. **The console** is a flat list with no filters (errors are buried in logs), no copy, and a count
that only shows inside the menu.
**Parity**
13. **Two renderers, two looks.** The full-screen pill is plain Views: framework `Switch`, framework
ripple, hand-rolled type sizes. Sharing the layout spec stopped the two drifting in content, but
they still don't look alike. `:nappletHost` already depends on `:commonsUI` (Compose), so the
full-screen window can host the same Compose pill in a `ComposeView`.
## 2. Principles
- **One tap for daily things, two for rare ones.** Navigation, star and share sit in a capsule.
Page actions are a grid of tiles. Privacy and developer settings are grouped cards below.
- **State is visible, even collapsed.** The security colour (error for HTTP, Tor accent for onion),
loading progress and console errors show on the grabber itself.
- **Inputs are first-class.** Every text input has:
- a real container and a leading icon that says what it is;
- a placeholder or label;
- clear and submit buttons;
- the right keyboard and IME action;
- an empty or error state.
Choices are segmented buttons or switches that state their consequence, never a bare "on".
- **Explain consequences in plain words.** Say "The site can't see your IP address", not "Loads over
Tor". Say "Allow while visiting / Only this time / Don't allow", not "Allow / Block".
- **One implementation.** Stateless composables in `commonsUI`, driven by `BrowserChrome` (already
shared). The embedded tab, the full-screen window (through `ComposeView`) and a future desktop browser
all draw the same pixels.
## 3. The redesign
### 3.1 Collapsed handle
A 48×24dp capsule at the top centre holding a 32dp bar.
- A 2dp progress line runs under the bar while the page loads.
- The bar takes the error colour on HTTP and the Tor accent when onion-routed.
- A 6dp error dot appears at the right edge when the console has errors.
- It keeps its current gestures: pull or tap to open.
### 3.2 Expanded pill
```
╭────────────────────────────────────────────╮
│ [P] Primal ( ✕ ) │ monogram tile · title · close (full screen)
│ ╭────────────────────────────────────────╮ │
│ │ 🧅 primal.net ✎ │ │ origin field: tap edits the address
│ ╰────────────────────────────────────────╯ │
│ ⓘ You left primal.net [ Back to app ] │ only when out of scope
│ ╭────────────────────────────────────────╮ │
│ │ ← → ↻ ★ ⇪ │ │ navigation capsule
│ ╰────────────────────────────────────────╯ │
│ ╭────╮ ╭────╮ ╭────╮ ╭────╮ │
│ │ ⧉ │ │ 🔍 │ │ Aa │ │ ⌂+ │ │ page actions (tiles, 4 per row)
│ │Copy│ │Find│ │Text│ │Home│ │
│ ╰────╯ ╰────╯ ╰────╯ ╰────╯ │
│ ╭────╮ ╭────╮ ╭────╮ │ toggle tiles fill when on (Desktop)
│ │ 🖥 │ │ ⬈ │ │ ⛶ │ │
│ ╰────╯ ╰────╯ ╰────╯ │
│ A ━━━━━━●━━━━━━━ A 115% Reset │ appears when "Text" is on
│ ╭ Privacy ───────────────────────────────╮ │
│ │ (🧅) Onion routing [ ● ] │ │
│ │ The site can't see your IP │ │
│ │ (⚙) Site settings › │ │
│ │ Camera allowed · Location blocked │ │
│ ╰────────────────────────────────────────╯ │
│ >_ Console (3 errors) [ ] │
╰────────────────────────────────────────────╯
```
- **Origin field.** It looks like an input on purpose (the address is one tap away without an address
bar taking space):
- a pill-shaped container on `surfaceContainerHighest`;
- the security icon in its colour;
- the host in `titleSmall`;
- a trailing pencil.
Tapping it opens the address editor (§3.3). Long-pressing copies the link.
- **Navigation capsule.** Five 48dp icon buttons on `surfaceContainerHigh`, `CircleShape`. Back and
forward dim when they can't be used. The star fills in `primary` when the page is pinned. Reload
becomes Stop, with a progress ring, while loading.
- **Tiles.** 72dp-tall rounded (16dp) cards on `surfaceContainer`, each with an icon and a two-line
`labelMedium` label. Toggle tiles (desktop site, text size open) switch to `secondaryContainer` with
a check badge.
- **Privacy card.** Grouped rows on one rounded container:
- each row has a leading icon in a 36dp tinted circle, a title, and a supporting line that states the
consequence;
- a trailing switch, or a chevron for navigation;
- site settings summarise camera / mic / location decisions inline.
- **Developer row.** The console with an error badge (`errorContainer` pill) and a switch. It's the
only developer item, so it gets no heading.
- **Sandboxed apps** use the same frame:
- the origin field reads "Sandboxed app" with a shield and doesn't edit;
- the capsule is reload + star;
- the tiles are find and text size;
- "What it can access" joins the privacy card.
### 3.3 Address editor (first-class input)
Replaces the origin field inside the pill:
- A 56dp pill field with the security/search icon leading, the URL selected, and trailing clear (✕)
and Go (a filled `primary` circle with an arrow). The keyboard is URI type with IME Go.
- Below it:
- a **Paste and go** chip when the clipboard holds a URL;
- then up to five suggestions (favorites first, then history, from `OmniboxSuggestions`), each with a
monogram, the title and the URL;
- each suggestion has a trailing ↖ that fills its URL into the field without going.
- Back or tapping outside collapses it to the origin field.
### 3.4 Bottom: find in page and console
- **Find pill.** A floating 56dp capsule, 12dp above the bottom edge, with shadow. It holds:
- a search icon;
- the field with placeholder "Find in page";
- a match chip ("3 / 12", tonal; "No matches" in `errorContainer`);
- up/down, a divider, then close.
The IME Search action jumps to the next match.
- **Console sheet.** A drag handle, then a title with filter chips "All 42 · Errors 3 · Warnings 5",
then Copy and Clear. Log rows have:
- a 3dp level-coloured stripe;
- the message in monospace;
- `file:line` muted on the right;
- long-press to copy.
Errors show first when the Errors chip is on.
### 3.5 Permission prompt
A card sheet:
- the origin field (read-only) on top;
- 48dp tinted icon circles for each thing asked (camera / mic / location);
- a title in plain words ("Use your camera and microphone?") and one body line;
- when the site is on Tor and asks for camera or mic, a muted note: "Calls can reveal your IP address
even over Tor".
Three stacked full-width buttons: **Allow while visiting** (filled, remembered), **Only this time**
(tonal, not remembered), **Don't allow** (text, remembered).
### 3.6 JS dialogs
A card with:
- the origin field as its header (so a page can't spoof Amethyst UI);
- the message in `bodyLarge`, scrolling past 40% of the screen;
- for `prompt`, an `OutlinedTextField` with a label, a clear button, autofocus, and IME Done that
submits;
- a **"Don't let this page show more dialogs"** checkbox (second dialog onward);
- right-aligned Cancel / OK. "Leave site?" uses a destructive-tinted Leave.
### 3.7 Page info
A sheet with:
- **Header:** monogram, host, and the full origin.
- **Connection:** rows with icons: encryption state (a warning tone for HTTP), then the route (Tor or
open web, with the same consequence line as the pill), then the certificate: issued to, issued by,
and expiry.
- **Permissions:** one row per camera / mic / location with a segmented button **Ask · Allow ·
Block**, editable in place.
- **Cookies and site data:** an outlined destructive button, "Clear site data". The first tap turns it
into an inline confirmation ("Sign out of this site and delete its data? · Cancel · Clear") instead
of acting at once.
## 4. Tokens
| Use | Token |
|---|---|
| Pill surface | `surface`, 0 tonal elevation, 6dp shadow, 24dp bottom corners |
| Inputs, origin field | `surfaceContainerHighest`, `CircleShape` |
| Navigation capsule | `surfaceContainerHigh`, `CircleShape` |
| Tiles, grouped cards | `surfaceContainer`, 16dp |
| Toggle "on" | `secondaryContainer` / `onSecondaryContainer` |
| Insecure | `error` / `errorContainer` |
| Tor accent | `tertiary` (no new colour, readable in both themes) |
| Spacing | 4dp grid, 16dp sheet padding, 8dp between tiles |
| Type | title `titleMedium`, origin `titleSmall`, tiles `labelMedium`, supporting `bodySmall` |
## 5. What shipped
Every browser surface now draws the components in `commonsUI/…/browser/ui/pill/`; the hand-built chrome
is gone.
- **Embedded tabs** (`EmbeddedTabLayer`): `EmbeddedTabChrome` now carries a `BrowserPillUi` and one
`onEvent(BrowserPillEvent)` callback. The layer draws `BrowserPill`, `FindInPagePill` and
`ConsoleSheet`, and handles find and the console itself. The address editor gets suggestions from
favorites and history (`OmniboxSuggestions`), and "Paste and go" checks only the clip's type.
- **Embedded page dialogs** (`WebAppScreen`): `PageDialogCard`, `PermissionPromptCard` and
`PageInfoSheet` in Compose `Dialog`s. `MSG_PAGE_INFO` now sends the certificate fields
(`KEY_CERT_ISSUED_TO` / `_BY` / `_VALID_UNTIL`) instead of a paragraph of text. Permissions in page
info are edited in place, straight into `WebSitePermissionRegistry`.
- **Full-screen windows** (`NappletBrowserActivity`, `NappletHostActivity`): `BrowserChromeHost` hosts
the same composables in two `ComposeView`s over the page. The top view grows to fill the window only
while the pill is open, so it can catch taps outside the pill; the bottom view holds find or the
console. Dialogs, the permission prompt and page info are Compose `Dialog`s. The browser asks the
broker for the site's decisions before showing page info. `:nappletHost` now applies the Compose
compiler and links the same JetBrains Compose libraries (Apache-2.0) the app already ships.
- **Permissions:** Allow while visiting (remembered), Only this time (granted, not remembered), and
Don't allow (remembered). Dismissing the prompt denies the request once and remembers nothing.
- **Clear site data** asks for confirmation inline, in both surfaces.
- **Removed:** `NappletControlSheet`, `BrowserFindBar`, `NappletConsolePanel`, `BrowserJsDialogs`,
`BrowserChromeLabels`, `TopControlSheet`, `EmbeddedFindBar`, `BottomConsoleSheet`,
`EmbeddedPageDialogs`, `ConsoleLogEntry` and `BrowserWebTools.pageInfo`, plus the Android strings only
they used.
**Sandboxed apps, embedded and full screen alike:**
- An embedded nSite is labelled as an nSite, not "Sandboxed app", and gets the Tor row. Switching the
route saves it in `NappletNetworkRegistry` and rebuilds the session, as the full-screen host relaunches.
- "What it can access" is `AccessInfoSheet`: the launch capabilities, the keys-stay-in-Amethyst row, the
route (nSites), and Manage permissions. It replaces both the platform `AlertDialog` and the embedded
tab's `AccessDialog`.
- The embedded tab has find, text size and the console. `NappletEmbedContract` gained `MSG_FIND`,
`MSG_FIND_NEXT`, `MSG_FIND_RESULT`, `MSG_SET_TEXT_ZOOM` and `MSG_CONSOLE_LOG`, which
`NappletHostService` serves the same way `NappletBrowserService` does. Load and HTTP errors show up as
console errors, as they do full screen.
@@ -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`.
@@ -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
@@ -26,7 +26,7 @@ import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.text.input.TextFieldBuffer
import androidx.compose.foundation.text.input.TextFieldState
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation
import com.vitorpamplona.amethyst.commons.ui.text.MentionPreservingInputTransformation
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
@@ -39,13 +39,11 @@ import androidx.compose.ui.unit.dp
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.error_video_open_in_browser
import com.vitorpamplona.amethyst.commons.resources.error_video_playback_failed
import com.vitorpamplona.amethyst.commons.resources.error_video_playback_failed_description
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.commons.ui.stringRes
import io.mockk.mockk
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertTrue
@@ -70,8 +68,6 @@ class PlaybackErrorOverlayFitTest {
@get:Rule
val rule = createComposeRule()
private val targetContext = InstrumentationRegistry.getInstrumentation().targetContext
/**
* Built outside composition on purpose: the mock and its error state are fixtures for the whole
* test, not per-composition state. Creating them inside `setContent` would rebuild both on every
@@ -141,10 +137,12 @@ class PlaybackErrorOverlayFitTest {
assertButtonUsable()
rule
.onNodeWithText(
stringRes(
Res.string.error_video_playback_failed_description,
"ERROR_CODE_PARSING_MANIFEST_MALFORMED",
),
runBlocking {
loadStringRes(
Res.string.error_video_playback_failed_description,
"ERROR_CODE_PARSING_MANIFEST_MALFORMED",
)
},
).assertDoesNotExist()
}
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings
import androidx.compose.ui.platform.UriHandler
import com.vitorpamplona.amethyst.commons.ui.settings.SettingsCategory
// F-Droid distributes Amethyst as MIT-licensed free software; the build must
// not surface links to external (e.g. GitHub-hosted) policy documents.
+37
View File
@@ -17,6 +17,17 @@
<action android:name="android.intent.action.TTS_SERVICE" />
</intent>
<!-- So the browser's hand-off tile can name the browser it would open.
Android 11+ package visibility answers resolveActivity with nothing
for a scheme that is not declared here, so without this the tile can
only ever say "Open in browser". Read-only: it names the target, the
hand-off itself still goes through a chooser. -->
<intent>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
</intent>
<!-- NIP-A3 payment targets. Android 11+ package visibility means
queryIntentActivities returns NOTHING for a scheme not declared here,
so without these the zap picker's pay-to chip is invisible on every
@@ -640,6 +651,22 @@
android:name=".service.call.CallNotificationReceiver"
android:exported="false" />
<!--
Compose Resources learns its Context from a ContentProvider the library
declares, and a provider is instantiated only in the process it belongs
to. The browser chrome is now drawn by shared composables that read
`Res.string`, and those run in `:napplet` — where the lookup threw
MissingResourceException ("Android context is not initialized") and killed
the sandbox. `multiprocess` is the platform's answer to exactly this: it
gives every process of the app its own instance of the provider. A second
<provider> element cannot work, because the manifest merger keys them by
android:name and would merge the two into one.
-->
<provider
android:name="org.jetbrains.compose.resources.AndroidContextProvider"
android:multiprocess="true"
tools:node="merge" />
<!-- Sandboxed napplet/nsite host. Runs in an isolated process that holds no keys. -->
<activity
android:name="com.vitorpamplona.amethyst.napplethost.NappletHostActivity"
@@ -665,6 +692,16 @@
android:windowSoftInputMode="adjustResize"
android:theme="@style/Theme.Amethyst" />
<!-- Target of web-app launcher shortcuts (Add to Home screen + favorites). Main process, so the
app and Tor are up before it hands the URL to the full-screen browser; draws nothing. -->
<activity
android:name=".favorites.WebShortcutActivity"
android:exported="false"
android:excludeFromRecents="true"
android:noHistory="true"
android:taskAffinity=""
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
<!-- Capability-consent dialog. Runs in the main process (the only side trusted to grant). -->
<activity
android:name=".napplet.NappletConsentActivity"
@@ -24768,17 +24768,17 @@ SPLcom/vitorpamplona/amethyst/ui/components/WindowUtilsKt;->getActivityOrNull(La
Lcom/vitorpamplona/amethyst/commons/ui/navigation/topbars/ShorterTopAppBarKt;
SPLcom/vitorpamplona/amethyst/commons/ui/navigation/topbars/ShorterTopAppBarKt;-><clinit>()V
SPLcom/vitorpamplona/amethyst/commons/ui/navigation/topbars/ShorterTopAppBarKt;->getTopBarSize()F
Lcom/vitorpamplona/amethyst/ui/note/elements/NowProviderKt;
SPLcom/vitorpamplona/amethyst/ui/note/elements/NowProviderKt;-><clinit>()V
SPLcom/vitorpamplona/amethyst/ui/note/elements/NowProviderKt;->NowProvider(Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;I)V
Lcom/vitorpamplona/amethyst/ui/note/elements/NowProviderKt$$ExternalSyntheticLambda1;
SPLcom/vitorpamplona/amethyst/ui/note/elements/NowProviderKt$$ExternalSyntheticLambda1;-><init>()V
Lcom/vitorpamplona/amethyst/ui/note/elements/NowProviderKt$NowProvider$now$1$1;
SPLcom/vitorpamplona/amethyst/ui/note/elements/NowProviderKt$NowProvider$now$1$1;-><init>(Lkotlin/coroutines/Continuation;)V
SPLcom/vitorpamplona/amethyst/ui/note/elements/NowProviderKt$NowProvider$now$1$1;->create(Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Lkotlin/coroutines/Continuation;
SPLcom/vitorpamplona/amethyst/ui/note/elements/NowProviderKt$NowProvider$now$1$1;->invoke(Landroidx/compose/runtime/ProduceStateScope;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
SPLcom/vitorpamplona/amethyst/ui/note/elements/NowProviderKt$NowProvider$now$1$1;->invoke(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;
SPLcom/vitorpamplona/amethyst/ui/note/elements/NowProviderKt$NowProvider$now$1$1;->invokeSuspend(Ljava/lang/Object;)Ljava/lang/Object;
Lcom/vitorpamplona/amethyst/commons/ui/note/elements/NowProviderKt;
SPLcom/vitorpamplona/amethyst/commons/ui/note/elements/NowProviderKt;-><clinit>()V
SPLcom/vitorpamplona/amethyst/commons/ui/note/elements/NowProviderKt;->NowProvider(Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;I)V
Lcom/vitorpamplona/amethyst/commons/ui/note/elements/NowProviderKt$$ExternalSyntheticLambda1;
SPLcom/vitorpamplona/amethyst/commons/ui/note/elements/NowProviderKt$$ExternalSyntheticLambda1;-><init>()V
Lcom/vitorpamplona/amethyst/commons/ui/note/elements/NowProviderKt$NowProvider$now$1$1;
SPLcom/vitorpamplona/amethyst/commons/ui/note/elements/NowProviderKt$NowProvider$now$1$1;-><init>(Lkotlin/coroutines/Continuation;)V
SPLcom/vitorpamplona/amethyst/commons/ui/note/elements/NowProviderKt$NowProvider$now$1$1;->create(Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Lkotlin/coroutines/Continuation;
SPLcom/vitorpamplona/amethyst/commons/ui/note/elements/NowProviderKt$NowProvider$now$1$1;->invoke(Landroidx/compose/runtime/ProduceStateScope;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
SPLcom/vitorpamplona/amethyst/commons/ui/note/elements/NowProviderKt$NowProvider$now$1$1;->invoke(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;
SPLcom/vitorpamplona/amethyst/commons/ui/note/elements/NowProviderKt$NowProvider$now$1$1;->invokeSuspend(Ljava/lang/Object;)Ljava/lang/Object;
Lcom/vitorpamplona/amethyst/ui/screen/AccountScreenKt;
SPLcom/vitorpamplona/amethyst/ui/screen/AccountScreenKt;->AccountScreen$lambda$0(Landroidx/compose/runtime/State;)Lcom/vitorpamplona/amethyst/ui/screen/AccountState;
SPLcom/vitorpamplona/amethyst/ui/screen/AccountScreenKt;->AccountScreen$lambda$2(Lcom/vitorpamplona/amethyst/ui/screen/AccountSessionManager;Lcom/vitorpamplona/amethyst/ui/screen/AccountState;Landroidx/compose/runtime/Composer;I)Lkotlin/Unit;
@@ -25005,11 +25005,11 @@ SPLcom/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup
Lcom/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupOpenChatTailSubAssembler;
SPLcom/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupOpenChatTailSubAssembler;-><clinit>()V
SPLcom/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupOpenChatTailSubAssembler;-><init>(Lcom/vitorpamplona/quartz/nip01Core/relay/client/INostrClient;Lkotlin/jvm/functions/Function0;)V
Lcom/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupOpenThreadsFilterAssembler;
SPLcom/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupOpenThreadsFilterAssembler;-><clinit>()V
SPLcom/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupOpenThreadsFilterAssembler;-><init>(Lcom/vitorpamplona/quartz/nip01Core/relay/client/INostrClient;)V
Lcom/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupOpenThreadsFilterAssembler$group$1;
SPLcom/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupOpenThreadsFilterAssembler$group$1;-><init>(Ljava/lang/Object;)V
Lcom/vitorpamplona/amethyst/commons/relayClient/channel/relayGroup/RelayGroupOpenThreadsFilterAssembler;
SPLcom/vitorpamplona/amethyst/commons/relayClient/channel/relayGroup/RelayGroupOpenThreadsFilterAssembler;-><clinit>()V
SPLcom/vitorpamplona/amethyst/commons/relayClient/channel/relayGroup/RelayGroupOpenThreadsFilterAssembler;-><init>(Lcom/vitorpamplona/quartz/nip01Core/relay/client/INostrClient;)V
Lcom/vitorpamplona/amethyst/commons/relayClient/channel/relayGroup/RelayGroupOpenThreadsFilterAssembler$group$1;
SPLcom/vitorpamplona/amethyst/commons/relayClient/channel/relayGroup/RelayGroupOpenThreadsFilterAssembler$group$1;-><init>(Ljava/lang/Object;)V
Lcom/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupOpenThreadsHistoryFilterAssembler;
SPLcom/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupOpenThreadsHistoryFilterAssembler;-><clinit>()V
SPLcom/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupOpenThreadsHistoryFilterAssembler;-><init>(Lcom/vitorpamplona/quartz/nip01Core/relay/client/INostrClient;)V
@@ -25535,13 +25535,13 @@ SPLcom/vitorpamplona/amethyst/commons/account/ui/login/LoginButtonKt;->LoginButt
Lcom/vitorpamplona/amethyst/commons/account/ui/login/LoginButtonKt$$ExternalSyntheticLambda0;
SPLcom/vitorpamplona/amethyst/commons/account/ui/login/LoginButtonKt$$ExternalSyntheticLambda0;-><init>(Z)V
SPLcom/vitorpamplona/amethyst/commons/account/ui/login/LoginButtonKt$$ExternalSyntheticLambda0;->invoke(Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;
Lcom/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginErrorManager;
SPLcom/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginErrorManager;-><clinit>()V
SPLcom/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginErrorManager;-><init>()V
SPLcom/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginErrorManager;->clearErrors()V
SPLcom/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginErrorManager;->getError()Lcom/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginErrorManager$IErrorMsg;
SPLcom/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginErrorManager;->setError(Lcom/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginErrorManager$IErrorMsg;)V
Lcom/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginErrorManager$IErrorMsg;
Lcom/vitorpamplona/amethyst/commons/account/ui/login/LoginErrorManager;
SPLcom/vitorpamplona/amethyst/commons/account/ui/login/LoginErrorManager;-><clinit>()V
SPLcom/vitorpamplona/amethyst/commons/account/ui/login/LoginErrorManager;-><init>()V
SPLcom/vitorpamplona/amethyst/commons/account/ui/login/LoginErrorManager;->clearErrors()V
SPLcom/vitorpamplona/amethyst/commons/account/ui/login/LoginErrorManager;->getError()Lcom/vitorpamplona/amethyst/commons/account/ui/login/LoginErrorManager$IErrorMsg;
SPLcom/vitorpamplona/amethyst/commons/account/ui/login/LoginErrorManager;->setError(Lcom/vitorpamplona/amethyst/commons/account/ui/login/LoginErrorManager$IErrorMsg;)V
Lcom/vitorpamplona/amethyst/commons/account/ui/login/LoginErrorManager$IErrorMsg;
Lcom/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginScreenKt;
SPLcom/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginScreenKt;->LoginPage$lambda$4(Lcom/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginViewModel;Lkotlin/jvm/functions/Function0;ILandroidx/compose/runtime/Composer;I)Lkotlin/Unit;
SPLcom/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginScreenKt;->LoginPage(Lcom/vitorpamplona/amethyst/ui/screen/AccountSessionManager;ZLjava/lang/String;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;II)V
@@ -25575,7 +25575,7 @@ SPLcom/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginViewModel;-><clinit
SPLcom/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginViewModel;-><init>()V
SPLcom/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginViewModel;->clear()V
SPLcom/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginViewModel;->getAcceptedTerms()Z
SPLcom/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginViewModel;->getErrorManager()Lcom/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginErrorManager;
SPLcom/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginViewModel;->getErrorManager()Lcom/vitorpamplona/amethyst/commons/account/ui/login/LoginErrorManager;
SPLcom/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginViewModel;->getKey()Landroidx/compose/ui/text/input/TextFieldValue;
SPLcom/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginViewModel;->getNeedsPassword()Z
SPLcom/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginViewModel;->getOfferTemporaryLogin()Z
@@ -25632,6 +25632,7 @@ SPLcom/vitorpamplona/amethyst/commons/ui/theme/ShapeKt;->getSize40dp()F
SPLcom/vitorpamplona/amethyst/commons/ui/theme/ShapeKt;->getSize50dp()F
SPLcom/vitorpamplona/amethyst/commons/ui/theme/ShapeKt;->getSmallBorder()Landroidx/compose/foundation/shape/RoundedCornerShape;
SPLcom/vitorpamplona/amethyst/commons/ui/theme/ShapeKt;->getSmallestBorder()Landroidx/compose/foundation/shape/RoundedCornerShape;
Lcom/vitorpamplona/amethyst/commons/ui/theme/AmethystThemeKt;
Lcom/vitorpamplona/amethyst/ui/theme/ThemeKt;
Lcom/vitorpamplona/amethyst/commons/ui/theme/MarkdownStyleKt;
Lcom/vitorpamplona/amethyst/commons/ui/theme/AmethystColorSchemeKt;
@@ -25648,13 +25649,13 @@ SPLcom/vitorpamplona/amethyst/ui/theme/ThemeKt;->AmethystTheme$lambda$9(Landroid
SPLcom/vitorpamplona/amethyst/ui/theme/ThemeKt;->AmethystTheme(Lcom/vitorpamplona/amethyst/commons/model/ThemeType;Lcom/vitorpamplona/amethyst/commons/model/AccentColorType;Lcom/vitorpamplona/amethyst/commons/model/FontFamilyType;Lcom/vitorpamplona/amethyst/commons/model/FontSizeType;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V
SPLcom/vitorpamplona/amethyst/ui/theme/ThemeKt;->AmethystTheme(Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;I)V
SPLcom/vitorpamplona/amethyst/commons/ui/theme/AmethystColorSchemeKt;->accentContainer-DxMtmZc(JZ)J
SPLcom/vitorpamplona/amethyst/ui/theme/ThemeKt;->accentPrimary(Lcom/vitorpamplona/amethyst/commons/model/AccentColorType;Z)J
SPLcom/vitorpamplona/amethyst/ui/theme/ThemeKt;->accentSecondary(Lcom/vitorpamplona/amethyst/commons/model/AccentColorType;Z)J
SPLcom/vitorpamplona/amethyst/ui/theme/ThemeKt;->darkColors(Lcom/vitorpamplona/amethyst/commons/model/AccentColorType;)Landroidx/compose/material3/ColorScheme;
SPLcom/vitorpamplona/amethyst/commons/ui/theme/AmethystThemeKt;->accentPrimary(Lcom/vitorpamplona/amethyst/commons/model/AccentColorType;Z)J
SPLcom/vitorpamplona/amethyst/commons/ui/theme/AmethystThemeKt;->accentSecondary(Lcom/vitorpamplona/amethyst/commons/model/AccentColorType;Z)J
SPLcom/vitorpamplona/amethyst/commons/ui/theme/AmethystThemeKt;->amethystDarkColors(Lcom/vitorpamplona/amethyst/commons/model/AccentColorType;)Landroidx/compose/material3/ColorScheme;
SPLcom/vitorpamplona/amethyst/commons/ui/theme/AmethystColorSchemeKt;->getPlaceholderText(Landroidx/compose/material3/ColorScheme;)J
SPLcom/vitorpamplona/amethyst/commons/ui/theme/AmethystColorSchemeKt;->getTransparentBackground(Landroidx/compose/material3/ColorScheme;)J
SPLcom/vitorpamplona/amethyst/commons/ui/theme/AmethystColorSchemeKt;->isLight(Landroidx/compose/material3/ColorScheme;)Z
SPLcom/vitorpamplona/amethyst/ui/theme/ThemeKt;->lightColors(Lcom/vitorpamplona/amethyst/commons/model/AccentColorType;)Landroidx/compose/material3/ColorScheme;
SPLcom/vitorpamplona/amethyst/commons/ui/theme/AmethystThemeKt;->amethystLightColors(Lcom/vitorpamplona/amethyst/commons/model/AccentColorType;)Landroidx/compose/material3/ColorScheme;
SPLcom/vitorpamplona/amethyst/commons/ui/theme/AmethystColorSchemeKt;->onAccent-8_81llA(J)J
SPLcom/vitorpamplona/amethyst/commons/ui/theme/AmethystColorSchemeKt;->onAccentContainer-DxMtmZc(JZ)J
Lcom/vitorpamplona/amethyst/ui/theme/ThemeKt$$ExternalSyntheticLambda0;
@@ -25685,7 +25686,7 @@ SPLcom/vitorpamplona/amethyst/commons/ui/theme/TypeKt;->getDefaultParagraphSpaci
SPLcom/vitorpamplona/amethyst/commons/ui/theme/TypeKt;->getFont14SP()J
SPLcom/vitorpamplona/amethyst/commons/ui/theme/TypeKt;->getFont17SP()J
SPLcom/vitorpamplona/amethyst/commons/ui/theme/TypeKt;->getTypography()Landroidx/compose/material3/Typography;
SPLcom/vitorpamplona/amethyst/ui/theme/ThemeKt;->toFontFamily(Lcom/vitorpamplona/amethyst/commons/model/FontFamilyType;)Landroidx/compose/ui/text/font/FontFamily;
SPLcom/vitorpamplona/amethyst/commons/ui/theme/AmethystThemeKt;->toFontFamily(Lcom/vitorpamplona/amethyst/commons/model/FontFamilyType;)Landroidx/compose/ui/text/font/FontFamily;
SPLcom/vitorpamplona/amethyst/commons/ui/theme/TypeKt;->withFontFamily(Landroidx/compose/material3/Typography;Landroidx/compose/ui/text/font/FontFamily;)Landroidx/compose/material3/Typography;
Lcom/vitorpamplona/amethyst/commons/ui/theme/TypeKt$$ExternalSyntheticLambda0;
SPLcom/vitorpamplona/amethyst/commons/ui/theme/TypeKt$$ExternalSyntheticLambda0;-><init>()V
@@ -23,9 +23,12 @@ package com.vitorpamplona.amethyst
import android.app.Application
import android.content.ComponentCallbacks2
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.WebShortcuts
import com.vitorpamplona.amethyst.napplet.WebAppNetworkRegistry
import com.vitorpamplona.amethyst.napplet.WebSitePermissionRegistry
import com.vitorpamplona.amethyst.service.logging.Logging
import com.vitorpamplona.amethyst.service.nests.AppForegroundRecycleHook
import com.vitorpamplona.amethyst.service.okhttp.isEmulator
@@ -35,6 +38,8 @@ import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.LogLevel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import java.io.File
@@ -142,6 +147,17 @@ class Amethyst : Application() {
// Hydrate the device-local favorite-apps list (main process only; the sandbox never reads it).
instance.favoriteApps.init()
// Mirror web-app favorites into the launcher's long-press shortcuts (open in Amethyst's browser).
CoroutineScope(Dispatchers.Default).launch {
instance.favoriteApps.favorites
.map { apps -> apps.filterIsInstance<FavoriteApp.WebApp>().map { it.url to it.label } }
.distinctUntilChanged()
.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).
instance.browserHistory.init()
@@ -1159,7 +1159,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
@@ -78,20 +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
@@ -952,15 +952,15 @@ object LocalPreferences {
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.backupRelayFeedsList?.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.backupChannelList?.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.backupHashtagList?.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) },
@@ -1222,15 +1222,15 @@ object LocalPreferences {
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 latestRelayFeedsListStr = stores.latestEvents[LatestEventSlot.RELAY_FEEDS_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 latestChannelListStr = stores.latestEvents[LatestEventSlot.CHANNEL_LIST]
val latestPublicChatListStr = stores.latestEvents[LatestEventSlot.CHANNEL_LIST]
val latestCommunityListStr = stores.latestEvents[LatestEventSlot.COMMUNITY_LIST]
val latestHashtagListStr = stores.latestEvents[LatestEventSlot.HASHTAG_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]
@@ -1282,19 +1282,19 @@ object LocalPreferences {
val pendingAttestations = async { parseOrNull<Map<HexKey, String>>(pendingAttestationsStr) ?: mapOf() }
val latestUserMetadata = async { parseEventOrNull<MetadataEvent>(latestUserMetadataStr) }
val latestContactList = async { parseEventOrNull<ContactListEvent>(latestContactListStr) }
val latestDmRelayList = async { parseEventOrNull<ChatMessageRelayListEvent>(latestDmRelayListStr) }
val latestDmRelayList = async { parseEventOrNull<DmRelayListEvent>(latestDmRelayListStr) }
val latestNip65RelayList = async { parseEventOrNull<AdvertisedRelayListEvent>(latestNip65RelayListStr) }
val latestSearchRelayList = async { parseEventOrNull<SearchRelayListEvent>(latestSearchRelayListStr) }
val latestIndexRelayList = async { parseEventOrNull<IndexerRelayListEvent>(latestIndexRelayListStr) }
val latestRelayFeedsList = async { parseEventOrNull<RelayFeedsListEvent>(latestRelayFeedsListStr) }
val latestFavoriteRelayList = async { parseEventOrNull<FavoriteRelayListEvent>(latestFavoriteRelayListStr) }
val latestBlockedRelayList = async { parseEventOrNull<BlockedRelayListEvent>(latestBlockedRelayListStr) }
val latestTrustedRelayList = async { parseEventOrNull<TrustedRelayListEvent>(latestTrustedRelayListStr) }
val latestMuteList = async { parseEventOrNull<MuteListEvent>(latestMuteListStr) }
val latestPrivateHomeRelayList = async { parseEventOrNull<PrivateOutboxRelayListEvent>(latestPrivateHomeRelayListStr) }
val latestAppSpecificData = async { parseEventOrNull<AppSpecificDataEvent>(latestAppSpecificDataStr) }
val latestChannelList = async { parseEventOrNull<ChannelListEvent>(latestChannelListStr) }
val latestPublicChatList = async { parseEventOrNull<PublicChatListEvent>(latestPublicChatListStr) }
val latestCommunityList = async { parseEventOrNull<CommunityListEvent>(latestCommunityListStr) }
val latestHashtagList = async { parseEventOrNull<HashtagListEvent>(latestHashtagListStr) }
val latestInterestList = async { parseEventOrNull<InterestListEvent>(latestInterestListStr) }
val latestGeohashList = async { parseEventOrNull<GeohashListEvent>(latestGeohashListStr) }
val latestEphemeralList = async { parseEventOrNull<EphemeralChatListEvent>(latestEphemeralListStr) }
val latestRelayGroupList = async { parseEventOrNull<SimpleGroupListEvent>(latestRelayGroupListStr) }
@@ -1342,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()
@@ -1444,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,
@@ -0,0 +1,80 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT 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 android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.lifecycle.lifecycleScope
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.tor.TorType
import com.vitorpamplona.amethyst.ui.MainActivity
import com.vitorpamplona.amethyst.ui.tor.TorServiceStatus
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
/**
* The target of every web-app launcher shortcut ([WebShortcuts]). Runs in the **main** process, so the app
* (account, broker, Tor) is up before the page opens, then hands off to the full-screen browser through
* [FavoriteAppLauncher.launchUrl] — the same path the Browser tab uses — and finishes without drawing.
*
* On a cold start from the launcher Tor is usually still bootstrapping. Opening straight away would load a
* Tor-routed site over the open web, so when Tor is configured this waits for it; if it doesn't come up in
* time, the user lands in Amethyst (where Tor's state is visible) rather than on the open web.
*/
class WebShortcutActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val url = intent.getStringExtra(EXTRA_URL)?.takeIf { it.startsWith("https://") || it.startsWith("http://") }
if (url == null) {
finish()
return
}
lifecycleScope.launch {
val app = Amethyst.instance
val torWanted = app.torPrefs.torType.value != TorType.OFF && app.torManager.activePortOrNull.value == null
val torReady =
!torWanted ||
withTimeoutOrNull(TOR_WAIT_MS) { app.torManager.status.first { it is TorServiceStatus.Active } } != null
if (torReady) {
FavoriteAppLauncher.launchUrl(this@WebShortcutActivity, url)
} else {
startActivity(Intent(this@WebShortcutActivity, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
}
finish()
}
}
companion object {
private const val EXTRA_URL = "url"
private const val TOR_WAIT_MS = 30_000L
fun intent(
context: Context,
url: String,
): Intent =
Intent(context, WebShortcutActivity::class.java)
.setAction(Intent.ACTION_VIEW)
.putExtra(EXTRA_URL, url)
}
}
@@ -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.amethyst.favorites
import android.content.Context
import android.graphics.BitmapFactory
import android.widget.Toast
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
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.sha256.sha256
import java.io.File
import com.vitorpamplona.amethyst.commons.R as CommonsR
/**
* Launcher shortcuts for web apps — the PWA "Add to Home screen" (pinned) and the long-press list on
* Amethyst's own icon (dynamic, from the favorites). Every shortcut opens the page **full screen in
* Amethyst's own browser** through [WebShortcutActivity], never the system browser, so the user's NIP-07
* signer is there.
*
* Main process only: the icon comes from [BrowserIconRegistry]'s captured favicon when it is a raster
* image, else Amethyst's launcher icon.
*/
object WebShortcuts {
private const val TAG = "WebShortcuts"
private const val MAX_DYNAMIC = 4
private const val DYNAMIC_PREFIX = "fav:"
private const val ICON_PX = 192
/** Asks the launcher to pin a shortcut to [url] labelled [title]. */
fun requestPin(
context: Context,
url: String,
title: String,
) {
if (!ShortcutManagerCompat.isRequestPinShortcutSupported(context)) {
Toast.makeText(context, CommonsR.string.browser_home_shortcut_unsupported, Toast.LENGTH_SHORT).show()
return
}
val info = build(context, "web:" + idOf(url), url, title)
runCatching { ShortcutManagerCompat.requestPinShortcut(context, info, null) }
.onFailure { Log.w(TAG, "Pin shortcut request failed", it) }
}
/** Mirrors the first few web-app favorites into Amethyst's long-press shortcut list. */
fun publishFavorites(
context: Context,
favorites: List<FavoriteApp>,
) {
val shortcuts =
favorites
.filterIsInstance<FavoriteApp.WebApp>()
.take(MAX_DYNAMIC)
.map { build(context, DYNAMIC_PREFIX + idOf(it.url), it.url, it.label) }
runCatching {
val stale =
ShortcutManagerCompat
.getDynamicShortcuts(context)
.map { it.id }
.filter { it.startsWith(DYNAMIC_PREFIX) && it !in shortcuts.map { s -> s.id } }
if (stale.isNotEmpty()) ShortcutManagerCompat.removeDynamicShortcuts(context, stale)
shortcuts.forEach { ShortcutManagerCompat.pushDynamicShortcut(context, it) }
}.onFailure { Log.w(TAG, "Could not publish favorite shortcuts", it) }
}
private fun build(
context: Context,
id: String,
url: String,
title: String,
): ShortcutInfoCompat {
val label = title.ifBlank { BrowserChrome.displayHost(url) }
return ShortcutInfoCompat
.Builder(context, id)
.setShortLabel(label.take(24))
.setLongLabel(label.take(48))
.setIcon(iconFor(context, url))
.setIntent(WebShortcutActivity.intent(context, url))
.build()
}
private fun iconFor(
context: Context,
url: String,
): IconCompat {
val bitmap =
Amethyst.instance.browserIcons
.iconModelFor(BrowserChrome.displayHost(url))
?.removePrefix("file://")
?.let { path -> runCatching { BitmapFactory.decodeFile(File(path).absolutePath) }.getOrNull() }
return if (bitmap != null) {
IconCompat.createWithBitmap(if (bitmap.width < ICON_PX) bitmap.scale(ICON_PX, ICON_PX, filter = false) else bitmap)
} else {
IconCompat.createWithResource(context, R.mipmap.ic_launcher)
}
}
private fun idOf(url: String): String = sha256(url.encodeToByteArray()).take(12).joinToString("") { "%02x".format(it) }
}
@@ -47,6 +47,7 @@ import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaces
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.cache.filter
import com.vitorpamplona.amethyst.commons.model.composer.NewMessageTagger
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannelListState
import com.vitorpamplona.amethyst.commons.model.concord.ConcordSessionManager
@@ -75,21 +76,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
@@ -145,18 +146,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
@@ -175,7 +176,6 @@ import com.vitorpamplona.amethyst.model.topNavFeeds.FeedTopNavFilterState
import com.vitorpamplona.amethyst.model.trustedAssertions.TrustProviderListState
import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.service.uploads.FileHeader
import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger
import com.vitorpamplona.amethyst.ui.screen.loggedIn.EventProcessor
import com.vitorpamplona.marmotquic.QuicAgentTextStreamTransport
import com.vitorpamplona.quartz.buzz.threading.buzzThread
@@ -238,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
@@ -281,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
@@ -320,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
@@ -639,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)
@@ -758,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)
@@ -777,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)
@@ -795,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)
@@ -893,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)
@@ -1030,10 +1030,10 @@ class Account(
val feedDecryptionCaches =
FeedDecryptionCaches(
peopleListCache = peopleListDecryptionCache,
peopleListCache = followSetDecryptionCache,
muteListCache = muteListDecryptionCache,
communityListCache = communityListDecryptionCache,
hashtagCache = hashtagListDecryptionCache,
hashtagCache = interestListDecryptionCache,
geohashCache = geohashListDecryptionCache,
)
@@ -1047,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,
@@ -1283,7 +1283,7 @@ class Account(
suspend fun updateZapAmounts(
amountSet: List<Long>,
selectedZapType: LnZapEvent.ZapType,
selectedZapType: ZapReceiptEvent.ZapType,
nip47Update: Nip47WalletConnect.Nip47URINorm?,
) {
var changed = false
@@ -1707,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 {
@@ -1738,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),
)
}
@@ -1749,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)
}
@@ -2126,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))
@@ -2176,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)
@@ -2212,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)
@@ -2597,7 +2597,7 @@ class Account(
}
}
} else {
FileHeaderEvent.build(url, alt) {
FileMetadataEvent.build(url, alt) {
hash(headerInfo.hash)
fileSize(headerInfo.size)
@@ -2711,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()
@@ -2937,7 +2937,7 @@ class Account(
broadcast.forEach { client.publish(it, relayList) }
}
override suspend fun sendNip04PrivateMessage(eventTemplate: EventTemplate<PrivateDmEvent>) {
override suspend fun sendNip04PrivateMessage(eventTemplate: EventTemplate<EncryptedDmEvent>) {
if (!isWriteable()) return
val newEvent = signer.sign(eventTemplate)
@@ -3440,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))
@@ -3481,10 +3481,10 @@ class Account(
suspend fun requestDVMContentDiscovery(
dvmPublicKey: User,
onReady: (event: NIP90ContentDiscoveryRequestEvent, relays: Set<NormalizedRelayUrl>) -> Unit,
onReady: (event: DvmContentDiscoveryRequestEvent, relays: Set<NormalizedRelayUrl>) -> Unit,
) {
val relays = nip65RelayList.inboxFlow.value.toSet()
val request = signer.sign<NIP90ContentDiscoveryRequestEvent>(NIP90ContentDiscoveryRequestEvent.build(dvmPublicKey.pubkeyHex, signer.pubKey, relays))
val request = signer.sign<DvmContentDiscoveryRequestEvent>(DvmContentDiscoveryRequestEvent.build(dvmPublicKey.pubkeyHex, signer.pubKey, relays))
val relayList =
dvmPublicKey.inboxRelays()?.toSet()?.ifEmpty { null }
@@ -3503,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
}
@@ -3519,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
@@ -3550,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<HexKey>): Boolean = users.all { isHidden(it) }
@@ -3602,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()
) {
@@ -3747,11 +3747,11 @@ class Account(
suspend fun saveTrustedRelayList(trustedRelays: List<NormalizedRelayUrl>) = sendMyPublicAndPrivateOutbox(trustedRelayList.saveRelayList(trustedRelays))
suspend fun saveRelayFeedsList(trustedRelays: List<NormalizedRelayUrl>) = sendMyPublicAndPrivateOutbox(relayFeedsList.saveRelayList(trustedRelays))
suspend fun saveFavoriteRelayList(trustedRelays: List<NormalizedRelayUrl>) = 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<NormalizedRelayUrl>) = sendMyPublicAndPrivateOutbox(blockedRelayList.saveRelayList(blockedRelays))
@@ -3783,7 +3783,7 @@ class Account(
proxyRelayList.getProxyRelayList(),
broadcastRelayList.getBroadcastRelayList(),
indexerRelayList.getIndexerRelayList(),
relayFeedsList.getRelayFeedsList(),
favoriteRelayList.getFavoriteRelayList(),
blockedRelayList.getBlockedRelayList(),
muteList.getMuteList(),
bookmarkState.getBookmarkList(),
@@ -4056,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)
}
@@ -4069,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)
}
@@ -29,8 +29,8 @@ import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.cache.filter
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel
import com.vitorpamplona.amethyst.commons.model.concord.ConcordCommunitySession
import com.vitorpamplona.amethyst.commons.model.concordChannelLastReadRoute
import com.vitorpamplona.amethyst.commons.viewmodels.ReplyMode
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.concordChannelLastReadRoute
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityList.withControlRoot
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
@@ -61,15 +61,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.AddressPin
import com.vitorpamplona.quartz.nip29RelayGroups.tags.EventPin
import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag
@@ -98,7 +98,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)
}
@@ -301,7 +301,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)
}
@@ -358,7 +358,7 @@ class AccountRelayGroupActions(
) { listOf(relay) }
val edit =
EditMetadataEvent.build(
GroupEditMetadataEvent.build(
groupId,
name = name,
about = about,
@@ -413,7 +413,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() }
}
@@ -426,7 +426,7 @@ class AccountRelayGroupActions(
channel: RelayGroupChannel,
pins: List<GroupPin>,
) {
val template = UpdatePinListEvent.build(channel.groupId.id, pins)
val template = GroupUpdatePinListEvent.build(channel.groupId.id, pins)
account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
}
@@ -488,7 +488,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() }
}
@@ -513,7 +513,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() }
}
@@ -574,7 +574,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,
@@ -602,7 +602,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() }
}
}
@@ -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<Boolean> = 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) }) {
@@ -1285,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()
}
}
@@ -1321,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()
}
}
@@ -1341,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()
}
}
@@ -29,9 +29,9 @@ import com.vitorpamplona.amethyst.commons.model.navigation.navBarItemsFromNames
import com.vitorpamplona.amethyst.commons.model.navigation.toNames
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.amethyst.commons.util.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<ImmutableList<Long>>,
val defaultZapType: MutableStateFlow<LnZapEvent.ZapType>,
val defaultZapType: MutableStateFlow<ZapReceiptEvent.ZapType>,
)
/**
@@ -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<Long> = DefaultOnchainZapAmounts,
val defaultZapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PUBLIC,
val defaultZapType: ZapReceiptEvent.ZapType = ZapReceiptEvent.ZapType.PUBLIC,
)
@Serializable
@@ -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<NormalizedRelayUrl>? = 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,
@@ -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;
@@ -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<NormalizedRelayUrl> = emptySet(),
val ids: Set<HexKey> = emptySet(),
val addresses: Set<String> = emptySet(),
val latestStatus: NIP90StatusEvent? = null,
val latestStatus: DvmStatusEvent? = null,
val errorMessage: String? = null,
)
@@ -139,9 +139,9 @@ class FavoriteAlgoFeedsOrchestrator(
launch {
account.cache
.observeLatestEvent<NIP90ContentDiscoveryResponseEvent>(
.observeLatestEvent<DvmContentDiscoveryResponseEvent>(
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<NIP90StatusEvent>(
.observeLatestEvent<DvmStatusEvent>(
Filter(
kinds = listOf(NIP90StatusEvent.KIND),
kinds = listOf(DvmStatusEvent.KIND),
tags = mapOf("e" to listOf(requestId)),
limit = 1,
),
@@ -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<NoteState> = dmListNote.flow().metadata.stateFlow
fun getDMRelayList(): ChatMessageRelayListEvent? = dmListNote.event as? ChatMessageRelayListEvent
fun getDMRelayList(): DmRelayListEvent? = dmListNote.event as? DmRelayListEvent
fun normalizeDMRelayListWithBackup(note: Note): Set<NormalizedRelayUrl> {
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<NormalizedRelayUrl>): ChatMessageRelayListEvent {
suspend fun saveRelayList(dmRelays: List<NormalizedRelayUrl>): 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)
}
}
@@ -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<Note>): 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)
}
@@ -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<LnZapPaymentRequestEvent, NormalizedRelayUrl> = sendNwcRequestToWallet(defaultWalletUri.value, request, onTimeout, onResponse)
): Pair<NwcRequestEvent, NormalizedRelayUrl> = 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<LnZapPaymentRequestEvent, NormalizedRelayUrl> {
): Pair<NwcRequestEvent, NormalizedRelayUrl> {
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<String, Any?>? = null,
onResponse: (Response?) -> Unit,
): Pair<LnZapPaymentRequestEvent, NormalizedRelayUrl> {
): Pair<NwcRequestEvent, NormalizedRelayUrl> {
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,
@@ -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<List<LabeledBookmarkListEvent>> =
labeledBookmarkListNotes
.transformLatest { emitAll(it.updateFlow<LabeledBookmarkListEvent>()) }
.onStart { emit(labeledBookmarkListNotes.value.events()) }
val latestBookmarkLists: StateFlow<List<BookmarkSetEvent>> =
bookmarkSetNotes
.transformLatest { emitAll(it.updateFlow<BookmarkSetEvent>()) }
.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<LabeledBookmarkListEvent>.toLabeledBookmarkListsFeed() = map { it.toLabeledBookmarkList() }.sortedBy { it.title }
suspend fun List<BookmarkSetEvent>.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<LabeledBookmarkList>.getList(bookmarkListId: String) =
fun List<BookmarkSet>.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<Note>): 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,
@@ -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<NoteState> = relayFeedListNote.flow().metadata.stateFlow
fun getFavoriteRelayListFlow(): StateFlow<NoteState> = 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<NormalizedRelayUrl> = relayFeedsListEvent(note)?.let { decryptionCache.relays(it) }?.ifEmpty { null } ?: emptySet()
suspend fun normalizeFavoriteRelayListWithBackup(note: Note): Set<NormalizedRelayUrl> = favoriteRelayListEvent(note)?.let { decryptionCache.relays(it) }?.ifEmpty { null } ?: emptySet()
suspend fun normalizeRelayFeedsListWithBackupNoDefaults(note: Note): Set<NormalizedRelayUrl> = relayFeedsListEvent(note)?.let { decryptionCache.relays(it) } ?: emptySet()
suspend fun normalizeFavoriteRelayListWithBackupNoDefaults(note: Note): Set<NormalizedRelayUrl> = 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<NormalizedRelayUrl>): RelayFeedsListEvent {
val relayFeedsList = getRelayFeedsList()
suspend fun saveRelayList(relayFeeds: List<NormalizedRelayUrl>): 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)
}
}
}
@@ -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<List<PeopleListEvent>> =
val latestLists: StateFlow<List<FollowSetEvent>> =
peopleListNotes
.transformLatest { emitAll(it.updateFlow<PeopleListEvent>()) }
.transformLatest { emitAll(it.updateFlow<FollowSetEvent>()) }
.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<PeopleListEvent>.mapToUserIdSet() = this.map { it.userIdSet() }.flattenToSet()
suspend fun List<FollowSetEvent>.mapToUserIdSet() = this.map { it.userIdSet() }.flattenToSet()
suspend fun List<PeopleListEvent>.mapGoodUsersToIdSet() =
suspend fun List<FollowSetEvent>.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<PeopleListEvent>.toUI() = this.map { it.toUI() }.sortedBy { it.title }
suspend fun List<FollowSetEvent>.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<Note>): 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,
@@ -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<List<FollowListEvent>> =
followListNotes
.transformLatest { emitAll(it.updateFlow<FollowListEvent>()) }
.onStart { emit(followListNotes.value.events()) }
val latestLists: StateFlow<List<StarterPackEvent>> =
starterPackNotes
.transformLatest { emitAll(it.updateFlow<StarterPackEvent>()) }
.onStart { emit(starterPackNotes.value.events()) }
.flowOn(Dispatchers.IO)
.stateIn(scope, SharingStarted.Eagerly, emptyList())
fun List<FollowListEvent>.mapToUserIdSet() = this.map { it.followIdSet() }.flattenToSet()
fun List<StarterPackEvent>.mapToUserIdSet() = this.map { it.followIdSet() }.flattenToSet()
val allPeopleListProfiles: StateFlow<Set<HexKey>> =
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<FollowListEvent>.toUI() = this.map { it.toUI() }
fun List<StarterPackEvent>.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<Note>): 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
@@ -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<NoteState> = hashtagListNote.flow().metadata.stateFlow
fun getInterestListFlow(): StateFlow<NoteState> = hashtagListNote.flow().metadata.stateFlow
fun getHashtagList(): HashtagListEvent? = hashtagListNote.event as? HashtagListEvent
fun getInterestList(): InterestListEvent? = hashtagListNote.event as? InterestListEvent
suspend fun hashtagListWithBackup(note: Note): Set<String> {
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<Set<String>> =
getHashtagListFlow()
getInterestListFlow()
.transformLatest { noteState ->
emit(hashtagListWithBackup(noteState.note))
}.onStart {
@@ -75,38 +75,38 @@ class HashtagListState(
emptySet(),
)
suspend fun follow(hashtags: List<String>): HashtagListEvent {
val hashtagList = getHashtagList()
suspend fun follow(hashtags: List<String>): 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)
}
}
}
@@ -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<Note>): 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)
}
@@ -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<DeletionEvent>()
.filterIsInstance<DeletionRequestEvent>()
.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 {
@@ -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,
),
@@ -33,6 +33,7 @@ import android.os.RemoteException
import android.os.SystemClock
import androidx.core.net.toUri
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.browser.BrowserSitePermission
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionLedger
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
import com.vitorpamplona.amethyst.commons.napplet.NappletBroker
@@ -42,6 +43,7 @@ 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.WebShortcuts
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.napplet.gateways.AccountNappletGateways
import com.vitorpamplona.amethyst.napplethost.NappletIpc
@@ -217,7 +219,7 @@ class NappletBrokerService : Service() {
return true
}
// The direct-WebView browser requests a favorite toggle for the current URL (main process only).
// The direct-WebView browser pins/unpins the page it shows (main process only).
if (msg.what == NappletIpc.MSG_TOGGLE_WEB_FAVORITE) {
val data = msg.data ?: return true
val url = data.getString(NappletIpc.KEY_FAVORITE_URL)?.takeIf { it.isNotBlank() } ?: return true
@@ -225,11 +227,69 @@ class NappletBrokerService : Service() {
val favorites = Amethyst.instance.favoriteApps
favorites.init()
val id = "url:$url"
if (favorites.isFavorite(id)) {
favorites.remove(id)
} else {
// 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 {
!favorites.isFavorite(id)
}
if (target) {
favorites.add(FavoriteApp.WebApp(url, label, System.currentTimeMillis()))
} else {
favorites.remove(id)
}
msg.replyTo?.let { replyWebFavoriteState(it, url) }
return true
}
// The direct-WebView browser asks whether the page it now shows is pinned, so its star is right.
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
Amethyst.instance.favoriteApps.init()
replyWebFavoriteState(replyTo, url)
return true
}
// The browser asks what the user already answered for a site's camera/microphone/location.
if (msg.what == NappletIpc.MSG_QUERY_SITE_PERMISSIONS) {
val replyTo = msg.replyTo ?: return true
val data = msg.data ?: return true
val origin = data.getString(NappletIpc.KEY_BROWSER_ORIGIN)?.takeIf { it.isNotBlank() } ?: return true
WebSitePermissionRegistry.init(applicationContext)
val reply =
Message.obtain(null, NappletIpc.MSG_SITE_PERMISSIONS).apply {
this.data =
Bundle().apply {
putLong(NappletIpc.KEY_REQUEST_ID, data.getLong(NappletIpc.KEY_REQUEST_ID))
putString(NappletIpc.KEY_BROWSER_ORIGIN, origin)
BrowserSitePermission.entries.forEach { permission ->
putString(NappletIpc.KEY_SITE_PERMISSION_PREFIX + permission.key, WebSitePermissionRegistry.decision(origin, permission).name)
}
}
}
runCatching { replyTo.send(reply) }
return true
}
// The browser relays the user's answer to a site's permission prompt; remember it per origin.
if (msg.what == NappletIpc.MSG_SET_SITE_PERMISSION) {
val data = msg.data ?: return true
val origin = data.getString(NappletIpc.KEY_BROWSER_ORIGIN)?.takeIf { it.isNotBlank() } ?: return true
val permission = BrowserSitePermission.fromKey(data.getString(NappletIpc.KEY_SITE_PERMISSION)) ?: return true
val decision = runCatching { BrowserSitePermission.Decision.valueOf(data.getString(NappletIpc.KEY_SITE_DECISION).orEmpty()) }.getOrNull() ?: return true
WebSitePermissionRegistry.init(applicationContext)
WebSitePermissionRegistry.set(origin, permission, decision)
return true
}
// The full-screen browser's "Add to Home screen": pin a shortcut that reopens it in Amethyst.
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
Amethyst.instance.browserIcons.init()
WebShortcuts.requestPin(applicationContext, url, data.getString(NappletIpc.KEY_FAVORITE_LABEL).orEmpty())
return true
}
@@ -460,6 +520,26 @@ class NappletBrokerService : Service() {
}
}
/** Tells a browser surface whether [url] is currently pinned, so its star shows the registry's truth. */
private fun replyWebFavoriteState(
replyTo: Messenger,
url: String,
) {
val message =
Message.obtain(null, NappletIpc.MSG_WEB_FAVORITE_STATE).apply {
data =
Bundle().apply {
putString(NappletIpc.KEY_FAVORITE_URL, url)
putBoolean(NappletIpc.KEY_FAVORITE_IS_FAVORITE, Amethyst.instance.favoriteApps.isFavorite("url:$url"))
}
}
try {
replyTo.send(message)
} catch (e: RemoteException) {
Log.w("NappletBrokerService", "Browser went away before the favorite state could be delivered", e)
}
}
/** Sends an unsolicited push (a `relay.event`/`relay.eose` envelope) for the host to forward verbatim. */
private fun push(
replyTo: Messenger,
@@ -67,7 +67,7 @@ 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
@@ -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
@@ -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.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 com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.browser.BrowserSitePermission
import com.vitorpamplona.amethyst.commons.browser.BrowserSitePermission.Decision
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.flow.update
import kotlinx.coroutines.launch
/**
* 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<Preferences>
get() = Amethyst.instance.appStores.getDataStore("web_site_permissions")
/**
* The user's answers to web sites' camera / microphone / location requests, per **origin**
* (`https://example.com`), the way Chrome's site settings keep them. Absent = [Decision.ASK]: the browser
* prompts the next time the site asks. The same answer holds on Tor and the open web — routing never
* changes it.
*
* Lives only in the **main process**. The keyless browser reads and writes it through the broker
* ([com.vitorpamplona.amethyst.napplethost.NappletIpc.MSG_QUERY_SITE_PERMISSIONS] /
* [com.vitorpamplona.amethyst.napplethost.NappletIpc.MSG_SET_SITE_PERMISSION]); the Connected Apps detail
* screen shows and resets it. In-memory state is authoritative for the session, written through to a
* DataStore stored under `"<origin>|<permission>"` keys.
*/
object WebSitePermissionRegistry {
private val _decisions = MutableStateFlow<Map<String, Map<BrowserSitePermission, Decision>>>(emptyMap())
/** origin → permission → decision (only answered permissions appear). */
val decisions: StateFlow<Map<String, Map<BrowserSitePermission, Decision>>> = _decisions.asStateFlow()
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@Volatile private var appContext: Context? = null
fun init(context: Context) {
if (appContext != null) return
val ctx = context.applicationContext
appContext = ctx
scope.launch {
val loaded = mutableMapOf<String, MutableMap<BrowserSitePermission, Decision>>()
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
loaded.getOrPut(origin) { mutableMapOf() }[permission] = decision
}
// Answers given this session (before hydration finished) win over the disk copy.
_decisions.update { current ->
(loaded.keys + current.keys).associateWith { origin -> loaded[origin].orEmpty() + current[origin].orEmpty() }
}
}
}
fun decision(
origin: String,
permission: BrowserSitePermission,
): Decision = _decisions.value[origin]?.get(permission) ?: Decision.ASK
fun set(
origin: String,
permission: BrowserSitePermission,
decision: Decision,
) {
_decisions.update { current ->
val forOrigin = current[origin].orEmpty().toMutableMap()
if (decision == Decision.ASK) forOrigin.remove(permission) else forOrigin[permission] = decision
if (forOrigin.isEmpty()) current - origin else current + (origin to forOrigin)
}
if (appContext == null) return
scope.launch {
webSitePermissionDataStore.edit { prefs ->
val key = stringPreferencesKey("$origin|${permission.key}")
if (decision == Decision.ASK) prefs.remove(key) else prefs[key] = decision.name
}
}
}
}
@@ -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 {
@@ -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<String>) -> 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<PodcastValueShare>,
message: String,
asZap: Boolean,
zapType: LnZapEvent.ZapType,
zapType: ZapReceiptEvent.ZapType,
zappedNote: Note?,
okHttpClient: (String) -> OkHttpClient,
context: Context,
@@ -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<Payable>) -> 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<MyZapSplitSetup>,
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(
@@ -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()
@@ -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)
@@ -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
}
@@ -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<Int> =
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
@@ -30,10 +30,10 @@ import androidx.core.content.ContextCompat
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.composer.NewMessageTagger
import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.cancelAndPrune
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.cancelChildlessGroupSummaries
import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
@@ -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 {
@@ -23,12 +23,12 @@ package com.vitorpamplona.amethyst.service.notifications
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.purposes
import com.vitorpamplona.amethyst.commons.relays.ui.SubPurposeLabels
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.relay_purpose_browsing
import com.vitorpamplona.amethyst.commons.resources.relay_purpose_line
import com.vitorpamplona.amethyst.commons.ui.loadPluralStringRes
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.SubPurposeLabels
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
/**
@@ -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)
@@ -24,12 +24,12 @@ import android.content.Context
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.app_notification_payments_channel_message
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.commons.util.showAmount
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.notifications.NotificationCategory
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.nip47WalletConnect.rpc.NwcTransaction
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -31,6 +31,7 @@ import com.vitorpamplona.amethyst.commons.resources.app_notification_zaps_channe
import com.vitorpamplona.amethyst.commons.resources.app_notification_zaps_channel_message_for
import com.vitorpamplona.amethyst.commons.resources.app_notification_zaps_channel_message_from
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.commons.util.showAmount
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.notifications.NotificationCategory
import com.vitorpamplona.amethyst.service.notifications.NotificationContent
@@ -38,9 +39,8 @@ import com.vitorpamplona.amethyst.service.notifications.NotificationEnricher
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)
@@ -22,9 +22,13 @@ package com.vitorpamplona.amethyst.service.playback
/**
* Shared logcat tag for the playback diagnostic trace (source routing, player lifecycle, error
* recovery, HLS liveness learning). Emitted with `Log.d`, so it appears only in a debug build
* (`Log.minLevel = DEBUG`) and is silent in benchmark/release (`ERROR`). To capture a playback
* investigation, install a debug build and run:
* recovery, HLS liveness learning).
*
* Emitted with `Log.d`, which fires only while `Log.minLevel <= DEBUG`. No shipped variant is
* there by default: [com.vitorpamplona.amethyst.Amethyst.DEFAULT_LOG_LEVEL] gives debug AND
* benchmark builds `INFO` (the `benchmark` build type counts as `isDebug`) and release `WARN`, so
* this trace is silent everywhere until `Amethyst.VERBOSE_LOGS` is flipped to true. To capture a
* playback investigation, set that flag, install a debug build and run:
*
* ```
* adb logcat -s PlaybackDiag
@@ -148,9 +148,11 @@ const val VIDEO_QUALITY_TAG = "VideoQuality"
* Traces which rendition adaptive selection landed on, against the full ladder the manifest
* offered.
*
* The listener is registered only when the trace can actually be emitted — debug builds set
* `Log.minLevel = DEBUG` while benchmark/release set `ERROR` (see [PLAYBACK_DIAG_TAG]) — so the
* release path keeps the "no listener per player" property that dropping the old selector bought.
* The listener is registered only when the trace can actually be emitted. `Log.minLevel` is above
* `DEBUG` in every variant by default — `INFO` for debug and benchmark builds, `WARN` for release
* (see [PLAYBACK_DIAG_TAG]) — so this costs nothing until `Amethyst.VERBOSE_LOGS` is turned on,
* and every build keeps the "no listener per player" property that dropping the old selector
* bought.
*/
@Composable
fun LogVideoQualitySelection(player: Player) {
@@ -36,9 +36,9 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.media3.common.Player
import com.linc.audiowaveform.infiniteLinearGradient
import com.vitorpamplona.amethyst.commons.ui.components.AudioWaveformReadOnly
import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState
import com.vitorpamplona.amethyst.service.playback.composable.WaveformData
import com.vitorpamplona.amethyst.ui.components.AudioWaveformReadOnly
@Composable
fun Waveform(
@@ -27,9 +27,11 @@ import androidx.media3.exoplayer.audio.TeeAudioProcessor
import com.vitorpamplona.amethyst.commons.audio.AudioWindow
import com.vitorpamplona.amethyst.commons.audio.Fft
import com.vitorpamplona.amethyst.commons.audio.Spectrum
import com.vitorpamplona.amethyst.commons.audio.SpectrumTrail
import com.vitorpamplona.amethyst.commons.audio.normalizeToPeakInPlace
import com.vitorpamplona.amethyst.commons.audio.toLogBins
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import java.nio.ByteBuffer
@@ -65,6 +67,10 @@ class SpectrumAudioBufferSink(
private var channels = 1
private var encoding = C.ENCODING_PCM_16BIT
// Audio time one fft frame covers: fftSize samples per channel at the stream's sample rate.
// Zero until the first flush reports a rate, which leaves the frame unpaced rather than wrong.
private var frameDurationNanos = 0L
@kotlin.OptIn(ExperimentalCoroutinesApi::class)
override fun flush(
sampleRateHz: Int,
@@ -73,6 +79,7 @@ class SpectrumAudioBufferSink(
) {
this.channels = channelCount.coerceAtLeast(1)
this.encoding = encoding
this.frameDurationNanos = if (sampleRateHz > 0) fftSize * 1_000_000_000L / sampleRateHz else 0L
filled = 0
output?.resetReplayCache()
}
@@ -98,7 +105,7 @@ class SpectrumAudioBufferSink(
// Skip the DC bin (index 0): toLogBins ignores it, so letting a DC/offset component be the
// peak would scale every audible bin toward zero and wash the spectrum out.
mags.normalizeToPeakInPlace(fromIndex = 1)
output?.tryEmit(Spectrum(mags.toLogBins(binCount)))
output?.tryEmit(Spectrum(mags.toLogBins(binCount), frameDurationNanos))
}
}
@@ -167,7 +174,15 @@ object PcmTapRegistry {
if (!fedByLiveSink && !stillCollected) iter.remove()
}
}
MutableSharedFlow(replay = 1, extraBufferCapacity = 1)
// Frames arrive in clusters (see SpectrumTrail) emitted within a few ms on the audio
// thread, while the collector sits on the main dispatcher and cannot run in between;
// a 2-slot buffer kept ~2 frames of each cluster and dropped the rest. Hold as much as
// the visualizer's own backlog, which is the real lag bound, dropping the stalest.
MutableSharedFlow(
replay = 1,
extraBufferCapacity = SpectrumTrail.MAX_BACKLOG_FRAMES,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
}
}
}
@@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.commons.relayClient.badges.BadgesFilterAssembl
import com.vitorpamplona.amethyst.commons.relayClient.badges.profile.ProfileBadgesFilterAssembler
import com.vitorpamplona.amethyst.commons.relayClient.calendars.CalendarsFilterAssembler
import com.vitorpamplona.amethyst.commons.relayClient.channel.ChannelFilterAssembler
import com.vitorpamplona.amethyst.commons.relayClient.channel.relayGroup.RelayGroupOpenThreadsFilterAssembler
import com.vitorpamplona.amethyst.commons.relayClient.chess.ChessFilterAssembler
import com.vitorpamplona.amethyst.commons.relayClient.communities.CommunityFilterAssembler
import com.vitorpamplona.amethyst.commons.relayClient.communities.list.CommunitiesListFilterAssembler
@@ -85,7 +86,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayG
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupJoinedStateFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupOpenChatHistoryFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupOpenChatTailFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupOpenThreadsFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupOpenThreadsHistoryFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupsDiscoveryFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupsOnRelayFilterAssembler
@@ -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,
@@ -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(
@@ -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<Nickname?> {
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<Boolean> {
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<String> {
@@ -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)
@@ -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<VideoHorizontalEvent>,
val template: EventTemplate<AddressableNormalVideoEvent>,
) : HlsVideoEventTemplate()
data class Vertical(
val template: EventTemplate<VideoVerticalEvent>,
val template: EventTemplate<AddressableShortVideoEvent>,
) : 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) }
@@ -32,6 +32,7 @@ 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.richtext.RichTextParser
import com.vitorpamplona.amethyst.commons.ui.note.elements.NowProvider
import com.vitorpamplona.amethyst.debugState
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.lang.LanguageTranslatorService
@@ -43,7 +44,6 @@ import com.vitorpamplona.amethyst.ui.navigation.findParameterValue
import com.vitorpamplona.amethyst.ui.navigation.findQueryParameterValue
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.navigation.routes.routeForPointer
import com.vitorpamplona.amethyst.ui.note.elements.NowProvider
import com.vitorpamplona.amethyst.ui.screen.AccountScreen
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
import com.vitorpamplona.quartz.buzz.invite.BuzzInviteLink
@@ -78,11 +78,14 @@ 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.service.upload.ui.StrippingFailureDialog
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
import com.vitorpamplona.amethyst.commons.ui.note.creators.invoice.AddLnInvoiceButton
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.text.MentionPreservingInputTransformation
import com.vitorpamplona.amethyst.commons.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.commons.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.commons.ui.theme.Size10dp
@@ -98,7 +101,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
@@ -38,6 +38,8 @@ import com.vitorpamplona.amethyst.commons.resources.failed_to_upload_media_no_de
import com.vitorpamplona.amethyst.commons.resources.login_with_a_private_key_to_be_able_to_sign_events
import com.vitorpamplona.amethyst.commons.resources.read_only_user
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.commons.service.upload.MediaUploadTracker
import com.vitorpamplona.amethyst.commons.service.upload.SuspendableConfirmation
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.commons.ui.text.currentWord
import com.vitorpamplona.amethyst.commons.ui.text.insertUrlAtCursor
@@ -45,9 +47,7 @@ import com.vitorpamplona.amethyst.commons.ui.text.onUiThread
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
import com.vitorpamplona.amethyst.service.uploads.SuspendableConfirmation
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.ui.actions.uploads.MediaUploadTracker
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState
@@ -36,11 +36,11 @@ import com.vitorpamplona.amethyst.commons.resources.failed_to_upload_media_no_de
import com.vitorpamplona.amethyst.commons.resources.login_with_a_private_key_to_be_able_to_sign_events
import com.vitorpamplona.amethyst.commons.resources.read_only_user
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.commons.service.upload.SuspendableConfirmation
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
import com.vitorpamplona.amethyst.service.uploads.SuspendableConfirmation
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing
@@ -71,6 +71,7 @@ import com.vitorpamplona.amethyst.commons.resources.strip_metadata_description
import com.vitorpamplona.amethyst.commons.resources.strip_metadata_label
import com.vitorpamplona.amethyst.commons.resources.video_codec_h265_description
import com.vitorpamplona.amethyst.commons.resources.video_codec_h265_label
import com.vitorpamplona.amethyst.commons.service.upload.ui.StrippingFailureDialog
import com.vitorpamplona.amethyst.commons.ui.components.TextSpinner
import com.vitorpamplona.amethyst.commons.ui.components.TitleExplainer
import com.vitorpamplona.amethyst.commons.ui.insets.imePaddingSafe
@@ -26,6 +26,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.style.TextDecoration
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.ui.text.MENTION_REGEX
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
import kotlin.coroutines.cancellation.CancellationException
@@ -61,6 +61,7 @@ import androidx.compose.ui.unit.sp
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.mediaServers.ServerHealth
import com.vitorpamplona.amethyst.commons.mediaServers.ui.MediaServerEditField
import com.vitorpamplona.amethyst.commons.model.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerName
@@ -23,6 +23,8 @@ package com.vitorpamplona.amethyst.ui.actions.mediaServers
import androidx.compose.runtime.Stable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.commons.mediaServers.MediaServerHealthProbe
import com.vitorpamplona.amethyst.commons.mediaServers.ServerHealth
import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerName
import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerType
import com.vitorpamplona.amethyst.commons.service.http.IRoleBasedHttpClientBuilder
@@ -37,6 +37,8 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.audio.ExpandingCirclesAnimation
import com.vitorpamplona.amethyst.commons.audio.FloatingRecordingIndicator
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.resources.Res
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.actions.uploads
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.vitorpamplona.amethyst.commons.audio.VoicePreset
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
@@ -32,6 +32,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.audio.VoicePreset
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.voice_anonymize_description
import com.vitorpamplona.amethyst.commons.resources.voice_anonymize_title
@@ -25,6 +25,8 @@ import android.media.MediaCodecInfo
import android.media.MediaExtractor
import android.media.MediaFormat
import android.media.MediaMuxer
import com.vitorpamplona.amethyst.commons.audio.PitchShifter
import com.vitorpamplona.amethyst.commons.audio.VoicePreset
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
@@ -57,8 +57,9 @@ import com.vitorpamplona.amethyst.commons.resources.re_record
import com.vitorpamplona.amethyst.commons.resources.record_a_message
import com.vitorpamplona.amethyst.commons.resources.recording_indicator_description
import com.vitorpamplona.amethyst.commons.resources.remove
import com.vitorpamplona.amethyst.commons.ui.components.AudioWaveformReadOnly
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.ui.components.AudioWaveformReadOnly
import com.vitorpamplona.amethyst.commons.util.formatSecondsToTime
import com.vitorpamplona.quartz.nipA0VoiceMessages.AudioMeta
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.delay

Some files were not shown because too many files have changed in this diff Show More