Every Concord/minichat screen now stands on its own regardless of how it's reached:
- MinichatScreen mounts its own datasource (the Concord plane subscription when the
root is a Concord message; a relay reply REQ for public chats) and drives its list
from a reactive MinichatFeedViewModel, so new replies arrive live and auto-scroll
into view even when opened via deep link. Adds a LazyListState that scrolls to the
newest reply as they land.
- ConcordMembersScreen and ConcordEditScreen now mount the plane subscription too
(previously they showed nothing when opened directly), and all three community
screens (channel list, members, edit) re-resolve sessionFor(communityId) on each
session revision instead of caching it once — so a deep link that lands before the
community has folded picks it up as soon as it does.
Minichats are deliberately kept OFF NIP-17 DMs: the "N replies" chip is gated to the
chat types where minichats are wired (Concord, NIP-28, NIP-29), since most clients
don't render kind-1111 replies in a DM view. The DM composer already has no thread
toggle, so DMs neither create nor surface minichats.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
The relay NOTIFY (payment prompt) went into a process-wide NotifyRequestsCache
and DisplayNotifyMessages showed it under any account whose relay list contained
the relay — so a prompt billed to Amethyst surfaced under Vitor, and stale
entries re-appeared on every account switch.
A NOTIFY doesn't reliably name a pubkey, so instead of parsing the message we
correlate it with the AUTH that triggered it: a paid relay answers an
unauthorized AUTH with `OK <authEventId> false …` right before the NOTIFY, and we
signed that auth event. NotifyCoordinator remembers each auth event's signer,
maps the failing OK back to it, and files the NOTIFY into THAT account's own
Account.relayNotifications. Unattributable NOTIFYs are dropped. DisplayNotifyMessages
now reads only the current account's cache.
Also removes the now-unused app-wide RelayAuthPermissionStore singleton and wires
NotifyCoordinator with the pubkey -> Account lookup.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
One shared NostrClient serves every logged-in account, so a relay's NIP-42 AUTH
challenge is not tied to any one of them. AuthCoordinator used to fold every
account's verdict into one decision and then sign with EVERY account (or a random
throwaway key), so a paid relay like inbox.nostr.wine billed and de-anonymized
accounts that never used it — including via a merged read filter that merely named
them, and via account switches / the background notification consumer.
Decide and sign PER ACCOUNT now: an account signs only if the relay is in its own
relay list, or it is publishing its own event there (RelayAuthFirstParty), AND its
own ledger verdict (Account.relayAuthLedger) allows it. A subscription merely
naming the account is NOT first-party — that is the merged-filter false positive.
The random-ephemeral-key fallback is removed. Own-inbox reads still qualify: the
relay serving them is by definition in the account's own list.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per-relay ALLOW/DENY AUTH overrides (and the policy ledger that reads them)
lived in one process-wide DataStore keyed only by relay URL, so a DENY set for
one account silently applied to every logged-in account. Move them to a
per-account file under accounts/<pubkey>/ and warm-cache the overrides in memory
on the Account (RelayAuthPermissionCache) so an AUTH challenge is answered
without a disk read.
DataStoreRelayAuthPermissionStore now shares one DataStore per file path —
DataStore v1 forbids two live instances on one file, and loadAccount can build
the store more than once per account.
Introduces the per-account state holders wired up by the following commits:
Account.relayAuthPermissions, relayAuthLedger and relayNotifications.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follows the code-review of the dual-reply + Concord work:
1. Public-chat/relay-group minichat replies no longer drop @-mentions, hashtags,
URL references, quotes, custom emojis, attachments, content-warning or
expiration: ChannelNewMessageViewModel.createTemplate's MINICHAT branch now runs
after NewMessageTagger and builds the kind-1111 from tagger.message with the same
enrichment the inline path uses (parent author is already tagged by replyBuilder).
2. refreshConcordChannelIndex no longer runs a full re-index + ban rescan on every
ingested message: the revision collector is sample(500)-throttled, coalescing
bursts into at most one pass per window.
3. Concord stream-key AUTH is no longer blocked behind the user-auth prompt: on a
relay that hosts our planes, the ASK path is DISMISSed so the derived stream-key
AUTHs return immediately instead of waiting on (or being dropped by) a dialog.
4. Account.sendMinichatReply evaluates chat.relays() once instead of twice.
5. AuthCoordinator caches the per-stream-key NostrSignerSync by secret, so the
secp256k1 keypair isn't re-derived for every plane on every relay challenge.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
Generalizes the minichat feature beyond Concord to the public chats:
- ChannelNewMessageViewModel gains a replyMode state + toggle; createTemplate now
builds a kind-1111 CommentEvent for a MINICHAT reply (with the NIP-29 `h` tag on
relay groups) instead of the native reply, so users can start threads in NIP-28
public chats and NIP-29 relay groups.
- Account.sendMinichatReply resolves PublicChatChannel and RelayGroupChannel from
the note's gatherer and publishes a public kind-1111 (h-tagged + host-relay for
groups), so replying inside a public-chat minichat works too.
- observeNoteMinichatReplyCount re-registers each visible message with the
EventFinder subscription, which batches their ids into shared REQs for kind-1111
replies — so the "N replies" chip appears on public-chat messages as their
threads load. (Concord's replies still arrive over the plane; that REQ no-ops.)
- The reply-mode toggle is extracted to a shared ReplyModeToggle composable used by
both the Concord and public-chat composers.
The chat-styled minichat screen already generalizes: it reads the root's kind-1111
replies and works for any chat type. DMs (NIP-17) remain the deferred Phase 3.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
Adds the "chat within a chat" screen the N-replies chip now opens: the root
message pinned at top, its kind-1111 thread replies below as chat bubbles (reusing
ChatroomMessageCompose so they look identical to the main chat), and a composer
that posts a kind-1111 rooted at that message — flat, so replying inside a minichat
doesn't spawn sub-threads.
- Route.ChatMinichat(rootId) + AppNavigation registration + MinichatScreen.
- The shared "N replies" chip now navigates to the minichat instead of the generic
thread view.
- Account.sendMinichatReply resolves the chat context from the note's gatherer and
drives the Concord channel path (kind-1111 on the plane). Observing the root's
replies also loads kind-1111s from relays, so public-chat minichats already
display; their send path (NIP-28/NIP-29) is the remaining follow-up.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
Adds the two-way reply model: an INLINE reply stays in the timeline (native chat
message referencing its parent), a MINICHAT reply is a kind-1111 NIP-22 comment
pulled into a thread opened from the parent — matching Armada.
Foundation (quartz/commons):
- ReplyMode enum (INLINE default, MINICHAT).
- ChannelChat.inlineReply / ConcordActions.buildChannelInlineReply restore the
kind-9 q-tag quote path alongside the existing kind-1111 ChannelChat.reply.
- ChannelFeedFilter excludes kind-1111 CommentEvents from the chat timeline (they
belong in the minichat), so inline replies stay and thread replies move aside.
- observeNoteMinichatReplyCount: local count of a message's kind-1111 replies.
Concord composer + send (amethyst):
- ConcordNewMessageViewModel gains a replyMode state + toggle; the composer shows a
"In chat" / "In thread" toggle beside the reply preview.
- Account.sendConcordChannelMessage routes MINICHAT to kind-1111, INLINE to kind-9
quote, fresh post to kind-9 message.
Shared row chip (all chat types):
- ChatMessageCompose's action row shows an "N replies" chip when a message has
kind-1111 thread replies; tapping opens the thread. Wired to the thread view for
now; a chat-styled minichat screen and NIP-28/NIP-29 loading follow.
Plan: amethyst/plans/2026-07-12-dual-reply-minichat.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
Concord replies were kind-9 chat messages carrying a `q` tag. In the Concord
model (matching Soapbox Armada) a `q` on a kind-9 is an inline *quote*, which
clients deliberately keep OUT of threads — a real thread reply is a kind-1111
NIP-22 comment. So our replies rendered (and were sent to Armada) as inline
quotes, never grouping into a message's thread.
ChannelChat.reply now builds a CommentEvent via CommentEvent.replyBuilder: the
uppercase K/E/P tags pin the immutable thread root and the lowercase k/e/p tags
point at the immediate parent (root inherited when the parent is itself a
comment, so the root is stable at any depth), plus the same channel/epoch binding
every Chat Plane rumor carries. This is byte-compatible with Armada's
buildV2CommentTags, so replies thread correctly in both directions. The read path
already accepts these (they carry the binding, and consumeConcordRumor handles
CommentEvent), so incoming Armada thread replies now land bound to their channel.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
Reverts the Kover coverage aggregation (introduced in dca345212b) from every
module, the root aggregation block, the Sonar coverage import, the version
catalog, and BUILDING.md. With the plugin present the 16 GB CI runner OOM-killed
during the Android test+build job even with Kover opt-in disabled; removing it
entirely clears the OOM. No other CI changes — the diff versus main is exactly
the inverse of dca345212b.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Each Concord channel row in the Messages tab has a chip naming its parent
community (tap → opens the community), mirroring the NIP-29 relay chip — but it
only renders when ConcordChannel.communityName is set, and that was populated
solely by refreshConcordChannelIndex() inside the Concord hub screen's
subscription composable. On the Messages tab that screen isn't mounted, so the
channel objects there never got their community name/icon and the chip was
absent.
Moves the channel-index refresh to an account-scoped collector on the
ConcordSessionManager revision, so community metadata (name/icon, channel flags,
membership) and per-community ban pruning apply across the whole app the moment a
Control Plane folds — not only while the hub screen is open. The hub subscription
now just re-derives its filters; the shared refresh lives in Account.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
The NIP-34 status buttons (Mark merged / Close / Reopen) and the PR
View-changes button used default Material3 sizing, which reads oversized
inside a note card. They now use the same 32dp compact height, tighter
content padding and labelMedium text as the app-recommendation buttons.
Issue and PR cards also showed the title twice when the authoring client
duplicated the subject tag as a leading markdown heading in the content
(gitworkshop/ngit do this). The body renderer now strips that first
heading when it matches the subject already rendered as the card title.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01USiovhenaijgVEFFzASgfy
Concord channel messages arrived as 6 rows stuck on the 'Event is loading or
can't be found in your relay list' placeholder. A Concord inner rumor is
unsigned (empty sig) and never exists on a relay, but consumeConcordRumor fed it
to justConsume with wasVerified=false, so justVerify ran a signature check,
failed, and the event never loaded onto its Note — the chat row then fell back to
loading-from-relay and showed the placeholder forever.
The rumor's authenticity is already established when the envelope is opened
(ConcordStreamEnvelope.open verifies the seal signature, binds rumor.pubKey ==
seal.pubKey, and checks rumor.verifyId()), exactly like a NIP-59 gift-wrapped DM
rumor. Consume it as pre-verified (wasVerified=true) so the event loads and the
message text renders in the Messages tab alongside other groups.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
Concord relays gate a plane's kind-1059 wraps behind NIP-42 and serve them only
to a connection authenticated AS the plane's derived stream key — a Concord wrap
is authored by the stream key and p-tagged to a throwaway ephemeral key, so the
member is neither author nor recipient. Amethyst authenticated only as the user,
so every plane REQ was refused ('auth-required: all authors must be
authenticated') and no channel or message ever loaded, even though the community
list (kind 13302) folded correctly.
Amethyst's relay-auth plumbing already supports multiple identities per
connection: RelayAuthenticator sends one AuthCmd per event in the list its
provider returns, deduped on (pubkey, challenge). This wires the derived stream
keys into that provider:
- ConcordCommunitySession.streamKeys() exposes control + folded-channel GroupKeys.
- ConcordSessionManager.streamAuthSecretsFor(relay) returns the stream secret keys
a NIP-42 challenge from that relay must be answered with (relay-scoped to each
community's own relays — the same scope the plane subscription uses).
- AuthCoordinator signs one kind-22242 per stream key locally (raw KeyPair via
NostrSignerSync — never the account signer, never exposing user identity), and
attaches them to every challenge independent of the user-auth policy. On auth
success the existing syncFilters re-fires the previously-refused plane REQ.
- RelayAuthStatus LruCaches widened 10 -> 200 since one connection now
authenticates as the user plus many plane keys (control + channels).
Mirrors Armada's streamAuth.ts (sign one AUTH per scoped stream key, accumulating
on the connection). Verified live on-device by the debug pass: authing as the
derived stream key returns the control-plane wraps that were previously refused.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
The community-list import only queried the 4 Concord stock relays, so a list the
user copied onto their own outbox/read relays (or that a client published there)
was never fetched — the normal account subscription never asks for kind 13302.
Widens the query to stock ∪ mineRelays ∪ outboxRelays with a 30s window (stock
relays like relay.ditto.pub can take 10–20s to first response), and logs how many
13302 events were fetched and how many entries decoded so an empty hub is
diagnosable. Renames importConcordCommunitiesFromStockRelays → importConcordCommunities.
Verified live: the account's newest kind-13302 is served without AUTH by the
dreamith stock relay (and, slowly, relay.ditto.pub); the codec, LocalCache
dispatch, and fold path all match.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
Removes the last catch-all ConcordKinds constant object. Each remaining kind
now lives on the class that owns it:
- wrap/seal kinds were already ConcordStreamEnvelope.KIND_WRAP/SEAL_* — callers
(ConcordActions, ConcordSubscriptionPlanner) reference those directly.
- guestbook join/leave (3306) and kick (3309) become Guestbook.KIND_JOIN_LEAVE /
KIND_KICK.
- direct-invite (3313), voice-presence (23313), and rekey (3303) inline their
literals on ConcordDirectInvite/VoicePresence/ConcordRekey.
The unused edit/webxdc/typing/snapshot/invite-list/ephemeral-wrap constants are
dropped; they will own their literals when those events get dedicated classes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
Amethyst serialized the joined-community list (kind 13302) as a bare JSON
array of flat camelCase entries, so it could not parse the document Soapbox
Armada actually publishes — a community created in Armada never appeared in
Amethyst even after the 13302 reached a shared relay.
Rewrites the codec to Armada's communityList.ts shape:
{ entries: [ { community_id, seed: JoinMaterial, current: JoinMaterial,
added_at } ], tombstones: [ { community_id, removed_at } ] }, where
JoinMaterial is the snake_case per-snapshot key bundle (community_id, owner,
owner_salt, community_root, root_epoch, channels[], relays, name, held_roots?,
refounder?). Hydration prefers current over seed; liveness is derived from
tombstones (an entry is dropped only when removed strictly after it was added).
New create/join entries now stamp added_at (ms) so the liveness tiebreak works.
Adds interop tests that decode a real Armada document and exercise the
tombstone-after-add drop.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
Removes the CONTROL/COMMUNITY_LIST/INVITE_BUNDLE constants from ConcordKinds
now that each has a dedicated Event class. Callers reference
ControlEditionEvent.KIND, ConcordCommunityListEvent.KIND, and
ConcordInviteBundleEvent.KIND directly, matching the nip88Polls per-kind
event convention.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
Search ran the term through Nip19Parser, which extracted the invite URL's embedded
kind-33301 naddr and routed to the generic addressable-event screen — which has no
renderer for 33301, so it showed 'unable to render kind 33301'. Detect a Concord
invite link before the nip19 parse and route to Route.ConcordInvite (carrying the
whole URL so the fragment token survives). Also guard the bare-naddr path: a 33301
naddr goes to the invite flow instead of the unrenderable event screen.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
- Apply Kover (Apache-2.0, build-time only) to the KMP/JVM modules and aggregate at the root
- include amethyst playDebug in the aggregated Kover coverage
Clients in the wild publish structurally wrong values in profile
metadata — e.g. "nip05":{} — and the strict field serializers made
JsonMapper throw, so contactMetaData() returned null and the entire
profile (name, picture, about…) was discarded.
Generalize the BirthdayTolerantSerializer precedent: string fields now
use TolerantStringSerializer (accepts any JSON primitive, ignores
objects/arrays/null) and the bot flag uses TolerantBooleanSerializer,
so a single malformed field is skipped instead of being fatal.
Non-JSON content (e.g. "Relay initialized") remains unrecoverable and
still parses to null; a test pins that it does so without throwing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSCGp6KgcnaepEMbxNroYe
The READ_OUTBOX relay-auth prompt and the settings toggle described the
permission as only loading posts, but authenticating to a follow's relay
also downloads their profile information and post engagement. Update the
prompt title, consequence line, reason label and toggle description to
say so.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012VmzSAtkFv29KWDzPVBbxS
Each PoW job now races PoWPolicy.minerWorkers(cores) = cores/2 parallel
searches (at least 1) instead of a single thread, so a post on an 8-core
phone mines ~4x faster while the other half of the cores stays free for
the UI, and the total CPU budget matches the old 2-job x 1-thread pool.
- PoWPolicy.minerWorkers(availableProcessors) is the single definition of
the per-job worker budget.
- PoWPublishQueue gains minerThreads (used by its built-in miner and
exposed for custom mine lambdas); AppModules wires maxConcurrent = 1 +
minerThreads = half the cores.
- PoWNostrSigner gains a workers param (default 1), covering reactions,
reposts and reports via Account.miningSigner; the anonymous-post paths
in the short-note and comment composers pass the same budget.
- Gift-wrap envelope mining stays single-threaded: the template
conversion is a non-suspend hook inside the synchronous NIP-59 build.
- UI time estimates (settings picker, composer chip, broadcast banner)
now benchmark at the queue's worker count via deviceHashesPerSecond(),
and PoWEstimator caches one rate per worker count.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhaF5scnAvhP9wr9R7bTWM
Third refactor slice (plan: quartz/plans/2026-07-11-concord-event-classes.md).
The kind-33301 public invite bundle is now a proper addressable Event class:
- New cord05Invites/bundle/ConcordInviteBundleEvent (BaseAddressableEvent), with a
build{} template that emits the ['d',''] + ['vsk','6'] tags via the standard DTag
ext and the shared VskTag (ControlEntityKind.INVITE_LIVE). Registered kind 33301
-> ConcordInviteBundleEvent in EventFactory so fetched bundles parse as the class.
- ConcordInviteBundle.build now signs ConcordInviteBundleEvent.build(...) with the
per-link key; the raw TAG_D/TAG_VSK/VSK_LIVE constants are gone. Tag order (d, vsk)
is preserved so the bundle event id / naddr are unchanged — the cord05Invites
tests (bundle round-trip + join flow) pass.
Remaining invite kinds: 3313 direct-invite (rumor has empty tags; p/k index is on
the giftwrap) and 13303 invite-list (unimplemented stub) — tracked in the plan.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig