Compare commits

..
Author SHA1 Message Date
Vitor Pamplona 1bda3ab31b v1.13.1 2026-07-28 23:17:02 -04:00
Vitor PamplonaandGitHub 87b83a7c16 Merge pull request #3792 from vitorpamplona/claude/highlight-author-attribution-25e4nb
Fix highlight author detection and improve source display
2026-07-28 22:21:02 -04:00
Vitor PamplonaandGitHub 551add4be0 Merge pull request #3791 from vitorpamplona/claude/highlighted-text-richtext-parser-88ghgv
Pass comment tags to DisplayHighlight for proper rendering
2026-07-28 22:16:53 -04:00
Claude 3eb8914c3e fix(highlights): attribute to the author-marked p tag, drop alt captions
A kind:9802 highlight that mentions users before its author rendered the
wrong name and a stray "published by …" caption.

- HighlightEvent.author() took the first p tag regardless of role, so a
  highlight with leading "mention" p tags was attributed to a mention
  instead of the "author"-marked one. Prefer the NIP-84 author marker,
  falling back to the first p tag for highlights (including Amethyst's own)
  that omit the marker.

- DisplayEntryForNote used the source note's title/subject/alt as a caption.
  For a kind-1 note there is no title/subject, so it fell to the NIP-31 alt
  tag — which clients like Jumble fill with a generic "This event was
  published by https://jumble.imwald.eu." line. Drop alt from the lookup and
  name the source by its event kind instead, kept clickable to the note.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LJh3QYonEa9hw6mJcnrmve
2026-07-29 02:07:11 +00:00
Claude 74be87c305 fix: render custom emoji in highlight comments
The NIP-84 highlight comment was passed to TranslatableRichTextViewer with
an empty tag list, so custom emoji (:shortcode:) never resolved against the
event's `emoji` tags and rendered as raw text.

Thread the highlight event's tags through to the comment's rich-text viewer
so the emoji map is built from them, matching how regular text notes render.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FyyodHWxSHeiY6pndPxGSb
2026-07-29 02:04:05 +00:00
Vitor PamplonaandGitHub ebf04de516 Merge pull request #3790 from vitorpamplona/claude/home-screen-event-toggles-q75xwc
Add per-content-type toggles for Home feed event kinds
2026-07-28 20:55:56 -04:00
Vitor PamplonaandGitHub 79e70e1161 Merge pull request #3789 from vitorpamplona/claude/buzz-media-upload-decode-gc5hxm
Add BUD-01 read-auth retry for gated Blossom blob downloads
2026-07-28 20:40:00 -04:00
Vitor PamplonaandGitHub 3411f75c4a Merge pull request #3788 from vitorpamplona/claude/nip29-wisp-relay-test-dloy9s
Support NIP-29 group relays in first-party AUTH logic
2026-07-28 20:38:50 -04:00
Claude defb898987 refactor: use Hex.isHex64 instead of a regex for the blob-id check
blossomHashOrNull runs on every media URL the feed loads. Quartz's unrolled
Hex.isHex64 is the fast path for validating a 32-byte hex id (~30% faster
than isHex, far faster than a regex match). It only checks the first 64
chars and doesn't verify length, so keep an explicit length == 64 guard to
reject longer segments.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ao9w26c2gAm4gjJdhgvLyp
2026-07-29 00:35:53 +00:00
Claude 7d1c45607b perf: preemptively sign Blossom reads for known auth-gated hosts
The retry-on-401 interceptor re-probed anonymously on every blob, so each
image from an auth-gated host (e.g. a Buzz community feed, where nearly all
media is on one host) paid a wasted 401 round trip before the signed retry.

Remember hosts that answered 401 and attach the cached token up front on
their subsequent blobs — one round trip instead of two in steady state. The
first blob per host still costs the probe; the learned set falls back to an
anonymous request when no signer is available so a logged-out user never
loops.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ao9w26c2gAm4gjJdhgvLyp
2026-07-28 23:54:31 +00:00
Vitor Pamplona bcfe2745f2 Merge remote-tracking branch 'upstream/main' 2026-07-28 19:50:55 -04:00
Vitor PamplonaandGitHub 723c90c0d7 Merge pull request #3787 from vitorpamplona/claude/geohash-single-level-precision-gise2y
Add CONTINENT geohash level for whole-globe location channels
2026-07-28 19:47:52 -04:00
Vitor PamplonaandClaude Opus 5 096fd0833a Merge PR: fix(desktop): cache OS Keyring handle so startup only prompts once
Merges nostr proposal ed443f87 into main:
- Cache the Keyring behind double-checked locking so the OS backend is
  opened once per SecureKeyStorage rather than per save/get/delete. Cold
  start touches storage at least twice (metadata AES key, then the active
  account nsec), which surfaced as two keychain unlock prompts.
- Add a KeyringHandle seam so the test suite can substitute an in-memory
  backend instead of touching the real OS keychain, plus tests pinning the
  single-open invariant, including a 16-thread concurrent first-touch race.

Sharing one handle across Dispatchers.IO threads is safe on all three
backends: the macOS path resolves to pt.davidafsilva.apple.OSXKeychain,
itself a static singleton with no mutable instance state, so every
Keyring.create() already shared it; the Freedesktop and Windows backends
hold a single final collaborator assigned in their constructor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 19:46:46 -04:00
Vitor PamplonaandClaude Opus 5 1a82c9b9c8 Merge PR: fix(desktop): reload the visible page when switching accounts
Merges nostr proposal 22a8247d into main:
- Wrap MainContent in key(account.pubKeyHex) so the account-scoped subtree
  is torn down on an account switch. Deck layout and workspace state are
  remembered outside this key and survive.

Fixes a stale-identity leak, not just staleness: NotificationsScreen holds
its accumulated items in an unkeyed `remember { EventCollectionState(...) }`,
so account A's notifications stayed on screen under account B even though
the subscription below re-keyed correctly. ReadsScreen has the same unkeyed
collection, and DeckColumnContainer's ColumnNavigationState is keyed on
column.id only, so each column's screen stack also survived the switch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 19:45:57 -04:00
Claude 7d1caf590a fix: authenticate to NIP-29 host relays of joined groups so private group content loads
A private/closed NIP-29 group serves its content (kind-9 chat, kind-11
threads, …) only over a NIP-42-authenticated connection. Amethyst's
group-content reads are all `#h`-scoped with all authors, so they never
name the user's pubkey, and a group host relay (e.g. wss://chat.wisp.talk)
is not in any of the NIP-65/DM/search/… lists that feed
`account.trustedRelays`. The per-account first-party AUTH gate therefore
returned false and Amethyst never AUTHed, so the relay refused the content
with `auth-required` — the group's public 39000 metadata still loaded, so
the group appeared but showed no messages.

Treat a NIP-29 relay group the user explicitly joined (their kind-10009
list) as a first-party reason to authenticate with its host relay,
mirroring the existing `BuzzWorkspaces.isJoined` carve-out. Reads of a
group the user has not joined (e.g. browsing a relay's public directory)
still do not AUTH.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018puT2YnevbLHYG2PdCLAeh
2026-07-28 23:45:49 +00:00
Claude 493aed8609 feat: sign Blossom read-auth to view media on auth-gated hosts
Buzz's private media relay (*.communities.buzz.xyz) gates blob downloads
behind BUD-01 read auth, returning `401 {"error":"authentication failed"}`
to anonymous GETs. Amethyst loaded every media URL anonymously through
Coil/OkHttp, so those images (and their thumbnails) never decoded.

Quartz already had BlossomAuthorizationEvent.createGetAuth but nothing in
the app ever called it. Wire it into the media HTTP client:

- BlossomReadAuthInterceptor: on a 401 for a GET whose URL last segment is
  a Blossom sha256 filename (covers `<hash>.png` and `<hash>.thumb.jpg`),
  retry once with a signed `Authorization: Nostr <event>` header. Narrowly
  gated so unrelated 401s never trigger a second request or any signing.
- BlossomReadAuthTokenProvider: bridges the suspend signer synchronously
  (runBlocking + timeout so a slow remote/external signer can't pin the
  OkHttp thread) and caches one server-scoped token per host, which also
  covers derived blobs like thumbnails.
- BlossomAuth.createGetAuth exposes the read-auth builder to the app layer.

Public Blossom/NIP-96 hosts stay zero-overhead: they answer 200, so no
token is ever signed for them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ao9w26c2gAm4gjJdhgvLyp
2026-07-28 23:25:57 +00:00
Claude cdfc275891 feat(home): add per-event-kind toggles for the home feed
Add a "Content in the feed" section to the Home settings screen with a
switch per event-kind group (text notes, reposts, comments, articles,
polls, voice, live activities, chess, ...). Disabling a group both drops
its kinds from the always-on home relay filters (the assembler) and hides
them from the New Threads / Conversations / Everything tabs (the DAL).

- HomeFeedType: single source of truth mapping each toggleable group to
  its Nostr kinds, with stable codes + encode/decode for persistence
  (mirrors the existing ChatFeedType pattern for Messages).
- AccountSettings.enabledHomeFeedTypes (+ setHomeFeedTypeEnabled),
  persisted per-account as the set of disabled codes so new groups
  default on.
- Assembler: HomeOutboxEventsEoseManager strips disabled kinds from every
  home relay filter at the single choke point, and re-arms on toggle.
- DAL: HomeNewThreadFeedFilter / HomeConversationsFeedFilter reject
  disabled kinds; AccountFeedContentStates rebuilds the home feeds when a
  toggle flips.

Also extracts the inbox / relay-auth / feed-type preference reads out of
LocalPreferences' account-load lambda into a helper: that lambda was
already at the JVM per-method bytecode limit and the new field tipped it
over ("Method too large").

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L576BhgU3c638PkGHL8YUS
2026-07-28 23:22:50 +00:00
Vitor PamplonaandGitHub 26e09339b5 Merge pull request #3786 from vitorpamplona/fix/import-nits-merged-proposals
style: import the symbols the two merged nostr proposals referenced inline
2026-07-28 19:21:40 -04:00
Claude 5f3848c47d feat: allow single-char (continent) geohash precision in location channels
Add a CONTINENT(1) level to GeohashChannelLevel so the coarsest selectable
geohash channel is a single character (~5000 km, one of 32 cells for the
globe), previously floored at REGION(2). Every precision picker iterates
GeohashChannelLevel.ordered, so the map picker, Teleport, "Near me" list and
New-geohash-chat all pick it up automatically; GeohashChatsScreen now labels a
1-char cell "Continent" instead of showing no level.

Adds the "Continent" label and "~5000 km" chip subtitle, and extends the
GeohashChannelLevel test coverage. REGION..BUILDING (2-8 chars) still mirror
the Bitchat channel levels; CONTINENT is an Amethyst extension beyond them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vtvo2iNSnG3M21QwbFPznU
2026-07-28 23:20:54 +00:00
Vitor PamplonaandClaude Opus 5 9e69198623 style: import the symbols the two merged proposals referenced inline
Both nostr proposals merged just now (82e72369, bdb4b03e) referenced types
by fully-qualified name inside function bodies, which CLAUDE.md's Kotlin
style rule forbids. Merged them as authored rather than rewriting someone
else's patch mid-merge; this is the follow-up.

- NamecoinNameResolver: import kotlinx.coroutines.CancellationException.
  Both catch sites were inline-qualified (one added by the proposal, one
  already there), and the sibling resolvers in this same package
  (Nip05Client, UserHexResolver) already import it.
- desktop Main.kt: import the five notification symbols in the block the
  proposal touched — the two Preferences* factories and the three
  Local*Notification* CompositionLocals. Each occurs exactly once, so no
  fully-qualified stragglers are left behind for those names.

Deliberately scoped to that block: Main.kt has ~130 other inline
fully-qualified names, and sweeping them belongs in its own change, not
tacked onto a style follow-up.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 19:15:29 -04:00
Vitor PamplonaandClaude Opus 5 4e42528591 Merge PR: fix(namecoin): don't leak lookup exceptions through resolve()
Merges nostr proposal bdb4b03e into main:
- Catch NamecoinLookupException in performLookup and return null, restoring
  resolve()'s documented "null on any failure" contract. nameShowWithFallback
  throws NameNotFound / NameExpired / ServersUnreachable, which escaped
  resolve() and reached Nip05State.checkAndUpdate as a hard error.
- CancellationException is rethrown first so coroutine cancellation is not
  swallowed.
- resolveDetailed() is unaffected: it goes through performLookupDetailed(),
  which still distinguishes each failure reason.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 19:06:54 -04:00
Vitor PamplonaandClaude Opus 5 02c2bea1f3 Merge PR: fix(desktop): make Enable OS notifications button actually enable them
Merges nostr proposal 82e72369 into main:
- Provide LocalNotificationSettings / LocalNotificationReadState at the app
  root. Both CompositionLocals were already consumed by NotificationsScreen
  and NotificationSettingsScreen but never provided, so each screen fell back
  to its own PreferencesNotificationSettings(); disk prefs stayed in sync but
  the in-memory StateFlow updates never crossed instances.
- Flip the master toggle when the OS permission is granted, so the "Enable OS
  notifications" button matches its label end to end.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 19:06:05 -04:00
Vitor PamplonaandGitHub ddbee398ca Merge pull request #3785 from vitorpamplona/fix/buzz-channel-row-background
fix(buzz): drop the grey slab behind the community Channels section
2026-07-28 18:48:08 -04:00
Vitor PamplonaandClaude Opus 5 15a389b155 fix(buzz): separate community rows with a hairline
Removing the per-row Cards also removed the only thing separating adjacent
rows. Restore that with the 0.25dp outlineVariant hairline the vanilla
NIP-29 branch of this same screen and the Concord server list already use.

Drawn before each row except the first, so a section's own header keeps
providing the separation at its boundaries — no stray line under the last
channel or above "Direct Messages". Applied to every row list on the screen
(chat channels, forums, inline DMs, hidden DMs) rather than just the
Channels section, so the screen stays one consistent surface; the vanilla
branch now shares the same helper instead of inlining it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 18:45:13 -04:00
Vitor PamplonaandClaude Opus 5 4c5fe75f0a fix(buzz): drop the grey slab behind the community Channels section
Each channel row was its own filled Material3 Card. They stack with no gaps,
so their container colour merged into one continuous grey block behind the
whole Channels (and Forums) section — reading as a box drawn around the
channels that the Direct Messages rows immediately below, which are plain
rows, didn't have. The two halves of the same screen looked like different
surfaces.

Render the row as a plain Box on the screen background instead, keeping the
click on the container so the whole row still opens the channel. Matches
BuzzDmInlineRow directly below it and the Concord server list.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 18:35:05 -04:00
Vitor PamplonaandGitHub 0373032339 Merge pull request #3784 from vitorpamplona/fix/concord-home-open-target
fix(concord): open a community by tapping its name, not just its avatar
2026-07-28 18:19:24 -04:00
mandClaude 74579778e2 fix(desktop): reload the visible page when switching accounts
`AccountManager.switchAccount()` correctly emits a new
`AccountState.LoggedIn` and the `remember(account, ...)` blocks around
`iAccount` / `accountRelays` in Main.kt already rebuild those. But the
Composables *inside* MainContent — feed screens, notification inbox,
profile screen, messages list — all held their own `remember { ... }`
state (LazyListState scroll position, expanded rows, per-column filter
tabs, in-flight metadata observers, view-models keyed on nothing) that
did NOT include the account as a key. So after a profile switch the
user still saw account A's feed items, notification unread state, and
follow-status overlays rendered under account B's identity, until they
navigated away and back to force the column to recompose from scratch.

Wrap the `MainContent(...)` call in `key(account.pubKeyHex) { ... }`.
This is the idiomatic Compose pattern for "identity changed — tear
down the entire subtree and rebuild it fresh": every child's
`remember { ... }` block re-runs, every `LaunchedEffect` re-enters,
every subscription restarts. Outer state (`deckState`, `workspaceManager`,
`singlePaneState`, `pinnedNavBarState`) lives above this call site so
the user's column layout / workspace / nav backstack are preserved —
only the account-owned content resets.

Concretely, after this change:

- Home Feed on account A → switch to account B → column now shows
  account B's home feed, following account B's follow-list.
- Notifications tab on A → switch to B → tab reloads with B's unread
  cursor, B's notification-kind toggles, B's mute/block enforcement.
- Direct Messages column stays on the Messages screen, but the
  chatroom list is B's, DM subscriptions restart against B's kind:10050
  inbox relays, giftwrap decryption uses B's signer.
- Profile screen viewing @alice: still viewing @alice, but the follow
  button / mute button reflect B's follow/mute state instead of A's.

No new tests: verifying this needs a Compose UI test harness Amethyst
Desktop doesn't ship yet. The scoped-teardown behaviour of `key()` is
Compose runtime contract, not app-level state to pin down.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-29 08:13:31 +10:00
mandClaude e9475dd079 fix(desktop): make Enable OS notifications button actually enable them
Two independent gaps meant the desktop notifications flow silently
did nothing after the user clicked the settings button:

1. `LocalNotificationSettings` and `LocalNotificationReadState` were
   declared but never `.provides()`'d anywhere. Both
   `NotificationSettingsScreen` and `NotificationsScreen` fell back to
   a fresh `PreferencesNotificationSettings()` / `PreferencesNotification-
   ReadState()` instance each time — the java.util.prefs backing store
   kept the values consistent across cold restarts, but each Composable
   held its own `MutableStateFlow`, so a `setEnabled(true)` in Settings
   never fired the collectors in the inbox banner or in the auto-
   dispatcher. The user could toggle the master switch back and forth
   and nothing observable happened until the app was restarted. The
   auto-dispatcher in Main.kt was already using its own hoisted
   `notifSettings` instance too — just never handed down through the
   composition — so it never even saw the toggle.

2. The "Enable OS notifications" button (only shown on macOS when
   permission == NotRequested) called `dispatcher.requestPermission()`
   and updated `permissionState`, but it never touched `settings.enabled`.
   So the OS prompt appeared, user clicked Allow, the UI cheerfully
   said "Permission granted" — and toasts still didn't fire because the
   master switch was still off (defaults to false, first-launch UX
   choice). The button label promised the whole flow; the code did
   half of it.

Fix:
- Hoist `notifSettings` (already existed for the auto-dispatcher) into
  the app-level `CompositionLocalProvider` as
  `LocalNotificationSettings provides notifSettings`, so every consumer
  reads/writes the same instance. Same-instance sharing means StateFlow
  emissions actually propagate.
- Add a per-account `PreferencesNotificationReadState`, keyed on
  `loggedIn?.pubKeyHex` via `remember(pubKeyHex)`, provided through
  `LocalNotificationReadState`. This also fixes the "Mark all as read"
  button in the inbox, which was `enabled = false` because the
  composition-local was always null.
- In `NotificationSettingsScreen`, if `requestPermission()` returns
  Granted and the master switch is currently off, call
  `settings.setEnabled(true)` alongside setting the status message. The
  master switch UI observes `settings.enabled` and recomposes
  automatically, so clicking one button now does the whole handshake.

Behaviour on other platforms is unchanged: Windows / Linux start in
`PermissionState.NotApplicable`, so the "Enable OS notifications" branch
never renders there — they see the "Send a test toast" button directly.
The auto-enable is guarded by `!enabled`, so users who deliberately
disabled the master switch and then re-granted permission (e.g. after
denying in System Settings) don't get overridden if the switch was
still on.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-29 08:11:02 +10:00
Vitor PamplonaandClaude Opus 5 fa1a32e91c fix(concord): open a community by tapping its name, not just its avatar
On the Concord home list the row's `clickable` expands/collapses, and the
only way to open a community was a separate `clickable` on the 40dp
avatar — nothing marked it as its own target, and tapping the name (the
thing most people reach for) silently expanded the row instead.

Give the name/subtitle column the same `onOpen` the avatar already has, so
the identity block — icon and title together — opens the community while
the rest of the row and the chevron keep expanding it. Tapping a title to
open the thing it names is the convention users arrive with; the disclosure
control stays the disclosure control.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 18:09:55 -04:00
mandClaude ae3218249a fix(desktop): cache OS Keyring handle so startup only prompts once
`SecureKeyStorage`'s three keyring paths (save/get/delete) each called
`Keyring.create()` on every invocation. Each call opens a fresh backend
session:

- macOS: a new Security Framework session against `login.keychain`.
  Depending on the user's keychain policy (short access window, ACL on
  the amethyst-desktop item, or first-touch after unlock timeout), this
  surfaces a Keychain Access prompt every time.
- Linux Secret Service / KWallet: a fresh session may re-trigger the
  wallet-unlock prompt if the daemon closed the previous session.
- Windows Credential Manager: less user-visible but still redundant.

Amethyst's cold-boot touches the store at least twice — once for
`DesktopAccountStorage`'s AES-256-GCM metadata key
(`account-metadata-key`), then again for the active account's nsec —
so the user was seeing the OS keychain unlock prompt twice in a row
before the UI was reachable.

Fix: memoise the `Keyring` handle for the lifetime of the process. The
`Keyring` object is thread-safe for the three ops we call, so a
double-checked lazy singleton behind `keyringLock` is sufficient. The
NPE hit path is a proper lazy: any `BackendNotSupportedException` bubbles
up on the first call and is caught by the existing outer try/catch,
which flips `keyringAvailable=false` and falls back to the encrypted
file path (unchanged).

Includes a small package-private `KeyringHandle` interface + real
delegator so `SecureKeyStorageKeyringCacheTest` can substitute an
in-memory handle and count backend-open invocations without touching
the OS keychain. Three cases:

1. Cold-boot storm (save/get/delete across metadata + account keys)
   opens the Keyring exactly once.
2. Repeated `hasPrivateKey` reuses the cache.
3. Concurrent first-touches from 16 threads still open the Keyring
   exactly once (double-checked locking is race-free).

No behavioural change beyond the prompt-count fix.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-29 08:06:02 +10:00
Vitor PamplonaandGitHub ae6f56283b Merge pull request #3782 from vitorpamplona/feat/buzz-messages-toggle
fix(buzz): make the Messages toggle read the list it claims to change
2026-07-28 17:31:21 -04:00
Vitor PamplonaandGitHub c9e79e31f8 Merge pull request #3783 from vitorpamplona/claude/highlight-excess-spaces-0678he
Trim highlight context to bounded window, collapse whitespace
2026-07-28 17:25:17 -04:00
Claude de86e54cf8 fix: trim edge blank lines and skip full scan in highlight windowing
Audit follow-ups on the highlight context window:

- Blank lines sitting at the very start/end of a `context` tag were passed
  through untouched when a side was short enough not to be trimmed, so a
  highlight whose context began or ended with blank lines still rendered
  empty space above/below the quote. Trim the outer edges of the windowed
  passage (re-basing the marked range accordingly).
- `locate` enumerated every occurrence of the quote — a full context scan
  plus a list allocation — even in the common no-prefix case where only the
  first match is needed. Short-circuit to a single indexOf there.
- Clamp the returned marked range to the trimmed text length so it can never
  point past the end (e.g. a quote ending in trimmed whitespace).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApuEseGcFjUFqYoLhCuR91
2026-07-28 21:11:37 +00:00
Vitor PamplonaandClaude Opus 5 39f69de5e6 fix(concord): leave room for the FAB in the community channel list
Same defect the relay-group and Buzz DM lists had: the Scaffold's padding
carries the top/bottom bars but deliberately not the FAB (a FAB overlays
content by design), so the last channel row's manager overflow menu sat
underneath it and couldn't be tapped.

As contentPadding rather than a modifier, so rows scroll *through* the
strip instead of the viewport shrinking and leaving the FAB over dead
space. Gated on canManageChannels because that is what renders the FAB —
a plain member has nothing to clear, and no reason to lose the space.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 17:08:40 -04:00
Claude 89aea073ed fix: bound the context shown around a highlight to a window
A kind:9802 highlight can carry a huge `context` tag — a quote pulled from
the middle of a long article may ship several paragraphs of surrounding
text. Rendered whole, that fills the feed card with paragraphs around a
one-sentence highlight.

Trim the context in `HighlightQuote.of` to at most ~160 characters on each
side of the marked quote, snapping the cut to a whole-word boundary and
marking it with an ellipsis. The quote itself is always kept in full and
the marked range is re-based onto the trimmed text, so the in-context
marker still lands exactly on the highlighted passage. Short contexts are
left untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApuEseGcFjUFqYoLhCuR91
2026-07-28 21:03:56 +00:00
Vitor PamplonaandGitHub 4f65058e87 Merge pull request #3777 from vitorpamplona/claude/lazycolumn-duplicate-key-ahgh60
Fix NoteListMatchingFilter to prevent duplicate entries under concurrent updates
2026-07-28 17:00:37 -04:00
Vitor PamplonaandGitHub a17e23d796 Merge pull request #3781 from vitorpamplona/claude/posts-malformed-r-tags-piu299
Fix bogus URL references from scheme-less prose tokens
2026-07-28 16:56:13 -04:00
Claude 0851768a8b fix: only tag explicit http(s) URLs as r references
Posts were publishing a flood of bogus `r` tags — the v1.13.0 release note
shipped 11 junk references such as `https://.deb/`, `https://window.nostr/`,
`https://kind:30166/` and `https://crowdin.pretended462/`.

The reference extractor `findURLs` fed the raw, scheme-less UrlDetector output
straight into `r` tags. That detector is deliberately eager: to let the
rich-text renderer linkify a bare `example.com`, it also reports every
`word.word`, `word/word` or `word:port` token with no real-TLD whitelist.
Prose is full of those (`.deb`, `.rpm`, `[database].backend`,
`nostr-wallet-connect/nwc`, `~2.5x`, `@mentions`), so each one became a
reference on the published note. The rich-text side already guards against
this via UrlParser (TLD validation + scheme separation); the tag path never
got the same guard.

Require an explicit http/https scheme and a valid TLD before a detected URL
becomes a reference. Rendering is untouched (separate parser), so bare domains
still show as links — they just no longer pollute the tags.

Adds regression coverage over the exact fragments from the v1.13.0 note plus
checks that real, explicitly-schemed links are still extracted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L8KXx8UQ6mgyxjBSWW6sQV
2026-07-28 20:51:35 +00:00
Vitor PamplonaandClaude Opus 5 618ef66e75 fix(buzz): make the Messages toggle read the list it claims to change
"Remove from Messages" was hardcoded on every surface, so it never showed
an "Add to Messages" counterpart for a channel that was already off the
list, and the Buzz workspace rows read a session-local snapshot of the
kind-10009 list that was seeded once and only ever grew — a channel taken
off Messages still rendered as a disabled "Added", leaving no way back.

Removal itself always worked (verified on-device: the kind-10009
republished and the row left Messages); what was missing was any read of
that list on the way back.

- RelayGroupListState: expose liveRelayGroupIds, the joined groups as
  normalized GroupIds, so the UI can ask whether a channel is on Messages
  without string-matching a raw relay url another client may not have
  normalized the way we do.
- RelayGroupTopBar / BuzzImportRow: one Add/Remove toggle driven by that
  flow. Remove no longer pops back — you stay a member reading the
  channel, and staying is what makes the entry flip so the action is
  visibly undoable. Leave still pops.
- BuzzRelayImportViewModel: track "added" against the live list instead of
  a one-shot seed, and add remove(); add() now also clears the dismissal
  so a relay's kind-44100 re-announcement isn't filtered back out.
- AccountViewModel: addRelayGroupToMessages() as the counterpart to
  removeRelayGroupFromMessages(); acceptChannelInvite() delegates to it.

Buzz DMs had the same one-way shape for a different reason: hiding is a
relay-side per-viewer flag (kind-41012 -> the kind-30622 snapshot), and
rebuildRows dropped hidden DMs on the floor, so a hidden conversation was
gone for good. There is no unhide command — re-opening is the unhide, a
kind-41010 with the same participants resolving to the same canonical
channel. Hidden DMs are now projected into their own list behind a
collapsible "Hidden (N)" header, faded but still openable, each offering
"Add to Messages". Also added to the community view's inline DM rows,
which had no menu at all and are where DMs actually live — the full inbox
sits behind a "see all" row that only appears above six DMs, so in a small
workspace the hidden section would have been unreachable.

Both list screens now leave bottom room for the FAB, which the Scaffold's
padding deliberately doesn't account for; the last row's overflow menu was
sitting underneath it.

Adds SimpleGroupListEventTest covering the removal path, including that a
renamed channel still matches (removal keys on group id + relay only).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 16:50:49 -04:00
Claude 4d24e2b09c fix: collapse scraped whitespace when reconstructing highlight context
A kind:9802 highlight with no `context` tag falls back to reconstructing
the surrounding passage from the W3C `textquoteselector` prefix/suffix.
Those fragments are scraped from the source web page, so they carry the
page's block-boundary whitespace (runs of newlines/spaces between DOM
nodes). Glued in verbatim as `prefix + content + suffix`, they render as a
stack of blank lines above the marked quote.

Collapse each whitespace run in the prefix/suffix to a single space (and
trim the outer edges) in `HighlightEvent.contextOrReconstructed()`. The
highlight's own `content` is left verbatim so its offsets inside the
reconstructed context stay exact for the in-context marker.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApuEseGcFjUFqYoLhCuR91
2026-07-28 20:47:53 +00:00
Claude 58004fa744 fix: dedup EventListMatchingFilter (observeEvents) and harden emission contract
EventListMatchingFilter had the same mutable-sort-key defect as
NoteListMatchingFilter: it stored Notes in a ConcurrentSkipListSet ordered by
the live created_at, so a newer replaceable version (which mutates the shared
AddressableNote in place) stranded its node and let the same instance be
inserted twice — the emitted event list then carried the same event twice. It
hadn't surfaced as a crash only because its consumers (app recommendations,
relay groups, room reactions) happen to dedup downstream.

Apply the same capture-key + idHex-dedup + per-key compute design, but preserve
EventListMatchingFilter's update-reflecting semantics: an addressable update
re-emits (the snapshot reads the refreshed event live off the note) rather than
being ignored. It keeps the entry's captured position instead of re-sorting —
re-sorting via remove+add let two entries with different captured keys for the
same note transiently coexist and both read the same live event, duplicating it.

Also harden both filters' emission: a ConcurrentSkipListSet iterator is weakly
consistent, so under concurrent add/remove churn a single traversal can
momentarily surface a key twice. snapshot() now dedups by idHex so the emitted
list — the LazyColumn's source of keys — is always unique, regardless of
transient internal states. Corrected the over-claimed "can never hold two"
docstrings accordingly.

Adds EventListMatchingFilterTest mirroring the note tests: update-reflection,
version-note re-emit, sorted order, remove-after-mutation, and two concurrency
stress tests (with/without limit) that failed before this fix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ah1aCniyjnzc27x4pwq2Df
2026-07-28 20:42:13 +00:00
Vitor PamplonaandGitHub 311b3ec7f0 Merge pull request #3779 from vitorpamplona/claude/buzz-delete-channel-n24icj
Add delete group/channel functionality (kind-9008)
2026-07-28 16:37:17 -04:00
Claude 83ec9b490b feat: allow admins to delete a Buzz channel / relay group
Wire up the existing kind-9008 DeleteGroupEvent, which was parsed on
receipt but never sent. Admins/owners can now delete a whole channel
(Buzz) or group (NIP-29) from the channel overflow menu.

- Account.deleteRelayGroup: sends the kind-9008 delete-group to the
  channel's relay(s) and drops it from the local list.
- AccountViewModel.deleteRelayGroup: signer-dispatched delegate.
- RelayGroupTopBar: admin-only "Delete channel" / "Delete group" item
  (error color), gated on RelayGroupMembership.ADMIN — the same
  authorization as Edit — behind a confirmation dialog that pops back
  off the deleted channel's screen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bfd77tUsZ8h6ywcgwY7VVB
2026-07-28 20:17:43 +00:00
Vitor PamplonaandGitHub af04267f9a Merge pull request #3778 from vitorpamplona/claude/back-arrow-responsiveness-sz40m9
Fix back arrow visibility during predictive back-swipe animations
2026-07-28 16:09:35 -04:00
Claude 88ee46df05 fix: keep back arrow visible until the exiting screen finishes leaving
canPop() read the globally-current back-stack entry via
currentBackStackEntryAsState(). A pop commits the instant it is accepted
— most visibly during a predictive back-swipe, whose exit animation is
long and finger-driven — so controller.currentBackStackEntry flips to the
destination while the screen being dismissed is still on screen, sliding
out and still composing its top bar. Evaluating canPop against the
incoming destination there dropped the back arrow (and re-showed the
bottom bar) before the outgoing screen had finished leaving, which is
exactly the flicker seen when back-swiping to Home or a bottom-nav root.

Evaluate poppability against the screen's own NavBackStackEntry — the one
the NavHost provides to each destination via LocalViewModelStoreOwner —
which is intrinsic to that screen and never changes for the life of its
composition. The arrow now stays put until the screen itself is gone.
Callers outside a NavHost destination (shell chrome, drawer) see the
account-scoped owner instead of an entry and fall back to the previous
globally-current behavior.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011matx1mGJ6ZyS5dS77EQUo
2026-07-28 20:03:43 +00:00
Vitor PamplonaandGitHub ab5a59a106 Merge pull request #3776 from vitorpamplona/claude/amethyst-lint-translation-hvu5pz
Remove unused buzz_dm_hide string resource
2026-07-28 15:55:03 -04:00
Claude c49e2d3390 fix: remove orphaned buzz_dm_hide translations
The `buzz_dm_hide` string key exists only in translation files but has no
entry in the default `values/strings.xml` and is not referenced anywhere in
code. This triggered the lint ExtraTranslation error and failed the
`:amethyst:lintFdroidBenchmark` build. Removed the orphaned key from all 7
locale files.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ksnCb8x45HnL7nFPzxh4B
2026-07-28 19:45:48 +00:00
Vitor PamplonaandGitHub f3db2c73dc Merge pull request #3773 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-28 15:41:40 -04:00
vitorpamplonaandgithub-actions[bot] 456684edb1 chore: sync Crowdin translations and seed translator npub placeholders 2026-07-28 19:22:32 +00:00
Vitor PamplonaandGitHub 31582320f2 Merge pull request #3775 from vitorpamplona/claude/resource-consumption-trigger-709n5v
Relax resource usage alert thresholds
2026-07-28 15:19:30 -04:00
Claude 5cb84e16dc test: cover the version-note guard path in observeNotes dedup
Add coverage for a real consumeBaseReplaceable call the suite was missing:
observers are also notified with the "version" note (getOrCreateNote(event.id),
a regular Note carrying the AddressableEvent), which the addressable-list guard
must drop while still listing the AddressableNote for the same event.

