First slice of the nip88Polls-structure refactor (plan in quartz/plans/
2026-07-11-concord-event-classes.md). A Concord chat rumor IS a standard Nostr
event plus a channel/epoch binding, so stop re-deriving it by kind number:
- New cord03Channels/tags/ChannelTag + EpochTag (typed, parse/assemble), plus
TagArrayBuilderExt (channel/epoch/channelBinding) and TagArrayExt
(concordChannel/concordEpoch/isConcordBoundTo) — the poll-package shape.
- ChannelChat.message/reply now build via ChatEvent.build{ channelBinding(...) }
and assemble the template into a rumor; reaction reuses ReactionEvent.KIND. The
minimal e/p/k reaction tags and q/p reply tags are kept byte-identical for Armada
interop (guarded by the Concord round-trip tests, which pass).
- Drop the ConcordKinds.MESSAGE/COMMENT/REACTION/DELETE aliases (they shadowed
ChatEvent/CommentEvent/ReactionEvent/DeletionEvent KINDs); callers use the real
event KINDs. VoicePresence uses the new tag classes instead of bindingTags.
Remaining Concord-specific kinds (control 3308, invites, guestbook, rekey, voice,
seals/wrap) get their own per-kind packages in later slices per the plan.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
Completes the parity gaps with NIP-29 relay groups:
- Rich invite card (ConcordInviteCard): an invite link in note content now renders
as a tappable card that peeks the kind-33301 bundle (Account.peekConcordInvite) to
show the community name, instead of a bare link. Wired into RichTextViewer.
- Bare naddr (kind 33301) in ClickableRoute now shows an informative label rather
than an empty addressable-note card (a naddr has no unlock token, so it can't be
joined — only the full link can).
- External invite URLs open the app: AndroidManifest intent-filter for
amethyst.social/invite/*, and MainActivity.uriToRoute maps the full URL (fragment
included) to Route.ConcordInvite so the redeem flow keeps the token.
- Messages group-by-community view mode (ConcordViewMode INLINE/GROUPED), the
analog of NIP-29's group-by-relay: GROUPED collapses each community's channels
into one ConcordServerRoomNote row (rendered by ConcordServerRoomCompose, opens the
channel list). Adds updateConcordViewMode + LocalPreferences persistence + feed
invalidation + the ChatroomListKnownFeedFilter branch + a Messages-settings toggle.
Also resolves a silent merge artifact: main's markDmRoomAsRead(signedEvents.msg)
landed in the wraps-only broadcastPrivately overload (no signedEvents in scope);
moved it to the Result overload that carries .msg.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
Adds the two management screens the NIP-29 relay groups have but Concord lacked,
plus a visual overhaul of the create screen:
- Members roster (ConcordMembersScreen): owner + role-holders + banned, each with
avatar/name and owner/admin/banned badge; overflow menu promotes/demotes (owner)
and bans/unbans, gated on the viewer's authority. New AuthorityResolver.roleHolders()
and bannedMembers() accessors expose the privileged roster (membership is otherwise
key possession, so there is no silent-member list).
- Edit metadata (ConcordEditScreen): new write path — ConcordModeration.editMetadata
(METADATA edition, entityId = communityId) + Account.editConcordMetadata; prefilled
from the folded state, honored on fold only for MANAGE_METADATA holders / owner.
- Create screen redesign: shared ConcordMetadataFields with a circular icon hero
(live preview from the URL) + section headers, mirroring the NIP-29 GroupImagePicker
layout, replacing the bare stack of text fields.
- Routes ConcordMembers/ConcordEdit + nav registration + top-bar entry points on the
channel-list screen (Members always, Edit for those who can manage metadata).
- Account.peekConcordInvite for the upcoming invite card.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
The per-post PoW button on the New Post screen now sits before the
Subject/Title toggle instead of last in the options row.
Every PoW surface (composer button, note chip, mining banner, settings tile,
relay-info minimum-PoW row) switches from the Bolt icon to Manufacturing
(gear, U+E726): Bolt is the app's zap/lightning glyph everywhere else, so a
Bolt PoW badge next to the zap-split/zapraiser/invoice buttons read as a
payment feature. Subset font regenerated for the new codepoint.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADb3dez9jPk6QqyQ1rTx4V
Durability & correctness:
- Keep the job entry + disk checkpoint alive until the publish continuation
completes (was: dropped at mining-complete); failed publishes keep their
checkpoint for restart retry and surface through a failures SharedFlow
- Move draft deletion into the publish continuations in every composer so a
cancelled or process-killed mining job can't destroy the only copy of a post
- Persist gift-wrap mining: split NIP17Factory into createSeals (signer
interaction, runs inline) + wrapSeal (pure CPU, runs on the queue), new
REPLAY_WRAPS records restore pending DM wraps after process death
- Clamp synced NIP-78 difficulty (PoWPolicy.MAX_DIFFICULTY), require a sane
target in PoWMiner, bound PoWRankEvaluator against short ids
- Reaction double-tap while mining now toggles (dedupeKey + cancelByKey)
instead of publishing duplicates
- Restore checkpoints for every loaded account, not just the active one
- logOff purges the account's checkpoints and cancels its queued jobs
- Private notes: composer chip now gates on the gift-wrap kind and the
per-post override reaches sendPrivateNote
Coverage:
- Public/live chat (kinds 42 + 1311) and voice replies (1244 + kind-1 audio
replies) now route through the mining gate
Perf:
- FGS start() dedupes with a running flag; PendingIntents built once
- Banner 1 Hz clock only ticks while a job shows elapsed time
- PoWEstimator benchmark is single-flight behind a Mutex
UX / l10n:
- Post-mining failures toast with retry information
- Count strings converted to <plurals>; elapsed time via DateUtils; settings
estimate uses localized units; shared powKindLabelRes replaces three
duplicated kind→label maps; dead pow_chip_* strings removed
- CLI: pow mine lowercases the pubkey before mining (uppercase hex mined an
id that never matches the signed event) and validates via quartz Hex
New queue tests: checkpoint lifetime, failure reporting, cancelByKey toggle,
per-owner cancellation.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADb3dez9jPk6QqyQ1rTx4V
The Android assembler was hand-rolling the plane-address -> per-relay
kind-1059 RelayBasedFilter collapse (with per-relay since). That logic is
platform-agnostic — RelayBasedFilter and SincePerRelayMap are both
commons/quartz types — so lift it to ConcordSubscriptionPlanner.relayBasedFilters
alongside the existing filtersByRelay one-shot variant. The assembler now only
does the account-dependent step (deriving channel planes from folded session
state) and delegates the collapse. Covered by a new planner unit test.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
- ConcordHomeScreen: each community row now shows a circular avatar
(RobohashFallbackAsyncImage, community icon or robohash fallback) and a
pluralized channel count, re-read reactively on each Control-Plane fold.
- ConcordChannelListScreen: channel rows show a type icon (# public / lock
private / mic voice) and an explicit empty state, styled like the relay-group
channel list (thin dividers).
- Inbox row (ConcordRoomCompose): passes the folded community icon to ChannelName
instead of null, so Messages shows the community avatar.
- ConcordChannel exposes communityIcon from the folded MetadataEntity.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
Replaces the comma-separated relay text box with the app's RelayUrlEditField
(type a relay → NIP-11 name/icon autocomplete, tap to add), shown above a
removable list of chosen relays. Adds an optional community icon URL, plumbed
through createConcordCommunity → ConcordActions.createCommunity →
ConcordCommunityFactory into MetadataEntity.icon. Section header styling matches
RelayGroupMetadataScreen.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
Adds an owner-only role toggle to the Concord note quick-action menu, alongside
Ban:
- Account.concordAdminTarget gates the action to the owner (only rank 0 strictly
outranks the position-1 Admin role, as the resolver requires), never the
owner's own note or the owner as target, and reports whether the author is
already an admin (via the new AuthorityResolver.rolesOf accessor).
- makeConcordAdmin mints a default Admin role (all management + moderation
permissions, position 1) if the community doesn't have one yet, then grants it;
removeConcordAdmin revokes via an empty grant.
- The menu item flips between "Make admin" and "Remove admin" and fires
toggleConcordAdmin.
Extends ConcordModerationTest to cover rolesOf and revoke-via-empty-grant.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
Restructure the global auth control into a top-level mode — Always authenticate,
Never authenticate, or Custom — where Custom reveals independent per-situation
toggles instead of the confusing single sub-toggle:
- My relays and venues (own relays + joined/subscribed/favorited venues) — on
- Read posts from people I follow — on
- Message people I follow (DMs, replies, notifications) — on
- Message anyone / strangers — off by default (you're asked each time instead)
RelayAuthPolicy is now {ALWAYS, NEVER, CUSTOM}. The resolver takes a
RelayAuthCustomToggles plus split serves-facts (followed-read, followed-write,
stranger-write, own-relay, venue) and, under CUSTOM, allows if any enabled
category matches — else falls through to a prompt. There is deliberately no
"read strangers' posts" category, so that always prompts.
Account settings replace the single delivery flag with four persisted booleans
(default policy CUSTOM; no migration, unreleased). The contextual DM/notification
prompt button now switches to CUSTOM and enables both message toggles. Resolver
tests rewritten per-toggle, including that reading a stranger is never
auto-allowed. Old IF_IN_MY_LIST / TRUSTED_FOLLOWS policies and their strings are
removed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZjmYpgHP4pf79Sav5QT8a
Restructure the two TRUSTED_FOLLOWS controls so they read as independent ideas
instead of a confusing read/write sub-toggle:
- The "My relays and people I follow" policy now trusts a followed user as any
counterparty — reading their posts AND reaching them (DM/notification) — plus
your own relays and joined venues. Reading your follows is no longer gated.
- The sub-toggle is repurposed to "Also log in to deliver my messages": trust a
relay to send DMs, replies or notifications to anyone you're talking to, even
people you don't follow.
Resolver: replace servesFollowed{Write,Read}Counterparty with a single
servesFollowedCounterparty, add servesWriteCounterparty (an inbox of anyone
you're messaging), and gate the latter behind the new
messageDeliveryTrustEnabled input. Rename the account setting
relayAuthTrustFollowsForReads -> relayAuthTrustMessageDelivery (+ pref key;
no migration needed, unreleased). The contextual prompt button moves from
read-post prompts to DM/notification prompts and now enables message delivery.
Resolver tests updated for the new inputs, including that the delivery toggle
is write-only and never auto-allows reading a stranger.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZjmYpgHP4pf79Sav5QT8a
The purpose model was person-centric, so an auth-required NIP-28 channel,
NIP-72 community, or NIP-53 live stream broke: reads filter by #e/#a with
no authors, so nothing was attributable -> silent DENY (chat wouldn't
load); top-level posts (no p tags) also silently failed; replies were
mis-attributed as "notify" and trusted on the wrong signal.
Add venue purposes (POST_VENUE / READ_VENUE) carrying the venue id
(channel event-id, or community/live `kind:pubkey:dTag` address). The
deriver recognizes kind-42 channel posts (root `e`), community/live posts
and reads (`#a` 34550/30311), and channel reads (`#e`). Venues are trusted
under TRUSTED_FOLLOWS when you've joined them (publicChatList /
communityList) or their owner — the pubkey in the address, e.g. a live
stream's host — is someone you follow; trusted venues auto-auth for both
reading and posting.
Safety net: any active use we still can't attribute yields an OTHER
purpose so the relay is prompted about instead of failing silently.
Prompt copy gains venue-aware titles/consequences ("Post to this room?",
"If you don't, your message won't be posted."). Default policy already
TRUSTED_FOLLOWS, so joined venues just work. Unit-tested across resolver
and deriver.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZjmYpgHP4pf79Sav5QT8a
Consume the new give-up signal: RelayPublishFailureToastSubscription
(hosted in LoggedInPage) listens for onEventGaveUp and toasts "couldn't
deliver to <relay>", so a dropped send is visible instead of silent.
Rationale polish in the auth settings screen: each relay card now shows
"Last used N ago" and a Forget button that clears both the ALLOW/DENY
override and the accumulated rationale for that relay. The store gains
clearRationale + allLastUsed (default-implemented on the interface) and
records a last-used timestamp on each grant; clearDecision/clearRationale
now prune the shared url key only when a relay has neither an override
nor rationale left, so a partial clear never orphans the reverse-lookup.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZjmYpgHP4pf79Sav5QT8a
Extend RelayAuthPermissionStore with grant rationale (purpose ->
counterparty pubkeys), default no-op so other implementers are
unaffected; DataStoreRelayAuthPermissionStore persists it per relay,
merging counterparties across grants. AuthCoordinator records the
rationale via ledger.recordGrant whenever it authenticates (auto-allow,
override, or approved prompt). The relay-auth settings screen adds a
"Why you're logged in to these relays" section: per relay, purpose-
grouped rows ("Send your private message to:", "Download posts from:")
with each counterparty's avatar + name. Unit-tested: grouping, cross-
grant merge, and that counterparty-less purposes aren't recorded.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZjmYpgHP4pf79Sav5QT8a
Introduce the platform-neutral decision core for contextual NIP-42 auth
in commons/relayauth:
- AuthPurpose / AuthPurposeKind / RelayAuthContext describe *why* a relay
wants auth (send DM, deliver notification, read outbox, own relay).
- RelayAuthVerdict adds an ASK outcome (runtime-only; never persisted,
unlike the two-value RelayAuthDecision override).
- RelayAuthResolver is a pure, unit-tested precedence ladder: blocked
list > per-relay override > policy > ASK-if-attributable-else-DENY.
- New TRUSTED_FOLLOWS policy: auto-auth for relays serving a followed
counterparty on a write purpose (DMs/notifications), and — behind a
read sub-toggle — read purposes; strangers fall through to ASK.
Wires the new enum value through the existing settings screen (new
option + strings, reusing the Group symbol) and the URL-only ledger path
(degrades to the my-list check until challenge context is plumbed).
Live prompt UI, grant-rationale persistence, and quartz challenge-context
plumbing are follow-up steps.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZjmYpgHP4pf79Sav5QT8a
Chat rows render the inner rumor note of a NIP-17 message, but relay
attribution only landed there through narrow windows, so accepted relays
often never showed as icons:
- Duplicate deliveries were stranded on the wrap: a gift wrap re-delivered
by a second relay hit the duplicate branch of consumeRegularEvent, which
tagged the outer wrap note only and never re-processed the event. Extract
the OK-path drilling (wrap -> seal -> rumor) into
LocalCache.addRelayToNoteAndInners and call it from both the OK
confirmation path (markAsSeen) and the duplicate EVENT path, replacing
CacheClientConnector's private copy.
- Cross-thread visibility: Note.event, Note.relays, Note.flowSet and the
innerEventId of GiftWrapEvent/SealedRumorEvent are written by decrypt/index
coroutines and read lock-free on relay socket threads; a stale read parks
an acceptance on the outer envelope permanently. Mark them @Volatile.
- Orphaned UI flows: RenderClosedRelayList/RenderAllRelayList and
createMustShowExpandButtonFlows captured note.flow().relays.stateFlow once
in remember/stateIn; MemoryTrimmingService.cleanObservers destroys the
unobserved NoteFlowSet while the lifecycle is stopped, so resumed rows
never saw another relay update. Wrap in a cold flow that re-resolves
flow() on every collection start.
- Indexing latency: sent DMs waited for the ~1s newEventBundles batcher
before the self-wrap was unwrapped and the message reached the chatroom,
parking early OKs on the wrap. broadcastPrivately and
sendNip04PrivateMessage now run the EventProcessor on the freshly
consumed note immediately; the batched re-delivery is idempotent.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YEiq1NMK3q12KGQ2yYhPEp
Findings from a full NIP-13 review:
- The spec recommends updating created_at while mining. Queue jobs now
re-stamp the template to "now" when a worker picks them up (a post can
wait behind other jobs, and a job restored after process death could be
hours old); the restorer does the same. Scheduled posts are exempt —
their future created_at is intentional. Anonymous posts re-stamp before
mining against the throwaway key.
- PoWTag.assemble(nonce, null) serialized the literal string "null" as
the third tag entry; a missing commitment now omits the entry entirely.
- New tests: PoWTagTest pins the NIP-13 example tag shape and the
no-commitment round trip; a queue test asserts the created_at
re-stamp at mining start.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADb3dez9jPk6QqyQ1rTx4V
Trailing spaces and newlines in a note's content produced empty trailing
paragraphs after the line split, each rendered as a blank FlowRow line
between the last visible word and the end of the component. Trim the
content's tail before segmenting, and return no paragraphs for
whitespace-only content.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UZET7ig8MvR5vSCSxvcURi
ConcordModeration builds the Control Plane editions for defining a role,
granting roles to a member, and banning/unbanning — each a kind-3308 edition,
plaintext-sealed and wrapped on the Control Plane. It chains the next version
onto the entity's current head (version+1, prevHash=head.hash) and unions the
banlist, taking the community's current editions as input.
Authority is enforced at fold time by the AuthorityResolver, not here: a round-
trip test confirms an owner can define an Admin role, grant it (member gains BAN),
ban/unban (version chaining heals), and that a grant forged by a non-outranking
troll is dropped by the fold.
- ConcordActions.controlEditions opens control wraps into editions
- ConcordCommunitySession exposes controlEditions()/controlPlaneKey() so edits
chain onto live state
- Account.grantConcordRole / banConcordMember / unbanConcordMember publish the
edition with an instant local echo (the session refolds it)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
- Composer chip: replaced the text button with a bolt icon carrying the
difficulty as a small badge (primary-tinted when active, dimmed when
off), matching the layered-icon language of the rest of the options
row; the current choice is bolded in the override menu.
- Mining banner: pulsing bolt while the nonce search runs, per-job
elapsed time driven by a 1 Hz clock ("Note • mining at 20 bits • 12s"),
and proper close IconButtons instead of a text "×". PoWJobState now
carries miningStartedAt for the elapsed display.
- Settings: a live "≈ 45s per post on this device" estimate under the
difficulty picker, from a one-shot cached sha256 benchmark
(PoWEstimator in commons) and the 2^d expected-attempts mean.
- Received notes: DisplayPoW's plain "PoW-24" text is now a compact
secondaryContainer pill (bolt + bits) used by NoteCompose, thread view
and chat messages.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADb3dez9jPk6QqyQ1rTx4V
Receiving reactions/replies already worked (they decrypt via channelRumors and
wire to their target Note by id). This adds the send side, which must NOT go
through the generic public/NIP-17 reaction path — a plaintext kind-7 would e-tag
the private rumor id onto public relays, and the NIP-17 path wraps to named
recipients, not the channel plane.
- ChannelChat.reaction/reply build kind-7/kind-9 rumors bound to channel+epoch
(reaction e-tags the target; reply q-tags the parent)
- ConcordActions.buildChannelReaction/buildChannelReply wrap them on the plane
- Account.reactToConcordMessage + sendConcordChannelMessage(replyTo) publish the
wrap with an instant local echo, factored through publishConcordWrap
- AccountViewModel.reactToOrDelete intercepts Concord notes (detected by the
ConcordChannel gatherer) and routes to the plane-wrapped reaction
- ConcordChannelScreen wires onWantsToReply into the composer with a reply banner
Zaps already route correctly: a Concord message is an unsigned rumor, so the
existing isPrivateRumor() path forces a PRIVATE (NIP-57 encrypted) zap, same as
NIP-17 DMs. A fully on-plane nutzap (no public receipt) remains a future upgrade.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
Backgrounding the app no longer pauses or loses mining:
- commons: PoWPublishQueue checkpoints template jobs through a
PoWJobPersistence hook (saved on enqueue, removed on finish/cancel,
deduped by id so restore is idempotent) and fires onQueueActive on
every enqueue. PoWReplay flattens the live continuation into a
PersistedPoWJob record (broadcast / to-relays / schedule) that can be
replayed headlessly. Opaque enqueueWork jobs (reactions, reposts, gift
wraps) and anonymous posts stay in-memory on purpose — a throwaway key
and its content must never touch disk.
- amethyst: PowJobStore (atomic-rename JSON file, single-lane writer,
3-day staleness purge) and PowJobRestorer, which re-enqueues an
account's checkpointed jobs on login and finishes them via the replay
descriptor. Composers now pass the matching PoWReplay for the public,
group-thread and scheduled paths.
- PowMiningForegroundService: a shortService-type FGS started on every
enqueue (~3 min guaranteed budget, no special permission) that keeps
the process out of the cached-apps freezer while mining. Stops itself
when the queue drains; on onTimeout it exits cleanly since the jobs
are checkpointed. The notification is a live progress card
(NotificationCompat.ProgressStyle): one track segment per post filling
as jobs finish, indeterminate for a single post, per-kind text, a
cancel-all action, and Live Updates rendering on Android 16+.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADb3dez9jPk6QqyQ1rTx4V
Follows the NIP-28/NIP-17 model instead of a private per-session flow. The
community session is now purely a decrypt-and-validate gate: each validated
inner rumor is handed to a sink (ConcordRumorSink) that the app wires to
LocalCache.consumeConcordRumor — landing it as a real Note keyed by rumor id
and attaching message-like kinds (9/1111) to the ConcordChannel.
Consequences: messages are first-class Notes, so reactions (kind 7), replies
(1111), deletes (5), OTS and zaps wire up automatically by id through the normal
consume path; the Messages inbox shows real last-message previews and updates
incrementally (filterRelevantConcordMessages, keyed on the ConcordChannel
gatherer) instead of feed() rebuilds; and the chat screen reuses the shared
ChannelFeedViewModel + RefreshingChatroomFeedView (full note UI) with only the
Concord-specific encrypted send path kept bespoke.
- ConcordActions.channelRumors returns validated bound rumors (all chat kinds)
- ConcordCommunitySession/SessionRegistry/SessionManager thread the sink; the
messagesFlow/ConcordChatMessage projection is gone from the session
- LocalCache.consumeConcordRumor attaches gatherer before justConsume so the
inbox incremental filter can route the row
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
Implements user-facing PoW publishing (#3317) on top of the existing
quartz miner:
- quartz: PoWMiner.run gains a cooperative isActive cancellation hook
(checked every ~VALID_BYTES^2 hashes); PoWNostrSigner decorator mines
kind-scoped templates pre-signature so it composes with every signer;
GiftWrapEvent.create/NIP17Factory can mine the outer ephemeral-key
wrap (never the seal/rumor); NostrSignerWithClientTag exposes
prepareTags so mining runs over the final tag set.
- commons: PoWPublishQueue (FIFO, capped worker pool on
Dispatchers.Default, per-job cancel, in-memory only — unmined posts
are lost on process death, logged) and PoWPolicy (kind-group
categories with a hardcoded NEVER list: auth, zap requests, NWC and
bunker RPC, HTTP/Blossom auth, drafts, metadata and lists, OTS).
- amethyst: per-account synced settings (difficulty Off/16/20/24/28 or
custom, per-category checklist) in Compose Settings; Post enqueues the
template and returns immediately; reactions, reposts, reports, private
notes, DMs and long-form route through the same shouldMine gate at
their existing choke points; per-post PoW override chip in the
composer options row; "Mining proof of work… (N in queue)" phase with
per-job cancel in the broadcast banner.
Scheduled posts and anonymous posts mine against the correct key
(scheduled posts skip mining in v1); the client tag is applied to the
template before mining so signing never invalidates the nonce.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADb3dez9jPk6QqyQ1rTx4V
RichTextParser now classifies `…/invite/<naddr>#<fragment>` URLs as a
ConcordInviteLinkSegment (cheap substring gate before the base64/bech32 parse),
and RichTextViewer renders them via ClickableConcordInviteLink — tap opens the
redeem flow, long-press copies the link.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
Adds ConcordSessionManager (commons) that keeps one folding session per
joined community in step with the concord list and turns folds into an
observable revision the app watches to re-derive subscription filters.
Account owns one per account; the giftwrap decrypt path (GiftWrapEventHandler)
routes recognized Concord plane wraps to it before the NIP-59 DM check, so
ephemeral-p Concord wraps fold instead of being dropped as undecryptable DMs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
Holds one live ConcordCommunitySession per joined community, fans inbound
kind-1059 wraps out to the owning session, and exposes the union of
control/channel plane addresses to subscribe. sync() reconciles sessions
against the joined list while preserving already-folded state.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
The live, stateful read-model of one joined community that a screen/ViewModel
binds to and a subscription feeds — bridging the pure logic to the UI:
- derives the Control Plane address up front (from the entry's secrets)
- ingest(wrap) routes by stream address: control wraps re-fold into a
state StateFlow (metadata + channels + authority) and re-derive each channel's
Chat Plane address; channel wraps re-project into per-channel message flows
- exposes controlPlaneAddress + channelAddresses() (what to subscribe to),
state, membership(), and messagesFlow(channelId)
- thread-safe; unknown wraps (other communities) are ignored
Test: feed genesis control wraps -> "Nostrichs" + #general + OWNER membership +
the general plane becomes a known address; feed a channel message -> it lands in
#general's flow; a stray community's wrap is ignored. Green on :commons:jvmTest.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
The read-path counterpart to the subscription planner: maps derived plane
addresses (group_key.pk) to the keys that open them, so an inbound kind-1059 wrap
is recognized as Concord traffic and decrypted with the right per-plane key.
- registerControlPlanes(entries): control addresses, known from secrets alone
- registerChannels(entry, foldedState): channel addresses, known after the
Control Plane folds
- route(wrap): if wrap.pubkey is a registered plane, open+verify and return the
routed rumor with its plane kind/community/channel; else null
Because the wrap p-tag is ephemeral, address matching is the only route — a
non-member never registers the address, so never decrypts. Thread-safe.
Test routes a genesis control wrap to CONTROL and a channel message to CHANNEL
(right channel id + decrypted content), and rejects a wrap from another
community. Green on :commons:jvmTest.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
The commons half of the "keep channels live" subscription, mirroring NIP-29's
RelayGroupMyJoinedGroupsFilterAssembler: turns the account's joined-communities
list into per-plane REQs by derived stream address (there is no #p=me for
Concord, so each plane is fetched by authors=[planePk]).
- ConcordSubscriptionPlanner.controlPlaneSubs: one Control Plane sub per joined
community (known from the entry's secrets alone)
- channelPlaneSubs: one Chat Plane sub per channel once the Control Plane folds
- filtersByRelay: collapses subs to one kind-1059 author filter per relay
- ConcordActions.planeFilterFor(authors): multi-author plane filter
Test derives a community, asserts the control/channel sub addresses equal the
derived plane pks and that the per-relay filter carries both. Green on
:commons:jvmTest.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
The channel model the shared chat UI renders, mirroring RelayGroupChannel:
- quartz ConcordChannelId(communityId, channelId): the stable channel address
(analog of NIP-29 GroupId, but community-scoped rather than relay-pinned)
- commons ConcordChannel : Channel — name/voice/private from the folded Control
Plane (no single relay-signed metadata event), relays() = the community relay
set (a plane may be mirrored on several), membership from the authority
resolver, canPost(), placeholderNote() for immediate Messages-list rows;
updateFrom(state, relays, myPubKey) refreshes on each re-fold
- LocalCache: concordChannels LargeCache index + getOrCreate/getIfExists
Compiles across :quartz/:commons/:amethyst. Next: the decryption subscription
that folds inbound channel-plane wraps into these, then the Messages inbox hook.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
The per-account home base for Concord Channels, mirroring RelayGroupListState:
- commons ConcordChannelListState observes the kind-13302 addressable note,
exposes liveCommunities (joined secret-bearing entries) and liveServers
(community ids), and follow/unfollow read-modify-write the self-encrypted list;
a ConcordListRepository backs offline restore
- AccountSettings implements ConcordListRepository (backupConcordList +
concordList/updateConcordListTo) and gains a concordViewMode setting
- Account instantiates concordChannelList and exposes joinConcordCommunity(entry)
/ leaveConcordCommunity(id), publishing the list via the account outbox
Concord's "in-between" shows here: same wiring as the NIP-29 list, but entries
carry the community secrets (decrypt yields root/salt/epoch/channel keys), so one
self-encrypted event both syncs membership and re-derives every plane. Compiles
across :commons and :amethyst.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
First slice of the Android integration, mirroring NIP-29's commons model:
- ConcordMembership (OWNER/ADMIN/MEMBER/BANNED/NONE) derived from the folded
owner-rooted AuthorityResolver + banlist — membership is key possession, roles
layer moderation power, the banlist removes standing; with isMember/canModerate
- ConcordViewMode (INLINE/GROUPED) for how joined channels surface in Messages
Test classifies owner/admin/member/banned/stranger from a folded control plane.
Green on :commons:jvmTest.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
Pure builders + relay-filter assembly + folding for Concord, usable from amy CLI
and the Android app (like DmActions, it never touches the network):
- plane key derivation (controlPlane/publicChannel)
- relay filters (planeFilter, bundleFilter, directInvitesFilter)
- createCommunity, foldCommunity (open control wraps -> editions -> live state)
- buildChannelMessage + channelMessages (open, bind-check, order oldest-first)
- invite helpers: inviteFor, mintInviteLink, parseInviteLink, openBundle
(decrypt+validate), controlPlaneFor
Test covers the create -> fold -> send -> read round-trip and the mint -> parse
-> open -> read invite flow. Green on :commons:jvmTest.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
Relay targeting is fully distributed: every feed, loader, finder and
broadcast path builds its own relay set and hands it to the shared
INostrClient. Only the follow-outbox flows and the top-nav feed filters
subtracted the NIP-51 kind:10006 blocked list, so blocked relays still
leaked in through the event/thread loaders (FilterMissingEvents /
FilterMissingAddressables), the user-metadata finder
(pickRelaysToLoadUsers), channel finder, DM targeting, the one-shot
fetch helpers, and the publish path (Account.computeRelayListToBroadcast)
— none of which consulted the blocked set.
Add BlockedRelayFilteringClient, a thin INostrClient decorator that
strips the active account's blocked relays from subscribe, count and
publish right before they reach the pool. Because the one-shot fetch
helpers route through subscribe/count, wrapping the client covers them
too. The blocked set is read per-call so account switches and list
edits apply with nothing to invalidate.
Wire it around the shared app client (blocked set from the logged-in
account) and around the per-account crawl client used by Event Sync and
Cashu discovery. Add commonTest coverage for the filtering, pass-through,
fully-blocked, and per-call-read behaviors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JNMPdC2eGUwTrt3XkQefuf
Add a regression test proving RelayGroupChannel.placeholderNote() carries the
channel as a gatherer and is cached/stable — this is what lets the Messages row
renderer resolve the event-less placeholder back to the group (the prior fix).
Also give the empty-group placeholder row a visible "No messages yet" second
line instead of blank content, matching the Marmot-group row, so a just-joined
group with no messages reads clearly rather than looking like an empty item.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
The Messages tab (INLINE mode) mapped each joined group to its newest cached
kind-9 message and dropped it when none existed. Since the joined-groups
subscription only fetches roster kinds (39000/1/2), not chat, a group you just
joined stayed invisible until you opened it (loading messages) or posted — unlike
Marmot groups, which already fall back to a placeholder row.
Mirror the Marmot pattern for relay groups:
- RelayGroupChannel.placeholderNote(): a cached synthetic note that adds the
channel as a gatherer, so the existing Messages row renderer resolves it back
to the group (RelayGroupRoomCompose already handles a null-event note).
- ChatroomListKnownFeedFilter.feed(): fall back to placeholderNote() when the
group has no loaded message.
- AccountFeedContentStates: rebuild dmKnown when relayGroupList (kind 10009)
changes — join/leave doesn't flow through newEventBundles, so without this the
placeholder wouldn't appear until a later event.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
Three group-discovery card changes:
- Reactive loaded-message count. The preview subscription streams the group's
recent kind-9 chats into the channel note cache; the card now shows that count
on the stats line ("12 members · 50+ messages"), updating live as messages
arrive. Bumped the preview page 15 -> 50 so an active chat reads as "50+"
(also a better warm-up); the display caps at DISCOVERY_MESSAGE_CAP.
- People-you-follow social proof. RelayGroupChannel.participatingFollows()
intersects the relay-signed roster with the kind-3 follow set; the card shows
an overlapping face pile + "%d people you follow" caption when non-empty.
- Split the relay chip's tap targets. Tapping the chip body now opens that
relay's full group list (Route.RelayGroupServer); only the star toggles the
relay favorite. Previously the whole chip favorited, which was easy to hit by
accident.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
The sealed GroupDiscoveryConstraint matcher (AllGroups/ByPeople/ByHashtags/
ByGeohashes/AnyOf) is pure over RelayGroupChannel + HexKey with no LocalCache,
eose-manager, or topNavFeeds dependency, so it belongs in :commons alongside
RelayGroupChannel where Desktop/CLI can reuse it. The amethyst dal keeps only
the platform-specific toGroupConstraints() mapping from the Android top-nav
filter set onto the shared matcher.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
The synchronized intrinsic is only available on JVM/Android, breaking the
iOS (compileKotlinIosSimulatorArm64) build in commonMain. Replace the Any()
lock with a kotlinx.coroutines Mutex + withLock, which is KMP-common and
safe here since all three store methods are already suspend functions.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A918G2Ks9i9LXRuVoNgm7R