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>
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>
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
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
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
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
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
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
"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
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
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
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
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
Sweep of every imePadding() in the app for the same nav-bar-over-IME
double-count fixed for the chats. Two more bare Material3 Scaffold screens
applied the scaffold content padding (which carries the nav-bar inset) and
then imePadding() without consuming in between, so the nav bar stacked on top
of the IME while the keyboard was up:
- NewGeohashChatScreen (geohash create form)
- MarmotGroupInfoScreen (has the add-member search field)
Both now consumeWindowInsets(pad) before imePadding(), matching the idiom the
other forms already use. Every other imePadding() call was verified fine:
DisappearingScaffold-based screens are handled by the scaffold's own
reservation, dialogs/bottom sheets carry no nav-bar content padding, and the
rest either apply imePadding() alone at a root or already use the
navigationBarsPadding()/consumeWindowInsets union.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012dT2RLbPZzan2hULL2cmc6
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
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
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