Confirmed the concurrency the stress tests exercise is real, not theoretical:
relay events are verified+consumed inline on per-relay socket dispatchers, so
distinct relays drive new()/remove() on the same note instance concurrently.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ah1aCniyjnzc27x4pwq2Df
2026-07-28 19:17:57 +00:00
Vitor PamplonaandGitHub 68664bfac1 Merge pull request #3774 from vitorpamplona/claude/buzz-community-channel-style-l2l63v
Simplify ConcordAuthorFacepile to use ClickableUserPicture
2026-07-28 15:10:44 -04:00
Claude d9d02110dd fix(resource-usage): raise the background mobile data trigger to 500 MB
Bump BG_MOBILE_BYTES_PER_DAY from 100 MB to 500 MB/day so the report
prompt only flags genuinely excessive background cellular traffic.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R2efhyiQHKFoNjQo6WnGRH
2026-07-28 19:10:43 +00:00
Claude 8a41129dfc fix(resource-usage): double the report-prompt trigger thresholds
Raise every ResourceUsageAlerts threshold to 2x so the "this app is
consuming too much" report prompt only fires at twice the previous
consumption levels, cutting false positives on heavy-but-normal days:

- background mobile data: 50 MB -> 100 MB / day
- background mobile relay-connection time: 12 h -> 24 h / day
- notification wakelock: 30 min -> 60 min / day
- app process starts: 75 -> 150 / day
- completed relay (re)connections: 5000 -> 10000 / day

Tests reference the constants symbolically, so the alert suite stays green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R2efhyiQHKFoNjQo6WnGRH
2026-07-28 18:57:30 +00:00
Claude baf5f37532 feat: render Buzz channel recent-poster facepile with the standard avatar
The recent-posters facepile in a Buzz community's channel rows drew each
poster with ObserveAndDrawInnerUserPicture — a bare cropped image wrapped
in a surface-coloured ring, overlapped into a deck. That omitted the
following badge and trust-score tag every other user avatar in the app
carries.

Draw each poster with ClickableUserPicture (the regular BaseUserPicture
path) instead, so the facepile shows the following icon (top-right) and
trust-score tag (bottom-centre). Lay the avatars out with a small gap
rather than an overlapping stack so those badges stay readable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013f3JZdoChYEEDtqTArFAxn
2026-07-28 18:54:23 +00:00
Vitor PamplonaandGitHub 3842ab14bb Merge pull request #3772 from vitorpamplona/claude/concord-buzz-messages-visibility-ye6ocz
Add long-press menu to relay group & Concord channels in Messages
2026-07-28 14:51:15 -04:00
Claude 49e4234f99 feat: align leave vs remove-from-messages actions across chat types
"Leave" meant three different things and the actions were scattered across
each channel's own screen, so they were hard to find from Messages. Make the
vocabulary consistent and reachable:

- "Leave" now always means "renounce membership / you're out" — the kind-9022
  LeaveRequestEvent for NIP-29/Buzz groups, and the kind-13302 self-list removal
  for Concord (its only exit).
- "Remove from Messages" is the single soft action: take it off my list but keep
  membership. For a joined relay group this drops the kind-10009 entry without a
  9022 (I stay in the roster) and dismisses the invite so a Buzz kind-44100
  re-announce can't bounce it back. The Buzz DM "Hide conversation" reuses the
  same label.

Surface both on the Messages rows via long-press (previously only reachable
inside each group/community screen):
- Relay-group row: "Remove from Messages" + "Leave".
- Concord row: "Leave" (reuses the existing confirm dialog; a community has no
  soft/hard split since the list entry is the whole membership).

Split the relay-group top-bar menu into the same two actions, thread an optional
onLongClick through ChannelName, and consolidate the buzz_dm_hide string into the
shared remove_from_messages string.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NRifAJ75U4zWbg3V3Y3g8g
2026-07-28 18:40:16 +00:00
Vitor PamplonaandGitHub 48eea9a350 Merge pull request #3771 from vitorpamplona/claude/top-nav-font-sizes-vg4l9q
Remove unnecessary FontWeight.Bold styling from Text components
2026-07-28 14:34:12 -04:00
davotoula 7718e3ab19 fix: extract duplicated string literals 2026-07-28 20:29:21 +02:00
Claude 47be9909da fix: normalize bold top nav bar titles to default weight
The Buzz relay group (community) top bar and its sub-screens (members,
threads, browse, channel list), the Buzz canvas, geohash chat/new/teleport
screens, the single-URL and single-geohash viewers, and the Marmot group
chat all forced FontWeight.Bold (geohash chat also titleMedium) on their
top-bar titles, while every sibling channel header — DM rooms, public
chats, ephemeral chats, live activities — and the shared TopBar wrappers
render the Material3 titleLarge default at normal weight.

Drop the bold (and size) overrides so all top nav bar titles share one
consistent treatment.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019N7b8D4vBvafhG9eU7M9iJ
2026-07-28 18:27:16 +00:00
Claude 636331487a fix: make observeNotes dedup lock-free and race-safe under concurrent consume
Audit follow-up. The previous fix used two independent concurrent structures
(ConcurrentSkipListSet + ConcurrentHashMap) coordinated with putIfAbsent, but
observer callbacks fire from multiple consume threads at once (relay ingest +
UI-side justConsume). A new()/remove() interleaving for the same idHex could
desync the two structures — remove() clears byId and no-ops on the sorted set
before new() has added the entry — leaving an orphan that a later new()
duplicates, reintroducing the duplicate-key crash.

Keep it lock-free (this observer is used everywhere and needs the throughput):
every write to the sorted index for a given idHex now happens inside that key's
ConcurrentHashMap.compute critical section, so the sorted set and membership map
move together. ConcurrentHashMap stripes per key, so same-idHex ops serialize
while different keys stay fully parallel. Invariant: an entry is added to the
sorted set only while its key is absent from byId, and every path that frees a
key removes its sorted entry first, so the set can never hold two entries for
one idHex.

Adds concurrency stress tests (with and without a relay limit) that fan out 8
threads hammering new/remove while created_at churns; both fail against the
non-atomic version and pass here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ah1aCniyjnzc27x4pwq2Df
2026-07-28 18:24:37 +00:00
David KasparandGitHub 153d28b420 Merge pull request #3770 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-28 20:17:44 +02:00
davotoulaandgithub-actions[bot] 8ce93f3700 chore: sync Crowdin translations and seed translator npub placeholders 2026-07-28 18:15:15 +00:00
davotoula 14db81177f update cs,pt,de,sv 2026-07-28 20:06:18 +02:00
Claude 65e30c0acd fix: keep observeNotes list sorted while deduping addressables by idHex
Address review feedback: keep the incrementally-maintained, created_at-sorted
structure (a feed must stay sorted like a relay) instead of re-sorting a hash
map on every emission.

The root cause is unchanged: there is one Note instance per id/address
(LocalCache owns creation), but a note's sort key is mutable — a newer
replaceable event swaps the event on the SAME AddressableNote instance,
changing created_at in place. A sorted set ordered on that live value corrupts:
the moved node leaves the add()/remove() search path, so the same instance is
inserted twice and the emitted list carries a duplicate idHex, crashing the
App Recommendations LazyColumn (keyed on idHex).

Fix: snapshot the sort key into an immutable Entry when the note first enters,
order a ConcurrentSkipListSet on that snapshot (never read live again), and
index entries by the stable idHex (ConcurrentHashMap + putIfAbsent) so
membership stays unique and removal is reliable regardless of later created_at
changes. Ordering and "new versions do not update the list" are preserved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ah1aCniyjnzc27x4pwq2Df
2026-07-28 18:03:26 +00:00
Claude d9ae9cbd37 fix: normalize top nav bar title font sizes
Three top nav bar titles overrode the Material3 titleLarge default with
titleMedium, rendering ~16sp while every other top bar title inherits
~22sp. Drop the titleMedium override so the Calendar event detail and Git
repository (home + sub-screen) titles match the rest of the app.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019N7b8D4vBvafhG9eU7M9iJ
2026-07-28 17:42:37 +00:00
David KasparandGitHub a585fe3664 Merge pull request #3764 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-28 19:41:02 +02:00
Vitor PamplonaandGitHub b085c8cc37 Merge pull request #3767 from vitorpamplona/claude/corcord-read-only-communities-gu1x4k
Implement CORD-02 §9 community dissolution seal
2026-07-28 13:40:28 -04:00
vitorpamplonaandgithub-actions[bot] 8145bedd15 chore: sync Crowdin translations and seed translator npub placeholders 2026-07-28 17:36:46 +00:00
Vitor PamplonaandGitHub 6666637f9c Merge pull request #3766 from vitorpamplona/claude/lazygrid-duplicate-key-v4k7sj
Fix duplicate app entries in Discover Apps grid
2026-07-28 13:34:03 -04:00
Vitor PamplonaandGitHub 6086e48859 Merge pull request #3765 from vitorpamplona/claude/keyboard-ime-stuck-state-et3yfu
Fix keyboard state tracking and back handler races
2026-07-28 13:33:14 -04:00
Claude 0b0abeebbb fix: prevent duplicate LazyColumn key from observeNotes on addressable updates
NoteListMatchingFilter (backing LocalCache.observeNotes) stored notes in a
ConcurrentSkipListSet ordered by CreatedAtIdHexComparator. AddressableNotes are
mutable: when a newer replaceable event arrives, LocalCache swaps the event on
the SAME note instance (consumeBaseReplaceable -> loadEvent), changing its
createdAt in place, then re-notifies observers. A sorted set cannot survive a
member's sort key mutating underneath it — the moved node is no longer found on
the add() search path, so the same note gets inserted a second time and the
emitted list carries a duplicate idHex.

