mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 03:38:23 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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/` |
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+6
-8
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
+16
-16
@@ -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>,
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
+2
-2
@@ -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;
|
||||
|
||||
+7
-7
@@ -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,
|
||||
),
|
||||
|
||||
+8
-8
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+4
-4
@@ -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)
|
||||
}
|
||||
|
||||
+9
-9
@@ -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,
|
||||
|
||||
+56
-56
@@ -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,
|
||||
+32
-32
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+29
-29
@@ -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,
|
||||
+28
-28
@@ -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
|
||||
+26
-26
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -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)
|
||||
}
|
||||
|
||||
+4
-4
@@ -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 {
|
||||
|
||||
+15
-15
@@ -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
|
||||
|
||||
+115
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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(
|
||||
|
||||
+4
-4
@@ -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()
|
||||
|
||||
|
||||
+13
-13
@@ -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)
|
||||
|
||||
+5
-5
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+15
-15
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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 {
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
/**
|
||||
|
||||
+2
-2
@@ -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)
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
+5
-5
@@ -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
|
||||
|
||||
+5
-3
@@ -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) {
|
||||
|
||||
+1
-1
@@ -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(
|
||||
|
||||
+17
-2
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+10
-10
@@ -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,
|
||||
|
||||
+2
-2
@@ -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(
|
||||
|
||||
+19
-19
@@ -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> {
|
||||
|
||||
+3
-3
@@ -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)
|
||||
|
||||
|
||||
+7
-7
@@ -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
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+2
@@ -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
|
||||
|
||||
+2
@@ -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
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+2
@@ -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
|
||||
|
||||
+2
-1
@@ -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
Reference in New Issue
Block a user