- 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.
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.
`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>
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.
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.
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.
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.
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
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
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
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
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>
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>
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
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
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
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
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>
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>
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>
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>
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>
`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>
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>
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>
`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>
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
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>
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
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
"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>
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
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
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