The App Recommendations screen keys its LazyColumn on note.idHex (an
AddressableNote's address, e.g. 31990:<pubkey>:nostr-dvm-labeler), so the
duplicate crashed with IllegalArgumentException: "Key ... was already used".

Dedupe by the immutable idHex instead of a createdAt-ordered set; ordering is
computed fresh on each emission. Adds a regression test reproducing the
multi-item corruption path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ah1aCniyjnzc27x4pwq2Df
2026-07-28 17:23:50 +00:00
Claude a569095e19 feat(concord): honor CORD-02 §9 read-only seal on dissolved communities
A dissolved community (owner-signed kind-3308 tombstone) is sealed
read-only per CORD-02 §9: held keys still open history, but nothing new
is honored. The `dissolved` flag was folded in quartz but ignored by the
write gates, so members — and the CLI — could still post to a dissolved
community.

- commons: ConcordChannel now tracks `dissolved` from the folded state
  and `canPost()` returns false when set, so the Android composer (which
  gates on it) is hidden. The self-delete carve-out is unaffected — it
  runs through the note context menu, not the composer.
- amethyst: show a read-only notice where the composer would be so the
  seal is explained rather than silent.
- cli: `amy concord send` folds the community and refuses with a
  `dissolved` error before building/publishing; `amy concord channels`
  surfaces the `dissolved` flag.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HateTjrutJ23wAEttEwHQA
2026-07-28 17:22:24 +00:00
Claude 5174c8e5a3 fix: dedupe Discover apps by coordinate to avoid duplicate LazyGrid keys
The Browser home "Discover nsites/napplets" sections render each followed
manifest in a LazyVerticalGrid keyed by "ns:"/"np:" + coordinate. The
observed store (NoteListMatchingFilter, backed by a ConcurrentSkipListSet
ordered by created-at/id) can surface the same addressable note twice when a
replaceable manifest gets a new version — mutating the note's sort key inside
the set breaks dedup. Both copies map to the same coordinate, producing a
duplicate grid key and crashing with IllegalArgumentException.

Collapse the mapped list by coordinate so each app appears once and grid keys
stay unique.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9TomvuP9YnPUDCZuiQae6
2026-07-28 17:09:31 +00:00
Vitor PamplonaandGitHub 84a96f1f86 Merge pull request #3763 from vitorpamplona/claude/fix-localcache-dropdown-warnings-y4952o
Refactor parameter names for clarity and update Material3 API
2026-07-28 11:50:54 -04:00
Claude 3ffa259cff fix: resolve LocalCache override, dropdown deprecation, and shadowed extension warnings
- Rename LocalCache Dao overrides to match supertype parameter names
  (getOrCreateUser: pubkey->hex, getOrCreateNote: idHex->hex,
  getOrCreateAddressableNote: key->address) so named-argument calls stay safe.
- Replace deprecated MenuAnchorType with ExposedDropdownMenuAnchorType in the
  Buzz dropdown composables.
- Remove the buzzChannelType() extension in BuzzChannelMetadata, now shadowed by
  the equivalent (stricter) member on GroupMetadataEvent, and drop its unused
  imports.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018VBP6HGj9M2kzA4WKAivjC
2026-07-28 15:37:01 +00:00
mstrofnone a87187151a fix(namecoin): don't leak lookup exceptions through resolve()
`IElectrumXClient.nameShowWithFallback` implementations always throw a
`NamecoinLookupException` subtype (NameNotFound, NameExpired,
ServersUnreachable) on failure — the return-nullable signature is a
legacy of the old contract but no live impl (`ElectrumXClient`,
`CompositeNamecoinBackend`) actually returns null anymore.

`NamecoinNameResolver.performLookup` (the code path taken by the
non-detailed `resolve()` used by `Nip05Client.verify()`) has no
try/catch around that call. So any transport-level failure propagates
out of `resolve()` into `Nip05State.checkAndUpdate`, where the
`catch (e: Exception)` handler calls `markAsError()` and the NIP-05
verification badge in the UI turns into a red "Report" icon with the
`nip05_failed` string — regardless of whether the actual cause was
"servers all unreachable" or "resolver returned the wrong pubkey".

That collapses two very different failure modes into the same
red-icon state:

  1. Real verification failure (kind-0 nip05 doesn't match the
     Namecoin record's pubkey) — user should investigate.
  2. Transient network issue (custom ElectrumX server offline;
     `fallbackToDefaultElectrumx` disabled; carrier blocking
     port 50002/57002; Tor toggle on with Tor unreachable; etc.)
     — user should retry or check settings.

`resolveDetailed()` already has the try/catch and returns structured
outcomes (`NameNotFound` / `ServersUnreachable` / etc.). This change
brings `performLookup` in line so `resolve()` honours its documented
"returns null on any failure" contract, matching how
`expandImportsIfPresent` already treats `NamecoinLookupException`
during import-target fetches (best-effort → null).

Repro:

  * Namecoin Settings → set backend to "Namecoin Core RPC" with a
    localhost URL, no fallback. Or set a custom ElectrumX server
    that the phone can't reach (different network, cellular vs.
    Wi-Fi, etc.). Or leave `fallbackToDefaultElectrumx = false`
    with unreachable customServers.
  * Open any profile whose `nip05` field ends in `.bit` — you
    get the red icon (Error state) even though the record itself
    is fine on-chain and the pubkey matches.
  * After this fix: same setup surfaces null through
    `resolve()` → `verify()` returns false →
    `markAsInvalid()` → still red (Failed state, not Error), but
    no exception leaks. And the identical
    `NamecoinLookupException` handling on both the primary lookup
    and the import-target fetch means resolution behaves
    consistently regardless of which hop fails.

Tests: `NamecoinNameResolverExceptionTest` pins the contract with
7 hermetic cases covering NameNotFound / NameExpired /
ServersUnreachable / generic transport failure / CancellationException
propagation / and continued `resolveDetailed()` visibility of the
specific outcome subtype.

Verification:

    ./gradlew :quartz:verifyKmpPurity \
              :quartz:compileKotlinLinuxX64 \
              :quartz:compileKotlinIosArm64 \
              :quartz:spotlessCheck \
              :quartz:jvmTest --tests \
              'com.vitorpamplona.quartz.nip05.namecoin.*'

  BUILD SUCCESSFUL. All namecoin nip05 tests green; KMP purity + iOS
  + Linux native + spotless all clean.
2026-07-23 15:23:01 +10:00
107 changed files with 3789 additions and 581 deletions
+3 -3
View File
@@ -7,7 +7,7 @@ description: Integration guide for using the Quartz Nostr KMP library in externa
Reference for integrating `com.vitorpamplona.quartz:quartz` into external Nostr KMP projects.
**Published artifact**: `com.vitorpamplona.quartz:quartz:1.13.0` (Maven Central)
**Published artifact**: `com.vitorpamplona.quartz:quartz:1.13.1` (Maven Central)
**Targets**: JVM 21+, Android (minSdk 21+), iOS (XCFramework `quartz-kmpKit`)
**License**: MIT
@@ -19,7 +19,7 @@ Reference for integrating `com.vitorpamplona.quartz:quartz` into external Nostr
```toml
[versions]
quartz = "1.13.0"
quartz = "1.13.1"
[libraries]
quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" }
@@ -41,7 +41,7 @@ kotlin {
```kotlin
dependencies {
implementation("com.vitorpamplona.quartz:quartz:1.13.0")
implementation("com.vitorpamplona.quartz:quartz:1.13.1")
}
```
@@ -3,7 +3,7 @@
## Current version
```
com.vitorpamplona.quartz:quartz:1.13.0
com.vitorpamplona.quartz:quartz:1.13.1
```
Check latest: https://central.sonatype.com/artifact/com.vitorpamplona.quartz/quartz
@@ -16,7 +16,7 @@ Check latest: https://central.sonatype.com/artifact/com.vitorpamplona.quartz/qua
```toml
[versions]
quartz = "1.13.0"
quartz = "1.13.1"
[libraries]
quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" }
@@ -55,7 +55,7 @@ kotlin {
```kotlin
// build.gradle.kts (app module)
dependencies {
implementation("com.vitorpamplona.quartz:quartz:1.13.0")
implementation("com.vitorpamplona.quartz:quartz:1.13.1")
}
```
@@ -70,7 +70,7 @@ plugins {
}
dependencies {
implementation("com.vitorpamplona.quartz:quartz:1.13.0")
implementation("com.vitorpamplona.quartz:quartz:1.13.1")
// JNA needed for libsodium (NIP-44) on JVM
implementation("net.java.dev.jna:jna:5.18.1")
}
+5 -5
View File
@@ -328,16 +328,16 @@ repositories {
Add the following line to your `commonMain` dependencies:
```gradle
implementation('com.vitorpamplona.quartz:quartz:1.13.0')
implementation('com.vitorpamplona.quartz:quartz:1.13.1')
```
Variations to each platform are also available:
```gradle
implementation('com.vitorpamplona.quartz:quartz-android:1.13.0')
implementation('com.vitorpamplona.quartz:quartz-jvm:1.13.0')
implementation('com.vitorpamplona.quartz:quartz-iosarm64:1.13.0')
implementation('com.vitorpamplona.quartz:quartz-iossimulatorarm64:1.13.0')
implementation('com.vitorpamplona.quartz:quartz-android:1.13.1')
implementation('com.vitorpamplona.quartz:quartz-jvm:1.13.1')
implementation('com.vitorpamplona.quartz:quartz-iosarm64:1.13.1')
implementation('com.vitorpamplona.quartz:quartz-iossimulatorarm64:1.13.1')
```
Check versions on [MavenCentral](https://central.sonatype.com/search?q=com.vitorpamplona.quartz)
@@ -73,6 +73,8 @@ import com.vitorpamplona.amethyst.service.notifications.AlwaysOnNotificationServ
import com.vitorpamplona.amethyst.service.notifications.NotificationDispatcher
import com.vitorpamplona.amethyst.service.notifications.NwcPaymentNotificationWatcher
import com.vitorpamplona.amethyst.service.notifications.PokeyReceiver
import com.vitorpamplona.amethyst.service.okhttp.BlossomReadAuthInterceptor
import com.vitorpamplona.amethyst.service.okhttp.BlossomReadAuthTokenProvider
import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManager
import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManagerForRelays
import com.vitorpamplona.amethyst.service.okhttp.EncryptionKeyCache
@@ -424,6 +426,16 @@ class AppModules(
},
onionCache = onionLocationCache,
usageInterceptor = httpUsageInterceptor,
// Retries auth-gated Blossom blob downloads (e.g. Buzz's private
// media relay) with a BUD-01 read-auth token signed by the current
// account on a 401. Reads the signer at call time so it always
// tracks the logged-in account.
blossomReadAuth =
BlossomReadAuthInterceptor(
BlossomReadAuthTokenProvider(
signerProvider = { sessionManager.loggedInAccount()?.signer },
)::authHeader,
),
)
// Offers easy methods to know when connections are happening through Tor or not
@@ -33,6 +33,7 @@ import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntr
import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntryNorm
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.HomeFeedType
import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.model.UiSettings
import com.vitorpamplona.amethyst.service.checkNotInMainThread
@@ -188,6 +189,10 @@ private object PrefKeys {
// Stores the DISABLED chat feed types (comma-joined codes) so absence = all-on and any newly
// added type defaults enabled for accounts that customized before it existed.
const val DISABLED_CHAT_FEEDS = "disabled_chat_feeds"
// Same convention as DISABLED_CHAT_FEEDS but for the Home feed's event-kind groups: stores the
// DISABLED codes so absence = all-on and any newly added group defaults enabled.
const val DISABLED_HOME_FEED_TYPES = "disabled_home_feed_types"
const val RELAY_AUTH_TRUST_MY_RELAYS = "relay_auth_trust_my_relays_and_venues"
const val RELAY_AUTH_TRUST_READ_FOLLOWS = "relay_auth_trust_read_follows"
const val RELAY_AUTH_TRUST_MESSAGE_FOLLOWS = "relay_auth_trust_message_follows"
@@ -604,6 +609,7 @@ object LocalPreferences {
putString(PrefKeys.RELAY_GROUP_VIEW_MODE, settings.relayGroupViewMode.value.name)
putString(PrefKeys.CONCORD_VIEW_MODE, settings.concordViewMode.value.name)
putString(PrefKeys.DISABLED_CHAT_FEEDS, ChatFeedType.encode(ChatFeedType.ALL - settings.enabledChatFeeds.value))
putString(PrefKeys.DISABLED_HOME_FEED_TYPES, HomeFeedType.encode(HomeFeedType.ALL - settings.enabledHomeFeedTypes.value))
putBoolean(PrefKeys.RELAY_AUTH_TRUST_MY_RELAYS, settings.relayAuthTrustMyRelaysAndVenues.value)
putBoolean(PrefKeys.RELAY_AUTH_TRUST_READ_FOLLOWS, settings.relayAuthTrustReadFollows.value)
putBoolean(PrefKeys.RELAY_AUTH_TRUST_MESSAGE_FOLLOWS, settings.relayAuthTrustMessageFollows.value)
@@ -731,17 +737,10 @@ object LocalPreferences {
val hideNIP17WarningDialog = getBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, false)
val callsEnabled = getBoolean(PrefKeys.CALLS_ENABLED, true)
val alwaysOnNotificationService = getBoolean(PrefKeys.ALWAYS_ON_NOTIFICATION_SERVICE, false)
val defaultRelayAuthPolicy =
getString(PrefKeys.DEFAULT_RELAY_AUTH_POLICY, null)
?.let { runCatching { RelayAuthPolicy.valueOf(it) }.getOrNull() }
?: RelayAuthPolicy.CUSTOM
val relayGroupViewMode = RelayGroupViewMode.fromName(getString(PrefKeys.RELAY_GROUP_VIEW_MODE, null))
val concordViewMode = ConcordViewMode.fromName(getString(PrefKeys.CONCORD_VIEW_MODE, null))
val enabledChatFeeds = ChatFeedType.ALL - ChatFeedType.decode(getString(PrefKeys.DISABLED_CHAT_FEEDS, null))
val relayAuthTrustMyRelays = getBoolean(PrefKeys.RELAY_AUTH_TRUST_MY_RELAYS, true)
val relayAuthTrustReadFollows = getBoolean(PrefKeys.RELAY_AUTH_TRUST_READ_FOLLOWS, true)
val relayAuthTrustMessageFollows = getBoolean(PrefKeys.RELAY_AUTH_TRUST_MESSAGE_FOLLOWS, true)
val relayAuthTrustMessageStrangers = getBoolean(PrefKeys.RELAY_AUTH_TRUST_MESSAGE_STRANGERS, false)
// Read as a group via a helper: this load lambda sits right at the JVM's
// per-method bytecode limit (see the note above the awaits below), so keeping
// these heavy string/enum decodes out of it preserves headroom.
val inboxPrefs = readInboxPrefs()
val splitNotificationsEnabled = getBoolean(PrefKeys.SPLIT_NOTIFICATIONS_ENABLED, false)
val showMessagesInNotifications = getBoolean(PrefKeys.SHOW_MESSAGES_IN_NOTIFICATIONS, true)
val hasDonatedInVersion = getStringSet(PrefKeys.HAS_DONATED_IN_VERSION, null) ?: setOf()
@@ -968,14 +967,15 @@ object LocalPreferences {
hideBlockAlertDialog = hideBlockAlertDialog,
hideNIP17WarningDialog = hideNIP17WarningDialog,
alwaysOnNotificationService = MutableStateFlow(alwaysOnNotificationService),
defaultRelayAuthPolicy = MutableStateFlow(defaultRelayAuthPolicy),
relayGroupViewMode = MutableStateFlow(relayGroupViewMode),
concordViewMode = MutableStateFlow(concordViewMode),
enabledChatFeeds = MutableStateFlow(enabledChatFeeds),
relayAuthTrustMyRelaysAndVenues = MutableStateFlow(relayAuthTrustMyRelays),
relayAuthTrustReadFollows = MutableStateFlow(relayAuthTrustReadFollows),
relayAuthTrustMessageFollows = MutableStateFlow(relayAuthTrustMessageFollows),
relayAuthTrustMessageStrangers = MutableStateFlow(relayAuthTrustMessageStrangers),
defaultRelayAuthPolicy = MutableStateFlow(inboxPrefs.defaultRelayAuthPolicy),
relayGroupViewMode = MutableStateFlow(inboxPrefs.relayGroupViewMode),
concordViewMode = MutableStateFlow(inboxPrefs.concordViewMode),
enabledChatFeeds = MutableStateFlow(inboxPrefs.enabledChatFeeds),
enabledHomeFeedTypes = MutableStateFlow(inboxPrefs.enabledHomeFeedTypes),
relayAuthTrustMyRelaysAndVenues = MutableStateFlow(inboxPrefs.relayAuthTrustMyRelays),
relayAuthTrustReadFollows = MutableStateFlow(inboxPrefs.relayAuthTrustReadFollows),
relayAuthTrustMessageFollows = MutableStateFlow(inboxPrefs.relayAuthTrustMessageFollows),
relayAuthTrustMessageStrangers = MutableStateFlow(inboxPrefs.relayAuthTrustMessageStrangers),
splitNotificationsEnabled = MutableStateFlow(splitNotificationsEnabled),
showMessagesInNotifications = MutableStateFlow(showMessagesInNotifications),
backupUserMetadata = latestUserMetadataResolved,
@@ -1184,3 +1184,36 @@ object LocalPreferences {
}
}
}
/**
* The inbox / relay-auth / feed-type preferences, read as one group. Extracted out of
* [LocalPreferences]' account-load lambda (which is right at the JVM's per-method bytecode limit)
* so these enum/set decodes don't count against that method's budget.
*/
private class InboxPrefs(
val defaultRelayAuthPolicy: RelayAuthPolicy,
val relayGroupViewMode: RelayGroupViewMode,
val concordViewMode: ConcordViewMode,
val enabledChatFeeds: Set<ChatFeedType>,
val enabledHomeFeedTypes: Set<HomeFeedType>,
val relayAuthTrustMyRelays: Boolean,
val relayAuthTrustReadFollows: Boolean,
val relayAuthTrustMessageFollows: Boolean,
val relayAuthTrustMessageStrangers: Boolean,
)
private fun SharedPreferences.readInboxPrefs() =
InboxPrefs(
defaultRelayAuthPolicy =
getString(PrefKeys.DEFAULT_RELAY_AUTH_POLICY, null)
?.let { runCatching { RelayAuthPolicy.valueOf(it) }.getOrNull() }
?: RelayAuthPolicy.CUSTOM,
relayGroupViewMode = RelayGroupViewMode.fromName(getString(PrefKeys.RELAY_GROUP_VIEW_MODE, null)),
concordViewMode = ConcordViewMode.fromName(getString(PrefKeys.CONCORD_VIEW_MODE, null)),
enabledChatFeeds = ChatFeedType.ALL - ChatFeedType.decode(getString(PrefKeys.DISABLED_CHAT_FEEDS, null)),
enabledHomeFeedTypes = HomeFeedType.ALL - HomeFeedType.decode(getString(PrefKeys.DISABLED_HOME_FEED_TYPES, null)),
relayAuthTrustMyRelays = getBoolean(PrefKeys.RELAY_AUTH_TRUST_MY_RELAYS, true),
relayAuthTrustReadFollows = getBoolean(PrefKeys.RELAY_AUTH_TRUST_READ_FOLLOWS, true),
relayAuthTrustMessageFollows = getBoolean(PrefKeys.RELAY_AUTH_TRUST_MESSAGE_FOLLOWS, true),
relayAuthTrustMessageStrangers = getBoolean(PrefKeys.RELAY_AUTH_TRUST_MESSAGE_STRANGERS, false),
)
@@ -299,6 +299,7 @@ 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
@@ -3451,6 +3452,18 @@ class Account(
unfollow(channel)
}
/**
* Delete the whole group with a kind 9008 delete-group event (owner/admin only the relay
* enforces this). Unlike [leaveRelayGroup], this destroys the channel for everyone rather than
* just removing me; the relay drops the group and its messages. Also drops it from our own list
* so it disappears from Messages immediately instead of lingering as a now-dead id.
*/
suspend fun deleteRelayGroup(channel: RelayGroupChannel) {
val template = DeleteGroupEvent.build(channel.groupId.id)
signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
unfollow(channel)
}
/**
* Create a new group on [relay]: kind 9007 (create-group) then kind 9002
* (edit-metadata) with the chosen name/visibility, then remember it. Returns
@@ -341,6 +341,9 @@ class AccountSettings(
// Which conversation protocols the Messages inbox loads and shows. A disabled type is both hidden
// from the inbox and dropped from the always-on downloading routes. Defaults to everything on.
val enabledChatFeeds: MutableStateFlow<Set<ChatFeedType>> = MutableStateFlow(ChatFeedType.ALL),
// Which event-kind groups the Home feed downloads (assembler) and renders (DAL). A disabled group
// is both dropped from the always-on home relay filters and hidden from the tabs. Everything on by default.
val enabledHomeFeedTypes: MutableStateFlow<Set<HomeFeedType>> = MutableStateFlow(HomeFeedType.ALL),
// The per-situation toggles applied under RelayAuthPolicy.CUSTOM.
val relayAuthTrustMyRelaysAndVenues: MutableStateFlow<Boolean> = MutableStateFlow(true),
val relayAuthTrustReadFollows: MutableStateFlow<Boolean> = MutableStateFlow(true),
@@ -391,6 +394,20 @@ class AccountSettings(
}
}
fun isHomeFeedTypeEnabled(type: HomeFeedType): Boolean = type in enabledHomeFeedTypes.value
fun setHomeFeedTypeEnabled(
type: HomeFeedType,
enabled: Boolean,
) {
val current = enabledHomeFeedTypes.value
val next = if (enabled) current + type else current - type
if (next != current) {
enabledHomeFeedTypes.tryEmit(next)
saveAccountSettings()
}
}
// ---
// Always-on Notification Service
// ---
@@ -0,0 +1,130 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT 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
import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent
import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent
import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent
import com.vitorpamplona.quartz.experimental.attestations.recommendation.AttestorRecommendationEvent
import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent
import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
import com.vitorpamplona.quartz.experimental.birdstar.BirdDetectionEvent
import com.vitorpamplona.quartz.experimental.birdstar.BirdexEvent
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent
import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent
import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent
import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
import com.vitorpamplona.quartz.nip64Chess.challenge.offer.LiveChessGameChallengeEvent
import com.vitorpamplona.quartz.nip64Chess.end.LiveChessGameEndEvent
import com.vitorpamplona.quartz.nip64Chess.game.ChessGameEvent
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent
import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent
/**
* The distinct event-kind groups the Home feed downloads (in the relay assembler) and renders (in
* the DAL). Each is independently toggleable in Settings Home: turning one off both drops its
* kinds from the always-on home relay filters AND hides them from the New Threads / Conversations /
* Everything tabs.
*
* [code] is the stable on-disk identifier (do NOT rename — it is what [encode]/[decode] persist);
* the enum ordinal is never stored, so entries may be reordered freely. [kinds] are the Nostr event
* kinds this group governs; they must stay disjoint across entries so a single toggle owns each kind.
*/
enum class HomeFeedType(
val code: String,
val kinds: List<Int>,
) {
TEXT_NOTES("text_notes", listOf(TextNoteEvent.KIND)),
REPOSTS("reposts", listOf(RepostEvent.KIND, GenericRepostEvent.KIND)),
COMMENTS("comments", listOf(CommentEvent.KIND)),
ARTICLES("articles", listOf(LongTextNoteEvent.KIND)),
WIKI("wiki", listOf(WikiNoteEvent.KIND)),
HIGHLIGHTS("highlights", listOf(HighlightEvent.KIND)),
POLLS("polls", listOf(PollEvent.KIND, ZapPollEvent.KIND, PollResponseEvent.KIND)),
CLASSIFIEDS("classifieds", listOf(ClassifiedsEvent.KIND)),
VOICE("voice", listOf(VoiceEvent.KIND, VoiceReplyEvent.KIND)),
LIVE_ACTIVITIES("live_activities", listOf(LiveActivitiesEvent.KIND, LiveActivitiesChatMessageEvent.KIND)),
EPHEMERAL_CHAT("ephemeral_chat", listOf(EphemeralChatEvent.KIND)),
INTERACTIVE_STORIES("interactive_stories", listOf(InteractiveStoryPrologueEvent.KIND)),
CHESS("chess", listOf(ChessGameEvent.KIND, LiveChessGameChallengeEvent.KIND, LiveChessGameEndEvent.KIND)),
BIRDS("birds", listOf(BirdDetectionEvent.KIND, BirdexEvent.KIND)),
ATTESTATIONS(
"attestations",
listOf(
AttestationEvent.KIND,
AttestationRequestEvent.KIND,
AttestorRecommendationEvent.KIND,
AttestorProficiencyEvent.KIND,
),
),
NIPS("nips", listOf(NipTextEvent.KIND)),
MUSIC("music", listOf(AudioTrackEvent.KIND, MusicTrackEvent.KIND, MusicPlaylistEvent.KIND, AudioHeaderEvent.KIND)),
PODCASTS("podcasts", listOf(PodcastEpisodeEvent.KIND, PodcastMetadataEvent.KIND)),
FUNDRAISERS("fundraisers", listOf(FundraiserEvent.KIND)),
;
companion object {
/** Every group, enabled by default so a fresh (or never-customized) account loads everything. */
val ALL: Set<HomeFeedType> = entries.toSet()
fun fromCode(code: String?): HomeFeedType? = entries.firstOrNull { it.code == code }
/** Serializes a set of groups as their comma-joined [code]s, for SharedPreferences. */
fun encode(types: Set<HomeFeedType>): String = types.joinToString(",") { it.code }
/** Parses a comma-joined [code] list back to a set, dropping any unknown codes. */
fun decode(joined: String?): Set<HomeFeedType> =
joined
?.split(",")
?.mapNotNull { fromCode(it.trim()) }
?.toSet()
?: emptySet()
/**
* The event kinds to drop from the home relay filters and the home DAL, given the currently
* [enabled] set. A kind stays live if ANY enabled group still owns it (guards against a
* future overlap between two groups), so disabling one group never silently hides a kind a
* still-enabled group also wants.
*/
fun disabledKinds(enabled: Set<HomeFeedType>): Set<Int> {
if (enabled.size == ALL.size) return emptySet()
val enabledKinds = enabled.flatMapTo(HashSet()) { it.kinds }
return (ALL - enabled).flatMapTo(HashSet()) { it.kinds }.apply { removeAll(enabledKinds) }
}
}
}
@@ -687,12 +687,12 @@ object LocalCache : ILocalCache, ICacheProvider, Dao {
fun load(keys: Set<String>): Set<User> = keys.mapNotNullTo(mutableSetOf(), ::checkGetOrCreateUser)
override fun getOrCreateUser(pubkey: HexKey): User {
require(isValidHex(key = pubkey)) { "$pubkey is not a valid hex" }
override fun getOrCreateUser(hex: HexKey): User {
require(isValidHex(key = hex)) { "$hex is not a valid hex" }
// Pass `this` as the UserContext — User now resolves each pinned
// addressable note (kind:10002 / 10050 / 10019) lazily on first
// read, instead of all-or-nothing at construction time.
return users.getOrCreate(pubkey) { User(it, userContext) }
return users.getOrCreate(hex) { User(it, userContext) }
}
/** [UserContext] bridge to this cache's addressable lookup. */
@@ -823,11 +823,11 @@ object LocalCache : ILocalCache, ICacheProvider, Dao {
}
}
override fun getOrCreateNote(idHex: String): Note {
require(isValidHex(idHex)) { "$idHex is not a valid hex" }
override fun getOrCreateNote(hex: String): Note {
require(isValidHex(hex)) { "$hex is not a valid hex" }
return notes.getOrCreate(idHex) {
Note(idHex)
return notes.getOrCreate(hex) {
Note(hex)
}
}
@@ -945,11 +945,11 @@ object LocalCache : ILocalCache, ICacheProvider, Dao {
fun getOrCreateAddressableNoteInternal(key: Address): AddressableNote = addressables.getOrCreate(key) { AddressableNote(key) }
override fun getOrCreateAddressableNote(key: Address): AddressableNote {
val note = getOrCreateAddressableNoteInternal(key)
override fun getOrCreateAddressableNote(address: Address): AddressableNote {
val note = getOrCreateAddressableNoteInternal(address)
// Loads the user outside a Syncronized block to avoid blocking
if (note.author == null) {
note.author = checkGetOrCreateUser(key.pubKeyHex)
note.author = checkGetOrCreateUser(address.pubKeyHex)
}
return note
}
@@ -0,0 +1,132 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.okhttp
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.utils.Hex
import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.Response
import java.util.concurrent.ConcurrentHashMap
/**
* Retries a Blossom blob download with a BUD-01 `t=get` authorization when the
* server answers `401`.
*
* Most Blossom / NIP-96 hosts serve blobs anonymously, so images load without
* any signing overhead. A few gate reads behind auth — Buzz's private media
* relay (`*.communities.buzz.xyz`) returns
* `401 {"error":"authentication failed"}` to an anonymous `GET`. For those we
* sign a kind-24242 read-auth event (via [authHeaderProvider]) and replay the
* request once with `Authorization: Nostr <base64-event>`.
*
* Gating is deliberately narrow so we never turn an unrelated `401` into a
* second request storm:
* - only `GET` requests,
* - only when the request doesn't already carry an `Authorization` header,
* - only when the URL's last path segment is a Blossom sha256 filename
* (`<64-hex>` optionally followed by an extension such as `.png` or
* `.thumb.jpg`),
* - only after the anonymous attempt actually returned `401`,
* - and at most one retry (an application interceptor's second `chain.proceed`
* runs the downstream chain again, it does not re-enter this interceptor).
*
* [authHeaderProvider] is `(host, sha256) -> header?`. It is synchronous by
* contract (the caller bridges the suspend signer), returns `null` when no
* signer is available or signing times out, and is only consulted on a real
* `401`, so an unauthenticated user simply keeps seeing the broken image
* rather than paying any signing cost.
*
* The first blob from an auth-gated host costs an extra round trip (anonymous
* `GET` → `401` → signed retry), but that host is then remembered in
* [knownAuthHosts] so every later blob from it is signed **up front** — one
* round trip, not two. This matters on a Buzz community feed where nearly every
* image comes from the same gated host: without it each image would keep paying
* the wasted 401 probe. The learned host also short-circuits to anonymous when
* no signer is available, so a logged-out user never re-probes needlessly.
*/
class BlossomReadAuthInterceptor(
private val authHeaderProvider: (host: String, sha256: HexKey) -> String?,
) : Interceptor {
// Hosts observed to answer 401 to an anonymous Blossom GET. Small (a user
// follows a handful of auth-gated servers at most) and shared across all
// clients derived from the same factory. newKeySet() is thread-safe for the
// concurrent reads/writes of parallel feed downloads.
private val knownAuthHosts: MutableSet<String> = ConcurrentHashMap.newKeySet()
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
if (!request.method.equals("GET", ignoreCase = true) ||
request.header("Authorization") != null
) {
return chain.proceed(request)
}
val sha256 = blossomHashOrNull(request.url.encodedPath) ?: return chain.proceed(request)
val host = request.url.host
// Known-gated host: skip the anonymous probe and sign the first attempt.
// Falls through to anonymous only when we can't produce a token (no
// signer / timeout) — the server would 401 either way.
if (host in knownAuthHosts) {
authHeaderProvider(host, sha256)?.let { header ->
return chain.proceed(request.withAuth(header))
}
}
val response = chain.proceed(request)
if (response.code != 401) return response
// Learn the host so its next blob is signed up front.
knownAuthHosts.add(host)
val header = authHeaderProvider(host, sha256) ?: return response
// Close the 401 body before replaying so the connection can be reused.
response.close()
return chain.proceed(request.withAuth(header))
}
private fun Request.withAuth(header: String) =
newBuilder()
.header("Authorization", header)
.build()
companion object {
/**
* Extracts the sha256 blob id from a Blossom URL path. The blob is the
* last path segment, up to its first `.` — so both `<hash>.png` and the
* derived `<hash>.thumb.jpg` resolve to `<hash>`. Returns `null` when the
* segment isn't a 64-char hex string.
*
* Uses Quartz's unrolled [Hex.isHex64] rather than a regex — this runs on
* every media URL the feed loads. [Hex.isHex64] only checks the first 64
* chars and doesn't verify total length, so the `length == 64` guard is
* what rejects longer segments.
*/
fun blossomHashOrNull(encodedPath: String): HexKey? {
val base = encodedPath.substringAfterLast('/').substringBefore('.').lowercase()
return if (base.length == 64 && Hex.isHex64(base)) base else null
}
}
}
@@ -0,0 +1,90 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.okhttp
import com.vitorpamplona.amethyst.commons.service.upload.BlossomAuth
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeoutOrNull
import java.util.concurrent.ConcurrentHashMap
/**
* Signs and caches BUD-01 read-auth headers for [BlossomReadAuthInterceptor].
*
* The interceptor is synchronous (it runs on an OkHttp dispatcher thread) but
* signing is `suspend`, so [authHeader] bridges with [runBlocking] guarded by a
* timeout: an internal key signs instantly, while a remote (NIP-46) or external
* (NIP-55) signer that hangs or needs user interaction simply yields `null` and
* the download stays unauthenticated instead of pinning the thread.
*
* Tokens are cached per host, not per blob. A BUD-11 `server`-scoped token
* grants reads for every blob on the host (thumbnails included), so one signed
* event covers a whole feed's worth of images from an auth-gated host for the
* life of the token. The blob hash of the request that first triggered signing
* is still included as the `x` tag for BUD-01 servers that check it.
*/
class BlossomReadAuthTokenProvider(
private val signerProvider: () -> NostrSigner?,
private val clock: () -> Long = { System.currentTimeMillis() },
) {
private class CachedToken(
val header: String,
val expiresAtMs: Long,
)
private val cache = ConcurrentHashMap<String, CachedToken>()
fun authHeader(
host: String,
sha256: HexKey,
): String? {
val now = clock()
cache[host]?.let { if (it.expiresAtMs > now) return it.header }
val signer = signerProvider() ?: return null
val header =
runBlocking {
withTimeoutOrNull(SIGN_TIMEOUT_MS) {
BlossomAuth.createGetAuth(
hash = sha256,
alt = "Downloading media from $host",
signer = signer,
servers = listOf(host),
)
}
} ?: return null
cache[host] = CachedToken(header, now + CACHE_TTL_MS)
return header
}
companion object {
// The signed event expires one hour out (BlossomAuthorizationEvent), so
// refresh a little early to avoid handing over a token that dies mid-flight.
private const val CACHE_TTL_MS = 55L * 60L * 1000L
// Bounds how long an image download may block waiting on a slow signer.
private const val SIGN_TIMEOUT_MS = 8_000L
}
}
@@ -50,8 +50,11 @@ class DualHttpClientManager(
// Resource-usage ledger counter, installed on the shared base client so
// every derived client is accounted. See [OkHttpClientFactory].
usageInterceptor: Interceptor? = null,
// Signs BUD-01 read-auth to retry auth-gated Blossom downloads on 401.
// See [BlossomReadAuthInterceptor].
blossomReadAuth: Interceptor? = null,
) : IHttpClientManager {
val factory = OkHttpClientFactory(keyCache, userAgent, dns, shouldBridgeBlossomCache, onionCache, usageInterceptor)
val factory = OkHttpClientFactory(keyCache, userAgent, dns, shouldBridgeBlossomCache, onionCache, usageInterceptor, blossomReadAuth)
val defaultHttpClient: StateFlow<OkHttpClient> =
combine(proxyPortProvider, isMobileDataProvider) { proxy, mobile ->
@@ -69,6 +69,13 @@ class OkHttpClientFactory(
* tests / pre-configuration call sites.
*/
private val usageInterceptor: Interceptor? = null,
/**
* Retries auth-gated Blossom blob downloads with a signed BUD-01 `t=get`
* token when the host answers `401` (e.g. Buzz's private media relay). Null
* in tests / pre-configuration call sites, in which case such downloads stay
* anonymous. See [BlossomReadAuthInterceptor].
*/
private val blossomReadAuth: Interceptor? = null,
) {
// val logging = LoggingInterceptor()
val keyDecryptor = EncryptedBlobInterceptor(keyCache)
@@ -116,6 +123,12 @@ class OkHttpClientFactory(
.apply {
blossomCacheRedirect?.let { addInterceptor(it) }
}
// Sits outside the network interceptors so its retry re-runs the
// full stack (content-type, blossom cache, key decryptor) for the
// authenticated response. Only signs on an actual 401.
.apply {
blossomReadAuth?.let { addInterceptor(it) }
}
// .addNetworkInterceptor(logging)
.addNetworkInterceptor(keyDecryptor)
// Passively populates [onionCache] from any HTTP/WebSocket response
@@ -221,6 +221,13 @@ class AuthCoordinator(
relayUrl = relayUrl,
pendingEvents = client.activeOutboxEvents(relayUrl),
myRelays = account.trustedRelays.flow.value,
// A NIP-29 relay group the user explicitly joined (kind-10009) is a first-party reason
// to authenticate with its host relay: private/closed group content is `#h`-scoped and
// never names the user, so it fails the pubkey checks above — without this, a joined
// private group's messages are refused with `auth-required` and the group stays empty.
myGroupRelays =
account.relayGroupList.liveRelayGroupIds.value
.mapTo(mutableSetOf()) { it.relayUrl },
)
fun destroy() {
@@ -34,7 +34,14 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
* - it is publishing its own event there ([pendingEvents] authored by it — e.g. delivering a DM to
* the recipient's inbox relay), or
* - the relay is one it configured itself ([myRelays] — its NIP-65 / DM / search / … lists, which
* is where its own inbox/outbox reads are routed anyway).
* is where its own inbox/outbox reads are routed anyway), or
* - the relay hosts a NIP-29 relay group the account explicitly joined ([myGroupRelays], from its
* kind-10009 list). A private/closed group's content (kind-9 chat, kind-11 threads, …) is
* `#h`-scoped and served only to authenticated members; that read never names the user, so it
* can't qualify via [pendingEvents], and a group host relay is not one of the account's own
* NIP-65/DM/… lists, so it can't qualify via [myRelays] either. Without this, a joined private
* group is refused with `auth-required` and renders empty — the group's own metadata (39000) is
* public and still loads, so the group appears but shows no messages.
*
* Crucially, an active subscription merely *naming* the account (a `#p` tag or `authors` entry) is
* NOT a first-party reason: the app packs several accounts' pubkeys into one merged filter and fans
@@ -49,8 +56,10 @@ object RelayAuthFirstParty {
relayUrl: NormalizedRelayUrl,
pendingEvents: List<Event>,
myRelays: Set<NormalizedRelayUrl>,
myGroupRelays: Set<NormalizedRelayUrl> = emptySet(),
): Boolean {
if (pendingEvents.any { it.pubKey == me }) return true
return relayUrl in myRelays
if (relayUrl in myRelays) return true
return relayUrl in myGroupRelays
}
}
@@ -46,25 +46,25 @@ object ResourceUsageAlerts {
val value: Long,
)
/** > 50 MB of background traffic on cellular in one day. */
const val BG_MOBILE_BYTES_PER_DAY = 50L * 1024L * 1024L
/** > 500 MB of background traffic on cellular in one day. */
const val BG_MOBILE_BYTES_PER_DAY = 500L * 1024L * 1024L
/** > 12 relay-connection-hours while backgrounded on cellular in one day. */
const val BG_MOBILE_RELAY_CONN_MS_PER_DAY = 12L * 60L * 60L * 1000L
/** > 24 relay-connection-hours while backgrounded on cellular in one day. */
const val BG_MOBILE_RELAY_CONN_MS_PER_DAY = 24L * 60L * 60L * 1000L
/** > 30 minutes of notification wakelock held in one day. */
const val WAKELOCK_MS_PER_DAY = 30L * 60L * 1000L
/** > 60 minutes of notification wakelock held in one day. */
const val WAKELOCK_MS_PER_DAY = 60L * 60L * 1000L
/** > 75 process starts in one day (WorkManager/restart churn). */
const val APP_STARTS_PER_DAY = 75L
/** > 150 process starts in one day (WorkManager/restart churn). */
const val APP_STARTS_PER_DAY = 150L
/**
* > 5000 completed relay (re)connections in one day. A healthy day is a
* > 10000 completed relay (re)connections in one day. A healthy day is a
* few hundred to ~2000 even with a large relay set; sustained thousands
* means something is cycling (a stuck relay tier, a flapping network, a
* Tor bootstrap loop) and every cycle pays a TLS handshake.
*/
const val RELAY_CONNECTS_PER_DAY = 5_000L
const val RELAY_CONNECTS_PER_DAY = 10_000L
const val MIN_DAYS_BETWEEN_PROMPTS = 7L
@@ -115,6 +115,9 @@ import kotlinx.coroutines.launch
import net.engawapg.lib.zoomable.rememberZoomState
import net.engawapg.lib.zoomable.zoomable
private const val MIME_IMAGE_PREFIX = "image/"
private const val MIME_VIDEO_PREFIX = "video/"
@Composable
fun BlossomBlobManagerScreen(
accountViewModel: AccountViewModel,
@@ -291,7 +294,7 @@ private fun OverflowMenuIcon(symbol: MaterialSymbol) {
/** Whether a blob is an image or a video, i.e. it can be previewed and shown full-screen. */
private val BlobRow.isViewable: Boolean
get() = type?.let { it.startsWith("image/") || it.startsWith("video/") } == true
get() = type?.let { it.startsWith(MIME_IMAGE_PREFIX) || it.startsWith(MIME_VIDEO_PREFIX) } == true
@Composable
private fun CenteredState(content: @Composable () -> Unit) {
@@ -381,7 +384,7 @@ private fun BlobPreview(
glyphSize: Dp = 34.dp,
playIconSize: Dp = 40.dp,
) {
val isVideo = row.type?.startsWith("video/") == true
val isVideo = row.type?.startsWith(MIME_VIDEO_PREFIX) == true
Box(modifier = modifier, contentAlignment = Alignment.Center) {
if (row.url != null && row.isViewable) {
SubcomposeAsyncImage(
@@ -483,7 +486,7 @@ private fun BlossomBlobViewer(
) {
val context = LocalContext.current
var drawerOpen by remember { mutableStateOf(false) }
val isVideo = row.type?.startsWith("video/") == true
val isVideo = row.type?.startsWith(MIME_VIDEO_PREFIX) == true
Dialog(
onDismissRequest = onDismiss,
@@ -739,8 +742,8 @@ private fun DetailAction(
private fun glyphFor(type: String?): MaterialSymbol =
when {
type?.startsWith("image/") == true -> MaterialSymbols.Image
type?.startsWith("video/") == true -> MaterialSymbols.PlayCircle
type?.startsWith(MIME_IMAGE_PREFIX) == true -> MaterialSymbols.Image
type?.startsWith(MIME_VIDEO_PREFIX) == true -> MaterialSymbols.PlayCircle
else -> MaterialSymbols.Storage
}
@@ -28,9 +28,10 @@ import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavHostController
import androidx.navigation.compose.currentBackStackEntryAsState
import com.vitorpamplona.amethyst.ui.navigation.BOTTOM_NAV_ROOT_KEY
import com.vitorpamplona.amethyst.ui.navigation.isBottomNavRoot
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
@@ -115,19 +116,35 @@ class Nav(
@Composable
override fun canPop(): Boolean {
// Observe the current entry as State so consumers recompose when the
// back stack settles after a navigation or back-swipe transition.
// A non-reactive read would leave a stale value behind: e.g. on
// back-swipe to Home, previousBackStackEntry is still the popping
// entry until the gesture finishes, and nothing would re-evaluate
// canPop afterwards.
val current by controller.currentBackStackEntryAsState()
val entry = current ?: return false
// Decide the back arrow / bottom-bar visibility from THIS screen's own
// back-stack entry — the one the NavHost hands to each destination
// through LocalViewModelStoreOwner — instead of the globally-current
// entry.
//
// A pop commits the moment it is accepted (most visibly during a
// predictive back-swipe, whose exit animation is long and finger-driven):
// controller.currentBackStackEntry flips to the destination while the
// screen being dismissed is still on screen, sliding out and still
// composing its top bar. Reading the global entry there re-evaluated
// canPop against the incoming destination and dropped the arrow before
// the outgoing screen had finished leaving. An entry is intrinsic to its
// screen and never changes for the life of that composition, so the arrow
// now stays put until the screen itself is gone.
//
// Outside a NavHost destination (shell chrome, drawer) the current owner
// is the account-scoped ViewModelStoreOwner, not an entry; fall back to
// the globally-current entry so those callers keep their prior behavior.
val entry =
(LocalViewModelStoreOwner.current as? NavBackStackEntry)
?: controller.currentBackStackEntry
?: return false
// Hidden on tab roots (reached via the bottom nav) and on Home (the
// graph's start destination): nothing sits below either that a back
// arrow could return to. Every other entry is a push on top of Home,
// so it can always pop.
if (entry.isBottomNavRoot()) return false
// Home is the graph's start destination and nothing can sit below
// it, so a back arrow there is never meaningful.
if (entry.destination.id == controller.graph.findStartDestination().id) return false
return controller.previousBackStackEntry != null
return entry.destination.id != controller.graph.findStartDestination().id
}
override fun popBack() {
@@ -766,6 +766,7 @@ private fun geohashBounds(geohash: String): BoundingBox? {
/** A rough physical size for a geohash cell at this precision, for the chip subtitle. */
private fun GeohashChannelLevel.areaSize(): String =
when (this) {
GeohashChannelLevel.CONTINENT -> "~5000 km"
GeohashChannelLevel.REGION -> "~1250 km"
GeohashChannelLevel.PROVINCE -> "~39 km"
GeohashChannelLevel.CITY -> "~5 km"
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.note.nip22Comments
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Box
@@ -66,7 +67,6 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton
import com.vitorpamplona.amethyst.ui.actions.uploads.TakeVideoButton
import com.vitorpamplona.amethyst.ui.navigation.bottombars.KeyboardAwareBackHandler
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar
import com.vitorpamplona.amethyst.ui.note.BaseUserPicture
@@ -177,7 +177,7 @@ fun GenericCommentPostScreen(
StrippingFailureDialog(postViewModel.strippingFailureConfirmation)
KeyboardAwareBackHandler {
BackHandler {
accountViewModel.launchSigner {
postViewModel.sendDraftSync()
postViewModel.cancel()
@@ -40,10 +40,13 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
import com.vitorpamplona.amethyst.commons.model.highlights.HighlightQuote
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
import com.vitorpamplona.amethyst.commons.ui.components.ClickableTextPrimary
import com.vitorpamplona.amethyst.commons.ui.note.HighlightQuoteIndent
import com.vitorpamplona.amethyst.commons.ui.note.HighlightQuoteSpacing
@@ -54,7 +57,6 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNo
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
import com.vitorpamplona.amethyst.ui.components.ClickableUrl
import com.vitorpamplona.amethyst.ui.components.CreateClickableTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.DisplayEvent
import com.vitorpamplona.amethyst.ui.components.RenderUserAsClickableText
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.components.measureSpaceWidth
@@ -64,6 +66,7 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.kindNameFor
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.firstTagValueFor
@@ -91,6 +94,7 @@ fun RenderHighlight(
DisplayHighlight(
comment = noteEvent.comment(),
commentTags = remember(noteEvent) { noteEvent.tags.toImmutableListOfLists() },
highlight = noteEvent.quote(),
context = noteEvent.contextOrReconstructed(),
authorHex = noteEvent.author(),
@@ -160,6 +164,7 @@ fun DisplayHighlightPreviewNewLine() {
@Composable
fun DisplayHighlight(
comment: String?,
commentTags: ImmutableListOfLists<String> = EmptyTagList,
highlight: String,
context: String?,
authorHex: String?,
@@ -181,7 +186,7 @@ fun DisplayHighlight(
canPreview = canPreview && !makeItShort,
quotesLeft = quotesLeft,
modifier = Modifier.fillMaxWidth(),
tags = EmptyTagList,
tags = commentTags,
backgroundColor = backgroundColor,
id = it,
callbackUri = null,
@@ -406,7 +411,11 @@ fun DisplayEntryForNote(
val noteEvent = noteState.note.event as? BaseThreadedEvent ?: return
val description = remember(noteEvent) { noteEvent.tags.firstTagValueFor("title", "subject", "alt") }
// A real title/subject from an article or wiki page describes the source well. `alt`
// (NIP-31) is deliberately excluded: it's accessibility fallback text, not a caption, and
// clients such as Jumble fill it with a generic "This event was published by …" line that
// has nothing to do with the highlighted passage.
val description = remember(noteEvent) { noteEvent.tags.firstTagValueFor("title", "subject") }
Text("-", maxLines = 1)
@@ -416,7 +425,13 @@ fun DisplayEntryForNote(
onClick = { routeFor(note, accountViewModel.account)?.let { nav.nav(it) } },
)
} else {
DisplayEvent(noteEvent.id, note.toNostrUri(), null, accountViewModel, nav)
// No title to show — name the source by its event kind (e.g. "Note", "Blogs") rather
// than a raw @note1… id, and keep it clickable through to the source event.
val kindName = kindNameFor(LocalContext.current, noteEvent.kind)
ClickableTextPrimary(
text = kindName,
onClick = { routeFor(note, accountViewModel.account)?.let { nav.nav(it) } },
)
}
}
@@ -248,6 +248,19 @@ class AccountFeedContentStates(
}
}
// Toggling a Home content type on/off in Settings Home changes which event kinds the tabs
// render, but no event flows through LocalCache — force a rebuild of all three home feeds so
// hidden kinds disappear (and re-enabled ones reappear from cache) immediately.
scope.launch(Dispatchers.IO) {
account.settings.enabledHomeFeedTypes
.drop(1)
.collect {
homeNewThreads.invalidateData()
homeReplies.invalidateData()
homeEverything.invalidateData()
}
}
// Pinning/unpinning a room only changes sort order, not membership, so no
// chat event flows through LocalCache. Force a rebuild to re-sort. This
// also fires when pins arrive via the synced AppSpecificData event.
@@ -1672,18 +1672,42 @@ class AccountViewModel(
fun leaveRelayGroup(channel: RelayGroupChannel) = launchSigner { account.leaveRelayGroup(channel) }
/** Delete the channel/group for everyone (kind-9008). Owner/admin only; the relay enforces it. */
fun deleteRelayGroup(channel: RelayGroupChannel) = launchSigner { account.deleteRelayGroup(channel) }
/**
* Accept a channel somebody added me to: write it into my kind-10009 so it shows on Messages and
* follows me to other devices. No kind-9021 join — the relay already put me in the roster, which is
* why the channel opens and accepts posts today; this only records *my* decision to surface it.
* Take a relay group off Messages WITHOUT leaving it: drop it from my kind-10009 list so it stops
* showing, but send no kind-9022 — I stay in the relay roster and can still read/post, and re-joining
* re-surfaces it instantly. Also records it in `dismissedChannelInvites` so a Buzz relay re-announcing
* my membership (kind-44100) can't bounce it back in as a pending invite. This is the soft counterpart
* to [leaveRelayGroup]; "Remove from Messages" vs "Leave" is the same split the invite card offers.
*/
fun acceptChannelInvite(channel: RelayGroupChannel) =
fun removeRelayGroupFromMessages(channel: RelayGroupChannel) =
launchSigner {
account.settings.dismissChannelInvite(channel.groupId.id)
account.unfollow(channel)
}
/**
* Put a relay group (back) on Messages: write it into my kind-10009 so it shows and follows me to
* other devices, and clear the dismissal [removeRelayGroupFromMessages] left behind so a Buzz relay
* re-announcing my membership isn't filtered out. No kind-9021 join — the relay roster is untouched
* by both halves of this toggle; this only records *my* decision to surface the channel.
*/
fun addRelayGroupToMessages(channel: RelayGroupChannel) =
launchSigner {
account.settings.undismissChannelInvite(channel.groupId.id)
account.follow(channel)
BuzzChannelInvites.remove(account.userProfile().pubkeyHex, channel.groupId.id)
}
/**
* Accept a channel somebody added me to. Identical to [addRelayGroupToMessages] — accepting an
* invite *is* surfacing the channel, since the relay already put me in the roster (which is why
* the channel opens and accepts posts today).
*/
fun acceptChannelInvite(channel: RelayGroupChannel) = addRelayGroupToMessages(channel)
/**
* Keep the channel off Messages without touching membership. Local and reversible — I stay in the
* roster and can still open and post; [leaveChannelInvite] is the one that actually removes me.
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.award
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -51,7 +52,6 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.ui.components.Nip05OrPubkeyLine
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.navigation.bottombars.KeyboardAwareBackHandler
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar
import com.vitorpamplona.amethyst.ui.note.UserPicture
@@ -88,7 +88,7 @@ fun AwardBadgeScreen(
onDispose { userSuggestions.reset() }
}
KeyboardAwareBackHandler {
BackHandler {
nav.popBack()
}
@@ -619,6 +619,10 @@ private fun List<Note>.toDiscoverApps(
matchAuthor(author)
}.mapNotNull { it.toDiscoverNostrApp() }
.filter { it.app.coordinate !in excludeCoordinates }
// The observed store can surface the same addressable manifest twice (a replaceable note whose
// new version mutates its sort key inside the backing set), so collapse by coordinate to keep the
// grid keys unique — otherwise LazyVerticalGrid throws on the duplicate "ns:"/"np:" key.
.distinctBy { it.app.coordinate }
.take(DISCOVER_NOSTR_LIMIT)
.toList()
@@ -46,7 +46,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -134,7 +133,6 @@ fun BuzzCanvasScreen(
Column {
Text(
text = stringRes(R.string.buzz_canvas_title),
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
@@ -56,7 +56,9 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -82,6 +84,12 @@ import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
import kotlinx.coroutines.launch
/**
* Bottom room the inbox leaves for its extended FAB — same clearance the community view uses, and
* wide enough for the taller extended variant.
*/
private val FAB_CLEARANCE = 96.dp
/**
* The **Buzz Direct Messages** inbox. A Buzz DM is a relay-authoritative NIP-29 group
* whose id is a UUID, so tapping a row opens the very same [Route.RelayGroup] chat screen
@@ -99,6 +107,11 @@ fun BuzzDmListScreen(
viewModel.bind(accountViewModel.account, relayUrl)
val rows by viewModel.rows.collectAsStateWithLifecycle()
val hiddenRows by viewModel.hiddenRows.collectAsStateWithLifecycle()
// Hidden conversations stay collapsed behind a header — they are off Messages by the user's own
// choice, so they must not compete with the live inbox; they only need to be *reachable* again.
var showHidden by remember { mutableStateOf(false) }
Scaffold(
topBar = { TopBarWithBackButton(stringRes(R.string.buzz_dm_title), nav) },
@@ -110,26 +123,79 @@ fun BuzzDmListScreen(
)
},
) { padding ->
if (rows.isEmpty()) {
if (rows.isEmpty() && hiddenRows.isEmpty()) {
EmptyDmInbox(modifier = Modifier.padding(padding))
} else {
LazyColumn(
modifier = Modifier.padding(padding).fillMaxSize(),
contentPadding = PaddingValues(16.dp),
// Extra room at the bottom so the last row's overflow clears the FAB, which the
// Scaffold's padding deliberately doesn't account for (a FAB overlays content).
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = FAB_CLEARANCE),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
items(rows, key = { it.channelId }) { row ->
DmRowCard(row, accountViewModel, nav)
DmRowCard(row, isHidden = false, viewModel = viewModel, accountViewModel = accountViewModel, nav = nav)
}
if (hiddenRows.isNotEmpty()) {
item(key = "hidden-header") {
HiddenDmHeader(
count = hiddenRows.size,
expanded = showHidden,
onToggle = { showHidden = !showHidden },
)
}
if (showHidden) {
items(hiddenRows, key = { "hidden-${it.channelId}" }) { row ->
DmRowCard(row, isHidden = true, viewModel = viewModel, accountViewModel = accountViewModel, nav = nav)
}
}
}
}
}
}
}
/**
* The collapsible "Hidden (N)" divider between the live inbox and the conversations I took off it.
* Shared with the community view's inline Direct Messages section, so a hidden DM is reachable from
* wherever the user's DMs are — this inbox screen is only reachable behind a "see all" row.
*/
@Composable
fun HiddenDmHeader(
count: Int,
expanded: Boolean,
onToggle: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier =
modifier
.fillMaxWidth()
.clickable(onClick = onToggle)
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Icon(
symbol = if (expanded) MaterialSymbols.ExpandMore else MaterialSymbols.ChevronRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(18.dp),
)
Text(
text = pluralStringResource(R.plurals.buzz_dm_hidden_count, count, count),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
/** One conversation: a participant avatar (stacked for group DMs), names, host, last-seen, kebab. */
@Composable
private fun DmRowCard(
row: BuzzDmListViewModel.DmRow,
isHidden: Boolean,
viewModel: BuzzDmListViewModel,
accountViewModel: AccountViewModel,
nav: INav,
) {
@@ -156,7 +222,9 @@ private fun DmRowCard(
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier.padding(12.dp),
// A hidden DM is still openable (it's a live conversation I merely took off the list), so
// it renders faded rather than disabled — visibly parked, not broken.
modifier = Modifier.padding(12.dp).alpha(if (isHidden) 0.55f else 1f),
verticalAlignment = Alignment.CenterVertically,
) {
DmAvatars(row.others, accountViewModel, nav)
@@ -204,21 +272,21 @@ private fun DmRowCard(
addMemberOpen = true
},
)
// A toggle, like the channel rows: hiding a DM is a per-viewer, reversible
// relay-side flag (kind-41012 / the 30622 snapshot), never a departure — so a
// hidden conversation must offer its own way back rather than vanishing for good.
DropdownMenuItem(
text = { Text(stringRes(R.string.buzz_dm_hide)) },
text = { Text(stringRes(if (isHidden) R.string.add_to_messages else R.string.remove_from_messages)) },
leadingIcon = {
Icon(
symbol = MaterialSymbols.VisibilityOff,
symbol = if (isHidden) MaterialSymbols.Add else MaterialSymbols.VisibilityOff,
contentDescription = null,
modifier = Modifier.size(20.dp),
)
},
onClick = {
menuOpen = false
scope.launch {
val channel = LocalCache.getOrCreateRelayGroupChannel(groupId)
accountViewModel.account.hideBuzzDm(channel)
}
if (isHidden) viewModel.addToMessages(row) else viewModel.removeFromMessages(row)
},
)
}
@@ -71,8 +71,11 @@ import java.util.concurrent.ConcurrentHashMap
* `t` = `dm` — that same 39000 also carries the roster the shared chat composer's member gate
* needs, and the DM participants;
* - subscribes the per-viewer [DmVisibilityEvent] (`kind:30622`) so a hidden DM (tracked in
* [BuzzDmRegistry]) drops out;
* - projects the visible DMs into [rows], sorted by last message time.
* [BuzzDmRegistry]) moves from [rows] to [hiddenRows];
* - projects the visible DMs into [rows] and the hidden ones into [hiddenRows], both sorted by
* last message time. Hidden DMs stay projected (rather than being dropped on the floor) so the
* inbox can offer them back — hiding is reversible, and a conversation with no way back is a
* conversation the user has lost.
*/
class BuzzDmListViewModel : ViewModel() {
@Volatile private var account: Account? = null
@@ -88,6 +91,10 @@ class BuzzDmListViewModel : ViewModel() {
private val _rows = MutableStateFlow<List<DmRow>>(emptyList())
val rows: StateFlow<List<DmRow>> = _rows.asStateFlow()
/** The DMs I hid (per the relay's 30622 snapshot), newest-first — offered back under "Hidden". */
private val _hiddenRows = MutableStateFlow<List<DmRow>>(emptyList())
val hiddenRows: StateFlow<List<DmRow>> = _hiddenRows.asStateFlow()
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
@@ -211,14 +218,17 @@ class BuzzDmListViewModel : ViewModel() {
account.client.fetchAllWithHooks(filters = byRelay, timeoutMs = 8_000, pendingOnAuthRequired = true) { _, _ -> false }
}
/** Project the discovered DM channels (metadata `t` = `dm`), minus my hidden set, newest-first. */
/**
* Project the discovered DM channels (metadata `t` = `dm`) newest-first, split by my hidden set
* (the relay's 30622 snapshot) into [rows] and [hiddenRows]. Both halves come from one pass so a
* DM can only ever be in one of them.
*/
private fun rebuildRows(account: Account) {
val myPubkey = account.userProfile().pubkeyHex
val hidden = BuzzDmRegistry.hiddenFor(myPubkey)
_rows.value =
val (hiddenDms, visibleDms) =
memberChannels.entries
.mapNotNull { (channelId, relay) ->
if (channelId in hidden) return@mapNotNull null
val channel = LocalCache.getOrCreateRelayGroupChannel(GroupId(channelId, relay))
val metadata = channel.event ?: return@mapNotNull null
if (!metadata.isBuzzDm()) return@mapNotNull null
@@ -231,6 +241,38 @@ class BuzzDmListViewModel : ViewModel() {
lastActivity = lastActivityFor(channelId),
)
}.sortedByDescending { it.lastActivity }
.partition { it.channelId in hidden }
_rows.value = visibleDms
_hiddenRows.value = hiddenDms
}
/**
* Take [row] off Messages with a kind-41012 hide command. Server-side and per-viewer: the relay
* republishes my 30622 snapshot with this channel in it, which moves the row to [hiddenRows].
* Membership is untouched — nobody else's inbox changes, and [addToMessages] brings it back.
*/
fun removeFromMessages(row: DmRow) {
val account = account ?: return
viewModelScope.launch(Dispatchers.IO) {
account.hideBuzzDm(LocalCache.getOrCreateRelayGroupChannel(GroupId(row.channelId, row.relayUrl)))
}
}
/**
* Put a hidden DM back on Messages. Buzz has no "unhide" command — re-opening the conversation is
* the un-hide: a kind-41010 with the same participants resolves to the same canonical channel and
* drops it from the 30622 hidden snapshot. A self-DM has no `others`, so send myself, which is
* what the relay derived that channel from (and satisfies kind-41010's 1-8 participant rule).
*/
fun addToMessages(row: DmRow) {
val account = account ?: return
viewModelScope.launch(Dispatchers.IO) {
val me = account.userProfile().pubkeyHex
account.openBuzzDm(row.relayUrl, row.others.ifEmpty { listOf(me) })
// The relay's new 30622 normally arrives on the live subscription; refresh anyway so the
// row returns even if this screen's socket missed the snapshot.
refresh()
}
}
/** Newest message `created_at` for [channelId] from [LocalCache], or 0 when the DM is empty. */
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -31,7 +32,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Card
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.IconButton
@@ -84,7 +84,8 @@ private const val CARD_WARMUP_LIMIT = 10
* recent-posters facepile, a preview of the last message (author + snippet, or the Buzz activity
* summary for system/diff/job rows), the relative time of that message, and an unread-count badge.
* Tapping the card opens the channel ([onOpen]); the trailing overflow (3-dot) menu holds the
* per-channel actions — Pin/Unpin and Add-to-my-list — so the row stays clean.
* per-channel actions — Pin/Unpin and the Add/Remove-from-Messages toggle ([isAdded] says which half
* to show, and it must come from the live kind-10009 list) — so the row stays clean.
*
* Reused by the relay group-list screen where Buzz membership discovery is folded in.
*
@@ -99,6 +100,7 @@ fun BuzzImportRow(
groupId: GroupId,
isAdded: Boolean,
onAdd: () -> Unit,
onRemove: () -> Unit,
accountViewModel: AccountViewModel,
onOpen: (() -> Unit)? = null,
isStarred: Boolean = false,
@@ -168,13 +170,18 @@ fun BuzzImportRow(
isStarred = isStarred,
onToggleStar = onToggleStar,
onAdd = onAdd,
onRemove = onRemove,
accountViewModel = accountViewModel,
)
}
// A plain row on the screen background, not a filled Card. Each row used to be its own Card, and
// because they stack with no gaps their container colour merged into one grey slab behind the
// whole Channels section — reading as a box around the channels that the Direct Messages rows
// right below (plain rows) didn't have. Matches [BuzzDmInlineRow] and the Concord server list.
if (onOpen != null) {
Card(onClick = onOpen, modifier = Modifier.fillMaxWidth()) { content() }
Box(Modifier.fillMaxWidth().clickable(onClick = onOpen)) { content() }
} else {
Card(modifier = Modifier.fillMaxWidth()) { content() }
Box(Modifier.fillMaxWidth()) { content() }
}
}
@@ -192,6 +199,7 @@ private fun BuzzImportRowContent(
isStarred: Boolean,
onToggleStar: (() -> Unit)?,
onAdd: () -> Unit,
onRemove: () -> Unit,
accountViewModel: AccountViewModel,
) {
Row(
@@ -249,6 +257,7 @@ private fun BuzzImportRowContent(
BuzzChannelRowMenu(
isAdded = isAdded,
onAdd = onAdd,
onRemove = onRemove,
isStarred = isStarred,
onToggleStar = onToggleStar,
)
@@ -307,6 +316,7 @@ private fun BuzzChannelPreviewLine(
private fun BuzzChannelRowMenu(
isAdded: Boolean,
onAdd: () -> Unit,
onRemove: () -> Unit,
isStarred: Boolean,
onToggleStar: (() -> Unit)?,
) {
@@ -338,20 +348,22 @@ private fun BuzzChannelRowMenu(
},
)
}
// A toggle, not a one-way "Added" badge: a channel already on the kind-10009 list offers
// the way back off it. Neither half touches the relay roster, so the channel stays in this
// list (and readable) either way — only whether it shows on Messages changes.
DropdownMenuItem(
leadingIcon = {
Icon(
symbol = if (isAdded) MaterialSymbols.Check else MaterialSymbols.Add,
symbol = if (isAdded) MaterialSymbols.VisibilityOff else MaterialSymbols.Add,
contentDescription = null,
tint = if (isAdded) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
},
text = { Text(stringRes(if (isAdded) R.string.buzz_import_added else R.string.buzz_import_add)) },
enabled = !isAdded,
text = { Text(stringRes(if (isAdded) R.string.remove_from_messages else R.string.add_to_messages)) },
onClick = {
expanded = false
onAdd()
if (isAdded) onRemove() else onAdd()
},
)
}
@@ -24,9 +24,9 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuAnchorType
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.MenuAnchorType
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -77,7 +77,7 @@ fun EditableSuggestDropdown(
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = showMenu) },
keyboardOptions = keyboardOptions,
supportingText = supportingText?.let { { Text(it) } },
modifier = modifier.fillMaxWidth().menuAnchor(MenuAnchorType.PrimaryEditable),
modifier = modifier.fillMaxWidth().menuAnchor(ExposedDropdownMenuAnchorType.PrimaryEditable),
)
ExposedDropdownMenu(expanded = showMenu, onDismissRequest = { expanded = false }) {
filtered.forEach { opt ->
@@ -39,7 +39,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.util.Collections
@@ -79,7 +78,12 @@ class BuzzRelayImportViewModel : ViewModel() {
private val _channels = MutableStateFlow<List<GroupId>>(emptyList())
val channels: StateFlow<List<GroupId>> = _channels.asStateFlow()
/** Channel ids already present in the user's kind-10009 list (seeded + updated as they add). */
/**
* Channel ids on this relay that are currently in the user's kind-10009 list. Mirrors the live
* list rather than snapshotting it: it used to be seeded once at [bind] and only ever grow, so a
* channel taken off Messages anywhere else still rendered as "Added" — with the Add action
* disabled, leaving no way back.
*/
private val _added = MutableStateFlow<Set<String>>(emptySet())
val added: StateFlow<Set<String>> = _added.asStateFlow()
@@ -110,12 +114,13 @@ class BuzzRelayImportViewModel : ViewModel() {
// every other `#p=me`-gated read on the shared socket.
if (newlyJoined) account.client.reconnect(onlyIfChanged = false, ignoreRetryDelays = true)
// Seed "already added" from the current kind-10009 list so channels the user already has
// render as added rather than offering a duplicate Add.
_added.value =
account.relayGroupList.liveRelayGroupList.value
.filter { RelayUrlNormalizer.normalizeOrNull(it.relayUrl) == normalized }
.mapTo(mutableSetOf()) { it.groupId }
// Track "already added" against the live kind-10009 list, scoped to this relay, so the rows
// follow every add/remove — from here, from the channel's top bar, or from another device.
viewModelScope.launch(Dispatchers.IO) {
account.relayGroupList.liveRelayGroupIds.collect { groups ->
_added.value = groups.filter { it.relayUrl == normalized }.mapTo(mutableSetOf()) { it.id }
}
}
discover(account, normalized)
}
@@ -176,13 +181,32 @@ class BuzzRelayImportViewModel : ViewModel() {
}
}
/** Append [groupId] to the user's kind-10009 list (public group tag), so it shows in Messages. */
/**
* Append [groupId] to the user's kind-10009 list (public group tag), so it shows in Messages, and
* clear any earlier dismissal so the relay's kind-44100 re-announcement isn't filtered back out.
* [_added] is not touched here — the live-list collector in [bind] reflects the new event.
*/
fun add(groupId: GroupId) {
val account = account ?: return
viewModelScope.launch(Dispatchers.IO) {
val channel = LocalCache.getOrCreateRelayGroupChannel(groupId)
account.settings.undismissChannelInvite(groupId.id)
account.follow(channel)
_added.update { it + groupId.id }
}
}
/**
* Take [groupId] off the kind-10009 list without leaving the channel: no kind-9022, so the relay
* roster (and therefore this very list of channels) is untouched and it can be added back. The
* dismissal keeps a Buzz relay's kind-44100 re-announcement from bouncing it back as an invite —
* mirrors `AccountViewModel.removeRelayGroupFromMessages`.
*/
fun remove(groupId: GroupId) {
val account = account ?: return
viewModelScope.launch(Dispatchers.IO) {
val channel = LocalCache.getOrCreateRelayGroupChannel(groupId)
account.settings.dismissChannelInvite(groupId.id)
account.unfollow(channel)
}
}
@@ -55,13 +55,13 @@ import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuAnchorType
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MenuAnchorType
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
@@ -823,7 +823,7 @@ private fun WorkflowPicker(
label = { Text(stringRes(R.string.buzz_workflow_picker_label)) },
placeholder = { Text(if (definitions.isEmpty()) stringRes(R.string.buzz_workflow_picker_empty) else stringRes(R.string.buzz_workflow_picker_choose)) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier.fillMaxWidth().menuAnchor(MenuAnchorType.PrimaryNotEditable),
modifier = Modifier.fillMaxWidth().menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable),
)
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
definitions.forEach { def ->
@@ -138,7 +138,6 @@ fun CalendarEventDetailScreen(
title = {
Text(
text = stringRes(R.string.route_calendar_event_detail),
style = MaterialTheme.typography.titleMedium,
)
},
navigationIcon = {
@@ -54,7 +54,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -427,10 +426,10 @@ private fun GeohashChatTopBar(
LoadCityName(
geohashStr = geohash,
onLoading = {
Text("#$geohash", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text("#$geohash")
},
) { cityName ->
Text(cityName, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text(cityName)
}
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -26,7 +26,6 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
@@ -52,7 +51,7 @@ fun GeohashTeleportScreen(
Scaffold(
topBar = {
TopBarExtensibleWithBackButton(
title = { Text(stringRes(R.string.geohash_teleport_title), fontWeight = FontWeight.Bold) },
title = { Text(stringRes(R.string.geohash_teleport_title)) },
popBack = nav::popBack,
)
},
@@ -97,7 +97,7 @@ fun NewGeohashChatScreen(
Scaffold(
topBar = {
TopBarExtensibleWithBackButton(
title = { Text("New location channel", fontWeight = FontWeight.Bold) },
title = { Text("New location channel") },
popBack = nav::popBack,
)
},
@@ -377,6 +377,7 @@ private fun TeleportCard(onClick: () -> Unit) {
internal fun GeohashChannelLevel.label(): String =
when (this) {
GeohashChannelLevel.CONTINENT -> "Continent"
GeohashChannelLevel.REGION -> "Region"
GeohashChannelLevel.PROVINCE -> "Province"
GeohashChannelLevel.CITY -> "City"
@@ -36,6 +36,7 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
@@ -103,6 +104,7 @@ fun MarmotGroupChatScreen(
DisplayUserSetAsSubject(
userList = memberPubkeys,
accountViewModel = accountViewModel,
fontWeight = FontWeight.Normal,
)
} else {
Text(stringRes(R.string.marmot_group_default_name))
@@ -25,6 +25,7 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
@@ -321,7 +322,15 @@ fun ConcordChannelListScreen(
)
}
} else {
LazyColumn(Modifier.fillMaxSize().padding(padding)) {
// Bottom room for the FAB, which the Scaffold's `padding` deliberately doesn't account for
// (a FAB overlays content) — without it the last row's manager overflow menu sits under it
// and can't be tapped. As contentPadding so rows scroll *through* the strip rather than the
// viewport shrinking. Gated on [canManageChannels] because that is what renders the FAB:
// a plain member has nothing to clear, and no reason to lose the space.
LazyColumn(
Modifier.fillMaxSize().padding(padding),
contentPadding = PaddingValues(bottom = if (canManageChannels) FAB_CLEARANCE else 0.dp),
) {
items(channels, key = { it.key }) { entry ->
val def = entry.value.definition
val name = def.name.ifBlank { entry.key }
@@ -452,6 +461,13 @@ private fun ConcordChannelListRow(
/** How many recent-poster avatars a channel row's facepile shows at most. */
private const val FACEPILE_MAX = 4
/**
* Bottom room the list leaves for the floating action button: a 56dp FAB + the Scaffold's 16dp margin
* + slack, so the last row's overflow menu stays tappable instead of sitting under the FAB. Matches
* the value `JobBoardScreen` and the relay-group list screens use.
*/
private val FAB_CLEARANCE = 96.dp
/**
* The line under a channel name. When someone is composing it shows a live italic "X is typing…";
* otherwise the last message's author + a snippet ("author: hello"), or a muted "No messages yet"
@@ -529,7 +545,7 @@ private fun rememberConcordDisplayName(
* so leaving is what actually retires the community for them.
*/
@Composable
private fun ConcordLeaveDialog(
internal fun ConcordLeaveDialog(
communityName: String,
isOwner: Boolean,
onDismiss: () -> Unit,
@@ -251,11 +251,29 @@ fun ConcordChannelScreen(
nav = nav,
onMessageSent = { feedViewModel.feedState.sendToTop() },
)
} else if (channel.dissolved) {
// CORD-02 §9: an owner-signed tombstone seals the community read-only — the composer is
// gone (canPost() is false) and this replaces it so the seal is explained, not silent.
ConcordDissolvedNotice()
}
}
}
}
/**
* The read-only notice shown where the composer would be once a community is dissolved (CORD-02 §9).
* The tombstone seals the community: history stays readable, but no member may post again.
*/
@Composable
private fun ConcordDissolvedNotice() {
Text(
text = stringRes(R.string.concord_dissolved_read_only),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.placeholderText,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
)
}
/** The number of messages a freshly-opened channel eagerly backfills to before paging goes demand-driven. */
private const val CONCORD_HISTORY_TARGET = 50
@@ -20,65 +20,41 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import com.vitorpamplona.amethyst.ui.note.DisplayBlankAuthor
import com.vitorpamplona.amethyst.ui.note.ObserveAndDrawInnerUserPicture
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser
import com.vitorpamplona.quartz.nip01Core.core.HexKey
/**
* A horizontal stack of overlapping avatars for the recent posters in a channel the "who's here"
* cue that makes a busy channel feel alive. Each avatar wears a thin ring in the row's background
* colour so the overlap reads as a deck of cards; the newest poster sits on top (leftmost, highest
* z-index). Renders nothing for an empty [authorHexes], so callers can drop it in unconditionally.
* A horizontal strip of the recent posters in a channel the "who's here" cue that makes a busy
* channel feel alive. Each poster is drawn with the app's standard profile avatar
* ([ClickableUserPicture]), so it carries the same following badge (top-right) and trust-score tag
* (bottom-centre) shown everywhere else a user appears, instead of a bare cropped image. Laid out
* with a small gap rather than an overlapping stack so those badges stay readable; the newest poster
* is leftmost. Renders nothing for an empty [authorHexes], so callers can drop it in unconditionally.
*/
@Composable
fun ConcordAuthorFacepile(
authorHexes: List<HexKey>,
accountViewModel: AccountViewModel,
modifier: Modifier = Modifier,
avatarSize: Dp = 22.dp,
avatarSize: Dp = 24.dp,
maxShown: Int = 4,
) {
if (authorHexes.isEmpty()) return
val shown = authorHexes.take(maxShown)
// A ring one-and-a-half dp wide, and each avatar pulled left over its neighbour by a third of
// its width — enough overlap to read as a stack without hiding faces.
val ring = 1.5.dp
val overlap = avatarSize * 0.36f
Row(modifier, horizontalArrangement = Arrangement.spacedBy(-overlap)) {
shown.forEachIndexed { index, hex ->
Box(
Modifier
// Newest (index 0) on top so it isn't clipped by the one after it.
.zIndex((shown.size - index).toFloat())
.size(avatarSize)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surface)
.padding(ring),
) {
LoadUser(baseUserHex = hex, accountViewModel) { user ->
if (user != null) {
ObserveAndDrawInnerUserPicture(user, avatarSize - ring * 2, accountViewModel)
} else {
DisplayBlankAuthor(avatarSize - ring * 2, Modifier, accountViewModel)
}
}
}
Row(modifier, horizontalArrangement = Arrangement.spacedBy(2.dp)) {
shown.forEach { hex ->
ClickableUserPicture(
baseUserHex = hex,
size = avatarSize,
accountViewModel = accountViewModel,
)
}
}
}
@@ -310,7 +310,11 @@ private fun CommunityHeader(
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
autoPlayGif = autoPlayGif,
)
Column(Modifier.weight(1f)) {
// The identity block — icon *and* name/subtitle — opens the community; the rest of the row and
// the chevron expand it. Tapping a title to open the thing it names is the convention users
// arrive with, and the name previously fell through to the row's expand, leaving the 40dp
// avatar as the only way in with nothing to suggest it was a separate target.
Column(Modifier.weight(1f).clickable(onClick = onOpen)) {
Text(
name,
style = MaterialTheme.typography.bodyLarge,
@@ -109,7 +109,6 @@ fun RelayGroupBrowseScreen(
title = {
Text(
text = stringRes(R.string.relay_group_browse_title),
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
@@ -24,6 +24,7 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@@ -31,11 +32,12 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider
@@ -53,6 +55,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.res.pluralStringResource
@@ -90,6 +93,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzDmListViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzImportRow
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzRelayImportViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzWorkspaceOverflowMenu
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.HiddenDmHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.PresenceDot
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.buzzTimelinePreviewSummary
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupCardWarmupSubscription
@@ -98,7 +102,6 @@ import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.warningColor
import com.vitorpamplona.quartz.buzz.workspace.BUZZ_CHANNEL_TYPE_DM
import com.vitorpamplona.quartz.buzz.workspace.BUZZ_CHANNEL_TYPE_FORUM
import com.vitorpamplona.quartz.buzz.workspace.buzzChannelType
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
@@ -114,6 +117,13 @@ private const val CHANNEL_LIST_WARMUP_LIMIT = 10
/** Grace period before offering the Tor→clearnet escape hatch, so a slow-but-working relay isn't nagged. */
private const val TOR_CLEARNET_HINT_DELAY_MS = 6_000L
/**
* Bottom room the list leaves for the floating action button: a 56dp FAB + the Scaffold's 16dp margin
* + slack, so the last row's overflow menu stays tappable instead of sitting under the FAB. Matches
* the value `JobBoardScreen` already uses.
*/
private val FAB_CLEARANCE = 96.dp
/**
* Lists every channel a relay hosts (its kind 39000-39003 directory), so the user
* can browse and open channels on that relay. The relay's directory is streamed by
@@ -201,6 +211,12 @@ fun RelayGroupChannelListScreen(
val dmVm: BuzzDmListViewModel = viewModel(key = "BuzzDmInline-${relay.url}")
LaunchedEffect(relay, isBuzz) { if (isBuzz) dmVm.bind(accountViewModel.account, relay.url) }
val dmRows by dmVm.rows.collectAsStateWithLifecycle()
val hiddenDmRows by dmVm.hiddenRows.collectAsStateWithLifecycle()
// DMs I took off Messages, parked behind a collapsed "Hidden (N)" tail below the section. They
// live here and not only on the full inbox screen because that screen sits behind a "see all"
// row that never appears until a community has more DMs than fit inline — so without this, a
// hidden DM in a small workspace would have no way back at all.
var showHiddenDms by remember { mutableStateOf(false) }
// A Buzz workspace's channels come in three flavours, distinguished by the relay-signed 39000
// `channel_type`: chat "stream" channels, "forum" channels (threaded posts), and "dm" channels
@@ -281,7 +297,6 @@ fun RelayGroupChannelListScreen(
title = {
Text(
text = relay.displayUrl(),
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.MiddleEllipsis,
)
@@ -347,7 +362,14 @@ fun RelayGroupChannelListScreen(
}
}
} else {
LazyColumn(modifier = Modifier.padding(padding)) {
// The Scaffold's `padding` carries the top/bottom bars but deliberately not the FAB — a FAB
// overlays content by design, so clearing it is the list's job. As contentPadding (not a
// modifier) so rows scroll *through* that strip and only come to rest clear of it; the
// modifier form would shrink the viewport and leave the FAB floating over dead space.
LazyColumn(
modifier = Modifier.padding(padding),
contentPadding = PaddingValues(bottom = FAB_CLEARANCE),
) {
if (showTorHint) {
item(key = "tor-hint") {
TorClearnetBanner(
@@ -388,11 +410,13 @@ fun RelayGroupChannelListScreen(
)
}
if (!channelsCollapsed) {
items(buzzChatChannels, key = { "chat-${it.id}" }) { groupId ->
itemsIndexed(buzzChatChannels, key = { _, it -> "chat-${it.id}" }) { index, groupId ->
RowHairline(index)
BuzzImportRow(
groupId = groupId,
isAdded = groupId.id in buzzAdded,
onAdd = { buzzVm.add(groupId) },
onRemove = { buzzVm.remove(groupId) },
accountViewModel = accountViewModel,
onOpen = { nav.nav(Route.RelayGroup(groupId.id, relay.url)) },
isStarred = groupId.id in starred,
@@ -413,11 +437,13 @@ fun RelayGroupChannelListScreen(
)
}
if (!forumsCollapsed) {
items(buzzForumChannels, key = { "forum-${it.id}" }) { groupId ->
itemsIndexed(buzzForumChannels, key = { _, it -> "forum-${it.id}" }) { index, groupId ->
RowHairline(index)
BuzzImportRow(
groupId = groupId,
isAdded = groupId.id in buzzAdded,
onAdd = { buzzVm.add(groupId) },
onRemove = { buzzVm.remove(groupId) },
accountViewModel = accountViewModel,
// A forum channel's primary content is its threads (kind-45001 posts), not a
// kind-9 chat, so open the forum/threads view directly instead of the chat.
@@ -456,8 +482,16 @@ fun RelayGroupChannelListScreen(
}
} else {
val shown = dmRows.take(INLINE_DM_LIMIT)
items(shown, key = { "dm-${it.channelId}" }) { row ->
BuzzDmInlineRow(row, myPubkey, accountViewModel, nav) {
itemsIndexed(shown, key = { _, it -> "dm-${it.channelId}" }) { index, row ->
RowHairline(index)
BuzzDmInlineRow(
row = row,
myPubkey = myPubkey,
isHidden = false,
onToggleMessages = { dmVm.removeFromMessages(row) },
accountViewModel = accountViewModel,
nav = nav,
) {
nav.nav(Route.RelayGroup(row.channelId, row.relayUrl.url))
}
}
@@ -470,13 +504,36 @@ fun RelayGroupChannelListScreen(
}
}
}
if (hiddenDmRows.isNotEmpty()) {
item(key = "dm-hidden-header") {
HiddenDmHeader(
count = hiddenDmRows.size,
expanded = showHiddenDms,
onToggle = { showHiddenDms = !showHiddenDms },
modifier = Modifier.padding(horizontal = 16.dp),
)
}
if (showHiddenDms) {
itemsIndexed(hiddenDmRows, key = { _, it -> "dm-hidden-${it.channelId}" }) { index, row ->
RowHairline(index)
BuzzDmInlineRow(
row = row,
myPubkey = myPubkey,
isHidden = true,
onToggleMessages = { dmVm.addToMessages(row) },
accountViewModel = accountViewModel,
nav = nav,
) {
nav.nav(Route.RelayGroup(row.channelId, row.relayUrl.url))
}
}
}
}
// Agent Console now lives in the community's top-bar overflow menu, not a footer card.
} else {
// Vanilla NIP-29 relay: flat channel directory (no forums/DMs/console).
itemsIndexed(channels, key = { _, channel -> channel.groupId.id }) { index, channel ->
if (index > 0) {
HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant)
}
RowHairline(index)
RelayGroupChannelRow(channel, myPubkey, accountViewModel) { nav.nav(routeFor(channel)) }
}
}
@@ -495,6 +552,17 @@ fun RelayGroupChannelListScreen(
}
}
/**
* The hairline separating two adjacent rows within a section. Drawn *before* row [index], and never
* before the first one, so a section's own header keeps providing the separation at its boundaries.
*/
@Composable
private fun RowHairline(index: Int) {
if (index > 0) {
HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant)
}
}
/** A first screen's worth of a community's DMs shown inline; the rest live behind the See-all row. */
private const val INLINE_DM_LIMIT = 6
@@ -574,18 +642,25 @@ private fun RelayGroupSectionHeader(
/**
* One inline Direct-Message conversation row inside the community view: the counterpart's avatar +
* name (or a "+N" cluster label for a group DM), a preview of the last message, and a compact
* last-activity time. The channel's recent content is warmed while the row is visible so the preview
* fills in ahead of a tap. Tapping opens the DM as its relay-group chat.
* name (or a "+N" cluster label for a group DM), a preview of the last message, a compact
* last-activity time, and an overflow holding the Add/Remove-from-Messages toggle. The channel's
* recent content is warmed while the row is visible so the preview fills in ahead of a tap. Tapping
* opens the DM as its relay-group chat.
*
* [isHidden] renders the row faded and flips the overflow to "Add to Messages" a hidden DM is a
* live conversation the viewer merely parked, so it stays openable and reversible.
*/
@Composable
private fun BuzzDmInlineRow(
row: BuzzDmListViewModel.DmRow,
myPubkey: HexKey,
isHidden: Boolean,
onToggleMessages: () -> Unit,
accountViewModel: AccountViewModel,
nav: INav,
onClick: () -> Unit,
) {
var menuOpen by remember { mutableStateOf(false) }
val others = row.others.ifEmpty { listOf(myPubkey) }
val leadHex = others.first()
val leadUser = remember(leadHex) { LocalCache.getOrCreateUser(leadHex) }
@@ -614,7 +689,8 @@ private fun BuzzDmInlineRow(
Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 10.dp),
.padding(start = 16.dp, end = 4.dp, top = 10.dp, bottom = 10.dp)
.alpha(if (isHidden) 0.55f else 1f),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
@@ -642,6 +718,33 @@ private fun BuzzDmInlineRow(
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Box {
IconButton(onClick = { menuOpen = true }) {
Icon(
symbol = MaterialSymbols.MoreVert,
contentDescription = stringRes(R.string.more_options),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
}
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
DropdownMenuItem(
leadingIcon = {
Icon(
symbol = if (isHidden) MaterialSymbols.Add else MaterialSymbols.VisibilityOff,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
},
text = { Text(stringRes(if (isHidden) R.string.add_to_messages else R.string.remove_from_messages)) },
onClick = {
menuOpen = false
onToggleMessages()
},
)
}
}
}
}
@@ -178,7 +178,6 @@ private fun RelayGroupMembers(
Column {
Text(
text = stringRes(R.string.relay_group_members_title),
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
@@ -71,7 +71,6 @@ import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.quartz.buzz.forum.ForumPostEvent
import com.vitorpamplona.quartz.buzz.workspace.BUZZ_CHANNEL_TYPE_FORUM
import com.vitorpamplona.quartz.buzz.workspace.buzzChannelType
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
@@ -155,7 +154,6 @@ private fun RelayGroupThreads(
Column {
Text(
text = stringRes(R.string.relay_group_threads_title),
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
@@ -28,6 +28,7 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.FilledTonalButton
@@ -35,6 +36,7 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
@@ -45,7 +47,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -113,8 +114,16 @@ fun RelayGroupTopBar(
val dmOther = if (isDm) channel.event?.buzzParticipants()?.firstOrNull { it != myPubkey } else null
var menuOpen by remember { mutableStateOf(false) }
// My kind-10009 list, live: drives the Add/Remove-from-Messages toggle in the overflow below.
val joinedGroupIds by accountViewModel.account.relayGroupList.liveRelayGroupIds
.collectAsStateWithLifecycle()
// Read once here (nav.canPop() is @Composable) so the post-action navigation can pop from a menu
// callback — leaving a group shouldn't strand the user on the screen of a group they left.
val canPop = nav.canPop()
var showInvite by remember { mutableStateOf(false) }
var showJoinCode by remember { mutableStateOf(false) }
var confirmDelete by remember { mutableStateOf(false) }
val isBuzzRelay = remember(channel.groupId.relayUrl) { BuzzRelayDialect.isBuzz(channel.groupId.relayUrl) }
TopBarExtensibleWithBackButton(
title = {
@@ -128,7 +137,6 @@ fun RelayGroupTopBar(
} else {
Text(
text = channel.toBestDisplayName(),
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
@@ -183,7 +191,6 @@ fun RelayGroupTopBar(
// outright. So a DM shows the icon only when a canvas already exists — which is also what
// keeps us from advertising "start a shared doc" in a two-person conversation.
val hasCanvas by observeBuzzCanvas(channel.groupId.id)
val isBuzzRelay = remember(channel.groupId.relayUrl) { BuzzRelayDialect.isBuzz(channel.groupId.relayUrl) }
if (isBuzzRelay && (!isDm || hasCanvas)) {
IconButton(onClick = { nav.nav(Route.BuzzCanvas(channel.groupId.id, channel.groupId.relayUrl.url)) }) {
Icon(
@@ -298,13 +305,53 @@ fun RelayGroupTopBar(
},
)
}
// Two distinct actions, never conflated: the Messages toggle adds/drops the group
// on my kind-10009 list but keeps my relay membership either way; "Leave" sends
// the kind-9022 that actually removes me. Same split as the channel-invite card.
//
// Reads the live kind-10009 list rather than assuming the group is on it: this
// bar also opens for channels reached from the workspace browse (a Buzz relay
// lists every channel you're a member of, joined or not) and for ones you removed
// earlier — both need the "Add" half. And because it's a reversible toggle, remove
// does NOT pop back: you're still a member reading the channel, and staying is
// what makes the entry flip so the action is visibly undoable. Leave still pops.
val onMyList = channel.groupId in joinedGroupIds
DropdownMenuItem(
text = { Text(stringRes(R.string.leave)) },
text = { Text(stringRes(if (onMyList) R.string.remove_from_messages else R.string.add_to_messages)) },
onClick = {
menuOpen = false
if (onMyList) {
accountViewModel.removeRelayGroupFromMessages(channel)
} else {
accountViewModel.addRelayGroupToMessages(channel)
}
},
)
DropdownMenuItem(
text = { Text(stringRes(R.string.leave), color = MaterialTheme.colorScheme.error) },
onClick = {
menuOpen = false
accountViewModel.leaveRelayGroup(channel)
if (canPop) nav.popBack()
},
)
// Deleting the whole channel/group (kind-9008) is destructive for everyone, so it's
// shown ONLY to an admin/owner — the same authorization gate as Edit above — and
// routed through a confirmation dialog rather than firing on tap.
if (displayMembership == RelayGroupMembership.ADMIN) {
DropdownMenuItem(
text = {
Text(
text = stringRes(if (isBuzzRelay) R.string.buzz_channel_delete else R.string.relay_group_delete),
color = MaterialTheme.colorScheme.error,
)
},
onClick = {
menuOpen = false
confirmDelete = true
},
)
}
}
}
}
@@ -326,6 +373,36 @@ fun RelayGroupTopBar(
onDismiss = { showJoinCode = false },
)
}
if (confirmDelete) {
val deleteLabel = stringRes(if (isBuzzRelay) R.string.buzz_channel_delete else R.string.relay_group_delete)
AlertDialog(
onDismissRequest = { confirmDelete = false },
title = { Text(deleteLabel) },
text = {
Text(
stringRes(
if (isBuzzRelay) R.string.buzz_channel_delete_confirm else R.string.relay_group_delete_confirm,
channel.toBestDisplayName(),
),
)
},
confirmButton = {
TextButton(onClick = {
confirmDelete = false
accountViewModel.deleteRelayGroup(channel)
if (canPop) nav.popBack()
}) {
Text(deleteLabel, color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = { confirmDelete = false }) {
Text(stringRes(R.string.cancel))
}
},
)
}
}
/** Title for a Buzz DM: the OTHER participant's display name (reactive), falling back to the channel name. */
@@ -340,7 +417,6 @@ private fun DmParticipantTitle(
val name by observeUserName(user, accountViewModel)
Text(
text = name.ifBlank { channel.toBestDisplayName() },
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = modifier,
@@ -93,6 +93,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.rememberM
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.RoomNameDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.reportWarningContentDescription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordCommunityPill
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordLeaveDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.concordChannelLastReadRoute
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.concordCommunityHasUnreadFlow
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.rememberConcordImageModel
@@ -489,36 +490,61 @@ private fun RelayGroupRoomCompose(
// A placeholder row (no messages yet) has a null createdAt and never lights the dot.
val lastReadTime by accountViewModel.account.loadLastReadFlow(relayGroupChannelLastReadRoute(channel.groupId)).collectAsStateWithLifecycle()
ChannelName(
channelIdHex = channel.groupId.id,
channelPicture = channelPicture,
channelTitle = { modifier ->
Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) {
Text(
text = channel.toBestDisplayName(),
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
Spacer(Modifier.width(6.dp))
RelayNameChip(
label = channel.groupId.relayUrl.displayUrl(),
onClick = { nav.nav(Route.RelayGroupServer(channel.groupId.relayUrl.url)) },
)
}
},
channelLastTime = lastMessage.createdAt(),
channelLastContent = lastContent,
hasNewMessages = (lastMessage.createdAt() ?: Long.MIN_VALUE) > lastReadTime,
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
autoPlayGif =
accountViewModel.settings.autoPlayVideosFlow
.collectAsStateWithLifecycle()
.value,
onClick = { nav.nav(Route.RelayGroup(channel.groupId.id, channel.groupId.relayUrl.url)) },
)
// Long-press brings the group's membership actions to the Messages row itself, mirroring the group
// top bar so "Remove from Messages" (drop from my list, stay a member) and "Leave" (kind-9022) are
// reachable without opening the group first.
var menuOpen by remember { mutableStateOf(false) }
Box {
ChannelName(
channelIdHex = channel.groupId.id,
channelPicture = channelPicture,
channelTitle = { modifier ->
Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) {
Text(
text = channel.toBestDisplayName(),
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
Spacer(Modifier.width(6.dp))
RelayNameChip(
label = channel.groupId.relayUrl.displayUrl(),
onClick = { nav.nav(Route.RelayGroupServer(channel.groupId.relayUrl.url)) },
)
}
},
channelLastTime = lastMessage.createdAt(),
channelLastContent = lastContent,
hasNewMessages = (lastMessage.createdAt() ?: Long.MIN_VALUE) > lastReadTime,
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
autoPlayGif =
accountViewModel.settings.autoPlayVideosFlow
.collectAsStateWithLifecycle()
.value,
onClick = { nav.nav(Route.RelayGroup(channel.groupId.id, channel.groupId.relayUrl.url)) },
onLongClick = { menuOpen = true },
)
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
DropdownMenuItem(
text = { Text(stringRes(R.string.remove_from_messages)) },
onClick = {
menuOpen = false
accountViewModel.removeRelayGroupFromMessages(channel)
},
)
DropdownMenuItem(
text = { Text(stringRes(R.string.leave), color = MaterialTheme.colorScheme.error) },
onClick = {
menuOpen = false
accountViewModel.leaveRelayGroup(channel)
},
)
}
}
}
@Composable
@@ -550,40 +576,78 @@ private fun ConcordRoomCompose(
.loadLastReadFlow(concordChannelLastReadRoute(channel.channelId.communityId, channel.channelId.channelId))
.collectAsStateWithLifecycle()
ChannelName(
channelIdHex = channel.channelId.channelId,
channelPicture = rememberConcordImageModel(channel.communityIcon, accountViewModel),
channelTitle = { modifier ->
Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) {
Text(
text = channel.toBestDisplayName(),
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
channel.communityName?.let { communityName ->
Spacer(Modifier.width(6.dp))
// The chip names the parent community and, when tapped, opens that community's
// channel list — the "chip that opens the Concord Channel" entry point.
ConcordCommunityPill(
communityName = communityName,
onClick = { nav.nav(Route.ConcordServer(channel.channelId.communityId)) },
// Concord has no server-side membership beyond my own kind-13302 list, so there is no soft
// "Remove from Messages" distinct from leaving — the only action is "Leave" (drop the community
// from my list = I'm out). Long-press surfaces it on the row with the same confirm the community
// screen uses; leaving a channel row leaves the whole community it belongs to (the dialog names it).
val communityId = channel.channelId.communityId
val isOwner =
accountViewModel.account.concordSessions
.sessionFor(communityId)
?.entry
?.owner == accountViewModel.account.signer.pubKey
var menuOpen by remember { mutableStateOf(false) }
var showLeave by remember { mutableStateOf(false) }
if (showLeave) {
ConcordLeaveDialog(
communityName = channel.communityName ?: channel.toBestDisplayName(),
isOwner = isOwner,
onDismiss = { showLeave = false },
onConfirm = {
showLeave = false
accountViewModel.leaveConcordCommunity(communityId)
},
)
}
Box {
ChannelName(
channelIdHex = channel.channelId.channelId,
channelPicture = rememberConcordImageModel(channel.communityIcon, accountViewModel),
channelTitle = { modifier ->
Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) {
Text(
text = channel.toBestDisplayName(),
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
channel.communityName?.let { communityName ->
Spacer(Modifier.width(6.dp))
// The chip names the parent community and, when tapped, opens that community's
// channel list — the "chip that opens the Concord Channel" entry point.
ConcordCommunityPill(
communityName = communityName,
onClick = { nav.nav(Route.ConcordServer(channel.channelId.communityId)) },
)
}
}
}
},
channelLastTime = lastMessage.createdAt(),
channelLastContent = lastContent,
hasNewMessages = (lastMessage.createdAt() ?: Long.MIN_VALUE) > lastReadTime,
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
autoPlayGif =
accountViewModel.settings.autoPlayVideosFlow
.collectAsStateWithLifecycle()
.value,
onClick = { nav.nav(Route.Concord(channel.channelId.communityId, channel.channelId.channelId)) },
)
},
channelLastTime = lastMessage.createdAt(),
channelLastContent = lastContent,
hasNewMessages = (lastMessage.createdAt() ?: Long.MIN_VALUE) > lastReadTime,
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
autoPlayGif =
accountViewModel.settings.autoPlayVideosFlow
.collectAsStateWithLifecycle()
.value,
onClick = { nav.nav(Route.Concord(channel.channelId.communityId, channel.channelId.channelId)) },
onLongClick = { menuOpen = true },
)
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
DropdownMenuItem(
text = { Text(stringRes(R.string.leave), color = MaterialTheme.colorScheme.error) },
onClick = {
menuOpen = false
showLeave = true
},
)
}
}
}
@Composable
@@ -935,6 +999,7 @@ fun ChannelName(
loadRobohash: Boolean,
autoPlayGif: Boolean,
onClick: () -> Unit,
onLongClick: (() -> Unit)? = null,
) {
ChannelName(
channelPicture = {
@@ -953,6 +1018,7 @@ fun ChannelName(
channelLastContent,
hasNewMessages,
onClick,
onLongClick,
)
}
@@ -964,6 +1030,7 @@ fun ChannelName(
channelLastContent: String?,
hasNewMessages: Boolean,
onClick: () -> Unit,
onLongClick: (() -> Unit)? = null,
) {
ChatHeaderLayout(
channelPicture = channelPicture,
@@ -998,6 +1065,7 @@ fun ChannelName(
}
},
onClick = onClick,
onLongClick = onLongClick,
)
}
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
@@ -95,7 +96,6 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.TakeVideoButton
import com.vitorpamplona.amethyst.ui.components.MyAsyncImage
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
import com.vitorpamplona.amethyst.ui.components.markdown.RenderContentAsMarkdown
import com.vitorpamplona.amethyst.ui.navigation.bottombars.KeyboardAwareBackHandler
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar
import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.ContentSensitivityExplainer
@@ -149,7 +149,7 @@ fun LongFormPostScreen(
StrippingFailureDialog(postViewModel.strippingFailureConfirmation)
KeyboardAwareBackHandler {
BackHandler {
accountViewModel.launchSigner {
postViewModel.sendDraftSync()
postViewModel.cancel()
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -52,7 +53,6 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton
import com.vitorpamplona.amethyst.ui.actions.uploads.TakeVideoButton
import com.vitorpamplona.amethyst.ui.navigation.bottombars.KeyboardAwareBackHandler
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar
@@ -141,7 +141,7 @@ fun NewProductScreen(
StrippingFailureDialog(postViewModel.strippingFailureConfirmation)
KeyboardAwareBackHandler {
BackHandler {
accountViewModel.launchSigner {
postViewModel.sendDraftSync()
postViewModel.cancel()
@@ -26,7 +26,6 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
@@ -124,7 +123,6 @@ fun DisplayGeoTagHeader(
LoadCityName(geohashStr = geohash) { cityName ->
Text(
cityName,
fontWeight = FontWeight.Bold,
modifier = modifier,
)
}
@@ -99,7 +99,6 @@ fun RepoTitleBar(
Column(Modifier.weight(1f, fill = false)) {
Text(
text = event?.name() ?: fallback,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
@@ -754,7 +754,6 @@ private fun TopBarTitle(
) {
Text(
text = event?.name() ?: fallback,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home
import android.annotation.SuppressLint
import android.content.Intent
import android.net.Uri
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
@@ -92,7 +93,6 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceMessagePreview
import com.vitorpamplona.amethyst.ui.components.OutlinedThinPaddingTextField
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
import com.vitorpamplona.amethyst.ui.components.getActivity
import com.vitorpamplona.amethyst.ui.navigation.bottombars.KeyboardAwareBackHandler
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar
@@ -235,7 +235,7 @@ internal fun NewPostScreenInner(
StrippingFailureDialog(postViewModel.strippingFailureConfirmation)
KeyboardAwareBackHandler {
BackHandler {
accountViewModel.launchSigner {
postViewModel.sendDraftSync()
postViewModel.cancel()
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.HomeFeedType
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByOutboxTopNavFilter
@@ -50,10 +51,11 @@ class HomeConversationsFeedFilter(
override fun feed(): List<Note> {
val filterParams = buildFilterParams(account)
val disabledKinds = HomeFeedType.disabledKinds(account.settings.enabledHomeFeedTypes.value)
return sort(
LocalCache.notes.filterIntoSet { _, it ->
acceptableEvent(it, filterParams)
acceptableEvent(it, filterParams, disabledKinds)
},
)
}
@@ -68,9 +70,10 @@ class HomeConversationsFeedFilter(
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val filterParams = buildFilterParams(account)
val disabledKinds = HomeFeedType.disabledKinds(account.settings.enabledHomeFeedTypes.value)
return collection.filterTo(HashSet()) {
acceptableEvent(it, filterParams)
acceptableEvent(it, filterParams, disabledKinds)
}
}
@@ -78,23 +81,27 @@ class HomeConversationsFeedFilter(
event: Event?,
relays: List<NormalizedRelayUrl>,
filterParams: FilterByListParams,
disabledKinds: Set<Int>,
): Boolean =
(
event is TextNoteEvent ||
event is ZapPollEvent ||
event is PollResponseEvent ||
event is ChannelMessageEvent ||
event is CommentEvent ||
event is VoiceReplyEvent ||
event is PublicMessageEvent ||
event is LiveActivitiesChatMessageEvent
) &&
event != null &&
event.kind !in disabledKinds &&
(
event is TextNoteEvent ||
event is ZapPollEvent ||
event is PollResponseEvent ||
event is ChannelMessageEvent ||
event is CommentEvent ||
event is VoiceReplyEvent ||
event is PublicMessageEvent ||
event is LiveActivitiesChatMessageEvent
) &&
filterParams.match(event, relays)
fun acceptableEvent(
note: Note,
filterParams: FilterByListParams,
): Boolean = acceptableEvent(note.event, note.relays, filterParams) && !note.isNewThread()
disabledKinds: Set<Int>,
): Boolean = acceptableEvent(note.event, note.relays, filterParams, disabledKinds) && !note.isNewThread()
override fun sort(items: Set<Note>): List<Note> = items.sortedByDefaultFeedOrder()
}
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.dal
import com.vitorpamplona.amethyst.commons.ui.feeds.isRenderableRepost
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.HomeFeedType
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.filterIntoSet
@@ -93,18 +94,19 @@ class HomeNewThreadFeedFilter(
override fun feed(): List<Note> {
val filterParams = buildFilterParams(account)
val disabledKinds = HomeFeedType.disabledKinds(account.settings.enabledHomeFeedTypes.value)
val notes =
LocalCache.notes.filterIntoSet { _, note ->
// Avoids processing addressables twice.
(note.event?.kind ?: 99999) < 10000 && acceptableEvent(note, filterParams)
(note.event?.kind ?: 99999) < 10000 && acceptableEvent(note, filterParams, disabledKinds)
}
val longFormNotes =
LocalCache.addressables.filterIntoSet(
kinds = ADDRESSABLE_KINDS,
) { _, note ->
acceptableEvent(note, filterParams)
acceptableEvent(note, filterParams, disabledKinds)
}
return sort(notes + longFormNotes)
@@ -114,17 +116,20 @@ class HomeNewThreadFeedFilter(
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val filterParams = buildFilterParams(account)
val disabledKinds = HomeFeedType.disabledKinds(account.settings.enabledHomeFeedTypes.value)
return collection.filterTo(HashSet()) {
acceptableEvent(it, filterParams)
acceptableEvent(it, filterParams, disabledKinds)
}
}
private fun acceptableEvent(
it: Note,
filterParams: FilterByListParams,
disabledKinds: Set<Int>,
): Boolean {
val noteEvent = it.event
val noteEvent = it.event ?: return false
if (noteEvent.kind in disabledKinds) return false
return (
noteEvent is TextNoteEvent ||
noteEvent is ClassifiedsEvent ||
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip65Follows
import com.vitorpamplona.amethyst.model.HomeFeedType
import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet
@@ -49,6 +50,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.launch
@@ -63,19 +65,24 @@ class HomeOutboxEventsEoseManager(
val feedSettings = key.followsPerRelay()
val newThreadSince = key.feedState.homeNewThreads.lastNoteCreatedAtIfFilled()
val repliesSince = key.feedState.homeReplies.lastNoteCreatedAtIfFilled()
return when (feedSettings) {
is AllCommunitiesTopNavPerRelayFilterSet -> filterHomePostsByAllCommunities(feedSettings, since, newThreadSince)
is AllFollowsTopNavPerRelayFilterSet -> filterHomePostsByAllFollows(feedSettings, since, newThreadSince, repliesSince)
is AuthorsTopNavPerRelayFilterSet -> filterHomePostsByAuthors(feedSettings, since, newThreadSince, repliesSince)
is GlobalTopNavPerRelayFilterSet -> filterHomePostsByGlobal(feedSettings, since, newThreadSince, repliesSince)
is HashtagTopNavPerRelayFilterSet -> filterHomePostsByHashtags(feedSettings, since, newThreadSince)
is LocationTopNavPerRelayFilterSet -> filterHomePostsByGeohashes(feedSettings, since, newThreadSince)
is MutedAuthorsTopNavPerRelayFilterSet -> filterHomePostsByAuthors(feedSettings, since, newThreadSince, repliesSince)
is RelayTopNavPerRelayFilterSet -> filterHomePostsByRelay(feedSettings, since, newThreadSince, repliesSince)
is SingleCommunityTopNavPerRelayFilterSet -> filterHomePostsByCommunity(feedSettings, since, newThreadSince)
is FavoriteAlgoFeedTopNavPerRelayFilterSet -> filterHomePostsByAlgoFeedIds(feedSettings, since, newThreadSince)
else -> emptyList()
}
val base =
when (feedSettings) {
is AllCommunitiesTopNavPerRelayFilterSet -> filterHomePostsByAllCommunities(feedSettings, since, newThreadSince)
is AllFollowsTopNavPerRelayFilterSet -> filterHomePostsByAllFollows(feedSettings, since, newThreadSince, repliesSince)
is AuthorsTopNavPerRelayFilterSet -> filterHomePostsByAuthors(feedSettings, since, newThreadSince, repliesSince)
is GlobalTopNavPerRelayFilterSet -> filterHomePostsByGlobal(feedSettings, since, newThreadSince, repliesSince)
is HashtagTopNavPerRelayFilterSet -> filterHomePostsByHashtags(feedSettings, since, newThreadSince)
is LocationTopNavPerRelayFilterSet -> filterHomePostsByGeohashes(feedSettings, since, newThreadSince)
is MutedAuthorsTopNavPerRelayFilterSet -> filterHomePostsByAuthors(feedSettings, since, newThreadSince, repliesSince)
is RelayTopNavPerRelayFilterSet -> filterHomePostsByRelay(feedSettings, since, newThreadSince, repliesSince)
is SingleCommunityTopNavPerRelayFilterSet -> filterHomePostsByCommunity(feedSettings, since, newThreadSince)
is FavoriteAlgoFeedTopNavPerRelayFilterSet -> filterHomePostsByAlgoFeedIds(feedSettings, since, newThreadSince)
else -> emptyList()
}
// Drop the kinds the user turned off in Settings Home from every home relay filter, so a
// disabled group is never downloaded regardless of which top-nav strategy built the filters.
return base.removeDisabledHomeKinds(HomeFeedType.disabledKinds(key.account.settings.enabledHomeFeedTypes.value))
}
override fun user(key: HomeQueryState) = key.account.userProfile()
@@ -108,6 +115,13 @@ class HomeOutboxEventsEoseManager(
invalidateFilters()
}
},
key.scope.launch(Dispatchers.IO) {
// Re-arm the home subscriptions when a content-type toggle flips, so a disabled
// group leaves the live REQ and a re-enabled one comes back without a restart.
key.account.settings.enabledHomeFeedTypes
.drop(1)
.collectLatest { invalidateFilters() }
},
key.account.scope.launch(Dispatchers.IO) {
key.feedState.homeNewThreads.lastNoteCreatedAtWhenFullyLoaded.sample(5000).collectLatest {
invalidateFilters()
@@ -131,3 +145,21 @@ class HomeOutboxEventsEoseManager(
userJobMap[key]?.forEach { it.cancel() }
}
}
/**
* Removes the [disabled] kinds from each home filter. A filter with no `kinds` (e.g. an
* algo-feed id/address fetch) is left untouched; a filter whose kinds all become disabled is
* dropped entirely, since sending it with an empty `kinds` would wrongly match every kind.
*/
private fun List<RelayBasedFilter>.removeDisabledHomeKinds(disabled: Set<Int>): List<RelayBasedFilter> {
if (disabled.isEmpty()) return this
return mapNotNull { relayFilter ->
val kinds = relayFilter.filter.kinds ?: return@mapNotNull relayFilter
val kept = kinds.filterNot { it in disabled }
when {
kept.size == kinds.size -> relayFilter
kept.isEmpty() -> null
else -> RelayBasedFilter(relayFilter.relay, relayFilter.filter.copy(kinds = kept))
}
}
}
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.nip75Goals
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -47,7 +48,6 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.navigation.bottombars.KeyboardAwareBackHandler
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar
@@ -81,7 +81,7 @@ fun NewGoalScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
KeyboardAwareBackHandler {
BackHandler {
goalViewModel.cancel()
nav.popBack()
}
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.publicMessages
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
import androidx.compose.foundation.layout.Column
@@ -63,7 +64,6 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton
import com.vitorpamplona.amethyst.ui.actions.uploads.TakeVideoButton
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
import com.vitorpamplona.amethyst.ui.navigation.bottombars.KeyboardAwareBackHandler
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar
@@ -134,7 +134,7 @@ fun NewPublicMessageScreen(
StrippingFailureDialog(postViewModel.strippingFailureConfirmation)
KeyboardAwareBackHandler {
BackHandler {
accountViewModel.launchSigner {
postViewModel.sendDraftSync()
postViewModel.cancel()
@@ -34,7 +34,9 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.model.HomeFeedType
import com.vitorpamplona.amethyst.model.UiSettingsFlow
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
@@ -65,53 +67,106 @@ fun HomeTabsSettingsScreen(
TopBarWithBackButton(stringRes(id = R.string.home_tabs_settings), nav)
},
) { padding ->
HomeTabsSettingsContent(accountViewModel.settings.uiSettingsFlow, Modifier.padding(padding))
Column(
modifier =
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(padding)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
HomeTabsSection(accountViewModel.settings.uiSettingsFlow)
HomeContentTypesSection(accountViewModel)
}
}
}
@Composable
fun HomeTabsSettingsContent(
ui: UiSettingsFlow,
modifier: Modifier = Modifier,
) {
private fun HomeTabsSection(ui: UiSettingsFlow) {
val showNewThreads by ui.showHomeNewThreadsTab.collectAsStateWithLifecycle()
val showConversations by ui.showHomeConversationsTab.collectAsStateWithLifecycle()
val showEverything by ui.showHomeEverythingTab.collectAsStateWithLifecycle()
val activeCount = listOf(showNewThreads, showConversations, showEverything).count { it }
Column(
modifier =
modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
SettingsSection(R.string.settings_section_home_tabs) {
SettingsSection(R.string.settings_section_home_tabs) {
SettingsSwitchTile(
icon = MaterialSymbols.Forum,
title = R.string.new_threads,
checked = showNewThreads,
// Don't allow disabling the last remaining tab.
enabled = !(showNewThreads && activeCount == 1),
onCheckedChange = { ui.showHomeNewThreadsTab.tryEmit(it) },
)
SettingsDivider()
SettingsSwitchTile(
icon = MaterialSymbols.Chat,
title = R.string.conversations,
checked = showConversations,
enabled = !(showConversations && activeCount == 1),
onCheckedChange = { ui.showHomeConversationsTab.tryEmit(it) },
)
SettingsDivider()
SettingsSwitchTile(
icon = MaterialSymbols.Public,
title = R.string.home_tab_everything,
checked = showEverything,
enabled = !(showEverything && activeCount == 1),
onCheckedChange = { ui.showHomeEverythingTab.tryEmit(it) },
)
}
}
/** One toggleable Home content group, mapping a [HomeFeedType] to its display title + icon. */
private data class HomeFeedTypeUi(
val type: HomeFeedType,
val titleRes: Int,
val icon: MaterialSymbol,
)
// Ordered by how common each group is on a typical home feed (everyday posts first, niche last).
private val HOME_FEED_TYPES =
listOf(
HomeFeedTypeUi(HomeFeedType.TEXT_NOTES, R.string.home_content_type_text_notes, MaterialSymbols.EditNote),
HomeFeedTypeUi(HomeFeedType.REPOSTS, R.string.home_content_type_reposts, MaterialSymbols.Forward),
HomeFeedTypeUi(HomeFeedType.COMMENTS, R.string.home_content_type_comments, MaterialSymbols.Chat),
HomeFeedTypeUi(HomeFeedType.ARTICLES, R.string.home_content_type_articles, MaterialSymbols.AutoMirrored.Article),
HomeFeedTypeUi(HomeFeedType.WIKI, R.string.home_content_type_wiki, MaterialSymbols.MenuBook),
HomeFeedTypeUi(HomeFeedType.HIGHLIGHTS, R.string.home_content_type_highlights, MaterialSymbols.FormatQuote),
HomeFeedTypeUi(HomeFeedType.POLLS, R.string.home_content_type_polls, MaterialSymbols.Poll),
HomeFeedTypeUi(HomeFeedType.CLASSIFIEDS, R.string.home_content_type_classifieds, MaterialSymbols.Storefront),
HomeFeedTypeUi(HomeFeedType.VOICE, R.string.home_content_type_voice, MaterialSymbols.Mic),
HomeFeedTypeUi(HomeFeedType.LIVE_ACTIVITIES, R.string.home_content_type_live_activities, MaterialSymbols.Sensors),
HomeFeedTypeUi(HomeFeedType.EPHEMERAL_CHAT, R.string.home_content_type_ephemeral_chat, MaterialSymbols.Forum),
HomeFeedTypeUi(HomeFeedType.INTERACTIVE_STORIES, R.string.home_content_type_interactive_stories, MaterialSymbols.AutoAwesome),
HomeFeedTypeUi(HomeFeedType.CHESS, R.string.home_content_type_chess, MaterialSymbols.ChessKnight),
HomeFeedTypeUi(HomeFeedType.BIRDS, R.string.home_content_type_birds, MaterialSymbols.TravelExplore),
HomeFeedTypeUi(HomeFeedType.ATTESTATIONS, R.string.home_content_type_attestations, MaterialSymbols.Shield),
HomeFeedTypeUi(HomeFeedType.NIPS, R.string.home_content_type_nips, MaterialSymbols.Code),
HomeFeedTypeUi(HomeFeedType.MUSIC, R.string.home_content_type_music, MaterialSymbols.MusicNote),
HomeFeedTypeUi(HomeFeedType.PODCASTS, R.string.home_content_type_podcasts, MaterialSymbols.Podcasts),
HomeFeedTypeUi(HomeFeedType.FUNDRAISERS, R.string.home_content_type_fundraisers, MaterialSymbols.Paid),
)
/**
* Per-content-type load toggles for the Home feed. Turning one off both drops its event kinds from
* the always-on home relay filters AND hides them from the New Threads / Conversations / Everything
* tabs. Everything is on by default.
*/
@Composable
private fun HomeContentTypesSection(accountViewModel: AccountViewModel) {
val enabled by accountViewModel.account.settings.enabledHomeFeedTypes
.collectAsStateWithLifecycle()
SettingsSection(R.string.settings_section_home_content_types) {
HOME_FEED_TYPES.forEachIndexed { index, item ->
if (index > 0) SettingsDivider()
SettingsSwitchTile(
icon = MaterialSymbols.Forum,
title = R.string.new_threads,
checked = showNewThreads,
// Don't allow disabling the last remaining tab.
enabled = !(showNewThreads && activeCount == 1),
onCheckedChange = { ui.showHomeNewThreadsTab.tryEmit(it) },
)
SettingsDivider()
SettingsSwitchTile(
icon = MaterialSymbols.Chat,
title = R.string.conversations,
checked = showConversations,
enabled = !(showConversations && activeCount == 1),
onCheckedChange = { ui.showHomeConversationsTab.tryEmit(it) },
)
SettingsDivider()
SettingsSwitchTile(
icon = MaterialSymbols.Public,
title = R.string.home_tab_everything,
checked = showEverything,
enabled = !(showEverything && activeCount == 1),
onCheckedChange = { ui.showHomeEverythingTab.tryEmit(it) },
icon = item.icon,
title = item.titleRes,
checked = item.type in enabled,
onCheckedChange = { accountViewModel.account.settings.setHomeFeedTypeEnabled(item.type, it) },
)
}
}
@@ -24,7 +24,6 @@ import android.annotation.SuppressLint
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
@@ -114,7 +113,6 @@ fun DisplayUrlHeader(
) {
Text(
url,
fontWeight = FontWeight.Bold,
modifier = modifier,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts
import androidx.activity.compose.BackHandler
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -60,7 +61,6 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.ui.navigation.bottombars.KeyboardAwareBackHandler
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar
@@ -80,7 +80,7 @@ fun NewWorkoutScreen(
postViewModel.init(accountViewModel)
postViewModel.prefill(prefill)
KeyboardAwareBackHandler {
BackHandler {
postViewModel.cancel()
nav.popBack()
}
+2 -1
View File
@@ -341,6 +341,7 @@
<string name="concord_leave_message">Opustit %1$s? Bude odebrána ze seznamu tohoto účtu a přestane se synchronizovat na vašich zařízeních. Komunita o tom nedostane oznámení a nebudete odebráni z jejího seznamu členů. Zprávy, které již nedokážete dešifrovat, mohou být neobnovitelné, a vrátit se můžete pouze s novou pozvánkou.</string>
<string name="concord_leave_owner_warning">Tuto komunitu jste vytvořili vy. Opuštěním ji nesmažete ani ji nepředáte nikomu jinému, ale zahodíte klíč vlastníka uložený ve vašem seznamu — už byste ji nemohli spravovat.</string>
<string name="concord_edit_relays_desc">Kde jsou publikovány a čteny šifrované roviny této komunity.</string>
<string name="concord_dissolved_read_only">Tato komunita byla rozpuštěna a je nyní pouze pro čtení. Její historii si stále můžete přečíst, ale nové zprávy už nelze odesílat.</string>
<string name="concord_typing_one">%1$s píše…</string>
<string name="concord_typing_two">%1$s a %2$s píší…</string>
<string name="concord_typing_many">Píše několik lidí…</string>
@@ -3347,7 +3348,6 @@
<string name="buzz_dm_empty_title">Zatím žádné přímé zprávy</string>
<string name="buzz_dm_empty_body">Začněte soukromou konverzaci s kýmkoli v pracovním prostoru Buzz.</string>
<string name="buzz_dm_more">Více</string>
<string name="buzz_dm_hide">Skrýt konverzaci</string>
<string name="buzz_dm_add_member">Přidat člena</string>
<string name="buzz_dm_add_member_title">Přidat někoho do této konverzace</string>
<string name="buzz_dm_add_member_invalid">Neplatný npub nebo hex klíč</string>
@@ -4704,6 +4704,7 @@
<string name="buzz_workflow_def_title">Nová definice workflow</string>
<string name="buzz_workflow_def_desc">Pojmenuje ho pro kanál a publikuje jeho YAML recept (kind-30620). Skutečný relay Buzz ten YAML spouští. Při vlastním hostování runner spouští svůj nakonfigurovaný příkaz — zde definice běh jen pojmenovává a kataloguje.</string>
<string name="buzz_workflow_def_name">Název</string>
<string name="buzz_workflow_def_name_hint">build-a-test</string>
<string name="buzz_workflow_def_yaml">YAML recept</string>
<string name="buzz_workflow_def_publish_failed">Definici se nepodařilo publikovat — ověřte, že můžete přispívat do tohoto pracovního prostoru.</string>
<string name="buzz_workflow_publishing">Publikování…</string>
@@ -333,6 +333,7 @@
<string name="concord_leave_message">%1$s verlassen? Sie wird aus der Liste dieses Kontos entfernt und hört auf, sich auf deinen Geräten zu synchronisieren. Die Community wird nicht benachrichtigt und du wirst nicht aus ihrer Mitgliederliste entfernt. Nachrichten, die du nicht mehr entschlüsseln kannst, sind möglicherweise nicht wiederherstellbar, und du kannst nur mit einer neuen Einladung zurückkehren.</string>
<string name="concord_leave_owner_warning">Du hast diese Community erstellt. Beim Verlassen wird sie weder gelöscht noch an jemand anderen übergeben, aber der auf deiner Liste gespeicherte Eigentümerschlüssel wird verworfen — du könntest sie nicht mehr verwalten.</string>
<string name="concord_edit_relays_desc">Wo die verschlüsselten Planes dieser Community veröffentlicht und gelesen werden.</string>
<string name="concord_dissolved_read_only">Diese Community wurde aufgelöst und ist jetzt schreibgeschützt. Du kannst ihren Verlauf weiterhin lesen, aber es können keine neuen Nachrichten mehr gepostet werden.</string>
<string name="concord_typing_one">%1$s schreibt…</string>
<string name="concord_typing_two">%1$s und %2$s schreiben…</string>
<string name="concord_typing_many">Mehrere Personen schreiben…</string>
@@ -3231,7 +3232,6 @@
<string name="buzz_dm_empty_title">Noch keine Direktnachrichten</string>
<string name="buzz_dm_empty_body">Beginne eine private Unterhaltung mit jemandem in einem Buzz-Workspace.</string>
<string name="buzz_dm_more">Mehr</string>
<string name="buzz_dm_hide">Unterhaltung ausblenden</string>
<string name="buzz_dm_add_member">Mitglied hinzufügen</string>
<string name="buzz_dm_add_member_title">Jemanden zu dieser DM hinzufügen</string>
<string name="buzz_dm_add_member_invalid">Kein gültiger npub- oder Hex-Schlüssel</string>
@@ -4537,6 +4537,7 @@
<string name="buzz_workflow_new_definition">Neue Definition…</string>
<string name="buzz_workflow_def_title">Neue Workflow-Definition</string>
<string name="buzz_workflow_def_desc">Benennt ihn für den Kanal und veröffentlicht sein YAML-Rezept (kind-30620). Ein echtes Buzz-Relay führt das YAML aus. Selbst gehostet führt der Runner seinen konfigurierten Befehl aus — hier benennt und katalogisiert die Definition den Lauf nur.</string>
<string name="buzz_workflow_def_name_hint">build-und-test</string>
<string name="buzz_workflow_def_yaml">YAML-Rezept</string>
<string name="buzz_workflow_def_publish_failed">Die Definition konnte nicht veröffentlicht werden — prüfe, ob du in diesem Workspace posten kannst.</string>
<string name="buzz_workflow_publishing">Wird veröffentlicht…</string>
@@ -3232,7 +3232,6 @@
<string name="buzz_dm_empty_title">कोई सीधेसन्देश नहीं अब तक</string>
<string name="buzz_dm_empty_body">आरम्भ करें निजी वार्तालप किसी से भी एक बज्स्स कार्यशाला में।</string>
<string name="buzz_dm_more">और अधिक</string>
<string name="buzz_dm_hide">संवाद छिपाएँ</string>
<string name="buzz_dm_add_member">सदस्य जोडें</string>
<string name="buzz_dm_add_member_title">किसी को जोडें इस सीधेसन्देश में</string>
<string name="buzz_dm_add_member_invalid">मान्य एनपुब॰ अथवा षोडषांक कुंचिका नहीं</string>
@@ -3297,7 +3297,6 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
<string name="buzz_dm_empty_title">Brak bezpośrednich wiadomości</string>
<string name="buzz_dm_empty_body">Rozpocznij prywatną rozmowę z kimkolwiek w przestrzeni roboczej Buzz.</string>
<string name="buzz_dm_more">Więcej</string>
<string name="buzz_dm_hide">Ukryj konwersację</string>
<string name="buzz_dm_add_member">Dodaj członka</string>
<string name="buzz_dm_add_member_title">Dodaj kogoś do tej wiadomości DM</string>
<string name="buzz_dm_add_member_invalid">Nieprawidłowy klucz npub lub hex</string>
@@ -333,6 +333,7 @@
<string name="concord_leave_message">Sair de %1$s? Ela é removida da lista desta conta e para de sincronizar nos seus dispositivos. A comunidade não é notificada e você não é removido da lista de membros. Mensagens que você não conseguir mais descriptografar podem ser irrecuperáveis, e você só poderá voltar com um novo convite.</string>
<string name="concord_leave_owner_warning">Você criou esta comunidade. Sair não a exclui nem a entrega a mais ninguém, mas descarta a chave de proprietário armazenada na sua lista — você não conseguiria gerenciá-la novamente.</string>
<string name="concord_edit_relays_desc">Onde os planos criptografados desta comunidade são publicados e lidos.</string>
<string name="concord_dissolved_read_only">Esta comunidade foi dissolvida e agora é somente leitura. Você ainda pode ler o histórico, mas não é possível publicar novas mensagens.</string>
<string name="concord_typing_one">%1$s está digitando…</string>
<string name="concord_typing_two">%1$s e %2$s estão digitando…</string>
<string name="concord_typing_many">Várias pessoas estão digitando…</string>
@@ -3229,7 +3230,6 @@
<string name="buzz_dm_empty_title">Nenhuma mensagem direta ainda</string>
<string name="buzz_dm_empty_body">Inicie uma conversa privada com qualquer pessoa em um espaço de trabalho Buzz.</string>
<string name="buzz_dm_more">Mais</string>
<string name="buzz_dm_hide">Ocultar conversa</string>
<string name="buzz_dm_add_member">Adicionar membro</string>
<string name="buzz_dm_add_member_title">Adicionar alguém a esta DM</string>
<string name="buzz_dm_add_member_invalid">Não é uma chave npub ou hex válida</string>
@@ -4538,6 +4538,7 @@
<string name="buzz_workflow_def_title">Nova definição de fluxo de trabalho</string>
<string name="buzz_workflow_def_desc">Dá um nome a ele para o canal e publica sua receita YAML (kind-30620). Um relay Buzz real executa o YAML. Auto-hospedado, o runner executa o comando configurado — aqui a definição apenas nomeia e cataloga a execução.</string>
<string name="buzz_workflow_def_name">Nome</string>
<string name="buzz_workflow_def_name_hint">build-e-teste</string>
<string name="buzz_workflow_def_yaml">Receita YAML</string>
<string name="buzz_workflow_def_publish_failed">Não foi possível publicar a definição — verifique se você pode publicar neste espaço de trabalho.</string>
<string name="buzz_workflow_publishing">Publicando…</string>
@@ -3362,7 +3362,6 @@ Za ohranitev zasebnosti to denarnico polni in prazni prek ne-zasebnih računov,
<string name="buzz_dm_empty_title">Ni še zasebnih sporočil</string>
<string name="buzz_dm_empty_body">Začnite zasebni pogovor s komerkoli v delovnem prostoru Buzz.</string>
<string name="buzz_dm_more">Več</string>
<string name="buzz_dm_hide">Skrij pogovore</string>
<string name="buzz_dm_add_member">Dodaj člana</string>
<string name="buzz_dm_add_member_title">Dodaj nekoga v to ZS</string>
<string name="buzz_dm_add_member_invalid">Neveljaven npub ali hex ključ </string>
@@ -333,6 +333,7 @@
<string name="concord_leave_message">Lämna %1$s? Den tas bort från det här kontots lista och slutar synkas på dina enheter. Gemenskapen meddelas inte och du tas inte bort från dess medlemslista. Meddelanden som du inte längre kan dekryptera kan gå förlorade, och du kan bara återvända med en ny inbjudan.</string>
<string name="concord_leave_owner_warning">Du skapade den här gemenskapen. Att lämna raderar den inte och överlämnar den inte till någon annan, men det kasserar ägarnyckeln som lagras i din lista — du skulle inte kunna hantera den igen.</string>
<string name="concord_edit_relays_desc">Var den här gemenskapens krypterade plan publiceras och läses.</string>
<string name="concord_dissolved_read_only">Den här gemenskapen har upplösts och är nu skrivskyddad. Du kan fortfarande läsa dess historik, men inga nya meddelanden kan publiceras.</string>
<string name="concord_typing_one">%1$s skriver…</string>
<string name="concord_typing_two">%1$s och %2$s skriver…</string>
<string name="concord_typing_many">Flera personer skriver…</string>
@@ -3229,7 +3230,6 @@
<string name="buzz_dm_empty_title">Inga direktmeddelanden än</string>
<string name="buzz_dm_empty_body">Starta en privat konversation med vem som helst i en Buzz-arbetsyta.</string>
<string name="buzz_dm_more">Mer</string>
<string name="buzz_dm_hide">Dölj konversation</string>
<string name="buzz_dm_add_member">Lägg till medlem</string>
<string name="buzz_dm_add_member_title">Lägg till någon i det här DM:et</string>
<string name="buzz_dm_add_member_invalid">Inte en giltig npub- eller hexnyckel</string>
@@ -4539,6 +4539,7 @@
<string name="buzz_workflow_def_title">Ny arbetsflödesdefinition</string>
<string name="buzz_workflow_def_desc">Namnger det för kanalen och publicerar dess YAML-recept (kind-30620). Ett riktigt Buzz-relä kör YAML:en. Självhostat kör runnern sitt konfigurerade kommando — här namnger och katalogiserar definitionen bara körningen.</string>
<string name="buzz_workflow_def_name">Namn</string>
<string name="buzz_workflow_def_name_hint">build-och-testa</string>
<string name="buzz_workflow_def_yaml">YAML-recept</string>
<string name="buzz_workflow_def_publish_failed">Kunde inte publicera definitionen — kontrollera att du kan posta i den här arbetsytan.</string>
<string name="buzz_workflow_publishing">Publicerar…</string>
+31 -1
View File
@@ -344,6 +344,7 @@
<string name="concord_leave_message">Leave %1$s? It is removed from this account\'s list and stops syncing on your devices. The community is not notified and you are not removed from its member roster. Messages you can no longer decrypt may be unrecoverable, and you can only return with a new invite.</string>
<string name="concord_leave_owner_warning">You created this community. Leaving does not delete it or hand it to anyone else, but it discards the owner key stored on your list — you would not be able to manage it again.</string>
<string name="concord_edit_relays_desc">Where this community\'s encrypted planes are published and read.</string>
<string name="concord_dissolved_read_only">This community has been dissolved and is now read-only. You can still read its history, but no new messages can be posted.</string>
<string name="concord_typing_one">%1$s is typing…</string>
<string name="concord_typing_two">%1$s and %2$s are typing…</string>
<string name="concord_typing_many">Several people are typing…</string>
@@ -448,6 +449,8 @@
<string name="refresh">Refresh</string>
<string name="changed_chat_profile_to">New chat profile:</string>
<string name="leave">Leave</string>
<string name="remove_from_messages">Remove from Messages</string>
<string name="add_to_messages">Add to Messages</string>
<string name="unfollow">Unfollow</string>
<string name="channel_created">Channel created</string>
<string name="channel_information_changed_to">"Channel Information changed to"</string>
@@ -2327,6 +2330,26 @@
<string name="settings_section_tor_routing">Route through Tor</string>
<string name="settings_section_profile_sections">Sections &amp; feeds</string>
<string name="settings_section_home_tabs">Visible tabs</string>
<string name="settings_section_home_content_types">Content in the feed</string>
<string name="home_content_type_text_notes">Text notes</string>
<string name="home_content_type_reposts">Reposts</string>
<string name="home_content_type_comments">Comments &amp; replies</string>
<string name="home_content_type_articles">Articles</string>
<string name="home_content_type_wiki">Wiki pages</string>
<string name="home_content_type_highlights">Highlights</string>
<string name="home_content_type_polls">Polls</string>
<string name="home_content_type_classifieds">Classifieds</string>
<string name="home_content_type_voice">Voice messages</string>
<string name="home_content_type_live_activities">Live activities</string>
<string name="home_content_type_ephemeral_chat">Ephemeral chats</string>
<string name="home_content_type_interactive_stories">Interactive stories</string>
<string name="home_content_type_chess">Chess games</string>
<string name="home_content_type_birds">Bird sightings</string>
<string name="home_content_type_attestations">Attestations</string>
<string name="home_content_type_nips">NIP drafts</string>
<string name="home_content_type_music">Music &amp; audio</string>
<string name="home_content_type_podcasts">Podcasts</string>
<string name="home_content_type_fundraisers">Fundraisers</string>
<string name="settings_section_reminders">Reminders</string>
<string name="wallet_connect">Wallet Connect</string>
<string name="language">Language</string>
@@ -2585,6 +2608,10 @@
<string name="relay_group_edit_confirm">Save</string>
<string name="relay_group_menu_members">Members</string>
<string name="relay_group_menu_edit">Edit group</string>
<string name="relay_group_delete">Delete group</string>
<string name="relay_group_delete_confirm">Delete \"%1$s\"? This removes the group and its history for everyone, and cannot be undone.</string>
<string name="buzz_channel_delete">Delete channel</string>
<string name="buzz_channel_delete_confirm">Delete \"%1$s\"? This removes the channel and its messages for everyone, and cannot be undone.</string>
<string name="relay_group_threads_title">Threads</string>
<string name="relay_group_pin_message">Pin message</string>
<string name="relay_group_unpin_message">Unpin message</string>
@@ -3535,7 +3562,6 @@
<string name="buzz_dm_empty_title">No direct messages yet</string>
<string name="buzz_dm_empty_body">Start a private conversation with anyone on a Buzz workspace.</string>
<string name="buzz_dm_more">More</string>
<string name="buzz_dm_hide">Hide conversation</string>
<string name="buzz_dm_add_member">Add member</string>
<string name="buzz_dm_add_member_title">Add someone to this DM</string>
<string name="buzz_dm_add_member_invalid">Not a valid npub or hex key</string>
@@ -3581,6 +3607,10 @@
<string name="buzz_pin">Pin channel</string>
<string name="buzz_unpin">Unpin channel</string>
<string name="buzz_dm_section_empty">No conversations yet</string>
<plurals name="buzz_dm_hidden_count">
<item quantity="one">%1$d hidden conversation</item>
<item quantity="other">%1$d hidden conversations</item>
</plurals>
<plurals name="buzz_dm_see_all_count">
<item quantity="one">See %1$d more conversation</item>
<item quantity="other">See all %1$d conversations</item>
@@ -0,0 +1,83 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class HomeFeedTypeTest {
@Test
fun allContainsEveryEntry() {
assertEquals(HomeFeedType.entries.toSet(), HomeFeedType.ALL)
}
@Test
fun kindsAreDisjointAcrossTypes() {
val seen = mutableSetOf<Int>()
HomeFeedType.entries.forEach { type ->
type.kinds.forEach { kind ->
assertTrue("kind $kind is owned by more than one HomeFeedType", seen.add(kind))
}
}
}
@Test
fun encodeThenDecodeRoundTrips() {
val disabled = setOf(HomeFeedType.CHESS, HomeFeedType.BIRDS)
val enabled = HomeFeedType.ALL - disabled
val stored = HomeFeedType.encode(HomeFeedType.ALL - enabled)
assertEquals(enabled, HomeFeedType.ALL - HomeFeedType.decode(stored))
}
@Test
fun decodeNullOrBlankIsEmpty() {
assertEquals(emptySet<HomeFeedType>(), HomeFeedType.decode(null))
// Absence of a stored value means "nothing disabled" -> everything enabled.
assertEquals(HomeFeedType.ALL, HomeFeedType.ALL - HomeFeedType.decode(null))
}
@Test
fun decodeDropsUnknownCodes() {
val decoded = HomeFeedType.decode("chess,future-kind")
assertEquals(setOf(HomeFeedType.CHESS), decoded)
assertNull(HomeFeedType.fromCode("future-kind"))
}
@Test
fun disabledKindsEmptyWhenEverythingEnabled() {
assertTrue(HomeFeedType.disabledKinds(HomeFeedType.ALL).isEmpty())
}
@Test
fun disabledKindsAreExactlyTheDisabledGroupsKinds() {
val enabled = HomeFeedType.ALL - HomeFeedType.TEXT_NOTES - HomeFeedType.REPOSTS
val disabled = HomeFeedType.disabledKinds(enabled)
assertTrue(TextNoteEvent.KIND in disabled)
HomeFeedType.REPOSTS.kinds.forEach { assertTrue(it in disabled) }
// A still-enabled group's kinds must not leak into the disabled set.
HomeFeedType.POLLS.kinds.forEach { assertFalse(it in disabled) }
}
}
@@ -0,0 +1,273 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.okhttp
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Interceptor
import okhttp3.Protocol
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import java.lang.reflect.Proxy
class BlossomReadAuthInterceptorTest {
private val sha = "2c5287a55cc550c9d6bc4206a4663900e083315f4a544ea3bc189e43dc330af6"
private val host = "nosfabrica.communities.buzz.xyz"
// --- blossomHashOrNull ------------------------------------------------
@Test
fun hashParsedFromPlainBlob() {
assertEquals(sha, BlossomReadAuthInterceptor.blossomHashOrNull("/media/$sha.png"))
}
@Test
fun hashParsedFromThumbnailVariant() {
// Buzz thumbnails use the dot form <hash>.thumb.jpg — the base is still the hash.
assertEquals(sha, BlossomReadAuthInterceptor.blossomHashOrNull("/media/$sha.thumb.jpg"))
}
@Test
fun hashParsedWithoutExtension() {
assertEquals(sha, BlossomReadAuthInterceptor.blossomHashOrNull("/$sha"))
}
@Test
fun hashLowercasedFromUppercaseSegment() {
assertEquals(sha, BlossomReadAuthInterceptor.blossomHashOrNull("/media/${sha.uppercase()}.png"))
}
@Test
fun nonBlobPathsReturnNull() {
assertNull(BlossomReadAuthInterceptor.blossomHashOrNull("/media/avatar.png"))
assertNull(BlossomReadAuthInterceptor.blossomHashOrNull("/media/nostr.build_$sha.jpg"))
assertNull(BlossomReadAuthInterceptor.blossomHashOrNull("/media/${sha}_thumb.jpg"))
// 65 hex chars: isHex64 checks only the first 64, so the length guard must reject it.
assertNull(BlossomReadAuthInterceptor.blossomHashOrNull("/media/${sha}a.png"))
assertNull(BlossomReadAuthInterceptor.blossomHashOrNull("/"))
}
// --- intercept behavior ----------------------------------------------
@Test
fun retriesWithAuthOn401() {
val provider = RecordingProvider(header = "Nostr token")
val chain = fakeChain("https://$host/media/$sha.png", codes = listOf(401, 200))
val response = BlossomReadAuthInterceptor(provider::header).intercept(chain.asChain())
assertEquals(200, response.code)
assertEquals(2, chain.requests.size)
assertNull("first attempt is anonymous", chain.requests[0].header("Authorization"))
assertEquals("Nostr token", chain.requests[1].header("Authorization"))
assertEquals(host to sha, provider.calls.single())
response.close()
}
@Test
fun thumbnailUrlAlsoRetries() {
val provider = RecordingProvider(header = "Nostr token")
val chain = fakeChain("https://$host/media/$sha.thumb.jpg", codes = listOf(401, 200))
val response = BlossomReadAuthInterceptor(provider::header).intercept(chain.asChain())
assertEquals(200, response.code)
assertEquals(sha, provider.calls.single().second)
response.close()
}
@Test
fun successfulRequestNeverSigns() {
val provider = RecordingProvider(header = "Nostr token")
val chain = fakeChain("https://blossom.example.com/$sha.png", codes = listOf(200))
val response = BlossomReadAuthInterceptor(provider::header).intercept(chain.asChain())
assertEquals(200, response.code)
assertEquals(1, chain.requests.size)
assertTrue("public host must not be signed", provider.calls.isEmpty())
response.close()
}
@Test
fun keepsThe401WhenNoSignerAvailable() {
val provider = RecordingProvider(header = null)
val chain = fakeChain("https://$host/media/$sha.png", codes = listOf(401))
val response = BlossomReadAuthInterceptor(provider::header).intercept(chain.asChain())
assertEquals(401, response.code)
assertEquals(1, chain.requests.size)
assertEquals(host to sha, provider.calls.single())
response.close()
}
@Test
fun nonBlobUrlNeverSignsEvenOn401() {
val provider = RecordingProvider(header = "Nostr token")
val chain = fakeChain("https://example.com/media/avatar.png", codes = listOf(401))
val response = BlossomReadAuthInterceptor(provider::header).intercept(chain.asChain())
assertEquals(401, response.code)
assertEquals(1, chain.requests.size)
assertTrue(provider.calls.isEmpty())
response.close()
}
@Test
fun requestWithExistingAuthPassesThrough() {
val provider = RecordingProvider(header = "Nostr token")
val chain = fakeChain("https://$host/media/$sha.png", codes = listOf(401), preAuthHeader = "Nostr existing")
val response = BlossomReadAuthInterceptor(provider::header).intercept(chain.asChain())
assertEquals(401, response.code)
assertEquals(1, chain.requests.size)
assertTrue(provider.calls.isEmpty())
response.close()
}
@Test
fun nonGetRequestPassesThrough() {
val provider = RecordingProvider(header = "Nostr token")
val chain = fakeChain("https://$host/media/$sha.png", codes = listOf(401), method = "PUT")
val response = BlossomReadAuthInterceptor(provider::header).intercept(chain.asChain())
assertEquals(401, response.code)
assertEquals(1, chain.requests.size)
assertTrue(provider.calls.isEmpty())
response.close()
}
@Test
fun learnsHostThenSignsSubsequentBlobsUpFront() {
val provider = RecordingProvider(header = "Nostr token")
val interceptor = BlossomReadAuthInterceptor(provider::header)
val otherSha = "b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553"
// First blob learns the host via the 401 probe + signed retry.
val first = fakeChain("https://$host/media/$sha.png", codes = listOf(401, 200))
interceptor.intercept(first.asChain()).close()
assertEquals(2, first.requests.size)
// Second, different blob on the same host is signed on the first attempt —
// no anonymous probe, so a single request.
val second = fakeChain("https://$host/media/$otherSha.png", codes = listOf(200))
val response = interceptor.intercept(second.asChain())
assertEquals(200, response.code)
assertEquals("no anonymous probe on a known-gated host", 1, second.requests.size)
assertEquals("Nostr token", second.requests.single().header("Authorization"))
response.close()
}
@Test
fun learnedHostWithoutSignerDoesNotLoop() {
val provider = RecordingProvider(header = null)
val interceptor = BlossomReadAuthInterceptor(provider::header)
val first = fakeChain("https://$host/media/$sha.png", codes = listOf(401))
interceptor.intercept(first.asChain()).close()
// Host is now known, but with no signer the preemptive path must fall
// back to a single anonymous request rather than retrying endlessly.
val second = fakeChain("https://$host/media/$sha.png", codes = listOf(401))
val response = interceptor.intercept(second.asChain())
assertEquals(401, response.code)
assertEquals(1, second.requests.size)
response.close()
}
private class RecordingProvider(
private val header: String?,
) {
val calls = mutableListOf<Pair<String, HexKey>>()
fun header(
host: String,
sha256: HexKey,
): String? {
calls.add(host to sha256)
return header
}
}
/**
* Records every [Request] it is asked to proceed and answers each with the
* next code from [codes], so `[401, 200]` models "anonymous fails,
* authenticated succeeds".
*/
private class FakeChain(
private val request: Request,
private val codes: List<Int>,
) {
val requests = mutableListOf<Request>()
fun asChain(): Interceptor.Chain =
Proxy.newProxyInstance(
Interceptor.Chain::class.java.classLoader,
arrayOf(Interceptor.Chain::class.java),
) { _, method, args ->
when (method.name) {
"request" -> request
"proceed" -> {
val proceeded = args[0] as Request
requests.add(proceeded)
Response
.Builder()
.request(proceeded)
.protocol(Protocol.HTTP_1_1)
.code(codes[requests.size - 1])
.message("msg")
.body("".toResponseBody(null))
.build()
}
else -> throw UnsupportedOperationException(method.name)
}
} as Interceptor.Chain
}
private fun fakeChain(
url: String,
codes: List<Int>,
method: String = "GET",
preAuthHeader: String? = null,
): FakeChain {
val request =
Request
.Builder()
.url(url.toHttpUrl())
.apply {
if (method == "GET") get() else method(method, "".toRequestBody())
preAuthHeader?.let { header("Authorization", it) }
}.build()
return FakeChain(request, codes)
}
}
@@ -0,0 +1,91 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.okhttp
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class BlossomReadAuthTokenProviderTest {
private val sha = "2c5287a55cc550c9d6bc4206a4663900e083315f4a544ea3bc189e43dc330af6"
private val host = "nosfabrica.communities.buzz.xyz"
private val signer = NostrSignerInternal(KeyPair())
@Test
fun signsAndFormatsHeader() {
val provider = BlossomReadAuthTokenProvider(signerProvider = { signer })
val header = provider.authHeader(host, sha)
assertTrue("expected a Nostr auth header, got $header", header!!.startsWith("Nostr "))
}
@Test
fun returnsNullWhenNoSigner() {
val provider = BlossomReadAuthTokenProvider(signerProvider = { null })
assertNull(provider.authHeader(host, sha))
}
@Test
fun cachesPerHostWithinTtl() {
// signerProvider is consulted only on a cache miss, so its invocation
// count is the number of times a fresh token was signed.
var lookups = 0
val provider =
BlossomReadAuthTokenProvider(
signerProvider = {
lookups++
signer
},
clock = { 0L },
)
val first = provider.authHeader(host, sha)
val second = provider.authHeader(host, sha)
assertEquals("second call must be served from cache", first, second)
assertEquals("signer must run only once for the same host", 1, lookups)
}
@Test
fun differentHostSignsSeparately() {
val provider = BlossomReadAuthTokenProvider(signerProvider = { signer }, clock = { 0L })
val a = provider.authHeader(host, sha)
val b = provider.authHeader("other.example.com", sha)
assertNotEquals(a, b)
}
@Test
fun refreshesAfterExpiry() {
var now = 0L
val provider = BlossomReadAuthTokenProvider(signerProvider = { signer }, clock = { now })
val first = provider.authHeader(host, sha)
now += 60L * 60L * 1000L // one hour later — past the 55-min TTL
val second = provider.authHeader(host, sha)
assertNotEquals("expired token must be re-signed", first, second)
}
}
@@ -71,4 +71,21 @@ class RelayAuthFirstPartyTest {
// Delivering my own DM/post to the recipient's relay, even one not in my list.
assertTrue(RelayAuthFirstParty.hasReason(me, relay, listOf(event(me)), emptySet()))
}
@Test
fun aRelayHostingAJoinedGroupIsFirstParty() {
// A NIP-29 group host relay is in none of my NIP-65/DM/… lists, and a private group's content
// is `#h`-scoped so it never names me — the joined-group set is the only signal that lets us
// AUTH so the relay serves the group's `auth-required` content instead of leaving it empty.
val groupRelay = NormalizedRelayUrl("wss://chat.wisp.talk/")
assertTrue(RelayAuthFirstParty.hasReason(me, groupRelay, emptyList(), emptySet(), setOf(groupRelay)))
}
@Test
fun aGroupRelayIHaveNotJoinedIsNotFirstParty() {
// Merely knowing a group relay exists (e.g. browsing its public directory) must not AUTH it;
// only a group on my own kind-10009 list counts.
val groupRelay = NormalizedRelayUrl("wss://chat.wisp.talk/")
assertFalse(RelayAuthFirstParty.hasReason(me, groupRelay, emptyList(), emptySet(), emptySet()))
}
}
@@ -146,12 +146,12 @@ object BuzzAgentCommands {
val repo = args.flag("repo") ?: return Output.error("bad_args", "pass --repo DIR (your git checkout — the agent works and opens PRs here)")
if (!File(repo).resolve(".git").exists()) return Output.error("bad_args", "--repo is not a git repository: $repo")
val approver = args.flag("approver") ?: return Output.error("bad_args", "pass --approver NPUB (the human who signs off each run)")
val baseRef = args.flag("base-ref")
val baseRef = args.flag(FLAG_BASE_REF)
val poll = args.flag("poll")
val timeout = args.flag("timeout")
val once = args.bool("once")
val explicitChannel = args.flag("channel")
args.rejectUnknown("repo", "approver", "channel", "base-ref", "poll", "timeout", "once")
args.rejectUnknown("repo", "approver", "channel", FLAG_BASE_REF, "poll", "timeout", "once")
val channel =
explicitChannel
@@ -320,7 +320,7 @@ object BuzzAgentCommands {
val timeoutSecs = args.flag("timeout")?.toLongOrNull() ?: 8
val execTimeoutSecs = args.flag("exec-timeout")?.toLongOrNull() ?: 1800
val worktreeBase = args.flag("worktree")
val baseRef = args.flag("base-ref") ?: "HEAD"
val baseRef = args.flag(FLAG_BASE_REF) ?: "HEAD"
val branchPrefix = args.flag("branch-prefix") ?: "claude/job-"
val fromChannel = args.bool("accept-from-channel")
val acceptFrom =
@@ -340,7 +340,7 @@ object BuzzAgentCommands {
"claim-untargeted",
"parallel",
"worktree",
"base-ref",
FLAG_BASE_REF,
"branch-prefix",
"poll",
"exec-timeout",
@@ -714,4 +714,7 @@ object BuzzAgentCommands {
}
private const val MAX_BODY = 60_000
/** Flag name for the git ref each job's worktree branches off. */
private const val FLAG_BASE_REF = "base-ref"
}
@@ -147,7 +147,7 @@ object BuzzCommands {
ctx.prepare()
val me = ctx.identity.pubKeyHex
val relays = relaysFor(ctx, relaysFlag)
if (relays.isEmpty()) return Output.error("no_relays", "no relays: pass --relays ws://…")
if (relays.isEmpty()) return Output.error("no_relays", NO_RELAYS_MSG)
// The deployed Buzz relay does NOT emit kind-41001; instead it (a) confirms a DM's
// channel id synchronously in the open OK, and (b) addresses each member a kind-44100
@@ -518,7 +518,7 @@ object BuzzCommands {
ctx.prepare()
val me = ctx.identity.pubKeyHex
val relays = relaysFor(ctx, relaysFlag)
if (relays.isEmpty()) return Output.error("no_relays", "no relays: pass --relays ws://…")
if (relays.isEmpty()) return Output.error("no_relays", NO_RELAYS_MSG)
val filter = Filter(kinds = listOf(AgentTurnMetricEvent.KIND), tags = mapOf("p" to listOf(me)))
val decrypted =
@@ -572,7 +572,7 @@ object BuzzCommands {
ctx.prepare()
val me = ctx.identity.pubKeyHex
val relays = relaysFor(ctx, relaysFlag)
if (relays.isEmpty()) return Output.error("no_relays", "no relays: pass --relays ws://…")
if (relays.isEmpty()) return Output.error("no_relays", NO_RELAYS_MSG)
val filter = Filter(kinds = listOf(PersonaEvent.KIND), authors = listOf(me))
val personas =
@@ -600,6 +600,9 @@ object BuzzCommands {
}
}
/** Message for the `no_relays` error: neither `--relays` nor the account's outbox had one. */
private const val NO_RELAYS_MSG = "no relays: pass --relays ws://…"
/** The `--relays` set if given, else the account's outbox relays. */
private suspend fun relaysFor(
ctx: Context,
@@ -125,7 +125,7 @@ object BuzzWorkflowCommands {
val wfId = args.positionalOrNull(1) ?: return Output.error("bad_args", usage)
val relay = normalizeGroupRelay(relayUrl) ?: return Output.error("bad_args", "invalid relay url: $relayUrl")
val task = args.flag("task")?.takeIf { it.isNotBlank() } ?: return Output.error("bad_args", "pass --task TEXT")
val channel = args.flag("channel") ?: return Output.error("bad_args", "pass --channel GID")
val channel = args.flag("channel") ?: return Output.error("bad_args", MISSING_CHANNEL_MSG)
args.rejectUnknown("task", "channel")
Context.open(dataDir).use { ctx ->
@@ -181,7 +181,7 @@ object BuzzWorkflowCommands {
val usage = "buzz workflow list RELAY --channel GID [--timeout SECS]"
val relayUrl = args.positionalOrNull(0) ?: return Output.error("bad_args", usage)
val relay = normalizeGroupRelay(relayUrl) ?: return Output.error("bad_args", "invalid relay url: $relayUrl")
val channel = args.flag("channel") ?: return Output.error("bad_args", "pass --channel GID")
val channel = args.flag("channel") ?: return Output.error("bad_args", MISSING_CHANNEL_MSG)
val timeoutSecs = args.flag("timeout")?.toLongOrNull() ?: 8
args.rejectUnknown("channel", "timeout")
@@ -301,7 +301,7 @@ object BuzzWorkflowCommands {
val relayUrl = args.positionalOrNull(0) ?: return Output.error("bad_args", USAGE)
val relay = normalizeGroupRelay(relayUrl) ?: return Output.error("bad_args", "invalid relay url: $relayUrl")
val exec = args.flag("exec") ?: return Output.error("bad_args", "pass --exec CMD (the agent's work step)")
val channel = args.flag("channel") ?: return Output.error("bad_args", "pass --channel GID")
val channel = args.flag("channel") ?: return Output.error("bad_args", MISSING_CHANNEL_MSG)
val approverInput = args.flag("approver") ?: return Output.error("bad_args", "pass --approver NPUB (who signs off the gate)")
val approver =
decodePublicKeyAsHexOrNull(approverInput.trim())?.takeIf { it.isValid() }
@@ -597,6 +597,10 @@ object BuzzWorkflowCommands {
.orEmpty()
private const val MAX = 60_000
/** Message for the `bad_args` error raised when `--channel` is missing. */
private const val MISSING_CHANNEL_MSG = "pass --channel GID"
private val LIFECYCLE_KINDS =
listOf(
WorkflowTriggeredEvent.KIND,
@@ -50,6 +50,8 @@ object ConcordChannelCommands {
mapOf(
"name" to state.metadata?.name,
"description" to state.metadata?.description,
// CORD-02 §9: once dissolved the community is sealed read-only (history only, no new posts).
"dissolved" to state.dissolved,
"icon" to state.metadata?.icon?.let { mapOf("url" to it.url, "key" to it.key, "nonce" to it.nonce, "hash" to it.hash) },
"banner" to state.metadata?.banner?.let { mapOf("url" to it.url, "key" to it.key, "nonce" to it.nonce, "hash" to it.hash) },
"channels" to
@@ -75,6 +77,11 @@ object ConcordChannelCommands {
Context.open(dataDir).use { ctx ->
ctx.prepare()
// CORD-02 §9: a dissolved community is sealed read-only — held keys still open history, but
// nothing new is honored, so refuse to post before we ever build/publish a wrap.
if (foldState(ctx, sc).dissolved) {
return Output.error("dissolved", "community '$handle' has been dissolved and is read-only (CORD-02 §9)")
}
val channelId = resolve(ctx, sc, channelRef) ?: return Output.error("not_found", "no channel '$channelRef'")
val channel = ConcordActions.publicChannel(sc.root.hexToByteArray(), channelId.hexToByteArray(), sc.rootEpoch)
val wrap = ConcordActions.buildChannelMessage(ctx.signer, channel, channelId, sc.rootEpoch, text, TimeUtils.now())
@@ -38,13 +38,16 @@ import java.io.File
* pushing objects back is still out of scope (see cli/ROADMAP.md).
*/
object GitBrowseCommands {
/** Name of the first positional argument: the repo's naddr or `kind:pubkey:d` coordinates. */
private const val ARG_REPO_COORD = "repo-naddr-or-coordinates"
/** `git browse REPO [PATH]` — list the tree entries at PATH (default: repo root). */
suspend fun browse(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val coord = args.positional(0, "repo-naddr-or-coordinates")
val coord = args.positional(0, ARG_REPO_COORD)
val path = args.positionalOrNull(1).orEmpty()
val ref = args.flag("ref")
val cloneOverride = args.flag("clone")
@@ -79,7 +82,7 @@ object GitBrowseCommands {
rest: Array<String>,
): Int {
val args = Args(rest)
val coord = args.positional(0, "repo-naddr-or-coordinates")
val coord = args.positional(0, ARG_REPO_COORD)
val path = args.positional(1, "file-path")
val ref = args.flag("ref")
val cloneOverride = args.flag("clone")
@@ -118,7 +121,7 @@ object GitBrowseCommands {
rest: Array<String>,
): Int {
val args = Args(rest)
val coord = args.positional(0, "repo-naddr-or-coordinates")
val coord = args.positional(0, ARG_REPO_COORD)
val ref = args.flag("ref")
val cloneOverride = args.flag("clone")
val depth = args.intFlag("depth", 50)
@@ -78,6 +78,16 @@ class ConcordChannel(
var membership: ConcordMembership = ConcordMembership.MEMBER
private set
/**
* True once the community has been dissolved by an owner-signed tombstone (CORD-02 §9). On sight
* the client seals the Community read-only: held keys still open history, but nothing new is
* honored, so no member not even the owner may post. Terminal and one-way (there is no
* un-dissolve). The one carve-out (a member's delete of their own past message stays honored even
* post-seal) runs through the note context menu, not the composer, so this gate never blocks it.
*/
var dissolved: Boolean = false
private set
/**
* Per-relay backward-pagination cursors for this channel's history (CORD-03). The live
* subscription only holds the recent tail the relay serves for the channel plane; older
@@ -110,6 +120,7 @@ class ConcordChannel(
val newCommunityIcon = state.metadata?.icon
val newCommunityBanner = state.metadata?.banner
val newMembership = ConcordMembership.of(state.authority, myPubKey)
val newDissolved = state.dissolved
val changed =
channelName != newChannelName ||
@@ -118,7 +129,8 @@ class ConcordChannel(
communityName != newCommunityName ||
communityIcon != newCommunityIcon ||
communityBanner != newCommunityBanner ||
membership != newMembership
membership != newMembership ||
dissolved != newDissolved
channelName = newChannelName
isVoice = newVoice
@@ -128,6 +140,7 @@ class ConcordChannel(
communityBanner = newCommunityBanner
communityRelays = relays
membership = newMembership
dissolved = newDissolved
return changed
}
@@ -136,7 +149,13 @@ class ConcordChannel(
override fun toBestDisplayName(): String = channelName ?: channelId.channelId
fun canPost(): Boolean = membership.isMember()
/**
* Whether this account may post to the channel: it must hold a live standing in the community
* ([ConcordMembership.isMember]) **and** the community must not have been dissolved (CORD-02 §9
* a tombstone seals it read-only for everyone). Deleting one's own past message stays allowed even
* after dissolution and does not go through this gate.
*/
fun canPost(): Boolean = membership.isMember() && !dissolved
// Synthetic note representing this channel in the Messages list before any
// message has loaded (so a just-joined channel appears immediately). Mirrors
@@ -24,9 +24,11 @@ package com.vitorpamplona.amethyst.commons.model.highlights
* What to render for a NIP-84 highlight: the passage to show, and the range inside it that
* the user actually marked.
*
* When the event carries a `context` tag the whole surrounding sentence is shown with the
* quote marked inside it. When it doesn't or the quote can't be located in the context
* [text] is the quote alone and [marked] is null, meaning "mark all of it".
* When the event carries a `context` tag the surrounding passage is shown with the quote
* marked inside it, trimmed to a bounded window on each side so a quote pulled from the
* middle of a long article doesn't drag whole paragraphs into the feed. When it doesn't
* or the quote can't be located in the context [text] is the quote alone and [marked] is
* null, meaning "mark all of it".
*/
data class HighlightQuote(
val text: String,
@@ -49,7 +51,7 @@ data class HighlightQuote(
val at = locate(context, highlight, prefix)
return if (at != null) {
HighlightQuote(context, at until (at + highlight.length))
window(context, at, at + highlight.length)
} else {
// Context that doesn't actually contain the quote is worse than no context:
// it would mark nothing and silently show text the user never highlighted.
@@ -57,6 +59,78 @@ data class HighlightQuote(
}
}
/**
* Most surrounding context to keep on each side of the marked quote, in characters.
* A highlight taken from the middle of a long article can carry the whole article in
* its `context` tag; without a cap the feed card would render several paragraphs around
* a one-sentence highlight. Just enough to frame the quote, no more.
*/
private const val MAX_CONTEXT_CHARS_PER_SIDE = 160
private const val ELLIPSIS = ""
/**
* Trims the context down to [MAX_CONTEXT_CHARS_PER_SIDE] on each side of the quote,
* snapping the cut to a whole-word boundary and marking it with an ellipsis. The quote
* itself ([start] until [endExclusive]) is always kept in full, and the returned
* [marked] range is re-based onto the trimmed text.
*/
private fun window(
context: String,
start: Int,
endExclusive: Int,
): HighlightQuote {
val lead = trimLead(context.substring(0, start))
val trail = trimTrail(context.substring(endExclusive))
val quote = context.substring(start, endExclusive)
val prefix = if (lead.trimmed) "$ELLIPSIS " else ""
val suffix = if (trail.trimmed) " $ELLIPSIS" else ""
// Drop any blank lines sitting at the very edges of the context — with the far text
// trimmed (or even when it isn't) they would otherwise render as empty space above or
// below the quote. Only the outer edges are touched; the whitespace framing the quote
// itself is preserved.
val raw = prefix + lead.text + quote + trail.text + suffix
val leadingBlank = raw.length - raw.trimStart().length
val text = raw.trim()
val markStart = (prefix.length + lead.text.length - leadingBlank).coerceAtLeast(0)
val markEnd = (markStart + quote.length).coerceAtMost(text.length)
return HighlightQuote(text, markStart until markEnd)
}
private class Side(
val text: String,
val trimmed: Boolean,
)
/** Keeps the tail of the leading context, starting at a whole word. */
private fun trimLead(text: String): Side {
if (text.length <= MAX_CONTEXT_CHARS_PER_SIDE) return Side(text, false)
var i = text.length - MAX_CONTEXT_CHARS_PER_SIDE
// Skip the partial word the budget landed inside, then the whitespace after it, so
// the kept text begins at the start of a whole word rather than mid-word.
while (i < text.length && !text[i].isWhitespace()) i++
while (i < text.length && text[i].isWhitespace()) i++
val cut = if (i >= text.length) text.length - MAX_CONTEXT_CHARS_PER_SIDE else i
return Side(text.substring(cut), true)
}
/** Keeps the head of the trailing context, ending at a whole word. */
private fun trimTrail(text: String): Side {
if (text.length <= MAX_CONTEXT_CHARS_PER_SIDE) return Side(text, false)
var i = MAX_CONTEXT_CHARS_PER_SIDE
// Retreat over the partial word the budget landed inside, then the whitespace before
// it, so the kept text ends at the end of a whole word rather than mid-word.
while (i > 0 && !text[i - 1].isWhitespace()) i--
while (i > 0 && text[i - 1].isWhitespace()) i--
val cut = if (i <= 0) MAX_CONTEXT_CHARS_PER_SIDE else i
return Side(text.substring(0, cut), true)
}
/**
* Finds [highlight] inside [context], preferring the occurrence whose preceding text
* ends with [prefix]. Highlighters emit that prefix precisely so a repeated quote can
@@ -67,9 +141,13 @@ data class HighlightQuote(
highlight: String,
prefix: String?,
): Int? {
// The common case — no prefix to disambiguate with — needs only the first match, so
// don't scan the whole context enumerating every occurrence.
if (prefix.isNullOrBlank()) return context.indexOf(highlight).takeIf { it >= 0 }
val occurrences = occurrencesOf(context, highlight)
if (occurrences.isEmpty()) return null
if (occurrences.size == 1 || prefix.isNullOrBlank()) return occurrences.first()
if (occurrences.size == 1) return occurrences.first()
val tail = prefix.trimEnd()
return occurrences.firstOrNull { context.substring(0, it).trimEnd().endsWith(tail) }
@@ -23,7 +23,9 @@ package com.vitorpamplona.amethyst.commons.model.nip29RelayGroups
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.NoteState
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.GroupTag
import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.SimpleGroupListEvent
import com.vitorpamplona.quartz.utils.Log
@@ -97,6 +99,27 @@ class RelayGroupListState(
.flowOn(Dispatchers.IO)
.stateIn(scope, SharingStarted.Eagerly, emptySet())
/**
* The joined groups as normalized [GroupId]s what the UI asks when it needs to know whether a
* channel is on my Messages list ("Remove from Messages" vs "Add to Messages"). The stored tag
* carries a raw relay url string (another client may not have normalized it the way we do), so
* normalize before comparing instead of matching [GroupTag] strings.
*/
@OptIn(ExperimentalCoroutinesApi::class)
val liveRelayGroupIds: StateFlow<Set<GroupId>> =
liveRelayGroupList
.transformLatest { groups ->
emit(
groups.mapNotNullTo(mutableSetOf()) { tag ->
RelayUrlNormalizer.normalizeOrNull(tag.relayUrl)?.let { GroupId(tag.groupId, it) }
},
)
}.flowOn(Dispatchers.IO)
.stateIn(scope, SharingStarted.Eagerly, emptySet())
/** Is [groupId] currently on my kind-10009 list (i.e. does it show on Messages)? */
fun isOnMyList(groupId: GroupId) = groupId in liveRelayGroupIds.value
private fun RelayGroupChannel.toGroupTag() = GroupTag(groupId.id, groupId.relayUrl.url, event?.name())
suspend fun follow(channel: RelayGroupChannel): SimpleGroupListEvent {
@@ -0,0 +1,75 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.model.concord
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* A member of a **dissolved** Concord community must not be able to post: CORD-02 §9 seals the
* community read-only on an owner-signed tombstone. History stays readable (the composer gate is the
* only thing this changes), so [ConcordChannel.canPost] must fold the dissolution flag in alongside
* membership.
*/
class ConcordChannelDissolvedTest {
private val owner = "0f".repeat(32)
private val channelId = "ce".repeat(32)
private fun ed(
kind: ControlEntityKind,
eid: String,
content: String,
author: String = owner,
) = ControlEdition(kind, eid.hexToByteArray(), 0, null, null, content, author, "r-$eid", 0)
private fun state(dissolved: Boolean): ConcordCommunityState {
val editions =
buildList {
add(ed(ControlEntityKind.CHANNEL, channelId, """{"name":"general"}"""))
if (dissolved) add(ed(ControlEntityKind.DISSOLVED, "dd".repeat(32), """{}"""))
}
return ConcordCommunityState.fold(editions, owner)
}
@Test
fun liveCommunityLetsTheOwnerPost() {
val channel = ConcordChannel(ConcordChannelId(owner, channelId))
channel.updateFrom(state(dissolved = false), emptySet(), owner)
assertFalse(channel.dissolved)
assertTrue(channel.canPost(), "a live community with a live standing must be postable")
}
@Test
fun dissolvedCommunitySealsPostingEvenForTheOwner() {
val channel = ConcordChannel(ConcordChannelId(owner, channelId))
// The owner is the most privileged member; if even they can't post, no one can.
val changed = channel.updateFrom(state(dissolved = true), emptySet(), owner)
assertTrue(changed, "flipping to dissolved is a displayed-field change (drives recomposition)")
assertTrue(channel.dissolved)
assertFalse(channel.canPost(), "a dissolved community is read-only (CORD-02 §9)")
}
}
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.commons.model.highlights
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
class HighlightQuoteTest {
@Test
@@ -89,4 +90,49 @@ class HighlightQuoteTest {
assertEquals("some context", quote.text)
assertNull(quote.marked)
}
@Test
fun keepsShortContextWholeWithoutEllipsis() {
val quote = HighlightQuote.of("the merge happens slowly", "We think the merge happens slowly. It does.")
assertEquals("We think the merge happens slowly. It does.", quote.text)
assertTrue('…' !in quote.text)
}
@Test
fun trimsLongContextToAWindowAroundTheQuote() {
val filler = "word ".repeat(200).trim() // ~1000 chars of context on each side
val context = "$filler the marked quote $filler"
val quote = HighlightQuote.of("the marked quote", context)
// The whole quote survives and stays marked...
assertEquals("the marked quote", quote.text.substring(quote.marked!!))
// ...but the surrounding text is trimmed with an ellipsis on each side...
assertTrue(quote.text.startsWith(""))
assertTrue(quote.text.endsWith(""))
// ...and the result is a small fraction of the original two-paragraph context.
assertTrue(quote.text.length < context.length / 2, "expected windowing, got ${quote.text.length} of ${context.length}")
}
@Test
fun dropsBlankLinesAtTheEdgesOfAShortContext() {
// A context whose paragraph boundaries left blank lines at its very start and end would
// otherwise render as empty space above and below the quote.
val quote = HighlightQuote.of("Forward Secrecy", "\n\nForward Secrecy is nice.\n\n")
assertEquals("Forward Secrecy is nice.", quote.text)
assertEquals(0 until 15, quote.marked)
assertEquals("Forward Secrecy", quote.text.substring(quote.marked!!))
}
@Test
fun trimmingSnapsToWholeWordsSoNoWordIsCutInHalf() {
val lead = "alpha bravo charlie delta echo foxtrot ".repeat(20) // long, space-separated
val context = "${lead}QUOTE"
val quote = HighlightQuote.of("QUOTE", context)
// The kept lead-in starts right after the ellipsis with a whole word, never a fragment.
val keptLead = quote.text.removePrefix("").removeSuffix("QUOTE")
assertTrue(keptLead.split(" ").first() in setOf("alpha", "bravo", "charlie", "delta", "echo", "foxtrot"))
}
}
@@ -24,22 +24,76 @@ import com.vitorpamplona.amethyst.commons.model.AddressableNote
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import java.util.SortedSet
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ConcurrentSkipListSet
/**
* Creates a list of events (regular and addressable)
* that is updated every time a new event that matches
* the filter is received, including addressables.
* Creates a list of events (regular and addressable), sorted by created_at, that
* is updated every time a new matching event is received INCLUDING newer
* versions of addressables, whose refreshed content re-emits and re-sorts.
*
* Like [NoteListMatchingFilter], this cannot store mutable [Note]s in a set
* ordered on their live created_at: a newer replaceable event mutates the SAME
* [AddressableNote] instance in place, which strands its node and lets the same
* instance be inserted twice the emitted list would then carry the same event
* twice. So the sort key is snapshotted into an immutable [Entry], [sorted] is
* ordered on that snapshot, and [byId] is the membership source of truth keyed
* by the stable idHex (the address for addressables, the event id otherwise).
*
* Unlike [NoteListMatchingFilter], an addressable update is NOT ignored: the
* list re-emits so consumers pick up the refreshed [Event] (read live off the
* note). The entry's captured position is kept re-sorting an updated entry
* would mean a remove+add on [sorted], and two entries with different captured
* keys for the same note would then transiently coexist and both read the same
* live event, duplicating it. Keeping the position matches the original's only
* non-corrupting behavior (it never reliably re-sorted either); consumers that
* care about order re-sort downstream.
*
* Observer callbacks fire concurrently from several consume threads (relay
* ingest + UI-side justConsume), so it stays lock-free: [sorted] is only ever
* written for a key INSIDE that key's `compute` critical section, and only while
* the key is absent. ConcurrentHashMap stripes per key, so same-idHex ops
* serialize while different keys run in parallel. An entry is added only while
* its key is absent from [byId], and every path that frees a key removes its
* entry from [sorted] first.
*
* That keeps [sorted] converged to one entry per idHex, but a
* ConcurrentSkipListSet iterator is only weakly consistent: under concurrent
* add/remove churn a single traversal can momentarily surface a key twice
* (lazy-deleted node not yet unlinked while its replacement is inserted). The
* emitted list must never carry a duplicate id a LazyColumn keyed on it would
* crash so [snapshot] deduplicates by the stable idHex as it materializes.
*/
class EventListMatchingFilter<T : Event>(
private val filter: Filter,
private val atOnce: (filter: Filter) -> SortedSet<Note>,
private val update: (List<T>) -> Unit,
) : Observable {
// Keeping this here blocks it from being cleared from memory
var currentResults: ConcurrentSkipListSet<Note> = ConcurrentSkipListSet(CreatedAtIdHexComparator)
/** A note plus the sort key captured at insertion time, so ordering never depends on mutable state. */
private class Entry(
val note: Note,
val createdAt: Long,
val id: HexKey,
)
// created_at descending, id ascending as a stable tiebreak. Both fields are
// immutable snapshots, so an Entry never moves once inserted.
private val order =
Comparator<Entry> { a, b ->
val byCreatedAt = b.createdAt.compareTo(a.createdAt)
if (byCreatedAt != 0) byCreatedAt else a.id.compareTo(b.id)
}
private val sorted = ConcurrentSkipListSet(order)
private val byId = ConcurrentHashMap<HexKey, Entry>()
private fun entryFor(note: Note): Entry {
val event = note.event
return Entry(note, note.createdAt() ?: Long.MIN_VALUE, event?.id ?: note.idHex)
}
@Suppress("UNCHECKED_CAST")
override fun new(
@@ -47,34 +101,68 @@ class EventListMatchingFilter<T : Event>(
note: Note,
) {
if (event is AddressableEvent && note !is AddressableNote) {
// event update
if (currentResults.contains(note)) {
update(currentResults.mapNotNull { it.event as? T })
}
// The "version" note (a regular note holding an addressable event) is
// never stored — the AddressableNote is. Re-emit if that addressable
// is already listed so consumers pick up the refreshed content.
if (byId.containsKey(event.address().toValue())) update(snapshot())
return
}
if (filter.match(event)) {
currentResults.add(note)
val limit = filter.limit
if (limit != null && currentResults.size > limit) {
currentResults.remove(currentResults.last())
}
if (!filter.match(event)) return
update(currentResults.mapNotNull { it.event as? T })
// Add to [sorted] atomically with claiming the idHex slot, only when the
// key is absent. An update keeps its entry (and position) — the re-emit
// below reflects the refreshed event read live off the note.
var added = false
byId.compute(note.idHex) { _, existing ->
existing ?: entryFor(note).also {
sorted.add(it)
added = true
}
}
if (added) {
val limit = filter.limit
if (limit != null && sorted.size > limit) {
// Drop the oldest (sorts last under [order]).
sorted.pollLast()?.let { byId.remove(it.note.idHex, it) }
}
}
// Always re-emit on a match: a first insert grows the list, an update
// refreshes the event content the snapshot reads off the note.
update(snapshot())
}
@Suppress("UNCHECKED_CAST")
override fun remove(note: Note) {
if (currentResults.remove(note)) {
update(currentResults.mapNotNull { it.event as? T })
var removed = false
byId.compute(note.idHex) { _, existing ->
if (existing != null) {
sorted.remove(existing)
removed = true
}
null
}
if (removed) update(snapshot())
}
@Suppress("UNCHECKED_CAST")
fun init() {
currentResults = ConcurrentSkipListSet(atOnce(filter))
update(currentResults.mapNotNull { it.event as? T })
sorted.clear()
byId.clear()
atOnce(filter).forEach { note ->
byId.computeIfAbsent(note.idHex) { entryFor(note).also { sorted.add(it) } }
}
update(snapshot())
}
@Suppress("UNCHECKED_CAST")
private fun snapshot(): List<T> {
// Dedup by the stable idHex: the weakly-consistent iterator can transiently
// surface a key twice under concurrent churn. Both would read the same live
// event off the same note, so keeping the first (newest position) is correct.
val seen = HashSet<HexKey>()
return sorted.mapNotNull { e -> if (seen.add(e.note.idHex)) e.note.event as? T else null }
}
}
@@ -24,22 +24,76 @@ import com.vitorpamplona.amethyst.commons.model.AddressableNote
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import java.util.SortedSet
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ConcurrentSkipListSet
/**
* Creates a list of notes (regular and addressable)
* that only gets updated when a new note appears.
* Creates a list of notes (regular and addressable), sorted by created_at like a
* relay, that only grows when a new note appears.
*
* New versions of addressables do not update the list
* New versions of addressables do not update the list.
*
* There is exactly one [Note] instance per id/address (LocalCache owns their
* creation), so uniqueness is a non-issue in principle except a note's sort
* key is mutable: a newer replaceable event swaps the event on the SAME
* [AddressableNote] instance, changing its created_at in place. A sorted set
* ordered on that live value cannot survive it the moved node is no longer on
* the search path of add()/remove(), so the same instance gets inserted twice
* and the emitted list carries a duplicate idHex, crashing any LazyColumn keyed
* on it.
*
* So the sort key is snapshotted into an immutable [Entry] when the note first
* enters and never read live again; [sorted] is ordered on that snapshot
* (stable) and [byId] is the membership source of truth, keyed by the stable
* idHex.
*
* Observer callbacks fire concurrently from several consume threads (relay
* ingest + UI-side justConsume), and this observer is used everywhere, so it
* stays lock-free: [byId] is a ConcurrentHashMap and every write to [sorted] for
* a given idHex happens INSIDE that key's `compute` critical section.
* ConcurrentHashMap stripes per key, so same-idHex ops serialize while different
* keys run fully in parallel. An entry is added to [sorted] only while its key
* is absent from [byId], and every path that makes a key absent removes its
* entry from [sorted] first, keeping [sorted] converged to one entry per idHex.
*
* That convergence isn't enough on its own: a ConcurrentSkipListSet iterator is
* only weakly consistent, so under concurrent add/remove churn a single
* traversal can momentarily surface a key twice (a lazy-deleted node not yet
* unlinked while its replacement is inserted). The emitted list must never carry
* a duplicate idHex the LazyColumn keyed on it would crash so [snapshot]
* deduplicates by idHex as it materializes.
*/
class NoteListMatchingFilter(
private val filter: Filter,
private val atOnce: (filter: Filter) -> SortedSet<Note>,
private val update: (List<Note>) -> Unit,
) : Observable {
var currentResults: ConcurrentSkipListSet<Note> = ConcurrentSkipListSet(CreatedAtIdHexComparator)
/** A note plus the sort key captured at insertion time, so ordering never depends on mutable state. */
private class Entry(
val note: Note,
val createdAt: Long,
val id: HexKey,
)
// created_at descending, id ascending as a stable tiebreak. Both fields are
// immutable snapshots, so an Entry never moves once inserted.
private val order =
Comparator<Entry> { a, b ->
val byCreatedAt = b.createdAt.compareTo(a.createdAt)
if (byCreatedAt != 0) byCreatedAt else a.id.compareTo(b.id)
}
private val sorted = ConcurrentSkipListSet(order)
private val byId = ConcurrentHashMap<HexKey, Entry>()
private fun entryFor(note: Note): Entry {
// A null event (unresolved note) sorts last, matching CreatedAtIdHexComparator.
val event = note.event
return Entry(note, note.createdAt() ?: Long.MIN_VALUE, event?.id ?: note.idHex)
}
override fun new(
event: Event,
@@ -47,26 +101,55 @@ class NoteListMatchingFilter(
) {
if (event is AddressableEvent && note !is AddressableNote) return
if (filter.match(event)) {
if (currentResults.add(note)) {
val limit = filter.limit
if (limit != null && currentResults.size > limit) {
currentResults.remove(currentResults.last())
}
if (!filter.match(event)) return
update(currentResults.toList())
// Add to [sorted] atomically with claiming the idHex slot. New versions
// of an already listed note return the existing entry untouched.
var added = false
byId.compute(note.idHex) { _, existing ->
existing ?: entryFor(note).also {
sorted.add(it)
added = true
}
}
if (!added) return
val limit = filter.limit
if (limit != null && sorted.size > limit) {
// Drop the oldest (sorts last under [order]).
sorted.pollLast()?.let { byId.remove(it.note.idHex, it) }
}
update(snapshot())
}
override fun remove(note: Note) {
if (currentResults.remove(note)) {
update(currentResults.toList())
// Remove from [sorted] atomically with releasing the idHex slot.
var removed = false
byId.compute(note.idHex) { _, existing ->
if (existing != null) {
sorted.remove(existing)
removed = true
}
null
}
if (removed) update(snapshot())
}
fun init() {
currentResults = ConcurrentSkipListSet(atOnce(filter))
update(currentResults.toList())
sorted.clear()
byId.clear()
atOnce(filter).forEach { note ->
byId.computeIfAbsent(note.idHex) { entryFor(note).also { sorted.add(it) } }
}
update(snapshot())
}
private fun snapshot(): List<Note> {
// Dedup by idHex: the weakly-consistent iterator can transiently surface a
// key twice under concurrent churn. Keeping the first (newest position) is
// correct — both nodes point at the same note.
val seen = HashSet<HexKey>()
return sorted.mapNotNull { e -> e.note.takeIf { seen.add(it.idHex) } }
}
}
@@ -25,6 +25,20 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent
object BlossomAuth {
/**
* BUD-01 read auth (`t=get`). Servers that gate downloads (e.g. Buzz's
* private media relay) require this on `GET /<sha256>`. The [servers] list
* adds BUD-11 `server` tags so a single token can be scoped to a whole host
* (which also covers derived blobs like `.thumb.jpg` whose hash differs
* from [hash]).
*/
suspend fun createGetAuth(
hash: HexKey,
alt: String,
signer: NostrSigner,
servers: List<String> = emptyList(),
): String = BlossomAuthorizationEvent.createGetAuth(hash, alt, signer, servers).toAuthorizationHeader()
suspend fun createUploadAuth(
hash: HexKey,
size: Long,
@@ -87,6 +87,28 @@ actual class SecureKeyStorage private actual constructor() {
private var fallbackPassword: String? = null
private val fallbackMutex = Mutex() // Protects concurrent access to fallback file
/**
* Cached Keyring instance. Opening a Keyring session is expensive and, on
* some OSes (notably macOS and locked GNOME/KWallet sessions), triggers a
* user-visible unlock prompt every time. Callers hit the storage at least
* twice on cold start (metadata AES key, then active account nsec), so a
* per-call [Keyring.create] would prompt the user twice on startup the
* exact bug this cache fixes.
*
* Guarded by [keyringLock] so probing/opening the backend happens exactly
* once per process; the [Keyring] itself is thread-safe once obtained.
*/
@Volatile
private var cachedKeyring: KeyringHandle? = null
private val keyringLock = Any()
/**
* Package-private factory used by tests to inject a stub Keyring backend
* and count backend-open invocations. Production code always defers to
* [Keyring.create] through [RealKeyringHandle].
*/
internal var keyringFactory: () -> KeyringHandle = { RealKeyringHandle(Keyring.create()) }
actual suspend fun savePrivateKey(
npub: String,
privKeyHex: String,
@@ -146,26 +168,40 @@ actual class SecureKeyStorage private actual constructor() {
actual suspend fun hasPrivateKey(npub: String): Boolean = getPrivateKey(npub) != null
// Keyring-based storage
/**
* Returns the process-wide [Keyring] instance, opening the OS-native
* backend on first call. Subsequent calls reuse the same handle so the
* user is only prompted (macOS Keychain Access, Secret Service unlock,
* KWallet unlock) once per app run.
*
* Callers must handle [BackendNotSupportedException] it can escape on
* the very first call if no backend is available at all.
*/
private fun keyring(): KeyringHandle {
cachedKeyring?.let { return it }
return synchronized(keyringLock) {
cachedKeyring ?: keyringFactory().also { cachedKeyring = it }
}
}
private fun saveToKeyring(
npub: String,
privKeyHex: String,
) {
val keyring = Keyring.create()
keyring.setPassword(SERVICE_NAME, npub, privKeyHex)
keyring().setPassword(SERVICE_NAME, npub, privKeyHex)
}
private fun getFromKeyring(npub: String): String? =
try {
val keyring = Keyring.create()
keyring.getPassword(SERVICE_NAME, npub)
keyring().getPassword(SERVICE_NAME, npub)
} catch (e: PasswordAccessException) {
null
}
private fun deleteFromKeyring(npub: String): Boolean =
try {
val keyring = Keyring.create()
keyring.deletePassword(SERVICE_NAME, npub)
keyring().deletePassword(SERVICE_NAME, npub)
true
} catch (e: PasswordAccessException) {
false
@@ -397,3 +433,59 @@ actual class SecureKeyStorage private actual constructor() {
return String(decrypted)
}
}
/**
* Small package-private abstraction over `com.github.javakeyring.Keyring`,
* mirroring the three operations `SecureKeyStorage` actually uses. The real
* implementation is a thin delegator; tests substitute an in-memory version
* so the desktop unit test suite doesn't touch the OS Keychain (which would
* be non-hermetic and slow, and on macOS would surface a user-visible prompt
* during test runs).
*
* Not part of the public API kept in this file so it stays private to the
* keystorage package.
*/
internal interface KeyringHandle {
@Throws(PasswordAccessException::class)
fun getPassword(
service: String,
account: String,
): String
@Throws(PasswordAccessException::class)
fun setPassword(
service: String,
account: String,
password: String,
)
@Throws(PasswordAccessException::class)
fun deletePassword(
service: String,
account: String,
)
}
internal class RealKeyringHandle(
private val keyring: Keyring,
) : KeyringHandle {
override fun getPassword(
service: String,
account: String,
): String = keyring.getPassword(service, account)
override fun setPassword(
service: String,
account: String,
password: String,
) {
keyring.setPassword(service, account, password)
}
override fun deletePassword(
service: String,
account: String,
) {
keyring.deletePassword(service, account)
}
}
@@ -0,0 +1,152 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.keystorage
import com.github.javakeyring.PasswordAccessException
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicInteger
/**
* Regression tests for the desktop `SecureKeyStorage` keyring-instance cache.
*
* Prior to the fix, every `savePrivateKey` / `getPrivateKey` / `deletePrivateKey`
* call opened a fresh `Keyring.create()` handle. On macOS, each fresh handle
* incurs a Security Framework session open; on GNOME/KWallet a fresh handle can
* re-trigger the OS unlock prompt. The Amethyst cold-boot path calls the
* storage at least twice (metadata AES key, then the active account nsec), so
* the user was seeing the keychain unlock prompt twice on startup.
*
* These tests pin the invariant that at most one `Keyring` is opened for the
* process lifetime of a `SecureKeyStorage` instance, no matter how many
* save/get/delete calls happen.
*/
class SecureKeyStorageKeyringCacheTest {
private class InMemoryKeyring : KeyringHandle {
private val store: ConcurrentHashMap<Pair<String, String>, String> = ConcurrentHashMap()
override fun getPassword(
service: String,
account: String,
): String = store[service to account] ?: throw PasswordAccessException("no entry")
override fun setPassword(
service: String,
account: String,
password: String,
) {
store[service to account] = password
}
override fun deletePassword(
service: String,
account: String,
) {
if (store.remove(service to account) == null) {
throw PasswordAccessException("no entry")
}
}
}
private fun newStorage(counter: AtomicInteger): SecureKeyStorage {
val storage = SecureKeyStorage.create()
storage.keyringFactory = {
counter.incrementAndGet()
InMemoryKeyring()
}
return storage
}
@Test
fun `single instance across many save-get-delete calls (double-prompt regression)`() =
runBlocking {
val opens = AtomicInteger(0)
val storage = newStorage(opens)
// Simulate the cold-boot storm: metadata key + active-account nsec
// + a handful of subsequent NWC / bunker ephemeral key touches.
storage.savePrivateKey("account-metadata-key", "aaaa")
assertEquals("aaaa", storage.getPrivateKey("account-metadata-key"))
storage.savePrivateKey("npub1alice", "bbbb")
assertEquals("bbbb", storage.getPrivateKey("npub1alice"))
storage.savePrivateKey("bunker-ephemeral-npub1alice", "cccc")
assertEquals("cccc", storage.getPrivateKey("bunker-ephemeral-npub1alice"))
assertEquals(true, storage.deletePrivateKey("bunker-ephemeral-npub1alice"))
assertNull(storage.getPrivateKey("bunker-ephemeral-npub1alice"))
// The cache must open the keyring exactly once for the process
// lifetime; each additional Keyring.create() call would surface as
// an OS-level unlock prompt on macOS / GNOME / KWallet.
assertEquals(
"SecureKeyStorage must open Keyring exactly once per process",
1,
opens.get(),
)
}
@Test
fun `hasPrivateKey reuses the cached keyring`() =
runBlocking {
val opens = AtomicInteger(0)
val storage = newStorage(opens)
storage.savePrivateKey("npub1x", "1111")
repeat(5) {
assertEquals(true, storage.hasPrivateKey("npub1x"))
assertEquals(false, storage.hasPrivateKey("npub1missing"))
}
assertEquals(1, opens.get())
}
@Test
fun `concurrent first-touches still open the keyring exactly once`() =
runBlocking {
val opens = AtomicInteger(0)
val storage = newStorage(opens)
val threads =
List(16) { idx ->
Thread {
runBlocking {
// Alternate between get and save so both call paths race to
// grab the cached keyring on the first invocation.
if (idx % 2 == 0) {
storage.getPrivateKey("npub1concurrent-$idx")
} else {
storage.savePrivateKey("npub1concurrent-$idx", "0x$idx")
}
}
}
}
threads.forEach { it.start() }
threads.forEach { it.join() }
// Race-free single-open guarantee under contention.
assertEquals(
"Concurrent first-touches must not open the Keyring more than once",
1,
opens.get(),
)
}
}
@@ -0,0 +1,203 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.model.observables
import com.vitorpamplona.amethyst.commons.model.AddressableNote
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
import com.vitorpamplona.quartz.utils.EventFactory
import java.util.TreeSet
import java.util.concurrent.CountDownLatch
import java.util.concurrent.atomic.AtomicReference
import kotlin.concurrent.thread
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class EventListMatchingFilterTest {
private val author = "d0d0a746b44c9de8422165aef520b1fe041eedf5794f7592505477eeac122c18"
private val filter = Filter(kinds = listOf(AppDefinitionEvent.KIND))
private fun noteFor(dTag: String) = AddressableNote(Address(AppDefinitionEvent.KIND, author, dTag))
private fun appDefinition(
dTag: String,
createdAt: Long,
): Event =
EventFactory.create(
// Unique per (dTag, createdAt): 8 hex of the dTag hash + 56 hex of createdAt,
// so distinct addresses never collide on an event id.
id = "%08x".format(dTag.hashCode()) + "%056x".format(createdAt),
pubKey = author,
createdAt = createdAt,
kind = AppDefinitionEvent.KIND,
tags = arrayOf(arrayOf("d", dTag)),
content = "{}",
sig = "00".repeat(64),
)
private fun AddressableNote.load(createdAt: Long): Event = appDefinition(dTag(), createdAt).also { this.event = it }
private fun newFilter(
withFilter: Filter = filter,
sink: (List<Event>) -> Unit,
) = EventListMatchingFilter<Event>(
filter = withFilter,
atOnce = { TreeSet(CreatedAtIdHexComparator) },
update = sink,
)
@Test
fun newerVersionReflectsUpdatedEventWithoutDuplicate() {
var last: List<Event> = emptyList()
val subject = newFilter { last = it }
subject.init()
// A crowd of app definitions, so a stale skip-set node could be bypassed.
val target = noteFor("nostr-dvm-labeler")
val other = noteFor("other-app")
val top = noteFor("top-app")
val v1 = target.load(1000)
subject.new(v1, target)
subject.new(other.load(2000), other)
subject.new(top.load(4000), top)
// Newer version replaces the event on the SAME instance, created_at 1000 -> 3000.
val v2 = target.load(3000)
subject.new(v2, target)
// Exactly one entry for the target, and it is the NEW version (reflected + re-sorted).
assertEquals(3, last.size, "no duplicate event for the updated addressable")
assertEquals(1, last.count { it.id == v2.id }, "the updated addressable appears exactly once")
assertEquals(0, last.count { it.id == v1.id }, "the old version is gone")
}
@Test
fun listStaysSortedByCreatedAtDescending() {
var last: List<Event> = emptyList()
val subject = newFilter { last = it }
subject.init()
val a = noteFor("app-a")
val b = noteFor("app-b")
val c = noteFor("app-c")
val ea = a.load(2000)
subject.new(ea, a)
val eb = b.load(4000)
subject.new(eb, b)
val ec = c.load(1000)
subject.new(ec, c)
assertEquals(listOf(eb.id, ea.id, ec.id), last.map { it.id })
}
@Test
fun versionNoteReEmitsWhenAddressableIsListed() {
val emissions = mutableListOf<List<Event>>()
val subject = newFilter { emissions.add(it) }
subject.init()
val target = noteFor("nostr-dvm-labeler")
val event = target.load(1000)
subject.new(event, target)
val countAfterInsert = emissions.size
// The "version" note: a regular Note holding the addressable event.
val versionNote = Note(event.id).apply { this.event = event }
subject.new(event, versionNote)
// It re-emits (addressable is listed) but never adds a second entry.
assertEquals(countAfterInsert + 1, emissions.size, "version note triggers a re-emit")
assertEquals(listOf(event.id), emissions.last().map { it.id })
}
@Test
fun removeDropsTheEventEvenAfterCreatedAtChanged() {
var last: List<Event> = emptyList()
val subject = newFilter { last = it }
subject.init()
val target = noteFor("nostr-dvm-labeler")
subject.new(target.load(1000), target)
assertEquals(1, last.size)
target.load(2000) // sort key moves before the delete arrives
subject.remove(target)
assertEquals(0, last.size, "remove finds the event despite the created_at change")
}
@Test
fun concurrentUpdatesNeverEmitDuplicateEvents() {
assertNoDuplicateUnderConcurrency(filter)
}
@Test
fun concurrentUpdatesWithLimitNeverEmitDuplicateEvents() {
assertNoDuplicateUnderConcurrency(Filter(kinds = listOf(AppDefinitionEvent.KIND), limit = 5))
}
private fun assertNoDuplicateUnderConcurrency(withFilter: Filter) {
val firstViolation = AtomicReference<List<String>?>(null)
val subject =
newFilter(withFilter) { emitted ->
val ids = emitted.map { it.id }
if (ids.size != ids.toSet().size) {
firstViolation.compareAndSet(null, ids)
}
}
subject.init()
val addresses = (0 until 12).map { "app-$it" }
val notes = addresses.associateWith { noteFor(it) }
val threadCount = 8
val iterations = 5_000
val start = CountDownLatch(1)
val threads =
(0 until threadCount).map { t ->
thread {
start.await()
var seed = t * 31 + 7
repeat(iterations) { i ->
seed = seed * 1103515245 + 12345
val note = notes.getValue(addresses[(seed ushr 16) % addresses.size])
val event = note.load(1_000L + (i % 9))
if ((seed ushr 8) % 3 == 0) {
subject.remove(note)
} else {
subject.new(event, note)
}
}
}
}
start.countDown()
threads.forEach { it.join() }
assertNull(firstViolation.get(), "an emission carried a duplicate event id: ${firstViolation.get()}")
}
}
@@ -0,0 +1,227 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.model.observables
import com.vitorpamplona.amethyst.commons.model.AddressableNote
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
import com.vitorpamplona.quartz.utils.EventFactory
import java.util.TreeSet
import java.util.concurrent.CountDownLatch
import java.util.concurrent.atomic.AtomicReference
import kotlin.concurrent.thread
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class NoteListMatchingFilterTest {
private val author = "d0d0a746b44c9de8422165aef520b1fe041eedf5794f7592505477eeac122c18"
private val filter = Filter(kinds = listOf(AppDefinitionEvent.KIND))
// Amethyst reuses a single AddressableNote instance per address (LocalCache),
// so a newer replaceable event mutates createdAt on the SAME object.
private fun noteFor(dTag: String) = AddressableNote(Address(AppDefinitionEvent.KIND, author, dTag))
private fun appDefinition(
dTag: String,
createdAt: Long,
): Event =
EventFactory.create(
id = "%064x".format(createdAt),
pubKey = author,
createdAt = createdAt,
kind = AppDefinitionEvent.KIND,
tags = arrayOf(arrayOf("d", dTag)),
content = "{}",
sig = "00".repeat(64),
)
private fun AddressableNote.load(createdAt: Long) {
event = appDefinition(dTag(), createdAt)
}
private fun newFilter(
withFilter: Filter = filter,
sink: (List<Note>) -> Unit,
) = NoteListMatchingFilter(
filter = withFilter,
atOnce = { TreeSet(CreatedAtIdHexComparator) },
update = sink,
)
@Test
fun newerVersionOfAnAddressableDoesNotDuplicateTheKey() {
var last: List<Note> = emptyList()
val subject = newFilter { last = it }
subject.init()
// Three app definitions arrive. Their createdAt spread matters: after the
// target moves, the sorted set's search path for the new key must be able
// to bypass the stale node, which is what corrupts a createdAt-ordered set.
val target = noteFor("nostr-dvm-labeler")
val newer = noteFor("other-app")
val newest = noteFor("top-app")
target.load(1000)
subject.new(target.event!!, target)
newer.load(2000)
subject.new(newer.event!!, newer)
newest.load(4000)
subject.new(newest.event!!, newest)
assertEquals(3, last.size)
// A newer definition replaces the event on the SAME target instance,
// moving its createdAt from 1000 to 3000 (now between 2000 and 4000).
// LocalCache then notifies the observer again. This must NOT insert the
// note a second time.
target.load(3000)
subject.new(target.event!!, target)
assertEquals(
listOf(newest.idHex, newer.idHex, target.idHex).sorted(),
last.map { it.idHex }.sorted(),
"each addressable must appear exactly once",
)
assertEquals(last.size, last.map { it.idHex }.toSet().size, "no duplicate keys")
}
@Test
fun listStaysSortedByCreatedAtDescendingAsNotesArriveOutOfOrder() {
var last: List<Note> = emptyList()
val subject = newFilter { last = it }
subject.init()
val a = noteFor("app-a")
val b = noteFor("app-b")
val c = noteFor("app-c")
// Arrive out of order; the emitted list must always be newest-first.
a.load(2000)
subject.new(a.event!!, a)
b.load(4000)
subject.new(b.event!!, b)
c.load(1000)
subject.new(c.event!!, c)
assertEquals(listOf(b.idHex, a.idHex, c.idHex), last.map { it.idHex })
}
@Test
fun concurrentNewRemoveNeverEmitsDuplicateKeys() {
// No limit: exercises the compute/remove per-key critical sections.
assertNoDuplicateUnderConcurrency(filter)
}
@Test
fun concurrentNewRemoveWithLimitNeverEmitsDuplicateKeys() {
// With a limit: also exercises the cross-key eviction (pollLast + byId.remove).
assertNoDuplicateUnderConcurrency(Filter(kinds = listOf(AppDefinitionEvent.KIND), limit = 5))
}
private fun assertNoDuplicateUnderConcurrency(withFilter: Filter) {
// Observer callbacks fire from several consume threads at once (relay
// ingest + UI-side justConsume). new()/remove() for the same idHex must
// keep the sorted index and the membership map consistent, or a duplicate
// idHex leaks into an emission and crashes the LazyColumn.
val firstViolation = AtomicReference<List<String>?>(null)
val subject =
newFilter(withFilter) { emitted ->
val ids = emitted.map { it.idHex }
if (ids.size != ids.toSet().size) {
firstViolation.compareAndSet(null, ids)
}
}
subject.init()
val addresses = (0 until 12).map { "app-$it" }
val notes = addresses.associateWith { noteFor(it) }
val threadCount = 8
val iterations = 5_000
val start = CountDownLatch(1)
val threads =
(0 until threadCount).map { t ->
thread {
start.await()
var seed = t * 31 + 7
repeat(iterations) { i ->
seed = seed * 1103515245 + 12345
val note = notes.getValue(addresses[(seed ushr 16) % addresses.size])
// Move created_at around so the sort key keeps changing under the set.
note.event = appDefinition(note.dTag(), 1_000L + (i % 9))
if ((seed ushr 8) % 3 == 0) {
subject.remove(note)
} else {
subject.new(note.event!!, note)
}
}
}
}
start.countDown()
threads.forEach { it.join() }
assertNull(firstViolation.get(), "an emission carried a duplicate idHex: ${firstViolation.get()}")
}
@Test
fun ignoresTheVersionNoteThatHoldsAnAddressableEvent() {
// consumeBaseReplaceable also notifies observers with the "version" note:
// getOrCreateNote(event.id), a regular Note (not AddressableNote) carrying
// the AddressableEvent. It must never enter the addressable list.
var last: List<Note> = emptyList()
val subject = newFilter { last = it }
subject.init()
val event = appDefinition("nostr-dvm-labeler", 1000)
val versionNote = Note(event.id).apply { this.event = event }
subject.new(event, versionNote)
assertEquals(emptyList(), last)
// The addressable note for the same event, however, is listed.
val addressable = noteFor("nostr-dvm-labeler").apply { this.event = event }
subject.new(event, addressable)
assertEquals(listOf(addressable.idHex), last.map { it.idHex })
}
@Test
fun removeDropsTheNoteEvenAfterCreatedAtChanged() {
var last: List<Note> = emptyList()
val subject = newFilter { last = it }
subject.init()
val target = noteFor("nostr-dvm-labeler")
target.load(1000)
subject.new(target.event!!, target)
assertEquals(1, last.size)
// The createdAt sort key moves before the delete arrives.
target.load(2000)
subject.remove(target)
assertEquals(0, last.size, "remove must find the note despite the createdAt change")
}
}
@@ -78,6 +78,8 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.ProvideMaterialSymbols
import com.vitorpamplona.amethyst.commons.moderation.LocalHashtagSpamSettings
import com.vitorpamplona.amethyst.commons.moderation.LocalSpamExemptKeys
import com.vitorpamplona.amethyst.commons.moderation.PreferencesHashtagSpamSettings
import com.vitorpamplona.amethyst.commons.moderation.notifications.PreferencesNotificationReadState
import com.vitorpamplona.amethyst.commons.moderation.notifications.PreferencesNotificationSettings
import com.vitorpamplona.amethyst.commons.relayClient.auth.AuthApprovalBanner
import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.DmInboxRelayResolver
import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.unwrapAndUnsealOrNull
@@ -131,6 +133,9 @@ import com.vitorpamplona.amethyst.desktop.ui.deck.param
import com.vitorpamplona.amethyst.desktop.ui.media.LocalAwtWindow
import com.vitorpamplona.amethyst.desktop.ui.media.LocalIsImmersiveFullscreen
import com.vitorpamplona.amethyst.desktop.ui.media.LocalWindowState
import com.vitorpamplona.amethyst.desktop.ui.notifications.LocalNotificationDispatcher
import com.vitorpamplona.amethyst.desktop.ui.notifications.LocalNotificationReadState
import com.vitorpamplona.amethyst.desktop.ui.notifications.LocalNotificationSettings
import com.vitorpamplona.amethyst.desktop.ui.profile.ProfileInfoCard
import com.vitorpamplona.amethyst.desktop.ui.relay.LocalRelayCategories
import com.vitorpamplona.amethyst.desktop.ui.relay.RelayStatusCard
@@ -1262,10 +1267,25 @@ private fun AppInner(
// Auto-dispatcher: subscribes to newEventBundles and fires OS toasts.
// Only starts once the user is logged in — pubKey and settings must exist.
val loggedIn = accountState as? AccountState.LoggedIn
// Single, process-wide NotificationSettings instance. Hoisted here (not
// inside NotificationSettingsScreen / NotificationsScreen) so the
// settings UI, the inbox column's OS-toasts banner, and the auto-
// dispatcher all read + write the same MutableStateFlow. Without this,
// each screen fell back to its own PreferencesNotificationSettings()
// instance — the disk-backed prefs stayed in sync, but the in-memory
// StateFlow updates never crossed instances, so toggling the master
// switch or granting permission never actually notified the auto-
// dispatcher until the app restarted.
val notifSettings =
remember {
com.vitorpamplona.amethyst.commons.moderation.notifications
.PreferencesNotificationSettings()
remember { PreferencesNotificationSettings() }
// Per-account read-state for the notification inbox. Keyed on
// pubKey so switching accounts gets a fresh cursor. `remember` here
// is intentionally keyed on pubKeyHex; a null pubKey (Loading /
// LoggedOut branches never render the settings screen anyway, but
// guard against a stale ReadState leaking between accounts).
val notifReadState =
remember(loggedIn?.pubKeyHex) {
loggedIn?.pubKeyHex?.let { pk -> PreferencesNotificationReadState(pk) }
}
DisposableEffect(loggedIn?.pubKeyHex, notifDispatcher, localCache) {
val myPk = loggedIn?.pubKeyHex
@@ -1293,7 +1313,9 @@ private fun AppInner(
LocalScheduledPostStore provides scheduledPostStore,
com.vitorpamplona.amethyst.desktop.service.drafts.LocalNoteDraftStore provides noteDraftStore,
LocalHashtagSpamSettings provides hashtagSpamSettings,
com.vitorpamplona.amethyst.desktop.ui.notifications.LocalNotificationDispatcher provides notifDispatcher,
LocalNotificationDispatcher provides notifDispatcher,
LocalNotificationSettings provides notifSettings,
LocalNotificationReadState provides notifReadState,
) {
when (accountState) {
is AccountState.Loading -> {
@@ -1451,37 +1473,52 @@ private fun AppInner(
modifier = bannerModifier,
)
Box(modifier = Modifier.weight(1f)) {
MainContent(
layoutMode = layoutMode,
deckState = deckState,
workspaceManager = workspaceManager,
singlePaneState = singlePaneState,
pinnedNavBarState = pinnedNavBarState,
relayManager = relayManager,
localCache = localCache,
accountManager = accountManager,
account = account,
iAccount = iAccount,
accountRelays = accountRelays,
dmSendTracker = dmSendTracker,
nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator,
indexRelaysStore = indexRelaysStore,
nip11Fetcher = nip11Fetcher,
dmInboxResolver = dmInboxResolver,
appScope = scope,
torStatus = currentTorStatus,
onShowComposeDialog = onShowComposeDialog,
onShowReplyDialog = onShowReplyDialog,
onEditInComposer = onEditInComposer,
onShowAppDrawer = onShowAppDrawer,
onOpenFeedsDrawer = {
appDrawerInitialTab =
com.vitorpamplona.amethyst.desktop.ui.deck.AppDrawerTab.FEEDS
onShowAppDrawer()
},
onShowImportFollowListDialog = onShowImportFollowListDialog,
)
// Force a Compose subtree teardown when the active
// account changes. Without this, the currently-open
// column keeps its account-A `remember { ... }`
// state (LazyListState scroll position, expanded
// rows, filter-tab selection, in-flight metadata
// observers, per-column view-models) even though the
// outer `iAccount` / `accountRelays` swap correctly.
// Users saw account A's notifications / profile /
// messages page rendered under account B's identity
// until they navigated away and back. `key(pubKeyHex)`
// is the idiomatic Compose way to reset an entire
// subtree on identity change while keeping the outer
// deck layout / workspace state (declared above) alive.
androidx.compose.runtime.key(account.pubKeyHex) {
MainContent(
layoutMode = layoutMode,
deckState = deckState,
workspaceManager = workspaceManager,
singlePaneState = singlePaneState,
pinnedNavBarState = pinnedNavBarState,
relayManager = relayManager,
localCache = localCache,
accountManager = accountManager,
account = account,
iAccount = iAccount,
accountRelays = accountRelays,
dmSendTracker = dmSendTracker,
nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator,
indexRelaysStore = indexRelaysStore,
nip11Fetcher = nip11Fetcher,
dmInboxResolver = dmInboxResolver,
appScope = scope,
torStatus = currentTorStatus,
onShowComposeDialog = onShowComposeDialog,
onShowReplyDialog = onShowReplyDialog,
onEditInComposer = onEditInComposer,
onShowAppDrawer = onShowAppDrawer,
onOpenFeedsDrawer = {
appDrawerInitialTab =
com.vitorpamplona.amethyst.desktop.ui.deck.AppDrawerTab.FEEDS
onShowAppDrawer()
},
onShowImportFollowListDialog = onShowImportFollowListDialog,
)
}
}
}
@@ -183,9 +183,20 @@ fun NotificationSettingsScreen(onBack: (() -> Unit)? = null) {
} finally {
requestingPermission = false
}
// Match the button label: "Enable OS notifications"
// must actually enable them end-to-end. Prior to this,
// clicking through the OS prompt granted permission
// but left the master toggle OFF, so the auto-
// dispatcher stayed muted and the user had to hunt
// for the switch above. Flip it here on grant — the
// switch UI observes settings.enabled and
// recomposes automatically.
if (newState == PermissionState.Granted && !enabled) {
settings.setEnabled(true)
}
testStatus =
when (newState) {
PermissionState.Granted -> "Permission granted. Try the test toast below."
PermissionState.Granted -> "Permission granted. Notifications are on — try the test toast below."
PermissionState.Denied -> "Permission denied. Enable in System Settings if you change your mind."
PermissionState.BundleRequired -> "Notifications need a bundled app — run `./gradlew :desktopApp:runDistributable`."
else -> null
+1
View File
@@ -2,6 +2,7 @@
Release notes for Amethyst, one file per version. Files are named with zero-padded version numbers so they sort correctly in any file browser. Use [`TEMPLATE.md`](TEMPLATE.md) as the starting point for the next release.
- [v1.13.1 — Follow-up Fixes for Buzz, Concord and the Keyboard](v1.13.01.md)
- [v1.13.0 — Web Apps, Communities & Git](v1.13.00.md)
- [v1.12.6 — Napplets, Static Sites & Cashu Multi-Mint](v1.12.06.md)
- [v1.12.5 — macOS Desktop Signing Fix (cont.)](v1.12.05.md)
+42 -103
View File
@@ -75,112 +75,11 @@
"rchk": ""
},
"sinceLastTag": {
"tag": "v1.12.6",
"since": "2026-06-19T19:01:59-04:00",
"tag": "v1.13.0",
"since": "2026-07-28T10:52:38-04:00",
"translators": [
{
"user": "vitorpamplona",
"languages": [
"Arabic, Saudi Arabia",
"Bengali",
"Chinese Simplified",
"Chinese Simplified, Singapore",
"Chinese Traditional",
"Chinese Traditional, Hong Kong",
"Czech",
"Dutch",
"Esperanto",
"Finnish",
"French",
"French, Canada",
"German",
"Greek",
"Hindi",
"Hungarian",
"Indonesian",
"Italian",
"Japanese",
"Korean",
"Latvian",
"Persian",
"Polish",
"Portuguese",
"Portuguese, Brazilian",
"Russian",
"Rսssian, Սkraine",
"Serbian (Cyrillic)",
"Slovenian",
"Spanish",
"Spanish, Mexico",
"Spanish, United States",
"Swahili, Kenya",
"Swedish",
"Tamil",
"Thai",
"Turkish",
"Ukrainian",
"Uzbek",
"Vietnamese"
]
},
{
"user": "rajs19420616",
"languages": [
"Hindi"
]
},
{
"user": "StellarStoic",
"languages": [
"Slovenian"
]
},
{
"user": "maxblake2015",
"languages": [
"Polish"
]
},
{
"user": "hypnotichemionus4",
"languages": [
"Chinese Simplified"
]
},
{
"user": "summoner001",
"languages": [
"Hungarian"
]
},
{
"user": "anthony-robin",
"languages": [
"French"
]
},
{
"user": "crowdin.pretended462",
"languages": [
"German"
]
},
{
"user": "Bardesss",
"languages": [
"Dutch"
]
},
{
"user": "BitByBit21",
"languages": [
"Spanish",
"Spanish, Mexico",
"Spanish, United States"
]
},
{
"user": "davotoula",
"languages": [
"Czech",
"German",
@@ -220,6 +119,14 @@
"user": "vazw",
"languages": []
},
{
"user": "Bardesss",
"languages": []
},
{
"user": "anthony-robin",
"languages": []
},
{
"user": "Pextar",
"languages": []
@@ -252,10 +159,18 @@
"user": "adhrasreoshiathoi",
"languages": []
},
{
"user": "davotoula",
"languages": []
},
{
"user": "crackadoo",
"languages": []
},
{
"user": "BitByBit21",
"languages": []
},
{
"user": "csavastel",
"languages": []
@@ -312,6 +227,10 @@
"user": "flobstr",
"languages": []
},
{
"user": "rajs19420616",
"languages": []
},
{
"user": "Maxblake",
"languages": []
@@ -328,10 +247,18 @@
"user": "hlcbump",
"languages": []
},
{
"user": "StellarStoic",
"languages": []
},
{
"user": "D4rkFIow",
"languages": []
},
{
"user": "summoner001",
"languages": []
},
{
"user": "fiddleway",
"languages": []
@@ -348,6 +275,14 @@
"user": "Coool",
"languages": []
},
{
"user": "hypnotichemionus4",
"languages": []
},
{
"user": "maxblake2015",
"languages": []
},
{
"user": "eiie7",
"languages": []
@@ -368,6 +303,10 @@
"user": "Kevin_1",
"languages": []
},
{
"user": "crowdin.pretended462",
"languages": []
},
{
"user": "rchk",
"languages": []
+80
View File
@@ -0,0 +1,80 @@
# v1.13.1: Follow-up Fixes for Buzz, Concord and the Keyboard
Highlights:
- Fixes the soft keyboard getting stuck open in chats.
- Fixes duplicate items crashing the Discover and feed lists.
- Adds per-event-kind toggles to the Home feed.
- Signs Blossom read requests so media on auth-gated hosts loads.
## New Features
- Adds per-event-kind toggles for the Home feed.
- Signs Blossom read-auth so media hosted on auth-gated servers displays.
- Lets admins delete a Buzz channel or relay group.
- Honors CORD-02 §9: seals dissolved Concord communities read-only.
- Allows single-character (continent) geohash precision in location channels.
## Performance
- Preemptively signs Blossom reads for known auth-gated hosts, so the first
request succeeds instead of round-tripping through a 401.
- Makes the `observeNotes` dedup lock-free and race-safe under concurrent
consumption.
## Improvements and Bug fixes
- Stops the soft keyboard state from getting stuck open.
- Stops nav-bar padding from stacking on top of the IME inset in chats.
- Unions nav-bar and IME insets on the two remaining bare-Scaffold forms.
- Prevents a duplicate LazyColumn key from `observeNotes` on addressable
updates, and keeps the list sorted while deduping by id.
- Dedupes Discover apps by coordinate to avoid duplicate LazyGrid keys.
- Dedups `EventListMatchingFilter` (`observeEvents`) and hardens its emission
contract.
- Authenticates to the NIP-29 host relays of joined groups so private group
content loads.
- Keeps the back arrow visible until the exiting screen finishes leaving.
- Bounds the context shown around a highlight to a window, trims edge blank
lines, and collapses scraped whitespace.
- Attributes a highlight to the author-marked `p` tag and drops alt captions.
- Renders custom emoji in highlight comments.
- Only tags explicit http(s) URLs as `r` references.
- Opens a Concord community by tapping its name, not just its avatar.
- Leaves room for the FAB in the Concord community channel list.
- Makes the Buzz Messages toggle read the list it claims to change.
- Renders the Buzz channel recent-poster facepile with the standard avatar.
- Separates Buzz community rows with a hairline and drops the grey slab behind
the Channels section.
- Aligns leave vs remove-from-messages actions across chat types.
- Normalizes top nav bar title font sizes and weights.
- Raises the background mobile-data trigger to 500 MB and doubles the
report-prompt thresholds.
- Removes orphaned `buzz_dm_hide` translations.
## Desktop
- Caches the OS Keyring handle so startup only prompts once.
- Makes the "Enable OS notifications" button actually enable them.
- Reloads the visible page when switching accounts.
## Quartz
- Stops Namecoin lookup exceptions from leaking through `resolve()`.
- Uses `Hex.isHex64` instead of a regex for the blob-id check.
## Build & Documentation
- Resolves the `LocalCache` override, dropdown deprecation, and shadowed
extension warnings.
- Extracts duplicated string literals.
## Contributors
- @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z
- @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef
- mstrofnone
## Translations
- Czech, German, Brazilian Portuguese, and Swedish by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef
+3 -3
View File
@@ -32,7 +32,7 @@ docker run -d --name geode -p 7447:7447 \
```
with `in_memory = false` and `file = "/var/lib/geode/events.db"` in your
`geode.toml`. Pin a version (`:1.13.0`) instead of `:latest` for reproducible
`geode.toml`. Pin a version (`:1.13.1`) instead of `:latest` for reproducible
deploys. To build the image yourself, from the repo root:
```bash
@@ -47,7 +47,7 @@ it — a minimal JRE is bundled, so no system Java is required. It installs to
`/opt/geode/` with the launcher at `/opt/geode/bin/geode`.
```bash
sudo dpkg -i geode-1.13.0-linux-x64.deb # or: sudo rpm -i geode-1.13.0-linux-x64.rpm
sudo dpkg -i geode-1.13.1-linux-x64.deb # or: sudo rpm -i geode-1.13.1-linux-x64.rpm
```
To run it as a managed service, wire up the shipped systemd unit
@@ -69,7 +69,7 @@ formula: [`packaging/homebrew/geode.rb`](packaging/homebrew/geode.rb).
Download `geode-<version>-<os>-<arch>.tar.gz`, unpack, and run:
```bash
tar xzf geode-1.13.0-linux-x64.tar.gz
tar xzf geode-1.13.1-linux-x64.tar.gz
./geode/bin/geode --config geode/share/geode/config.example.toml
```
+2 -2
View File
@@ -2,8 +2,8 @@
# Amethyst app version — single source of truth consumed by both Android (amethyst/)
# and Desktop (desktopApp/). `appCode` is the Android versionCode: a monotonic
# integer that must increment on every release, even when `app` is unchanged.
app = "1.13.0"
appCode = "455"
app = "1.13.1"
appCode = "456"
accompanistAdaptive = "0.37.3"
cachemapVersion = "0.2.4"
composeMultiplatform = "1.11.1"
@@ -21,7 +21,6 @@
package com.vitorpamplona.quartz.buzz.workspace
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.firstTagValue
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
@@ -38,9 +37,6 @@ import com.vitorpamplona.quartz.utils.RandomInstance
* (emit_group_discovery_events).
*/
/** The Buzz channel type from the relay's `t` tag ("stream" / "forum" / "dm"), or null. */
fun GroupMetadataEvent.buzzChannelType(): String? = tags.firstTagValue("t")
/** True when the relay marks this channel a DM (`t` = "dm"). */
fun GroupMetadataEvent.isBuzzDm(): Boolean = buzzChannelType() == BUZZ_CHANNEL_TYPE_DM
@@ -21,8 +21,13 @@
package com.vitorpamplona.quartz.experimental.bitchat.geohash
/**
* The named geohash precision levels Bitchat exposes as location channels, each a
* fixed geohash character length. Coarser (fewer chars) = larger area.
* The named geohash precision levels exposed as location channels, each a fixed
* geohash character length. Coarser (fewer chars) = larger area.
*
* [REGION][BUILDING] (28 chars) mirror the levels Bitchat exposes, so those cells
* interoperate with Bitchat peers. [CONTINENT] (1 char) is an Amethyst extension: a
* single character splits the globe into 32 ~5000 km cells, coarser than any Bitchat
* channel, for a whole-continent room.
*
* Because a geohash is a prefix code, the channel for any level is just the first
* [chars] characters of a finer cell so one precise fix yields every level via
@@ -31,6 +36,7 @@ package com.vitorpamplona.quartz.experimental.bitchat.geohash
enum class GeohashChannelLevel(
val chars: Int,
) {
CONTINENT(1),
REGION(2),
PROVINCE(4),
CITY(5),
@@ -47,7 +53,7 @@ enum class GeohashChannelLevel(
companion object {
/** Coarsest → finest, the order a location-channel picker should list them. */
val ordered = listOf(REGION, PROVINCE, CITY, NEIGHBORHOOD, BLOCK, BUILDING)
val ordered = listOf(CONTINENT, REGION, PROVINCE, CITY, NEIGHBORHOOD, BLOCK, BUILDING)
/** The named level whose precision matches [chars], if any. */
fun forChars(chars: Int): GeohashChannelLevel? = entries.firstOrNull { it.chars == chars }
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
@@ -222,7 +223,27 @@ class NamecoinNameResolver(
// ── Lookup & Value Parsing ─────────────────────────────────────────
private suspend fun performLookup(parsed: ParsedIdentifier): NamecoinNostrResult? {
val nameResult = electrumxClient.nameShowWithFallback(parsed.namecoinName, serverListProvider()) ?: return null
// nameShowWithFallback never returns null in practice — every failure
// path throws a NamecoinLookupException subtype (NameNotFound,
// NameExpired, ServersUnreachable). If we don't catch them here, they
// propagate out through resolve() and up to Nip05State.checkAndUpdate,
// which lumps every lookup failure into markAsError() → red icon with
// no way for the user to tell "servers unreachable" from "wrong pubkey".
//
// resolve() is documented as returning null on any failure. Preserve
// that contract by mapping expected lookup exceptions to null. Callers
// that want the specific failure reason use resolveDetailed() instead.
val nameResult =
try {
electrumxClient.nameShowWithFallback(parsed.namecoinName, serverListProvider())
?: return null
} catch (e: CancellationException) {
throw e
} catch (e: NamecoinLookupException) {
// NameNotFound / NameExpired / ServersUnreachable → null per
// resolve()'s contract. resolveDetailed() surfaces which one.
return null
}
val valueJson = tryParseJson(nameResult.value) ?: return null
val merged = expandImportsIfPresent(valueJson)
@@ -296,7 +317,7 @@ class NamecoinNameResolver(
return NamecoinImportResolver.expandImports(root) { name ->
try {
electrumxClient.nameShowWithFallback(name, serverListProvider())?.value
} catch (e: kotlinx.coroutines.CancellationException) {
} catch (e: CancellationException) {
throw e
} catch (e: NamecoinLookupException) {
// Best-effort: missing/expired/unreachable → contribute nothing.

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