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
Chats no longer participate in the complete-UI toggle: tapping a bubble
no longer swaps in a detail row, and the isComplete/hasDetailsToShow/
detailRow machinery is gone from ChatBubbleLayout. Everything the detail
row exposed now lives in the compact design:
- Reply / Like / Zap were already reachable via swipe, double-tap, and the
long-press sheet, so those buttons simply drop.
- A new ChatMessageFooter carries the timestamp plus per-message status
glyphs that used to hide in the detail row — legacy-DM marker, NIP-40
expiration, geohash, and proof-of-work — each shown only when present.
It renders on the last message of an author run (for the time) or on any
message that carries such metadata.
- Own messages keep the relay-acceptance delivery ticks; received messages
get a tappable timestamp (ChatReceivedTimeInfo) that opens the same
relay-list dialog, so "seen on relays" is now available for every message.
- The zapraiser goal bar moves inline under the message content.
Net removal of code; the bubble tap is now a no-op except for inner quotes
(which still scroll to the quoted message).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129yvP2hmVeDFfuKKy94tqX
- PoWPublishQueue: use non-deprecated PersistentMap.putting()/removing()
- NappletBrokerTest: drop cast that can never succeed after assertIs
- PrivacyLockStateTest: remove redundant !! (smart-cast already non-null)
- MinichatScreen: drop unnecessary !! on smart-cast non-null Strings
- Concord screens: remove unnecessary safe calls on non-null ChannelEntity
and ResponseBody, and the now-dead elvis fallbacks
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Myhus2x1c3BSWCtjenSmkf
Rework the Bottom Navigation Bar settings picker so users can pin specific
joined chats — not just the aggregate list screens.
- Add PublicChat, RelayGroup and Concord variants to BottomBarEntry (stable
@SerialName discriminators), so a specific NIP-28 channel, NIP-29 relay group
or Concord community can be pinned as its own tab. The bottom bar and the
navigation rail resolve each to its avatar + chat/home route, live from the
local cache via a shared GroupBottomBarEntries resolver.
- Redesign BottomBarSettingsScreen: the pinned bar stays a drag-reorderable
section on top; the "Available" list is now grouped into ordered, collapsible
categories (Main, Chats & Groups, You, Feeds, Apps & Web, Other) instead of
the flat, scattered catalog order. Browser expands to your favorite apps, and
each chat type expands to your joined groups, each child pinnable with a
toggle.
- Curated category ordering lives in BottomBarCategories, covered by a test that
asserts every catalog id is placed in exactly one category.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017mxdSFQcarsKaL179tSub1
The compact timestamp was always aligned to the bubble's end, so on the
logged-in user's own (right-aligned) bubbles it hugged the screen edge
while everyone else's sat on the center-facing side. Align it to the start
for own bubbles so both sides mirror — the time always sits on the edge
facing the center of the chat.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129yvP2hmVeDFfuKKy94tqX
Render the "Public Chat" / "Marmot Group" (and the other room-type)
labels in the Messages list as a muted HeaderPill chip — the same faint
rounded chip the Concord community label uses — instead of plain gray
inline text, so every group kind reads the same way across the screen.
Each type gets a fitting icon (Public, Timer, Lock, Dns, Group).
Also rename the "MLS Group" label to "Marmot Group".
Two issues with the engagement-row zap chip:
- It sat below the bubble border instead of riding it like the reaction
chips. The zap (and minichat) chips used the Surface onClick overload,
which pads content to the 48dp minimum touch target, so their content
centered ~12dp lower than the shorter reaction chips. Switched both to a
plain Surface + Modifier.clickable, matching the reaction chip, so all
chips share one height and center on the border.
- After firing a zap from the drawer there was no feedback until the receipt
landed, because the sats chip only appears once an amount exists. Added a
per-note "zap in flight" signal on AccountViewModel that the drawer sets on
send; the bubble now shows a pending lightning+spinner chip in that gap. It
settles into the real sats chip when the receipt arrives, and clears on
error, on external-wallet handoff, or by a safety timeout.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129yvP2hmVeDFfuKKy94tqX
The long-press sheet opened at nearly full height because every action
tile section was always laid out. Two changes:
- The quick-reaction row now wraps onto multiple rows (FlowRow) like the
zap amount grid, instead of a single horizontally-scrolling strip, so
long custom-reaction sets stay reachable.
- The full note-action inventory is tucked behind a "More Options" toggle.
The sheet now opens compact — react, zap, reply — and only grows to full
height when the user expands it, so it no longer occupies the whole screen.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129yvP2hmVeDFfuKKy94tqX
Adds note scheduling and NIP-37 opt-in encrypted draft sync to Amethyst
Desktop, and extracts the existing Android scheduled-post code into
`commons` so both platforms (and PowJobRestorer) share one implementation.
- Compose → clock icon → date/time picker (presets + exact-minute); the
note is pre-signed and stored locally, then published at its time.
- Publishes while the app is open (45s in-app tick + launch catch-up) AND
while fully closed: an OS job (launchd / schtasks / systemd, registered
only while the queue is non-empty) relaunches the binary in a headless,
key-free `--publish-scheduled` mode that opens a websocket and pushes the
pre-signed bytes.
- A "Scheduled" deck destination (tabs Scheduled / Drafts / Articles):
status, cancel, publish-now, edit (cancel + reopen prefilled).
- Drafts: save-as-draft with a default-OFF "Sync across devices
(encrypted)" toggle publishing a NIP-37 DraftWrapEvent (kind 31234,
NIP-44 to self); drafts sync down on a fresh device.
Extraction / de-dup: ScheduledPost → commons/commonMain; ScheduledPostStore
+ ScheduledPostPublisher → commons/jvmAndroid (Jackson/java.io.File are
gate-forbidden in commonMain). The commons store is a strict superset of
upstream's parallel Android store (account-scoped claim, CLAIM_TTL crash
recovery, PUBLISHING-only status guards, reload-before-claim); upstream's
new ScheduledPostWorkGate gating is adopted to drive it. Single-writer file
lock + reload-before-claim so the in-app timer and headless process never
double-publish. Store file 0600, dir 0700.
macOS verified on the packaged app-image (compose+schedule, in-app publish,
app-closed launchd firing, Scheduled screen, NIP-37 draft round-trip).
Windows/Linux OS-integration authored but untested; headless has no Tor
routing yet — both documented in the PR.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
RelayAuthenticator.signWithAllLoggedInUsers gained an `interactive`
Boolean parameter, but these two geode auth tests still passed a
two-arg lambda and no longer compiled. Accept and ignore the flag.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129yvP2hmVeDFfuKKy94tqX
The Concord send path published the encrypted plane wrap but never
registered with chatDeliveryTracker, so own messages showed no
acceptance ticks. Relays OK the wrap (kind-1059) while the feed row is
the decrypted inner rumor, so a plain trackPublic wouldn't match.
Added trackWrappedPublic, which maps the wrap id onto the displayed
rumor id (relay-only delivery, no per-recipient breakdown — the same
ticks a public room gets). The Concord message/image send paths re-open
the wrap they just built to key the tracker by the rumor id; reactions
and typing wraps are skipped since they never become a feed row.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129yvP2hmVeDFfuKKy94tqX
Concord channel messages render through the redesigned chat feed, so two
features that main wired to the old surfaces were unreachable there:
- Ban / make-admin moderation lived only in the social-feed NoteQuickActionMenu.
Moved into the shared noteActionSections catalog (gated by concordAdminTarget /
concordBanTarget), so both the chat long-press sheet and the 3-dot menu now
expose it. The community-rekeying ban keeps a confirmation dialog, shared by
both hosts via ConcordBanConfirmationDialog.
- The minichat reply-count chip sat in the tap-to-expand detailRow, hidden in the
modern compact layout. Moved into the compact engagement row (ChatReactionChips)
as a "N replies" chip alongside reactions and zaps, so the thread affordance is
always visible; tapping opens the message's minichat thread.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129yvP2hmVeDFfuKKy94tqX
`kotlin.jvm.*` is a default import on the JVM target but not on Kotlin/Native,
so `@JvmInline` on the ConcordPermissions value class resolved on JVM/Android
yet failed the iOS (compileKotlinIosSimulatorArm64) build with "Unresolved
reference 'JvmInline'". Add the explicit import; JVM is unaffected.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
The channel-list screen titled itself `state.metadata.name ?: app_name`, so
before the metadata edition folded it showed the app's own name — "Amy Debug"
in a debug build. Prefer the folded name, then the stored community name from
the list entry (always present from the join/create, and what shows everywhere
else); the app-name fallback is now effectively unreachable.
The channel-create FAB used Material 3's default rounded-square shape; every
other FAB in the app is a circle. Set shape = CircleShape to match.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Picking a community icon or banner always failed with "Failed to upload media".
The upload was launched from the form's rememberCoroutineScope() (the Compose
Main dispatcher), so the very first pipeline step — MediaCompressor.compress —
hit Amethyst's checkNotInMainThread() guard and threw OnMainThreadException
before any bytes left the device.
Run ConcordImageUploader.uploadEncrypted inside withContext(Dispatchers.IO) so
the whole compress → strip → AES-GCM-encrypt → Blossom pipeline is off-main; the
Compose state write stays on the launching (Main) scope.
Also stop the form's catch from swallowing the real cause: surface the actual
exception message in the toast (falling back to the generic string only when it
has none), log it, and rethrow CancellationException instead of eating it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
NotificationFeedFilter derived `isConcord` from the note's own gatherers
(is it in a joined ConcordChannel), but LocalCache.consumeConcordRumor only
attaches kind-9 messages and kind-1111 replies to the channel — never a kind-7
reaction. So a reaction's `isConcord` was always false, it didn't bypass the
follow filter, and since a fellow member usually isn't a follow it was dropped
in Curated/Selected mode.
Recognize a reaction/repost as Concord through its TARGET instead: if
`replyTo.lastOrNull()` is a message in a community I've joined, it bypasses the
follow filter exactly like a reply. Relevance is still the existing p-tag gate,
so only reactions that actually tag me notify (a well-formed NIP-25 kind-7
p-tags the reacted author, which is what our own ChannelChat.reaction writes).
The "Messages in notifications" toggle now gates only Concord messages, not
reactions — a like isn't a message, so it follows the same rule as any other
reaction.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A Concord (and MLS/WhiteNoise) chat reply is a kind-9 ChatEvent that carries
its reply target as a NIP-18 `q` (or NIP-10 `e`) tag, not a NIP-10 thread — so
it isn't a BaseThreadedEvent and RenderTextEvent's reply-to preview never fires
for it. On the chat feed the preview is drawn by chat-only code, but everywhere
else NoteCompose routes kind-9 through RenderChat, which rendered only the
content and never `note.replyTo`. Result: on the Notifications tab a Concord
reply showed no quoted parent (no border) — most visibly when replying to an
image, whose target is likewise a kind-9.
RenderChat now takes unPackReply and, when FULL and not makeItShort, renders
ReplyNoteComposition(note.replyTo.lastOrNull()) like the threaded path does,
skipping it when the parent is already cited inline (`nostr:...`) so an
MLS-style quote isn't drawn twice. NoteCompose forwards unPackReply; the thread
view passes NONE since its structure already shows the parent.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>