- Jump effect: key on the note id alone and always clear the request
after one attempt. Previously keyed on (id, items.list), so a message
arriving mid-jump cancelled the scroll animation and could leave the
request stuck (never cleared when the target wasn't loaded), blocking
repeat taps. Now the animation can't be interrupted and a re-tap always
re-fires.
- Back-fill pinned bodies: add an id-scoped filter for the group's
pinnedEventIds on the host relay. A pin can point at a message older
than the 200-item timeline window, which the h-scoped timeline never
returns — without this the bar shows a blank preview and can't jump.
- Re-invalidate the relay-group filter when the pin list changes (keyed
on the pin ids only, so roster/metadata churn doesn't re-subscribe), so
the new pin ids are actually requested when 39005 arrives.
A chat bubble reserves ~8dp beneath its last line of text so the reaction
chips, which overlap the bottom border, ride over padding instead of the
text. That reserve was gated on `reactionsRow != null`, but the reaction row
was passed for every non-inner-quote message regardless of whether it had any
engagement — so every footerless bubble (the middle messages of an author
run, which don't show the timestamp) reserved space for an empty chip row,
leaving a visible gap between the text and the bubble's bottom edge.
Gate the chip row on actual engagement via a new `hasChatEngagement` helper
that mirrors the render gate in `RenderChatReactionChips` (reactions, zaps,
pending zaps, minichat replies) and keeps the same live subscriptions, so a
message that gains its first reaction still mounts the row. Footerless
messages with no engagement now hug their text; the reserve returns only when
chips are actually present.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ghZ7egU86LT4Z5BBvyLvY
The unread count, the channel-list/hub last-message previews, and the
Messages-row summary all read a broader note set than the channel feed
renders: the feed's ChannelFeedFilter hides kind-1111 CommentEvent thread
replies (and unacceptable authors), but these row surfaces counted/showed
them. A trailing minichat reply therefore stuck the unread badge at a count
opening the channel could never clear (markAsRead is monotonic and only
advances for rendered timeline messages), and showed up as a "last message"
that isn't on the timeline.
Centralizes the feed's predicate as isConcordTimelineMessage (loaded,
acceptable, not a CommentEvent) plus a newestTimelineNote helper, and routes
the unread count, both list-row previews, and the Messages hub filter through
it — so every channel-row summary agrees with what the open channel renders.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
consumeConcordRumor attaches a message row to its ConcordChannel BEFORE
justConsume loads the event (so the note carries its gatherer through the
Messages filter). That means Channel.addNote ran while createdAt() was still
null and never set lastNote — and on reprojects the containsKey guard skips
addNote entirely, so lastNote stayed null forever for every Concord channel.
The list/hub rows read lastNote, so they always showed "No messages yet" (and
activity sorting was a no-op) even after messages had loaded.
Adds Channel.refreshAfterEventLoad(note), called right after justConsume once
the event is present: it picks lastNote when newer and invalidates the notes
flow so previews, unread counts, and ordering recompute. Regression-tested in
commons (ConcordChannelLastNoteTest).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9Uv3h6GhSQVuUTY5Ffm6e
Implements NIP-29 message pinning (nostr-protocol/nips#2379):
Protocol (quartz):
- GroupPinnedEvent (kind 39005): relay-signed pinned-message list, `d`
group id + ordered `e` ids.
- UpdatePinListEvent (kind 9010): moderator `update-pin-list` write,
carries the full list so pin/unpin/reorder/clear are one submission.
- Register both in EventFactory; add a pinnedEventIds tag helper.
Model + cache:
- RelayGroupChannel now folds the pin list (pinnedEventIds / isPinned)
with the same createdAt-supersede guard as the roster.
- LocalCache consumes 39005 into the channel and stores the 9010 write;
39005 added to the group's metadata REQ filter so pins load.
Publish path:
- Account.pin/unpin/updateRelayGroupPins + AccountViewModel wrappers.
UI (non-intrusive, self-hiding):
- Collapsed pinned-message bar under the top bar: shows the current pin,
N-count cycling, tap to jump to the message in-feed (hoisted jump
request threaded through the shared chat feed view). Renders nothing
when the group has no pins.
- Moderator-only Pin/Unpin action under "Show more" in the chat message
bottom drawer, gated on membership.canModerate().
- Small pin glyph on pinned bubbles' footer.
Tests: quartz build/parse round-trip for both kinds; channel pin-fold
supersede/replace/clear semantics.
Builds on the last commit to push the server channel list beyond a plain
messaging list:
- Recent-poster facepile: a stack of overlapping avatars (newest on top) for
each channel's latest distinct authors, so a busy channel reads as "who's
here" at a glance. New ConcordAuthorFacepile reuses the existing avatar
primitives; recentAuthorHexes() picks the most-recent distinct posters in one
O(notes) pass.
- Live "X is typing…": each row consumes the community session's typing
heartbeats (kind 23311) and swaps its preview line for an italic typing
indicator, driven by one screen-level freshness ticker that only spins while a
heartbeat is live.
- Rows are now a two-line layout (name + facepile / preview + time + badge), and
the unread badge animateContentSizes as its count changes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9Uv3h6GhSQVuUTY5Ffm6e
The community "server" channel list (ConcordChannelListScreen) showed only an
icon and name per channel. It now mirrors the chat-room list feel: each row
carries a preview of the last message (author + snippet), the relative time of
that message, and an unread-message count badge, with the name/icon/time
emphasized while unread.
Adds a shared, reactive unread-message counter (concordChannelUnreadCountFlow)
that combines each channel's persisted last-read marker with its own note store,
plus a shared ConcordUnreadBadge pill (capped at 99+, plural content
description). The Concord Channels hub (ConcordHomeScreen) now reuses both:
per-channel rows show the real new-message count instead of a bare dot, and a
community's badge sums new messages across its channels.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9Uv3h6GhSQVuUTY5Ffm6e
- Collect ConcordSession.state via collectAsStateWithLifecycle instead of
reading StateFlow.value in composition (fixes StateFlowValueCalledInComposition
lint error in GroupBottomBarEntries.kt).
- Add @OptIn(ExperimentalCoroutinesApi::class) for testScheduler.advanceTimeBy
and runCurrent usages in ResourceUsageLedgerTest and RelayAuthPromptBusTest.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wx8SvzsWsru7rEQ1KcbSCW
Like NIP-17's addRelayToNoteAndInners, propagate the relays a Concord plane
wrap was seen on down to its inner rumor, so a received Concord/Armada
message shows the relays it actually came from — not just the channel's
configured relay set.
The wrap note already carries its seen-on relays (added by consumeRegularEvent
before the gift-wrap handler runs), so GiftWrapEventHandler hands them to
concordSessions.ingest, which threads them through the session/registry to the
rumor sink; LocalCache.consumeConcordRumor then stamps them onto the rumor note
after justConsume. Local-echo sends and buffer re-projections pass no relays
(default empty) since there's no per-wrap relay to attribute there.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129yvP2hmVeDFfuKKy94tqX
Narrow appendMissingImetaUrls to fire only when the message content is
blank: in that case return the imeta URLs joined one per line, otherwise
leave the content exactly as-is. A message that already carries text is
never rewritten, so inline URLs and ordering are preserved untouched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SF85F6GHu7aQZYpTGNYdWk
The chat timestamp tap toggled relative/absolute time instead of opening
the relay dialog, because ToggleableTimeAgoText has its own inner click.
Give it a `toggleable` flag and turn it off for the chat time, so the
enclosing tap target opens the delivery dialog; the absolute time now shows
as a header inside that dialog so nothing is lost.
Also:
- Encrypted-channel messages (Concord/Armada) decrypt locally with no
per-relay attribution, so the dialog showed "no relay information". Fall
back to the channel's own relays as "where this message lives".
- Move the "Message Delivery" sheet tile out of the always-visible row into
the Show More section — it's a secondary action.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129yvP2hmVeDFfuKKy94tqX
Seeing which relays a message came from was hard to reach: the received
timestamp was only tappable when it had seen-on relays, own messages only
made the tiny tick icon tappable, and the target was a hair-thin text glyph
nested in the bubble's long-press handler.
- Unify the two into ChatTimeWithDelivery: the whole time (+ the own-message
acceptance tick when we have data) is one padded tap target that always
opens the relay/delivery dialog. The dialog distinguishes "waiting for a
relay" (our own pending send) from "no relay information" (old/received).
- Also expose it from the long-press sheet as a "Message Delivery" tile, so
it's reachable even on messages with no visible timestamp (mid-run).
- Reserve a little bottom space in bubbles that have overlapping reaction/zap
chips but no footer, so the chips no longer draw over the last line of text.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129yvP2hmVeDFfuKKy94tqX
The joined-groups preload (RelayGroupMyJoinedGroupsFilterAssembler) only
subscribed to roster kinds (39000/39001/39002). It never requested the
kind-9 chat timeline, so the Messages-list preview for a group reflected
only whatever chat events happened to already be in LocalCache from
unrelated subscriptions — reading as "scattered" messages — and the full,
recent timeline was fetched for the first time only when the group was
opened (filterMessagesToRelayGroup, limit 200).
Also prefetch a bounded slice of each joined group's most recent chat
(kind 9 + polls, limit 50, `#h`-scoped, pinned to the host relay) alongside
the roster, so list previews show the true newest message and opening a
group lands on already-cached content. Shares the per-relay EOSE/since with
the roster filter, matching the existing warmup/channel assembler pattern.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Vvc7LeGNTpg1ggm5P8AtH
The earlier imeta-only fix patched RenderChat (the thread-view path), but
the Concord channel feed renders messages through ChatMessageCompose →
RenderRegularTextNote, which handed the raw decrypted content straight to
the media renderer. So a NIP-C7/Concord image message from an interop
client (e.g. Ditto/Soapbox Armada) — empty content, image carried only as
a NIP-92 `imeta` tag — never rendered in the feed, even though the
encrypted blob's key/nonce were already registered for transparent
decryption at ingest.
Extract appendMissingImetaUrls into a shared helper (ui/note/types/
ImetaContent.kt) and apply it in RenderRegularTextNote against the
decrypted content, symmetric to the RenderChat fix and to
ChannelChat.imageMessage which appends the URL on send. Normal messages
whose imeta URLs are already inline are untouched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SF85F6GHu7aQZYpTGNYdWk
The public-chat, relay-group and Concord-community screens are reached both as
pushed details (from a list) and as pinned bottom-nav tabs. Give them the same
dual-mode chrome the other tab roots use, keyed off nav.canPop():
- Show the back arrow only when there is something to pop. Added an optional
showBackButton (defaulting to the current behavior) to TopBarExtensibleWithBackButton
and passed nav.canPop() from PublicChatTopBar and RelayGroupTopBar; gated the
Concord list screen's back icon the same way.
- Render AppBottomBar in all three screens. It hides itself when canPop, so a
pushed instance shows the back arrow and no bar, and a bottom-nav instance
shows the bar and no back arrow.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017mxdSFQcarsKaL179tSub1
The read-only settings picker never fetches a group's kind-39000 metadata, so with
no cached event RelayGroupChannel.toBestDisplayName() fell back to the raw group id.
Resolve the label from the group's metadata name when loaded, otherwise the name the
user's joined-groups list already stored for it (the NIP-51 ["group", id, relay, name]
tag), and only then the id. Also helps the live bar before 39000 arrives.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017mxdSFQcarsKaL179tSub1
- Drag reorder: key each preview tab by its stable identity. Without a key the
tabs were position-identified, so when a swap reordered the list, the slot under
the finger recomposed with a different entry — restarting its pointerInput (keyed
on the entry) and cancelling the in-flight gesture, so dragging stopped on the
first swap. Keying moves the dragged composable (and its live gesture) instead.
- Add/Added button: both states now share one Surface + Row body (only color,
border and tint differ) instead of an OutlinedButton vs a Surface. The
OutlinedButton's ~40dp min height made "Add" taller than "Added" and broke row
alignment; a single body keeps the pill height constant.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017mxdSFQcarsKaL179tSub1
AnimatedVisibility's default enter (fadeIn + expandIn from the bottom-end) made
the category / chat child lists slide in from the top-left. Switch both to a pure
vertical expandVertically(Top) / shrinkVertically(Top) so the options unroll
straight down with the toggle, matching the accordion's open gesture.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017mxdSFQcarsKaL179tSub1
Address two gaps in the redesigned bottom-bar setup screen.
- The preview IS the editor now: the mini nav bar is the reorder & remove
surface. Drag a tab within the bar to reorder it (holder moveTransient/commit),
tap its ✕ badge to remove. Drops the separate chip strip — one WYSIWYG bar
instead of a preview plus a duplicate list.
- Favorites render their real favicon / nsite / napplet icon (via the same
FavoriteAppIcon + rememberFavoriteIconModel the live bar uses) instead of a
generic globe glyph — in the preview tabs and the Browser picker rows alike.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017mxdSFQcarsKaL179tSub1
- Private Message trade-off now reads "Can't add users later" (accurate
for NIP-17: you can't add members to an existing DM room).
- Darken the Concord/Public/Disappearing accents so the white icon-tile
glyph and the white Create-button label meet WCAG AA contrast (the
amber/teal/orange were ~2.6–3.4:1 on white text).
- Bound the row title/tagline/chip with maxLines + ellipsis so a longer
localized string can't wrap and break the one-line row layout.
- Mark ConversationType/ConversationSection @Immutable so ConversationRow
is skippable — an accordion toggle now recomposes only the two affected
rows instead of all six.
- Fix ProConRow icon modifier order (padding before size) so the check/
close glyph is nudged, not squished.
Give the settings screen a point of view instead of a stock Material list.
Presentation only — same BottomBarEntry model, holder and resolver underneath.
- Live preview: a real mini nav bar at the top renders the pinned tabs (first
one highlighted like the bar on open) and updates as you add, remove and
reorder — the screen is now WYSIWYG.
- Your tabs: the pinned set is a horizontal, drag-to-reorder chip strip that
mirrors the bar's own shape, each chip with a leading avatar/icon and an ✕,
plus an "n / 5" slot hint that turns red past the recommended count.
- Available catalogue: collapsible category cards with a leading icon tile;
every option row has a tinted circular leading and an "Add → ✓ Added" pill
instead of a bare switch, so the control states its action and result.
- Group rows show the real group avatar; Browser / Public Chats / Relay Groups
/ Concord expand to your favorites / joined groups (read-only, no REQ storm).
Reorder reuses the holder's moveTransient/commit; all edit logic stays in the
unit-tested BottomBarSettingsState.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017mxdSFQcarsKaL179tSub1
Reworks the "Start a conversation" chooser to be scannable instead of a
wall of text. Each conversation type is now a one-line row — a
color-coded icon tile, the name, a short tagline, and a single chip
naming its deciding axis (1:1–5 rooms / Device-bound / Workspaces /
Unmoderated / Moderated / Live now). Tapping a row expands it in place to
reveal "Best for" and a compact Good / Trade-offs split, then a Create
button that routes to that type's existing flow (accordion: one open at a
time).
Copy now leads with each protocol's real differentiator — Marmot's
device-bound gotcha (chats don't follow an nsec to another app), Concord
as channel-split workspaces, and the relay-aware section grouping Public
Chat + Relay Group by the fact the relay can see and moderate who's there.
Per-type accent colors are lightened in dark theme for legibility; the
solid icon tile keeps the saturated hue in both themes.
Concord/NIP-C7 image messages from interop clients (e.g. Ditto/Soapbox
Armada) attach the image purely as a NIP-92 `imeta` tag and leave the
message content empty. Amethyst's shared renderer only shows media whose
URL appears in the text, so those images never rendered — even though the
encrypted blob's key/nonce were already registered for transparent
decryption at ingest.
RenderChat now appends any imeta URL missing from the content before
handing it to the media renderer (symmetric to ChannelChat.imageMessage,
which appends the URL on send), so imeta-only attachments display.
Also forward the thumbhash on the encrypted-image imeta: FileHeader
computes both blurhash and thumbhash, but only blurhash was being sent.
encryptedImageImeta now emits an additive `thumbhash` field and
toConcordImeta forwards the computed value, so receivers can paint a
placeholder while the blob decrypts.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016TGjBiJL2fvTR37eePBKrG
Quality follow-up to the customizable bottom nav, addressing the audit's three
highest-impact items — behavior unchanged.
- Extract BottomBarSettingsState + the pure BottomBarEditing transforms out of
BottomBarSettingsContent, so pin/unpin/reorder/restore-default are unit-tested
(BottomBarSettingsStateTest) instead of only exercisable through the drag UI.
The composable now just renders and forwards events; moveTransient reorders
mid-drag and commit() persists once on drag end.
- Unify the phone bottom bar and the navigation rail on one rememberBottomBarSlot
resolver (route + icon per entry), removing the duplicated built-in/favorite/
group branches and the divergent selection logic that had drifted between them.
- Give the group resolvers a `subscribe` flag: the live bar/rail keeps a REQ open
per pinned group (bounded by the few slots), but the settings picker reads
cached metadata only — so expanding a chat category with many joined groups no
longer fans out into one relay subscription per row.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017mxdSFQcarsKaL179tSub1
Messages sent in a previous session have no delivery-tracker entry, and
frequently no seen-on relays either, so the ticks rendered a "pending"
clock and stayed tappable — implying a days-old message was still waiting
for a relay, and opening a near-empty Message Delivery dialog. When we have
neither tracker data nor seen-on relays there is nothing meaningful to
report, so render no tick at all and just show the timestamp. Messages sent
in the current session keep their live pending -> accepted ticks.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129yvP2hmVeDFfuKKy94tqX
Tapping the delivery tick on a just-sent message that no relay has
acknowledged yet opened the Message Delivery dialog with only its title:
no tracker entry, no target relays, and no seen-on relays meant every
branch rendered an empty list. Fall back to the pending status line
("Waiting for a relay to accept this message") so the dialog always says
something, and guard the recipient/relay branches against empty lists.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129yvP2hmVeDFfuKKy94tqX
The sats count in the under-bubble zap chip was gray like the reaction
counts. Match it to the lightning glyph's bitcoin orange (theme-aware for
light/dark contrast) so the zap reads as a payment, not a reaction.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129yvP2hmVeDFfuKKy94tqX
detectCleartextNetwork() is enforced by netd per-UID at the packet level: it
flags any socket whose first bytes aren't a TLS handshake, with no per-host
exemption. Every relay WebSocket tunneled over the embedded Arti Tor SOCKS
proxy on 127.0.0.1:17392 opens with a cleartext SOCKS5 greeting, so the
detector fired constantly on legitimate loopback traffic.
The Network-Security-Config localhost allowlist does NOT silence it — that flag
only governs the voluntary NetworkSecurityPolicy.isCleartextTrafficPermitted()
check that HTTP stacks consult, not netd's packet inspection. The old XML
comment claiming otherwise was wrong.
Drop the detector (the app already permits cleartext globally for ws:// relays,
so it produced little signal) and correct the misleading comment.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Messages "+" button used to fan out four cryptic one-word FABs
(Private / Public / Group / Find groups). The labels didn't explain what
each protocol does, and two conversation types (Disappearing Chat and
Concord communities) had no entry point at all.
Replace the speed-dial with a single "+" that opens a new full-screen
"Start a conversation" chooser. It groups the six conversation types into
three intent buckets — Direct & private, Encrypted groups, Public & open —
and presents each as a card with an icon, a one-line tagline, a "Best for"
hint, and short pros/cons lists, so users can pick the right message
protocol for what they're building. Each card routes to that type's
existing creation (or browse) flow.
- New Route.NewConversation + NewConversationScreen
- ChannelFabColumn simplified to a single button opening the chooser
- Adds the previously-unreachable Disappearing Chat and Concord entries