18136 Commits
Author SHA1 Message Date
Claude 55cc4ce9dc feat(highlights): highlight-a-note menu action; drop composer preview
- Add a "Highlight" action to the note action menu (3-dot menu + chat long-press
  sheet). It opens the NIP-84 composer pre-tagged with the note as source — an
  `a` reference for an addressable article, else an `e` reference, plus the
  author `p` — with the passage left for the user to type/paste. Prose kinds
  only (text notes, long-form), and never a private rumor (a public highlight
  would leak an e-tag of the unsigned rumor). This is the in-app entry point for
  "post with the source as an event".
- Remove the live preview from the New Highlight screen per review.
- Add two real-world regression tests for the shared-highlight parser: Chrome
  "copy link to highlight" with a prefix/suffix fragment (incl. an encoded
  hyphen inside the prefix) and a long start-only fragment with encoded commas.

Verified with :quartz:jvmTest and :amethyst:compileFdroidDebugKotlin.
2026-07-29 02:50:53 +00:00
Claude 155ef8e46c feat(highlights): richer composer UI and full NIP-84 source coverage
Redesign the New Highlight composer as a pull-quote you craft: a rounded tonal
hero card with a quotation-mark watermark, a highlighter-amber accent bar, and
the passage in large type, plus a live highlighter-pen preview (reusing the feed's
HighlightedQuote) and icon-led source/note fields.

Also extend HighlightEvent.create()/build() to emit a/e/p tags, so the builder
now covers every NIP-84 source — a web page (r), a nostr article (a), a nostr
note (e), each with optional author attribution (p) and context — not just web
highlights. Route.NewHighlight and the composer ViewModel carry the nostr source
(address/event/author) and context through so a future in-app "highlight this
passage" action can open the composer for a nostr article/note.

Covered by a new builder test for the a/e/p tags; verified with :quartz:jvmTest
and :amethyst:compileFdroidDebugKotlin.
2026-07-29 02:27:49 +00:00
Vitor PamplonaandClaude 9189955155 fix(quartz): drop the paren the URL trim orphans in the passage
`trimUrlEnd` strips a wrapping `)` off the URL token, but the `(` it opened
stayed at the tail of the passage, so sharing
`See this quote (https://example.com/article)` published a highlight whose
content read `See this quote (`.

Trim an unbalanced bracket left at the end of the passage after the URL token
is removed. Only an unbalanced one at the very end goes, so `He said (see
below) https://…` keeps its matched pair, and `.../Mercury_(planet)` — where
nothing was trimmed off the URL — is untouched on both sides.

The existing paren tests only asserted `result.url`, which is why this slipped
through; the new cases assert `result.quote` too, including a regression guard
for the slug-paren direction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 02:22:07 +00:00
Claude 5b07817cf8 feat(android): Highlights feed with top-nav filters and add button
Adds a browsable Highlights feed (NIP-84 kind:9802) modeled file-for-file on
the Git Repositories feed pattern, since highlights are announcement-like
regular events with the same follow-list top-nav filtering.

New feed stack under ui/screen/loggedIn/highlights:
- dal/HighlightsFeedFilter — AdditiveFeedFilter scanning LocalCache.notes for
  kind 9802 (regular events, so notes rather than addressables), gated by the
  selected top-nav follow list via FilterByListParams.
- datasource/ assembler + compose subscription + PerUserAndFollowList sub-
  assembler + makeHighlightsFilter dispatcher, with per-relay subassemblies for
  follows / authors / muted / global / hashtag / geohash. Communities aren't a
  highlight concept, so those sets fall through and aren't offered.
- HighlightsScreen (DisappearingScaffold + shared RenderFeedContentState; the
  existing RenderHighlight card handles rendering) with a HighlightsTopBar
  FeedFilterSpinner and a NewHighlightButton FAB into Route.NewHighlight.

Wiring into the shared plumbing mirrors gitRepositories exactly: Route.Highlights
+ AppNavigation, NavBarItem enum/catalog/drawer lists (FormatQuote icon, already
in the subset font), AccountFeedContentStates (feed state + update/delete/trim),
RelaySubscriptionsCoordinator, TopNavFilterState.highlightsRoutes,
AccountSettings.defaultHighlightsFollowList + changer, Account live follow-list
flows, LocalPreferences persistence, ScrollStateKeys, and the bottom-bar
preloader. Verified with :amethyst:compileFdroidDebugKotlin.
2026-07-29 02:22:07 +00:00
Claude 927cc9449c fix(quartz): keep balanced trailing parens in shared highlight URLs
The URL trailing-punctuation trim now leaves a closing bracket in place when
it is balanced within the token, so a shared Wikipedia link like
`.../wiki/Mercury_(planet)` keeps its `)` while a wrapping `(https://…)` still
loses the paren the surrounding prose added.
2026-07-29 02:22:06 +00:00
Claude 1f35339866 feat(android): share browser selection to a NIP-84 highlight composer
Adds a "New Highlight" share target and composer so a user can select text in
a browser, hit Share → Amethyst, and publish it as a kind:9802 highlight.

- Manifest: a text/plain-only ShareAsHighlightAlias activity-alias, matched at
  runtime by ShareIntentRouting.isShareAsHighlight (mirrors the Send-as-DM
  alias pattern).
- AppNavigation: both the launch-intent and warm onNewIntent parse blocks now
  branch on the highlight alias, run the shared text through
  SharedHighlightParser, and open Route.NewHighlight pre-split into
  quote/url/prefix/suffix.
- NewHighlightScreen + NewHighlightPostViewModel: a trimmed composer (passage,
  source URL, optional note — no polls/zaps/media/scheduling) that publishes via
  account.signAndComputeBroadcast(HighlightEvent.build(...)).

Also adds HighlightEvent.build(), the unsigned EventTemplate counterpart of
create(), for the sign-and-broadcast pipeline. Verified with :quartz:jvmTest
and :amethyst:compileFdroidDebugKotlin.
2026-07-29 02:22:06 +00:00
Claude 69c3e3accc feat(quartz): parse browser shares into NIP-84 highlights
Adds a shared-highlight parsing layer under nip84Highlights/parse that turns
the free-form text a browser hands Amethyst on "Share selection" into the
pieces of a kind:9802 highlight:

- SharedHighlightParser normalises selection-only, selection+URL, URL-only and
  "link to highlight" (#:~:text= fragment) shares into a SharedHighlight.
- TextFragmentParser decodes/strips WICG text-fragment directives (prefix,
  start, end, suffix), leaving literal '+' verbatim.
- UrlTrackerCleaner strips utm_*/fbclid/etc. from the source URL per NIP-84's
  "clean the URL from trackers" guidance, preserving path and fragment.

Also adds a HighlightEvent.create() builder overload that assembles the r,
textquoteselector, context and comment tags from parsed data, centralising
what the desktop publish action does by hand.

Covered by commonTest suites for each piece plus a builder round-trip.
2026-07-29 02:22:06 +00: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