Long-pressing a private chat row in the messages tab now offers Pin to
top / Unpin. Pinned rooms sort above everything else in the known-chats
list (ties broken by the usual newest-first order) and show a small pin
icon next to the room name.
Pins are stored per account as a local-only setting (encrypted
SharedPreferences via AccountSettings.pinnedChatrooms) because there is
no standard NIP-51 list for pinned DMs; this can be migrated to a synced
list later if one is standardized.
https://claude.ai/code/session_0131YwG6bE3yH8Kk9MxjMA5i
LocalPreferences.deleteAccount() wiped the encrypted preference file but
left the in-memory cachedAccounts entry behind, so deleting and re-adding
the same account could resurrect stale settings from the cache.
https://claude.ai/code/session_0131YwG6bE3yH8Kk9MxjMA5i
The test lived in commons androidHostTest, but no CI workflow or
pre-push task runs :commons:testAndroidHostTest — and running it
manually fails before reaching any assertion: quartz's android
PlatformLog actual hits unmocked android.util.Log stubs
(NoSuchMethodError), since the source set is not configured with
returnDefaultValues. The end-to-end leave/rejoin coverage was
therefore never executed anywhere.
:commons:jvmTest runs in CI and in the pre-push hook, already has the
secp256k1 JVM bindings the test needs, and uses quartz's JVM logger.
Verified green there alongside MarmotManagerRestoreTest.
The user-only LnZapRequestEvent.create overload marked ANONYMOUS requests
with a blank-valued anon tag, which the signer treats as an unsigned
private zap: the message was encrypted to the recipient under the
throwaway key instead of staying public. Use the valueless anon tag, as
the event-targeted overload already does. Adds a regression test.
Also carries the nutzap note into the notification gallery chips so the
long-press reply-to-zap gesture works for NIP-61 nutzaps too — no extra
tagging needed there since nutzaps are signed by the sender.
https://claude.ai/code/session_01LM3KTECMMAdNBHZfs1dANa
Second audit pass over the remaining skills (amy-expert, auth-signers,
find-*, nostr-expert, quartz-integration, vendored technique skills),
verifying every concrete claim against the code:
- auth-signers: bunker login goes through NostrSignerRemote.fromBunkerUri
+ connect(), not the nonexistent RemoteSignerManager.connect(url)
- nostr-expert: NIP count 57 -> 80+; replace invented Nip44v2/Nip19
static APIs with the real Nip44 facade, ByteArray bech32 extensions,
entity create() helpers, and Nip19Parser.uriToRoute()?.entity
- nip-catalog: heading counts corrected to 87 standard + 23 experimental
packages with a ground-truth pointer
- quartz-integration: NIP-19 example rewritten for ParseReturn.entity;
Event Store is commonMain (all platforms), not Android-only, with the
real store.sqlite.EventStore import and suspend query<T> API
amy-expert, find-missing-translations, find-non-lambda-logs, the rest of
auth-signers, and the vendored technique skills audited clean.
https://claude.ai/code/session_01EC7LdXjatFTh1CJSP4qKRn
The Marmot subscription since, the processed-event dedup set, and the
application ratchet position (group state persists only at commits) are
all in-memory only. On restart, relays therefore redeliver the group's
entire kind:445 history and the rewound ratchet re-decrypts old
application messages as if they had just arrived — wasted decryption
work and, when a replay beats the disk restore, duplicate entries
appended to the persisted plaintext message log.
Two defenses:
- MarmotManager.restoreAll() now seeds each restored group's
subscription since from the newest persisted decrypted message, minus
a one-day overlap window for late/out-of-order publishes. Seeding
happens before syncWithGroupManager registers default entries, so
even the first filter set sent to relays carries it. The CLI is
unaffected: it builds group filters from its own persisted since.
- MarmotMessageStore appends are now explicitly idempotent (contract
was previously ambiguous and both real stores appended blindly):
the Android and CLI file stores skip an entry that is already in the
group's log, so replays inside the overlap window cannot grow it.
Covered by MarmotManagerRestoreTest in commons jvmTest — placed there
rather than androidHostTest because CI only runs :commons:jvmTest (the
androidHostTest task currently fails on android.util.Log stubs even
for the pre-existing Marmot test).
Zap receipts (kind 9735) are signed by the recipient's lightning provider,
not by the person who zapped, so both the reply tagging and the rendering
around replies-to-zaps need the sender resolved from the embedded kind 9734
zap request:
- Long-press on a zap chip in the notification galleries (MultiSetCard and
ZapUserSetCard) opens the NIP-22 comment composer targeting the zap
receipt, reusing the existing generic-comment fallback in routeReplyTo.
- CommentPostViewModel now p-tags the zap request author when replying to a
zap so the zapper actually gets notified (the receipt's own author tags
point at the custodian). Requests carrying an anon tag (anonymous or
private zaps) are skipped: the embedded key is ephemeral and tagging the
decrypted sender of a private zap would publicly expose them.
- The notifying chip row shows the zapper and removing the chip is
respected, including across draft reload.
- FirstUserInfoRow and the compact reply-to label now display the zap
sender (decrypted for private zaps, locally only) instead of the wallet
service when the note or the replied-to parent is a zap receipt.
- ZapAmountCommentNotification carries the receipt note so chips can act on
the zap itself.
- Adds LnZapRequestEvent.hasAnonTag() with tests covering public, anonymous
and private zap requests.
https://claude.ai/code/session_01LM3KTECMMAdNBHZfs1dANa
The unread dot for Marmot/MLS group rooms was driven by an in-memory
unreadCount on MarmotGroupChatroom. On restart the MLS group state is
restored from the last persisted commit, the kind:445 subscription
restarts with since=null, and the in-memory processed-event dedup set is
empty — so relays redeliver old group events, they re-decrypt as fresh
application messages, and the counter was re-bumped, resurrecting the
dot for chats already read.
Marking-as-read was already persisted: opening a group chat writes the
newest rendered message's createdAt to the MarmotGroup/<groupId> route
in lastReadPerRoute (saved to disk with account settings). Compute the
unread indicators from that timestamp instead — exactly how DM rooms
and public channels do it — in both the Messages screen row and the
Marmot group list row.
With no consumer left, drop the volatile counter and collapse the
addMessageSync/restoreMessageSync split (they only differed in the
counter bump).
Audit pass that verified every concrete claim in .claude/ against the
repository:
- account-state: Account.kt no longer exposes followListFlow-style
StateFlows; document the state-object pattern (kind3FollowList,
muteList, bookmarkState, ... each exposing .flow) and rewrite the
catalog reference from the real Account.kt
- feed-patterns: filter bases (FeedFilter, AdditiveFeedFilter,
ChangesFlowFilter, FeedContentState) moved to commons/ui/feeds;
ui/dal keeps AdditiveComplexFeedFilter/FilterByListParams plus
back-compat typealiases; fix recipe example signatures
- relay-client: add nip17Dm/, eoseManagers and subscriptions entries
to the layout tree
- gradle-expert: 4-module claim -> 10 modules; refresh compose/kotlin/
BOM versions; rewrite dependency graph with verified edges for cli,
geode, quic, nestsClient, quic-interop, benchmark
- desktop-expert: drop drifted Main.kt line numbers; sidebar is the
custom MainSidebar in DeckSidebar.kt, not a NavigationRail in
SinglePaneLayout.kt
- android-expert: compileSdk/targetSdk 36 -> 37, versionName via
generateVersionName()
- kotlin-expert: remove reference to nonexistent commit 258c4e011
- CLAUDE.md: add missing geode/benchmark/quic-interop modules
- desktop-run: packageRpm + correct binaries output path; extract.md:
drop duplicated find clause
- session-start.sh: /home/user/Amber fallback was a copy-paste from
another repo; fall back to CLAUDE_PROJECT_DIR
https://claude.ai/code/session_01EC7LdXjatFTh1CJSP4qKRn
StrictMode flagged the offer round-trip (ephemeral keygen, JSON serialization,
NIP-44 encrypt/decrypt, signing) running on the UI thread, because
ClinkOfferPreview launches it from a Compose (Main) scope. Wrap the heavy work
in withContext(Dispatchers.IO) in both ClinkOfferPayer.requestInvoice and
ClinkDebitPayer.payInvoice/requestBudget so the payers are main-safe regardless
of caller dispatcher.
https://claude.ai/code/session_01NM2TyJtosLdY5ycjyabSRS
Adds a ContentCopy IconButton at the right of the CLINK Offer card title that
copies the noffer string (the active pointer, after any moved-offer redirect) to
the clipboard with a confirmation toast.
https://claude.ai/code/session_01NM2TyJtosLdY5ycjyabSRS
Mirrors the app's NIP-05 .well-known clink_offer discovery fallback (kind-0
offers are already readable via 'amy profile show'). Reuses the Context's
nip05Client.loadClinkOffer and decodes the resolved noffer into its fields.
Adds a bad-nip05 validation case to the headless harness; 17/17 pass.
https://claude.ai/code/session_01NM2TyJtosLdY5ycjyabSRS
amy zap printed the invoice but never paid it. With --with <ndebit> it now
settles the fetched BOLT-11 in-place through a CLINK debit pointer (kind-21002,
reusing DebitCommands.settle), mirroring how the app routes a zap through its
default payment source. Works for both single-recipient (zap user) and
split zaps (zap event) — each recipient reports paid + preimage (or pay_error).
Adds a --with validation case to the headless harness; 16/16 pass.
https://claude.ai/code/session_01NM2TyJtosLdY5ycjyabSRS
Brings amy's CLINK surface closer to the app's:
- profile edit --clink-offer <noffer|"">: set/clear the kind-0 clink_offer
(validated as a real noffer; "" clears). MetadataEvent already carried the field.
- offer request --follow: chase an 'Expired or Moved' (code 3) reply to its
'latest' pointer (bounded hops), mirroring the app; the error output now also
carries code/latest/range so a script can follow or correct manually.
- offer pay <noffer> --with <ndebit> [--amount]: end-to-end — fetch the invoice
(21001) and settle it through a debit pointer (21002), reusing DebitCommands.settle.
- Structured GFY detail (code, range, retry_after, delta) in debit/offer errors,
via a new Output.error(extra=) overload.
Adds local-validation cases to the headless harness (offer pay --with, profile
edit --clink-offer); 15/15 pass.
https://claude.ai/code/session_01NM2TyJtosLdY5ycjyabSRS
The profile CLINK offer showed the full ClinkOfferPreview payment card up front.
Render it instead as a compact payment-target-style chip (Bolt icon + 'Lightning
Offer' label, matching the PaymentTargetChip look); tapping it expands the
payable card, collapsed by default — same expand-on-click idiom as the lightning
address row.
https://claude.ai/code/session_01NM2TyJtosLdY5ycjyabSRS
- CLAUDE.md: drop the 5-step skill-approval workflow (skills auto-trigger
and the approval loop blocked autonomous sessions), condense Verify-Don't-
Guess to the repo-specific tooling pointers, remove references to the
uncommitted /bugfix and /investigate skills, and replace the mandated
emoji survey matrix with one-line guidance
- android-expert / desktop-expert: add missing YAML frontmatter so the
skills carry trigger descriptions and can actually auto-invoke
- extract.md: fix stale shared-ui/ module name -> commons/
- delete skills/quartz-kmp.md breadcrumb (migration long complete)
- gate the Stop spotlessApply hook on modified Kotlin files via
hooks/stop-spotless.sh so Q&A-only turns skip the Gradle run
- condense core-skills-plan.md to a historical changelog
https://claude.ai/code/session_01EC7LdXjatFTh1CJSP4qKRn
progressAllPayments was a non-atomic Float var incremented from the concurrent
mapNotNullAsync bodies AND the async response callbacks (NWC onResponse / the
CLINK launched coroutine), so parallel zap splits raced and could leave the
progress bar below 100%. Replace it with a shared PaymentProgress(AtomicInteger
over 2*N half-steps) used by both payViaNWC and payViaClinkDebit, which also
removes the duplicated half-step arithmetic.
Note: NWC's response half-step still won't fire if a wallet never replies within
its 60s window (sendZapPaymentRequestFor doesn't signal onResponse on timeout);
that progress-stall is pre-existing and separate from this race fix.
https://claude.ai/code/session_01NM2TyJtosLdY5ycjyabSRS
From the audit of this session's changes:
- Error surfacing: the budget (WalletScreen) and offer/invoice card
(InvoicePaymentDispatcher) paths now use DebitResponse.failureDetail() like the
zap path, so a GFY code-5/code-4 surfaces its range/retry_after instead of just
the bare error string.
- NOffer.priceType is now non-null: decode already defaults an absent TLV 3 to
SPONTANEOUS, so the nullable type was misleading and the '?: SPONTANEOUS'
fallbacks in ClinkOfferPreview were dead. Drops them and the now-redundant
always-emit-TLV3 test (covered by the spontaneous round-trip).
- WalletViewModel.requestDebitBudget catches the budget-validation
IllegalArgumentException so a malformed frequency dismisses the dialog instead
of hanging the spinner.
- Document why ClinkDebitPayer signs with the persistent account key (stable
identity for budgets) while ClinkOfferPayer uses an ephemeral key.
https://claude.ai/code/session_01NM2TyJtosLdY5ycjyabSRS
Sweeps every typed kind: addressable kinds (30000..39999) must read
their d tag, plain replaceables (10000..19999, 0, 3) must ignore stray
ones — the invariant the kind-34235/34236 fix restores.
Advance half the per-payable progress on dispatch and the other half when the
async debit response arrives, exactly like payViaNWC, instead of jumping the
full share on dispatch.
https://claude.ai/code/session_01NM2TyJtosLdY5ycjyabSRS
Extracts the priority sort into rankPriorityFirst() and covers: priority
users move to the top, stable order within both groups, no injection of
non-matching priority keys, and untouched list when priority is empty.
The MLS/Marmot inner message kind was missing from isChatEvent, so a
chat message quoted inside an MLS chatroom message still rendered as the
default NoteCompose card instead of the chat reply design.
https://claude.ai/code/session_01DSQW7kku5cGEL36icXg6BC
payViaClinkDebit blocked the zap on the debit service's res:ok/GFY reply (up to
30s) before reporting a result. Mirror the NWC rail instead: dispatch the debit
on the account scope and report each payable paid optimistically so the zap UI
completes promptly; a GFY/failure (or no reply) surfaces asynchronously through
onError rather than blocking. The programmatic App Functions debit path is
unchanged (it still awaits the real result).
https://claude.ai/code/session_01NM2TyJtosLdY5ycjyabSRS
Moves the Marmot composer's inline state (message TextFieldState,
reply state, upload state, @-mention suggestion wiring, send) into a
ViewModel mirroring ChatNewMessageViewModel / ChannelNewMessageViewModel /
NestNewMessageViewModel, so all four chat types share the same
init/load structure. No behavior change.
Renders a small chip on suggestion rows whose pubkey is in the
suggestion state's priorityPubkeys set, unless the caller supplies
its own trailingContent. Priority keys only reorder and label the
users that already matched the search — they never inject results.
The MLS/Marmot inner message kind was missing from isChatEvent, so a
chat message quoted inside an MLS chatroom message still rendered as the
default NoteCompose card instead of the chat reply design.
https://claude.ai/code/session_01DSQW7kku5cGEL36icXg6BC
- Move NewMessageTagger from the Marmot composer into
AccountViewModel.sendMarmotGroupMessage so every send path gets
mention rewriting + p-tagging, not just the chat composer.
- Pass the parent's MarmotGroupChatroom into the composer instead of
re-fetching it from the group list.
- Bound the public-channel participant scan with a one-month cutoff
(matches the recency-cutoff convention in ChannelObservers).
- Simplify the nests participant-set construction.
Follow-ups from the line-by-line spec audit, scoped to the consume-only client:
- NDebit.parse rejects a TLV-3 session id that isn't exactly 32 bytes (64 hex),
per clink-debits: a wrong-length k1 is a malformed session pointer.
- DebitClient.requestBudget validates frequency.unit is one of day/week/month
(DebitFrequency.VALID_UNITS) instead of sending a unit a node service will GFY.
- OfferClient caps the invoice description at 100 chars per clink-offers.
- DebitResponse.failureDetail() composes the GFY error with its actionable extra
(allowed range for code 5, retry_after for code 4); the debit zap path now
surfaces that instead of the bare error string.
Adds regression tests for each (malformed-k1 rejection, invalid-unit throw,
description truncation, failureDetail range/retry_after).
https://claude.ai/code/session_01NM2TyJtosLdY5ycjyabSRS
In MLS kind-9 chats Note.replyTo keeps both replies and quotes (e and q
tags), so a message quoting another chat message rendered it twice: once
in the reply row and once at its inline nostr: mention. Render the reply
row only when the target is not cited in the content; the inline quote
renderer already draws it in place.
https://claude.ai/code/session_01DSQW7kku5cGEL36icXg6BC
Adds an optional priorityPubkeys supplier to UserSuggestionState that
stable-sorts search results so the current conversation's participants
appear before network-wide matches (ranking only, never filters).
Wired per chat context:
- MLS/Marmot groups: live MLS member list
- NIP-17 DMs/groups: the room's users
- Public chats (NIP-28): authors who have posted in the channel
- Nests audio rooms: MeetingSpaceEvent participants + host
https://claude.ai/code/session_013NWdjCSegsf2FYSPPANX3n