Makes location channels reachable and manageable natively:
- quartz GeohashChannelLevel: the named Bitchat precision levels
(region2/province4/city5/neighborhood6/block7/building8) with cellFor
truncation, so one location fix yields the whole ladder. Tested.
- LocationState.preciseGeohashStateFlow: an 8-char (building) location flow
alongside the existing 5-char one (untouched, so the "around me" feed is
unchanged); channels truncate it per level.
- NewGeohashChatScreen (Route.NewGeohashChat): join a cell from your current
location (region -> building, with place names via LoadCityName) or by typing
a geohash. Joining adds it to the kind-10081 geohash list (followGeohash) and
opens the chat. Includes a "Your channels" section that lists joined cells with
open + Leave (unfollow) -- the management surface the list previously lacked
(only the per-cell Follow toggle on the notes screen existed).
- Adds a "Location channel" option to the Messages "+" new-conversation chooser.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0172JoMccseEKenyWan6txWV
Geohash chat stays anonymous by default (posting under the real npub would
broadcast the user's location tied to their public identity), but instead of a
random per-device seed the throwaway identity is now derived from the account:
- GeohashKeyDerivation.accountSeed(privKey) = HMAC(privKey, versioned label);
per-geohash keys are HMAC(seed, geohash). The result is stable across the
user's devices and recoverable from the account, yet publicly unlinkable to
their npub and across cells (all one-way).
- GeohashChatIdentity picks the account-derived seed for local-key accounts and
falls back to the random encrypted device seed for NIP-46/NIP-55 signers whose
key we can't reach.
Tests cover determinism, account-specificity, and cross-cell unlinkability.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0172JoMccseEKenyWan6txWV
Adds the wire-level protocol for interoperating with Bitchat's Nostr location
channels:
- GeohashChatEvent (kind 20000): plain-text public geohash message with a single
exact ["g", geohash] tag plus optional ["n", nickname] and ["t","teleport"].
- GeohashPresenceEvent (kind 20001): presence heartbeat carrying only the g tag.
- GeohashKeyDerivation: deterministic, unlinkable per-geohash ephemeral identity
(HMAC-SHA256(deviceSeed, geohash||counter) with retry + SHA-256 fallback).
- Registers both kinds in EventFactory; NIP-13 PoW reuses the existing PoWTag.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0172JoMccseEKenyWan6txWV
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
`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
Address the deep-audit findings across the new Concord code:
- H1: stop persisting kind-21059 ephemeral typing wraps as durable notes.
EphemeralGiftWrapEvent extends GiftWrapEvent, so every heartbeat was
stored forever; drop it once the session has ingested it.
- H2: follow()/unfollow() now read the offline backup (entriesWithBackup),
so a join racing the async backup load can no longer wipe the joined list.
- M1 (banlist): fold to the head (honors a chained unban) then union in
authorized editions that aren't ancestors of the head — concurrent bans
are healed without resurrecting an on-chain unban (CORD-06 down-only).
- M2: reproject only the newly-arrived channel wrap incrementally instead
of re-decrypting the whole buffer per message (was O(n^2)); refold only
projects newly-folded channels.
- M3: cancel a session's old state-watcher before replacing it on a
Refounding rebuild (was a coroutine + session leak per rekey).
- M4: publish typing/state/members/observed-authors under the lock and
make revision/observedAuthors updates atomic; clamp future-dated typing.
- M5: notification Concord bypass now requires the community to be one this
account has currently joined (mirrors the Marmot guard).
- M6: Concord chat honors the "Messages in notifications" toggle.
- L1: carry NIP-30 emoji tags on minichat replies, image captions and
custom-emoji reactions.
- C1: make the composer VM init() idempotent so recomposition can't wipe a
picked image or an open suggestion list.
- C2/C3: ConcordHome channel rows and unread badges react to the channel's
own notes flow instead of the global revision (no stale rows / flicker).
- C4: try/finally around mint-invite / create / save so a thrown call can't
strand the button disabled.
- C5: gate the typing ticker on active heartbeats so an idle channel stops
waking a 2s loop.
Adds regression tests for concurrent-ban union-heal and unauthorized bans.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
Verified against the mdk-core revision whitenoise-rs pins (marmot-protocol/mdk
@e8cd584): its NostrGroupDataExtension parser consumes name/description/admins/
relays/image_hash/image_key/image_nonce/image_upload_key and rejects ANY trailing
bytes at a known version, and its extension/group_image.rs fully implements avatar
encryption. The previous "canonical raw-key + media_type" approach both (a) added a
trailing media_type field that mdk rejects — breaking the whole group for whitenoise
members — and (b) used a key scheme mdk can't decrypt.
Re-implement to mdk's exact MIP-01 v2 scheme so avatars interoperate byte-for-byte:
- image_key / image_upload_key are HKDF seeds (reusing Mip01ImageCrypto's
mip01-image-encryption-v2 / mip01-blossom-upload-v2 labels; HKDF-SHA256 with empty
salt == mdk's Hkdf::new(None, seed)). AEAD key derived from the seed.
- ChaCha20-Poly1305, 12-byte nonce, EMPTY AAD, image_hash = SHA-256(ciphertext).
- Decrypt tries v2 (HKDF) then falls back to v1 (raw key), exactly like mdk.
- Remove media_type from the wire entirely (and from the model/cipher/uploader), so a
v2 image extension ends at image_upload_key with zero trailing bytes. The plaintext
MIME isn't stored; the display path lets Coil sniff the format.
- Derive the Blossom upload keypair from image_upload_key instead of storing a raw key.
Adds a regression test that reproduces mdk's v1/v2 field consumption and asserts a
v2 image extension has no trailing bytes, plus a test pinning the HKDF-seed + empty-AAD
scheme so future drift from mdk is caught.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JL3GXW1fmHa3xWfQjLLqfp
Wire the shared NIP-30 custom-emoji picker into the Concord composer like the
@-mention flow: typing `:shortcode:` opens ShowEmojiSuggestionList (backed by
EmojiSuggestionState(account.emoji)); WatchAndLoadMyEmojiList loads the user's
packs. On send, account.emoji.findEmojiTags(text) attaches the NIP-30 emoji tags
to the kind-9 rumor (plain message + inline reply), so recipients render the
custom image inline via the shared chat renderer. Image uploads in messages,
icon and banner were already wired.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
Add first-class support for Marmot (MLS-over-Nostr) group avatars.
Protocol (quartz):
- Implement the canonical `marmot-group-image-v1` scheme: raw ChaCha20-Poly1305
key + 12-byte nonce, AAD = "marmot-group-image-v1" || 0x00 || media_type,
image_hash = SHA-256(ciphertext). MarmotGroupImageEncryption emits canonical
and decrypts both canonical and the deprecated MIP-01 HKDF-seed scheme.
- Add the `image_media_type` field to MarmotGroupData as a trailing TLS field
(older readers ignore it; disappearing_message_secs stays positionally
unambiguous). Add withImage/withoutImage helpers.
- MarmotGroupImageCipher (NostrCipher) drives both encrypted upload and
transparent decrypt-on-download.
Model/manager (commons):
- MarmotGroupChatroom exposes an `image` StateFlow; MarmotManager.syncMetadataTo
populates it from the group metadata.
Android:
- Show the decrypted group icon in the Messages feed; when a group has no image,
fall back to the NIP-11 icon of one of its relays (fetched on cache miss).
- Group metadata editing gains an icon picker (add/change/remove) in both the
create and edit screens; create also gains a description field. Icons are
encrypted and uploaded to Blossom via UploadOrchestrator, signed with a fresh
per-image keypair stored as image_upload_key.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JL3GXW1fmHa3xWfQjLLqfp
Two roster gaps against the reference client (Armada):
1. Moderators showed as "Admin" and role definitions were often empty. The
displayed roles came from ConcordCommunityState.roles, which was built from the
RAW structural fold heads — so a rogue higher-version edition on a role's
coordinate (e.g. marking the Admin role deleted) corrupted or emptied the roster,
and even when present the UI collapsed every role-holder to a single "Admin"
badge. Now state.roles comes from the authority-gated resolver
(AuthorityResolver.roles(), exposed alongside rolesFor()), and ConcordMembersScreen
renders each member's actual most-privileged role name (Admin / Moderator / custom).
2. Member count was a fraction of the real one (e.g. 10 vs ~44). CORD-02 §5: "an
author seen publishing is observably present, auto-included even if their Join
never arrived." The roster only counted Guestbook joiners + the privileged roster,
omitting the bulk of members who never post a Join. ConcordCommunitySession now
tracks observedAuthors from every decrypted channel message and folds them into
allMembers() and the roster.
Also: amy's `concord roles/grant/ban/...` now register the control-plane stream key
before draining (like `channels`/`read`/`send` already do), so the mod verbs aren't
served an empty fold on NIP-42-gated relays — used to ground-truth the resolved roles.
Verified via amy against live Soapbox: `concord roles` now returns Admin (pos 1) and
Moderator (pos 2) instead of []. quartz + commons concord suites green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A "like" on a group message was built as a plain NIP-25 reaction — `e`
(message) + `p` (author) + `k` — with no `h` tag. The recipient's only
notification query that reaches the group's host relay,
filterGroupNotificationsToPubkey, is scoped `#p`=them AND `#h`=their
groups (kind 7 is already in GroupNotificationKinds), so a like with no
`h` tag is never matched there. It would only surface if NIP-65 routing
happened to drop it on one of the recipient's inbox relays — never for a
host-relay-only group — so likes on group messages effectively never
notified.
Copy the target's `h` tag onto public reactions to group-scoped events,
mirroring how kind-9 replies carry it. ReactionEvent.build gains an
`initializer` (the API GroupScope's KDoc already documented); ReactionAction
applies the group `h` tag for both the tracked and fire-and-forget paths.
The like now lands on the host relay in-group and the existing kind-7
`#p`+`#h` query picks it up.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VxpT7J4xt37EF5yJDw1htK
The fold selected each entity's head by STRUCTURAL fold only and never dropped
editions from unauthorized signers, so a spoofed edition that structurally
supersedes a legit one won — on the live Soapbox community this surfaced a decoy
metadata edition ("invalid ... clients should be ignoring this", no icon) over
the real owner-authorized one, so no community icon/banner ever rendered.
Two stacked wire/authority bugs, both fixed:
- RoleEntity.scope was typed String, but the reference client (Armada) writes it
as an object ({"kind":"server"} / {"kind":"channel","channel_id":...}) per
CORD-04 §2. The type mismatch failed the whole RoleEntity decode, dropping the
role — and with it every grant depending on it — so no admin ever resolved.
Added RoleScope and retyped the field (no writer set it, so no migration).
- AuthorityResolver.resolve now folds each role/grant/banlist CHAIN through
authorized editions only, via an owner-rooted fixpoint over the full edition
set (not the post-hoc structural heads). A rogue higher-version grant from an
unprivileged key is dropped instead of superseding the owner's grant, so the
legit authority stands. ConcordCommunityState.fold then gates metadata by
MANAGE_METADATA, channels by MANAGE_CHANNELS, the banlist by BAN, and the
dissolution tombstone to the owner alone.
Verified live end-to-end: `amy concord channels Soapbox` now folds the real
metadata (name "Soapbox Community", the blossom icon + banner pointers) and all
9 channels; fetching + AES-256-GCM-decrypting the icon pointer yields a
hash-verified PNG. Regression tests cover the object-scoped role and the rogue
superseding grant.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CORD-05 Appendix A.1 says the HKDF `id` is always present — 32 bytes,
all-zero for a label with no meaningful id — and A.6 lists
`concord/invite-key` with `id = 0…0`. inviteBundleKey built its info with
no id at all, so it derived a different bundle key than the reference
client (Armada) and NIP-44 decryption of an Armada-minted invite bundle
failed with "Invalid Mac" — the join aborted with "no valid bundle for
this link". amy round-tripped with itself (same wrong key both sides), so
its own tests passed and the divergence went unnoticed.
Verified by decrypting a real Armada invite bundle: the no-id key fails the
MAC; the zero-id key yields a valid CommunityInvite JSON. Pass ByteArray(32)
like the sibling banlist/dissolved derivations already do.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
After a Refounding (CORD-06 §3), compaction re-wraps each entity's head
verbatim, so a head edited past genesis still carries an `ep`/prev citing
an edition in the prior epoch that a fresh joiner never fetches.
EditionFold.foldEntity required a genesis edition (prevHash == null) and
returned null otherwise, so a fresh login folded the whole Control Plane to
nothing: no community icon, no name, no edited channels (while Armada,
which implements the CORD-04 §1 fresh-joiner rule, showed them all).
Anchor at the lowest-version edition when no genesis is present and walk up
from there. Safe: Amethyst always re-folds the whole buffer from scratch
(no persistent floor), so it is structurally always a fresh joiner;
authority is validated on top by AuthorityResolver, so an unrooted forgery
is still dropped; and a genuine mid-chain gap still stops at the intact
prefix.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Every `amy graperank` / `graperank score` run now reconciles its result into
the local event store as NIP-85 kind:30382 cards signed by the per-observer
service key (cutoff --min-rank, default 2): changed ranks are re-signed,
unchanged ones skipped (no event-id churn), dropped targets retracted with a
kind:5 the store applies on insert. The store is the source of truth, so the
score of a run is durable and reusable instead of ephemeral stdout.
New verbs complete the crawl -> score -> publish pipeline:
- `graperank publish [OBSERVER] [--relay URL[,URL...]]` — transport only:
one NIP-77 up-only reconcile per operator relay over the provider key's
kind:30382 + kind:5, converging the relay to the local set (deletions
propagate, lost cards restored, full-set publish fallback for relays
without negentropy); also refreshes the observer's kind:10040 pointer.
- `graperank rank USER [--provider P] [--refresh]` — the consumer side:
newest card per provider from the local store, with a relay drain on miss.
GrapeRankPublisher (quartz) is reworked accordingly: reconcileAndPublish is
replaced by reconcileLocal (sign+insert+retract against the store) and
syncToRelays (NegentropyStoreSync up-only + blastPublish fallback), with a
commonTest covering upsert/skip/retract and re-carding a retracted target.
BREAKING (--json / flags): `--publish`, `--publish-limit`, `--publish-relay`
and `--bench-sign` are removed from the score command. Its JSON drops
published/publish_rejected/deleted/delete_rejected/skipped_unchanged/
publish_truncated/published_kind/published_to/publish_error/observer_10040/
bench_signed/bench_sign_ms and gains provider_pubkey/min_rank/cards_total/
cards_signed/cards_unchanged/cards_retracted/cards_ms. Publishing moved to
the new `graperank publish` verb.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013WSzVX9RoUxyV3nT3dfc56
Concord channel messages can now carry images, wire-identical to Soapbox
Armada's `encryptAttachments`: a normal channel-bound kind-9 whose ciphertext
URL is appended to the content and annotated by a NIP-92 `imeta` tag with
`encryption-algorithm aes-gcm`, hex `decryption-key`/`decryption-nonce`, and the
plaintext `ox` hash (no `x`). The blob is AES-256-GCM ciphertext on Blossom, so
the media host and relays only ever see encrypted bytes — the community's E2E
guarantee holds.
Reuses the NIP-17 encrypted-media stack end to end: quartz's imeta tag vocab
and IMetaTagBuilder to build/parse the tag (ChannelChat.imageMessage /
encryptedImageImeta / encryptedImagesOf), the shared UploadOrchestrator
encrypted upload + ChatFileUploadDialog picker on the send side, and the OkHttp
EncryptedBlobInterceptor keyCache on the receive side — registering each
attachment's cipher (keyed by URL) lets the normal feed renderer display the
decrypted image with no shared-render changes. Encryption is mandatory
(no toggle, and a missing cipher fails closed).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
Clients in the wild publish kind-4550 approvals whose content is plain
text (e.g. braille ASCII art) or a custom non-event JSON wrapper instead
of the NIP-72 stringified approved event. containedPost() fed any
non-blank content straight into Event.fromJson, producing a
JsonParseException and a noisy logcat warning for every such approval.
Only attempt the parse when the trimmed content starts with '{', so
plain-text contents are skipped silently while genuinely malformed
event-looking JSON still logs.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EXiy3Ajmr3vdrV83jZsUx6
Publish a kind-23311 typing heartbeat as an ephemeral (21059) stream wrap
on the channel plane, throttled to once every few seconds while composing.
The session folds inbound heartbeats into a per-channel typing map with an
8s freshness window (never echoing the local user), and the channel screen
renders a slim "X is typing…" line above the composer with a ticker so a
typist who stops silently fades out.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
Concord community icons never showed (robohash instead), and the community
name silently fell back to the invite name. Root cause: the icon/banner are
CORD-02 §6 **encrypted media** — the metadata entity carries an
`ImagePointer` object `{url,key,nonce,hash}` (AES-256-GCM ciphertext at
`url`, decrypted with `key`/`nonce`, `hash` = SHA-256 of the plaintext) —
but `MetadataEntity.icon` was typed `String?`. An object where a String is
expected fails the whole entity's decode, so metadata came back null: no
icon, and the name dropped to the entry fallback. Matches the Concord v2
reference client (Armada `concord-v2/lib/{types,image}.ts`).
- Promote `ImagePointer` to a shared CORD-02 type (was invite-only) and give
it `decryptOrNull` (AES-256-GCM via the existing `AESGCM`, verifying the
plaintext SHA-256 — a swapped blob fails closed).
- `MetadataEntity.icon`/`banner` are now `ImagePointer?`, so the entity (and
the community name) decodes. `ConcordChannel` carries the pointers.
- `rememberConcordImageModel` resolves a pointer for the avatar: a plain-URL
pointer (Amethyst's own form) passes through; an encrypted one is fetched,
decrypted, verified, cached to disk, and rendered — else the robohash. Wired
into the Concord hub avatars and the Messages-tab community chip.
- Amethyst's create/edit still take a URL and wrap it as a url-only pointer;
authoring encrypted images (encrypt + upload) is a follow-up.
Adds ImagePointerTest: Armada-shape object decode, decrypt round-trip, and
fail-closed on a tampered hash.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Concord channels loaded empty because the channel-plane REQ was refused
until the relay happened to re-issue an AUTH challenge (~27s later, or
never on relays that don't re-challenge).
A channel's derived stream key only exists after the community's Control
Plane folds, but by then the connection's initial NIP-42 AUTH already ran
with just the control key. `RelayAuthenticator` only re-authenticated on a
fresh `AUTH` message and ignored `auth-required` CLOSED frames — so the
newly-revealed channel keys were never sent.
NIP-42 says the client must store the connection's challenge and reuse it
"in response to the auth-required CLOSED message". Do that: remember the
last challenge per relay, and on an `auth-required:` CLOSED re-run the
sign/send pass with it. `saveAuthSubmission` dedups by (pubkey, challenge)
so only not-yet-authed identities (the folded-in keys) are sent — a no-op
once they all are, so no loop. A burst guard skips re-signing while an AUTH
is already in flight (syncFilters re-drives the REQ when it settles), and
the re-auth is non-interactive: it re-sends only already-approved
identities (ledger-ALLOW accounts + stream keys) and never raises a prompt,
so it can't drag a bystander account onto a paid relay.
Adds an `interactive` flag to the signing callback and a
RelayAuthenticatorReauthOnClosedTest covering reuse, loop-safety,
burst-coalescing, and the non-auth-required CLOSED no-op.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ban is only a soft removal — the member still holds the room key and can
decrypt everything; clients just decline to show their posts. This wires
CORD-06 Refounding: the hard removal that rotates the community_root so a
removed member's key stops working for anything sent afterwards.
Quartz:
- ConcordKeyDerivation: baseRekeyAddress / channelRekeyAddress (the rekey
stream addresses) and epochKeyCommitment (prevcommit, CORD-02 A.5).
- ConcordRekey: signer-based blobForSigner / findNewKeyWithSigner so a
bunker account opens its blob with a single nip44Decrypt.
- ConcordRefounding: compactControlPlane (re-wrap each head edition's
original plaintext seal under the new root, preserving signatures),
buildBaseRekeyWraps, build, findNewRoot. OpenedStreamEvent now carries
the inner seal for compaction. ConcordRefoundingTest.
Commons:
- ConcordActions: guestbookPlane / nextBaseRekeyPlane, buildGuestbookJoin /
guestbookMembers, buildRefounding, openBaseRekey.
- ConcordCommunitySession folds the Guestbook plane into members (the
recipient set), buffers inbound base-rekey wraps, exposes controlPlaneWraps,
and AUTHs to + subscribes the Guestbook and next-epoch base-rekey planes.
- ConcordSessionRegistry.sync rebuilds a session when its entry's root/epoch
changed; ConcordSubscriptionPlanner.auxiliaryPlaneSubs REQs the new planes.
Amethyst:
- Account announces a Guestbook JOIN on create/join; refoundConcordCommunity
(owner / BAN-holder) bans + rolls + publishes + persists; drainConcordRekeys
adopts an inbound rotation from an authorized rotator; adoptConcordRoot
persists the new root (prior kept as a HeldRoot) and re-seeds the new epoch's
Guestbook, guarded against double-adopt.
- AccountViewModel.removeConcordMember; ConcordMembersScreen "Remove from
community" action + confirmation dialog.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
Replicates the PollEvent layout: all tag parsing moves to TagArray extensions
in TagArrayExt.kt with the event accessors delegating to them, builder
extensions use addUnique for single-instance tags, and construction goes
through template-returning builders instead of methods that sign internally.
- build() returns an EventTemplate<ContactCardEvent> (the signer is only used
to NIP-44 encrypt the private tags, matching TrustProviderListEvent);
create() remains as the signer.sign(build(...)) convenience and now takes
the emoji list directly
- updatePetNameAndSummary() returns an unsigned EventTemplate; callers sign
it (ContactCardsState and tests updated)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QdvE4LvgkSewJXyFzyyAUY
Nicknames can now use NIP-30 custom emojis: typing : in the nickname dialog
autocompletes from the account's emoji packs, and the emoji mappings for any
shortcode used are embedded in the card's NIP-44 encrypted content next to the
petname — so even the emoji set stays private. Renderers resolve the petname's
shortcodes against the card's decrypted tags instead of the profile's metadata
tags.
- quartz: updatePetNameAndSummary replaces the private emoji tag set wholesale
and keeps it out of the public tags; round-trip test added
- commons: PetName(name, tags) holder with content equality, decryption cache
returns the merged decrypted tag list, EmojiPackState.findEmojiTags resolves
:codes: against the selected packs
- amethyst: Account embeds resolved emoji tags on save; all petname render
sites pass the card tags to the WithEmoji composables; nickname dialog gets
the : emoji autocomplete via EmojiSuggestionState
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QdvE4LvgkSewJXyFzyyAUY
Adds petnames/nicknames per https://github.com/nostr-protocol/nips/pull/761,
reusing the kind:30382 contact card already used for WoT scores — a card only
acts as a nickname when signed by the account's main key. Petname and summary
are always stored in the card's NIP-44 encrypted content.
quartz:
- ContactCardEvent.updatePetNameAndSummary edits both fields in the encrypted
private tags, strips stray public copies, preserves every other tag
- TagArray.petName()/summary() parsers for decrypted tag lists + tests
commons:
- ContactCardDecryptionCache: LRU NIP-44 decrypt cache for own cards
- ContactCardsState: account-scoped access to the account's own cards,
petname flow per target user, create/update entry point
amethyst:
- Account.updateContactCardPetName publishes through the extended outbox
relays (NIP-65 write + private outbox + local + broadcast) and the login
subscription now downloads the account's own kind:30382s from its relays
- petname renders instead of the display name in usernames, profile header,
chats and @mentions (observeUserPetName)
- Edit-nickname action + dialog on the profile actions menu
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QdvE4LvgkSewJXyFzyyAUY
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
The ping-interval change shipped on reasoning and was overturned by
measurement; these tests give the remaining battery changes the same
scrutiny where the claims are about OUR code (deterministically
testable), instead of leaving them reasoning-only:
- NostrClientKeepAliveTest (quartz): a server-closed relay is redialed
within one 60s sweep while active; ZERO dials happen while the client
is inactive no matter how much time passes; and the sweep provably
resumes after connect() — the failure mode a suspend/resume bug in
the new isActiveFlow gate would cause.
- ScheduledPostWorkGate extracted from AppModules + tests: schedules on
the first PENDING post, cancels when the last drains, re-schedules on
publishNow-retry, and — the race that motivated the extraction —
never emits a spurious cancel from the store flow's empty placeholder
before the disk load completes.
- CalendarReminderWorker.couldStillFire extracted + tests against
LocalCache: future target keeps the chain, past target lets it end,
and an unresolved/start-less target keeps it alive so a reminder
can't be lost while the target event is still being fetched.
The watchdog alarm change (wakeup -> non-wakeup) remains reasoning-only
by nature: it asserts Android OS alarm semantics on OEM devices, which
no JVM test can exercise; its blast radius is limited to opt-in
always-on users with two independent restart layers still active.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016RJ8EAsdkHx5WHHU2eQJ1P
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
Five targeted fixes for the biggest idle drains:
- NostrClient keep-alive sweep: the 60s reconnect loop kept ticking for
the whole life of the process even after disconnect() (i.e. the entire
time the app sat in the background). isActive is now a StateFlow and
the loop suspends on it, so an inactive client schedules zero timers.
- Relay WebSocket pings on cellular: every ping on an idle connection
promotes the radio out of its low-power state, per connection. The
mobile-data client now pings every 240s instead of 120s (wifi keeps
120s), staying under common carrier NAT idle timeouts.
- Always-on service watchdog: the 5-min health-check alarm no longer
uses the _WAKEUP variant. Pulling the CPU out of sleep to restart a
service that can't do useful network work on a sleeping device was
pure cost; the alarm now fires as soon as the device is next awake,
and the WorkManager + FCM/UnifiedPush layers still cover doze.
- ScheduledPostWorker: the 15-min periodic worker was enqueued forever
for every user, waking (often cold-starting) the process with nothing
to publish. It is now enqueued exactly while the store holds a PENDING
post, driven by a store-flow observer in AppModules.
- CalendarReminderWorker: same unconditional 15-min periodic schedule,
but worse — LocalCache is memory-only, so a WorkManager wake of a dead
process can never find an RSVP to remind about. The worker is now
scheduled when an ACCEPTED RSVP lands in LocalCache and cancels its
own chain when nothing upcoming remains (or the feature is disabled).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016RJ8EAsdkHx5WHHU2eQJ1P
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
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
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
Second refactor slice (plan: quartz/plans/2026-07-11-concord-event-classes.md).
The kind-3308 control edition now follows the nip88Polls per-kind shape:
- New cord04Roles/control/ package: ControlEditionEvent (the Event subclass +
build{} template + typed accessors), TagArrayBuilderExt (vsk/eid/ev/ep/vac),
TagArrayExt (readers), and tags/ classes VskTag/EidTag/EvTag/EpTag/VacTag with
parse/assemble/isTag.
- ControlEditionBuilder.rumor now delegates to ControlEditionEvent.build +
RumorAssembler; the raw stringly-typed tag construction and the
ControlEdition.TAG_* constants are gone. Tags stay in the fixed vsk,eid,ev,ep,vac
order so chain rumor ids are unchanged.
- ControlEdition.fromRumor reads the typed tags, preserving exact validation
(present-but-malformed ep/vac still rejected; absent = genesis/owner).
- Register kind 3308 -> ControlEditionEvent in EventFactory so decrypted control
rumors parse as the typed class.
The edition hash is over content fields (not tags), and all Concord tests —
ControlEditionTest, the fold/authority suite, and the join-flow round-trip — pass,
so the wire format and chain are preserved.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
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