Three findings from auditing the previous commit.
observeChannel registered a ChannelFinder query per invite row that could
never produce a filter: every assembler under ChannelFinderFilterAssemblyGroup
is gated on `is PublicChatChannel` / `is LiveActivitiesChannel`, so a
RelayGroupChannel contributes nothing and the registration only churned the
app-wide key set (an allKeys() Set copy per bundled invalidation) on every
mount and unmount. Collect the channel's metadata stateFlow directly instead —
same live name updates when the group's kind-39000 lands, none of the churn.
TimeAgo used the default Dotted style inside a row that already spaces its
children, so the variant's own leading " • " doubled the gap. DottedTight is
what the note header uses in exactly this position.
Key each row by channel id. The list is sorted newest-first, so an arriving
invite shifts every row below it; without a key Compose matches children by
call-site position and each shifted row recomposed against a different invite,
re-resolving the actor and reloading their avatar. Also remember the row
modifier rather than rebuilding the chain on every recomposition.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGB1z4VcNfDw5cMt9V5NCG
The invite prompt was a floating Material card with a surfaceVariant fill,
sitting above the Notifications feed and Messages > New Requests as its own
visual language — nothing else on either surface looks like that.
Render it through NoteComposeLayout instead, the same layout every note and
notification card uses: the actor becomes the row's author (picture, name and
time in the usual header), "Added to <channel>" plus the host relay become the
row's content, and the three choices take the reactions slot. So the prompt now
reads like the reply and mention notifications it sits next to, and a divider
closes each row the way the feed does.
The channel name is now read through observeChannel, the metadata-only observer
the channel rows use, so a stranger's add resolves to a name instead of a raw
group id. It does not open the channel's message subscription — holding that
back until the viewer answers is the whole point of the prompt.
Also relabel "Show" to "Add to Messages". acceptChannelInvite is defined as
`= addRelayGroupToMessages(channel)`, literally the call behind the channel top
bar's "Add to Messages" item, so one action had two words for it. Reusing the
existing string drops channel_invite_accept and channel_invite_body (the actor
and the question both live in the row now).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGB1z4VcNfDw5cMt9V5NCG
Flipping either local-cache setting called probe.invalidate(), which only
clears the TTL timestamp — it never re-runs the probe. Nothing else does
either: isAvailable() is reached only from the one-shot startup warm and
from inside findServers().
That leaves the feature dormant when enabled mid-session. The feed image
path gates on `available.value` (AccountViewModel.useLocalBlossomBridge
and shouldBridgeBlossomCache), so while the probe reads false no request
ever routes through the resolver, and the resolver is the only thing that
would have refreshed the probe. The settings "detected" chip reads the
same flow, so it also stays stale.
Re-probe right after invalidating so enabling the toggle activates the
feature — and reports its true state — in the same session.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PCgrM83QetNPJTg3Z5VWnF
BlossomServerResolver.findServers and LocalBlossomCacheProbe.probe are
suspend functions that don't confine to Dispatchers.IO. They're reached
from Compose produceState/LaunchedEffect (RichTextViewer,
MarmotGroupIconDisplay, ZoomableContentView), which run on the main
dispatcher, so the pre-suspension work — BlossomUri regex parsing,
LruCache lookups, the probe's OkHttpClient build on TTL expiry, and the
server-list flow setup — executed on the UI thread on every uncached
resolution during feed scroll.
Wrap findServersInner and the probe's client-build + HEAD in
withContext(Dispatchers.IO) so that work stays off the main thread.
Behaviour is unchanged; callers already on IO (Coil BlossomFetcher,
PlaybackService) are unaffected.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PCgrM83QetNPJTg3Z5VWnF
The leftover inner "a" ring sat in front of the ostrich, making the logo
look like it was behind the circle. Drop the inner-a segments from the @
outline, leaving just the outer ring + tail, and size the ostrich so it
reads clearly as the "a".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
Route the Concord community screen and hub through ShorterTopAppBar (50dp)
instead of the raw Material3 TopAppBar (64dp default), so the top bar — and
its 3-dot overflow menu — sits at the same height as the Buzz/relay-group
community screen, which already uses the shorter bar.
Also drop the recent-posters facepile from the Concord and Buzz channel
rows, and move the last-message time up to the first line (next to the
channel name), leaving the unread-message badge on the second line.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017x22okBh6HbiCrJN3zf5xn
RenderNoteRow matched the four concrete video subtypes (VideoNormalEvent 21,
VideoShortEvent 22, VideoHorizontalEvent 34235, VideoVerticalEvent 34236) with
four identical branches. Collapse them into a single `is VideoEvent` branch,
matching how ThreadFeedView's NoteMaster already dispatches, so any future
VideoEvent subtype renders through VideoDisplay automatically instead of
falling through to the text fallback.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QCKP4tD2seoDpr3ZcdPJNx
The create screen no longer shows a "Forum channel" toggle — the type is decided
by the community's per-section "+" (Channels vs Forums) and a Buzz channel's
channel_type isn't editable anyway (the relay's 9002 has no such key). The screen
loads with the fixed isForum parameter and creates the right type; its top-bar
title reads "New forum" when isForum, "New channel" otherwise (on Buzz).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016MNVEKhaAu4vQRZnXv3rfG
Tapping a note in the thread view toggles the collapse/expand state of
its children. For the user's own drafts, tap now opens the edit-draft
screen (via routeEditDraftTo) so the post can be resumed, matching the
behavior everywhere else a draft is tapped.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQCNuxdYkfouNN3ujxm3mR
A channel deleted elsewhere (or before this device saw its metadata) keeps its
kind-44100 membership — the relay never retracts it — but the relay soft-deletes
its 39000, and it does NOT serve a channel_deleted 40099 for an already-deleted
channel, so the discovery fetch can't catch it. Such a channel arrived with no
metadata and was shown optimistically: a bare UUID row with "No messages yet".
The reliable signal is that very metadata absence. BuzzRelayImportViewModel.discover
now drops channels the relay serves no 39000 for — but only when the fetch clearly
worked (some channel returned a 39000); if none did, the read failed (auth/
connectivity) and the whole set is kept rather than blanking the community. A
genuinely new channel whose 39000 is merely slow returns on the next bind once its
metadata is cached, so the prune is live (not persisted) and self-correcting.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016MNVEKhaAu4vQRZnXv3rfG
The "+" was gated on BuzzCommunityMembership (kind-13534), but that roster is only
fetched by the Members screen — nothing on the channel-list screen subscribes to
it, so isMember read false and the "+" hid even from admins. Match the workspace
overflow menu's approach instead: offer create on any Buzz community and let the
relay reject a non-member's kind-9007 (its own doc: "any member sees them, the
relay only serves the owner/admin ones").
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016MNVEKhaAu4vQRZnXv3rfG
- Mention: gem centered like the "a" with a near-complete circle around it,
opened at the lower-right with a small tail — a proper @ shape, ring no
longer touching the gem.
- Chess: enlarge the gem 2%.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
- Code: widen the < > chevrons (0.62 -> 0.85) and shrink the gem (12 -> 10)
for a more even balance.
- Chess: enlarge the Amethyst gem so it rides over and blends into the
bottom of the knight.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
- Mention: thinner ring whose open lower-left end flows into the gem's
bottom-left facet via a tapered stroke, echoing how an @ is drawn.
- Zap: enlarge the bolt and shrink the top-right gem.
- Code: squeeze the < > brackets narrower (width only, height kept) so they
hug the centered gem.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
Two channel-management gaps vs the Buzz interface (both ride kind-9002 tags; no
new protocol), verified against block/buzz:
Archive/Unarchive — the reversible hide-from-the-sidebar the Buzz client has,
distinct from delete. EditMetadataEvent gains an `archived` tag; Account/
AccountViewModel expose archiveRelayGroup; the channel and forum top bars offer
Archive/Unarchive to admins (no confirm — it's reversible). The relay stamps
`["archived","true"]` on the 39000, so GroupMetadataEvent.isArchived() /
RelayGroupChannel.isArchived() read it directly; the community list drops archived
channels out of Channels/Forums into a collapsed "Archived" tail from which they
can be reopened and unarchived.
Visibility-on-edit — a Buzz relay reads its own `visibility` (open/private) tag,
not the NIP-29 `private` status flag, so the edit screen's private toggle was a
silent no-op on Buzz. editRelayGroupMetadata now sends the `visibility` tag on
Buzz relays (status flag still sent for vanilla NIP-29).
Not gaps (checked): topic/purpose/TTL are in the relay's system-message
vocabulary but not extracted on 9002/9007, so there's nothing to mirror.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016MNVEKhaAu4vQRZnXv3rfG
drainPeerInitiatedUniStreamsIntoBlackHole_keeps_connection_alive passed
in isolation but flaked under parallel load with the audit-4 #3
"slow consumer overflowed incoming channel" teardown.
Root cause: the test ran the black-hole drainer on the shared
Dispatchers.Default pool. The drainer only has to keep the 64-deep
per-stream incomingChannel drained, but when the rest of the JVM test
suite saturates every Default worker thread the drainer coroutine can be
denied a scheduling slot long enough for the synchronous feed loop to
push more than 64 chunks ahead of it, tripping the slow-consumer
teardown. The fixed-delay yields (delay(1) every 16 chunks) do not help
when all pool threads are pinned.
Fix: run the drainer on the runBlocking coroutine's own single-threaded
event loop (CoroutineScope(coroutineContext + SupervisorJob())) and pace
the feed loop with cooperative yield() instead of wall-clock delays. Each
yield deterministically runs the drainer's queued channel-receive
continuation before the producer resumes, so the buffer never approaches
its bound regardless of machine load. The drain contract under test
(parser trySend -> incomingChannel -> collect -> discard) is exercised
identically; only the scheduling is pinned.
Verified with a temporary reproduction that pins every Default worker
thread: the old Default-scheduled drainer overflows to CLOSED while the
confined drainer stays CONNECTED under the same saturation.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGZt6D298kGv2XgAi3Uuaj
Two ways the bubble could get stuck shifted sideways (dragOffset frozen at a
non-zero value, reply icon left visible), both fixed:
1. Cancelling the settle-back animation on awaitFirstDown — which fires for
every touch, including one that turns out to be a vertical scroll — while
settleBack() only ran on the horizontal-drag path. A scroll started during
an in-flight settle froze dragOffset. Now the settle is cancelled only once
a horizontal drag is actually committed, so every cancel is paired with a
settleBack().
2. Keying pointerInput on the onSwipeReply lambda. The caller allocates a fresh
lambda every recomposition (it captures the note), so the detector was torn
down and restarted on every row update. A restart mid-drag cancelled
horizontalDrag before settleBack() ran, stranding the bubble; idle restarts
also churned the gesture coroutine across all visible bubbles. Now keyed on
the stable isLoggedInUser, with the callback read through rememberUpdatedState
and the drag wrapped in try/finally so an interrupted drag always settles.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016f7dTu8RoJRXjnHM7FKAE2
- Reply: keep the arrow at its size but move it to the bottom-left and put
the Amethyst gem in the top-right corner.
- Zap: scale the bolt up to fill the canvas and knock the gem out of its
center (inverted), so the logo reads in front of the bolt.
- Reaction: grow the heart's knocked-out gem ~10% and nudge it toward the
bottom of the heart.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
The community list rows (channels, forums, DMs) each carried a 3-dot overflow —
Pin/Unpin and Add/Remove-from-Messages. Those are gone; each row is now a clean
tap-to-open target and its actions live in the top-bar overflow of the screen it
opens:
- Channels & DMs open RelayGroupChatScreen (RelayGroupTopBar): add Pin/Unpin
(Buzz, non-DM) alongside the existing Messages toggle. A DM has no kind-10009
entry, so its Messages toggle drives the DM-specific hide/unhide (kind-41012 /
re-open) read from the per-viewer 30622 snapshot; the DM overflow is always
shown so that action stays reachable.
- Forums open RelayGroupThreadsScreen: give its top bar an overflow with Pin/Unpin
and Add/Remove-from-Messages.
Shared BuzzPinDropdownItem / RelayGroupMessagesDropdownItem keep the two bars
consistent. AccountViewModel gains hideBuzzDm/unhideBuzzDm wrappers. The
community-list top-bar overflow (Add people, Invite, Agent Console, Add all) is
unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016MNVEKhaAu4vQRZnXv3rfG
The reply icon's fade/scale read dragOffset in composition, and because Box is
an inline composable that read invalidated the whole ChatBubbleLayout on every
drag and settle frame. Gate the icon on a derivedStateOf boolean that flips only
at the 0<->non-0 edges (icon added/removed twice per gesture) and move the
progress math into the icon's graphicsLayer block so it's read in the layer
phase. The bubble translation was already deferred; now the entire swipe and
settle animate with zero recomposition.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016f7dTu8RoJRXjnHM7FKAE2
Only a Buzz community member may create a channel (the relay makes the creator
its owner; a non-member's kind-9007 is rejected), so the section-label "+" now
shows only when the viewer is in the community's NIP-43 roster. Reactive: the
relay-signed roster snapshot (kind 13534, republished on every membership change)
can arrive after first composition, so BuzzCommunityMembership is re-read whenever
one lands. The section labels themselves still render for everyone; only the "+"
is gated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016MNVEKhaAu4vQRZnXv3rfG
- Repost: scale the repeat-arrow glyph up ~15% so it fills the canvas like
the original, with the centered gem enlarged to match.
- Badge: replace the medal ring + inner-circle-with-logo with a solid disc
that has the Amethyst gem knocked straight out of it (inverted), sized to
fill the disc. Ribbon tails kept.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
Bumps the gem so the brand mark reads bigger inside each glyph: the heart
knockout (6.7 -> 8.2), the repost loop center (5.4 -> 6.9) and between the
code brackets (6.2 -> 7.6).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
The inverted DM bubble read smaller than the original because its body
stopped short and the tail was a small stub. Grow the bubble to the same
footprint as the original glyph, scale the knocked-out gem and the three
chat lines up to match, and give it a full corner tail.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
On a Buzz community the FAB (which created a channel) is replaced by a "+" on the
Channels label — matching how Direct Messages already offers a "+" — and a Forums
label gains its own "+" that starts the create flow pre-set to a forum channel.
Both section headers now always render so the "+" is reachable even before any
channel loads; the collapse chevron is shown only when the section is non-empty.
The create route gains an isForum flag (threaded onto RelayGroupCreateScreen →
RelayGroupMetadataViewModel.isForum) so the Forums "+" opens the create screen on
a forum, the Channels "+" on a chat channel. The vanilla NIP-29 relay (a flat
directory with no sections) keeps its FAB. The stale "accept the invite in the
browser" empty text is dropped — an empty community is now a starting point.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016MNVEKhaAu4vQRZnXv3rfG
The inverted reaction icon carried a leftover inner-heart cutout from the
previous outlined design plus a duplicated gem subpath, so the gem rendered
as a solid island inside a heart-shaped hole instead of a clean knockout.
Regenerated as a solid heart with the Amethyst gem punched straight out.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
Per design feedback, the direct-message and reaction icons now invert:
instead of an outlined shape with a filled gem inside, the shape is solid
and the Amethyst gem is punched out of it (evenOdd knockout).
- Direct message: solid speech bubble; the gem is knocked out full-height
on the left, with three chat lines knocked out on the right to read
clearly as a conversation. The inner rectangle cutout is gone.
- Reaction: solid heart with the gem knocked out of its center.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
Every per-category status-bar icon now carries the Amethyst crystal so a
notification reads as an *Amethyst* reply/zap/mention at a glance, not a
generic Material glyph. Android renders small icons as a flat alpha-only
silhouette, so the gem is composed as a spatially distinct mark rather than
layered behind the glyph:
- Direct message, mention, repost, reaction, code and badge: gem sits in
the natural cavity of the glyph (bubble, @ ring, repeat loop, heart,
brackets, medal center).
- Media: gem is the sun rising over the hills.
- Article: gem is the drop-cap starting the first line.
- Chess: switched to the outlined knight from the app's icon set
(MaterialSymbols.ChessKnight) with a small brand gem.
- Reply and zap: open glyphs that can't hold a centered gem keep a small
gem corner mark.
Action buttons stay unbranded — a gem on a "Reply"/"Mark read" button is
noise, not identity — so those now use dedicated plain glyphs
(ic_action_reply, ic_action_mark_read).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi
The chat bubble used detectHorizontalDragGestures, which claims the pointer
as soon as horizontal movement crosses touch-slop regardless of how much
vertical movement is happening. A mostly-vertical scroll with a little
diagonal drift got grabbed as a swipe, so the bubble slid sideways while the
user was just trying to scroll the conversation.
Replace it with a custom awaitEachGesture detector that only consumes the
pointer once the motion is clearly more horizontal than vertical
(abs(over.x) > abs(over.y)). A predominantly-vertical drag leaves the pointer
unconsumed, so the enclosing scroll keeps it and the bubble stays put. Once
committed to a horizontal drag, behavior (clamp, haptic tick at threshold,
release-past-threshold to reply, settle-back animation) is unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016f7dTu8RoJRXjnHM7FKAE2
The first pass only recorded a deletion when THIS device published the 9008, so a
channel deleted on another Buzz client — or before the fix existed — kept showing
and survived restarts. The Buzz relay's handle_delete_group soft-deletes the
channel and its 39000/39001/39002 discovery events but emits NO member-removed
notification and never retracts the kind-44100 that seeds the browse list; its
only cross-device signal is the relay-signed kind-40099 system message with
type=channel_deleted (verified against block/buzz side_effects.rs).
Consume that signal: LocalCache records a relay-signed 40099 channel_deleted into
RelayGroupDeletions (gated on isRelaySignedGroupEvent so a spoofed one can't hide a
channel), and BuzzRelayImportViewModel.discover now also fetches the #h-scoped
40099 for its candidate channels and drops deleted ones — the relay keeps
re-announcing their 44100 with blank metadata, so this is the only place they
leave the list. Cross-device and retroactive: the relay replays the 40099 on
subscribe.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016MNVEKhaAu4vQRZnXv3rfG
Separate the Home feed video toggle into two independent groups,
matching how the app already classifies them (see ShortsFeedFilter):
- Videos (long-form): normal (21) + horizontal (34235)
- Shorts: short (22) + vertical (34236)
Each is its own Settings › Home tile with distinct icon/string. The
relay assembler filters and New Threads DAL already list all four
kinds, so only the HomeFeedType grouping changes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0198T5VTjA1x2dCfBLhXZaiL
Deleting a channel (kind-9008) published the delete and dropped it from the
user's kind-10009 list, but the community's browse list is built from the
cached kind-39000 metadata and the Buzz membership (kind-44100) set — neither
of which the delete touched — so the channel lingered in the list, and a stale
44100 re-announcement could bring it back after a restart.
Track deleted relay-group channels in a device-global RelayGroupDeletions
registry (keyed by GroupId.toKey, so it stays relay-scoped), persist it via
RelayGroupDeletionPreferences, mark the channel on deleteRelayGroup, and filter
deleted keys out of both the directory channels and the Buzz membership list in
RelayGroupChannelListScreen. The delete now removes the row live and it stays
gone across restarts.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016MNVEKhaAu4vQRZnXv3rfG
Extend the per-content-type Home feed toggles to the remaining root
feed (non-chat) kinds that render as cards but weren't yet part of the
Home feed:
- Pictures (NIP-68, kind 20)
- Videos (NIP-71: normal 21, short 22, horizontal 34235, vertical 34236)
- Torrents (NIP-35, kind 2003)
Each gets a HomeFeedType group (kinds stay disjoint), is added to the
always-on home relay assembler filters, and is accepted by the New
Threads DAL (which also feeds the Everything tab). The addressable
video kinds are registered in the DAL's addressable-kind scan. Wired
into Settings › Home with icons/strings; toggling still drops the kinds
from both the relay REQ and the rendered feed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0198T5VTjA1x2dCfBLhXZaiL
Address composer feedback:
- The annotation field is now the short-note composer's rich MessageField
(@-mention + custom-emoji autocomplete, inline previews). On publish it becomes
the highlight's `comment` tag, with the mentions (`p`), emoji, URLs (`r`),
hashtags and quotes it references emitted as their own tags — via
NewMessageTagger + a new tag-builder initializer on HighlightEvent.build().
- A nostr source now renders as a reply-style preview card (NoteCompose, quoted
style) instead of showing a URL field; the URL field appears only for web
sources. The source note is resolved from the a/e coordinate carried in the
route.
Verified with :quartz:jvmTest and :amethyst:compileFdroidDebugKotlin.
- 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.
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
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
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
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
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