deleteNote() (NIP-09) and removeFromCache() (prune) had drifted into two
near-duplicate "detach a note from the cache" routines. Removal really has
two halves — (1) unlink the note from everything that points AT it, and
(2) handle the note's OWN children — and only the second half differs
between the paths. removeFromCache() already implemented half (1)
completely, so deleteNote() now delegates to it and keeps only its two
delete-specific responsibilities: tearing down gift-wrap hosts and
severing (but keeping) its children via detachFromChildren().
This also fixes a real leak the duplication was hiding. computeReplyTo()
has no ReportEvent branch, so a report note's replyTo is empty and the
report→target link lives only in the explicit reported* index handling.
The old deleteNote() only undid reportedAuthor(), so deleting an
event-level report (reportedPost / reportedAddresses) left the reported
note's `.reports` map holding the removed report note — a partial deletion
that leaked the shell and risked a duplicate Note for the same id.
Delegating to removeFromCache() (author + post + addresses, all
idempotent) closes that gap.
Net behavior change is the report-leak fix only; the redundant
TorrentCommentEvent case is dropped because the torrent target is already
in replyTo (and removed via removeNote), and its @Suppress("DEPRECATION")
goes with it. Adds KDoc to both methods documenting the two-halves model.
https://claude.ai/code/session_01RqJPYzmjb1pR3NBeH2yY3s
deleteNote() removed the target from its parents, gatherers, and the cache
map, but never cleared its own child collections nor dropped itself from
its children's replyTo. That left a partial deletion: every child kept the
removed shell alive through replyTo (a leak), and a reply resolved later
via computeReplyTo would getOrCreateNote a *second* Note for the same id —
breaking the one-Note-per-id invariant.
Adds Note.detachFromChildren(), which clears the note's forward child
collections (via removeAllChildNotes) and severs this note from each
child's replyTo (keeping any other parents). deleteNote() now calls it
before notes.remove(), so once the note leaves the map nothing points at
the dead shell. Orphaned replies become roots, which is correct once their
parent is hard-deleted from the cache.
Adds detachFromChildren coverage to NotePruningReferenceTest.
https://claude.ai/code/session_01RqJPYzmjb1pR3NBeH2yY3s
removeFromCache() relied solely on note.inGatherers to detach a pruned
note from its channels, while deleteNote() additionally resolved the
channel via getAnyChannel() and removed the note there too. inGatherers
is normally authoritative (Channel.addNote always calls addGatherer), so
this is defensive rather than a confirmed live leak — but it closes the
divergence so both removal paths detach channels identically. Guards
against any future consume path that adds a note to a getAnyChannel-
resolvable channel without the gatherer link: otherwise the note would
linger in the channel's notes map after leaving the cache, leaking it and
letting a relay echo mint a duplicate Note with the same id.
https://claude.ai/code/session_01RqJPYzmjb1pR3NBeH2yY3s
LocalCache must hold a single Note per event id/address and must never
remove a Note from the cache map while another Note still strongly
references it — a dangling reference both leaks the shell and lets a
relay echo mint a second Note with the same id.
Note.onchainZaps (NIP-BC) and Note.nutzaps (NIP-61) were added after the
removal/migration routines were written and were never wired into them,
so a pruned zap-source Note leaked through its target's maps:
- removeNote() only detached reply/boost/reaction/zap/zapPayment/report/
label, leaving the target's onchainZaps/nutzaps entry dangling when the
source note was pruned. Now also calls removeNutzap + a new
source-keyed removeOnchainZap (unconditional cache removal, distinct
from the verdict-respecting removeOnchainZapForSource).
- removeAllChildNotes() cleared onchainZaps but never returned the source
notes for removal from the cache map (asymmetric with nutzaps), so they
lingered orphaned. Now included.
- moveAllReferencesTo() dropped labels, zapPayments, and onchainZaps when
a replaceable's old version was superseded — silent data loss plus
orphaned onchain sources. Now migrated and cleared like the rest.
Adds NotePruningReferenceTest covering all three paths.
https://claude.ai/code/session_01RqJPYzmjb1pR3NBeH2yY3s
The SPI name leaned on "Req" (the NIP-01 command), which isn't self-explanatory.
Rename to read as what it is — a source of events for a query:
- ReqResponder -> EventSource (method respond() -> events())
- ReqResponderBackend -> EventSourceBackend (param responder -> source)
- ReqResponderServer -> EventSourceServer
- *Test + RELAY.md + KDoc references updated to match.
Also drop the JwtAuthPolicy snippet from RELAY.md (it was only an illustrative
doc example, never a real class); the surrounding prose still documents the
`authorize` bridge hook.
Pure rename + doc edit, no behavior change; full suite green.
https://claude.ai/code/session_016YBS2pWCBSDgAMthHzfCTr
Cleanup pass (4 review angles), behaviour-preserving, full suite green:
- Extract RelayServerBase: NostrServer and ReqResponderServer duplicated
connect/serve/buildPolicy/activeConnections and the connection scope verbatim
(ConnectionRegistry only unified the bookkeeping). They now share one engine
base and contribute only their backend + teardown; ReqResponderServer is ~10
lines, NostrServer just its ingest/store wiring.
- HyperLogLog.leadingZeroBits: replace the hand-rolled per-byte bit loop with
stdlib Int.countLeadingZeroBits() (- 24 for the 0..255 byte).
- LimitsPolicy.clampLimits: drop the `var changed` + throwaway-list map for a
`none{} -> map{}` that's simpler and allocates nothing when no filter is
clamped (the common case once maxLimit is set).
- PolicyStack: collapse the two first-non-null hook loops to firstNotNullOfOrNull.
- RelaySession.handleAuth: use OkMessage.rejected(MachineReadablePrefix.ERROR, …)
instead of hand-writing the "error:" prefix, matching the COUNT path.
Skipped: rewriting FullAuthPolicy's pre-existing auth-required:/invalid: reason
strings to MachineReadablePrefix — unchanged context lines, out of this diff's
scope.
https://claude.ai/code/session_016YBS2pWCBSDgAMthHzfCTr
The pendingNewlyAdded field was a rollback token faked through instance state:
FullAuthPolicy.accept(AuthCmd) committed the pubkey eagerly, so a later
rejection (composed policy or a throwing hook) had to be undone, and the field
existed only to avoid dropping a pubkey that was already authenticated.
Fix the root cause — the eager commit. accept(AuthCmd) now only validates; the
pubkey is recorded in FullAuthPolicy.onAuthenticated (made final), which the
engine calls only after accept AND the whole policy chain approve the AUTH.
External-auth bridges override a new open `authorize` hook that runs before the
commit; throwing rejects the login with nothing committed to undo.
This deletes pendingNewlyAdded, IRelayPolicy.onAuthenticationFailed, its
PolicyStack override, and both rollback call-sites in RelaySession.handleAuth —
and makes the prior compose-after-reject / failed-re-AUTH cases correct by
construction (no rollback to get wrong). Tests updated to override `authorize`;
the two regression tests pass unchanged.
https://claude.ai/code/session_016YBS2pWCBSDgAMthHzfCTr
Behaviour-preserving readability improvements (full suite green):
- LimitsPolicy: drop the <T : Command> generic reject helpers (which forced
rejectSubId<ReqCmd>(...) call-site type args). Each check is now a
"rejection reason or null" function (eventRejection / subscriptionRejection)
and the accept overloads just wrap a non-null reason — the three overloads
read almost identically.
- Extract ConnectionRegistry: NostrServer and ReqResponderServer duplicated
the connection bookkeeping (stable-id keying, active gauge, once-only
teardown accounting). That subtle logic now lives in one named class both
servers delegate to; their connect()/close() shrink to the parts that
actually differ (the backend and what else teardown closes).
- HyperLogLog.addPubKey: rename `ri` -> `registerIndex`.
https://claude.ai/code/session_016YBS2pWCBSDgAMthHzfCTr
Final-review findings on the relay tooling:
- Auth bypass (security): FullAuthPolicy.accept(AuthCmd) commits the pubkey,
but handleAuth only rolled back on the hook-throw path — a policy composed
AFTER FullAuthPolicy that rejects the AuthCmd left the connection
authenticated behind an OK false. handleAuth now also rolls back on the
reject path, preserving OK-true-iff-authenticated for any composition order.
- Failed re-AUTH no longer drops a prior valid auth: onAuthenticationFailed
removes only the pubkey THIS AUTH newly added (tracked via Set.add's return),
not one already authenticated earlier on the connection.
- Rename the server-side RelayConnectionListener -> RelayServerListener to
avoid colliding with the existing client-side
relay.client.listeners.RelayConnectionListener (published-API clarity).
- RelayLimits.toNip11Limitation clamps created_at bounds to Int range so a
post-2038 epoch second can't wrap negative in the NIP-11 document.
- Add regression tests for both auth cases (reject-after-FullAuth; failed
re-AUTH keeps prior auth).
https://claude.ai/code/session_016YBS2pWCBSDgAMthHzfCTr
Nip11RelayInformation modeled `icon` but not `banner` (NIP-11's wide
promotional image, distinct from the square icon), so relays advertising a
banner couldn't round-trip it. Add the field + a round-trip test.
https://claude.ai/code/session_016YBS2pWCBSDgAMthHzfCTr
Previously max_message_length and max_subscriptions were hard-coded in
RelaySession behind a parallel `limits` param, while the per-command limits
went through LimitsPolicy — two mechanisms, and a custom policy couldn't
influence the session-level ones.
Unify them: add two default-noop hooks to IRelayPolicy —
acceptMessage(raw) (pre-parse) and acceptSubscription(subId, openCount) —
chained through PolicyStack so they compose across multiple policies.
LimitsPolicy now implements all limit checks; RelaySession just invokes the
hooks and no longer takes a `limits` param. Servers compose LimitsPolicy
whenever `limits` is set and keep `limits` only to advertise via NIP-11.
Behaviour is unchanged (oversized -> NOTICE invalid:, sub cap -> CLOSED
rate-limited:); the enforcement now lives in the policy layer. Adds direct
hook unit tests; existing end-to-end limit tests still pass.
https://claude.ai/code/session_016YBS2pWCBSDgAMthHzfCTr
A relay author no longer hand-rolls limit policies or wires NIP-11 twice.
- RelayLimits: one config that is both enforced and advertised. Pass it to
NostrServer/ReqResponderServer and every limit is applied; toNip11Limitation()
renders the same numbers into the NIP-11 limitation block so they can't drift.
- LimitsPolicy: per-command enforcement (max_content_length, max_event_tags,
created_at bounds reject EVENT; max_filters / max_subid_length reject
REQ/COUNT; max_limit clamps, default_limit fills) with invalid: prefixes.
Servers prepend it automatically when limits declares command caps.
- RelaySession: session-level caps that a policy can't see — max_message_length
(NOTICE before parse) and max_subscriptions (rate-limited: CLOSED on new sub).
- NIP-11 serving: Nip11RelayInformation.toJson() + CONTENT_TYPE
(application/nostr+json); the existing model was parse-only.
- Tests for the policy, the session-level caps end-to-end, and the
limits->NIP-11 round trip; RELAY.md Limits + Serving NIP-11 sections.
https://claude.ai/code/session_016YBS2pWCBSDgAMthHzfCTr
The HLL aggregation side (estimate/merge/encode) existed but the construction
side did not, and the Jackson wire (de)serializer silently dropped the `hll`
field — so a JVM/Android relay could not actually answer COUNT with HLL.
- HyperLogLog.addPubKey(): the NIP-45 construction (register index = pubkey
byte at the filter offset; value = leading-zero-bits from offset+1, +1),
KMP-safe. Plus HyperLogLog.builderFor(filter) and an HllBuilder that streams
event pubkeys into registers and yields an approximate CountResult.
- Plumb CountResult through the count path: SessionBackend.countResult /
ReqResponder.countResult (default = exact count(); override for approximate/
hll); RelaySession sends the returned CountResult.
- Fix CountResultSerializer/Deserializer (jvmAndroid) to write/read the `hll`
hex field, matching the kotlinx serializer the native targets already use.
- Tests for construction (index/value/merge-idempotence) and the wire path;
RELAY.md Approximate COUNT section.
https://claude.ai/code/session_016YBS2pWCBSDgAMthHzfCTr
Gives relay operators metrics/logging hooks without patching the engine, and
removes the hashCode()-keyed connection registry the audit flagged.
- RelaySession gains a stable, process-unique `id` (monotonic counter).
- RelayConnectionListener (onConnect/onDisconnect, no-op default) is accepted
by NostrServer and ReqResponderServer; both now key their connection
registry by `id` instead of hashCode() (no more identity-collision hole).
- Both servers expose a live `activeConnections` gauge. Teardown accounting is
idempotent (double close counted once) and onDisconnect fires for any
connections still open at server close.
- Tests + RELAY.md Observability section.
https://claude.ai/code/session_016YBS2pWCBSDgAMthHzfCTr
Add cs-rCZ, de-rDE, sv-rSE and pt-rBR translations for the new settings
search field (placeholder + no-results) and the share-to-DM flow strings.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Audit finding: FullAuthPolicy.accept(AuthCmd) added the pubkey to the
authenticated set before onAuthenticated ran, so a bridge that threw from
onAuthenticated (its whole point — reject when e.g. a JWT exchange fails)
produced an OK false while the connection stayed authenticated server-side.
Subsequent REQ/EVENT/COUNT were then allowed despite the failed login — an
auth bypass.
- Add IRelayPolicy.onAuthenticationFailed(pubKey) (default no-op), forwarded
by PolicyStack and overridden by FullAuthPolicy to drop the pubkey.
- RelaySession.handleAuth calls it when onAuthenticated throws, restoring the
invariant that a client treated as authenticated is exactly one that got
OK true. The rollback is itself guarded so a misbehaving policy can't also
swallow the failing OK.
- Tests: failed-hook now asserts the connection is NOT authenticated and that
a follow-up REQ is rejected with auth-required.
- RELAY.md: note that throwing from onAuthenticated rolls auth back.
https://claude.ai/code/session_016YBS2pWCBSDgAMthHzfCTr
Lets non-storage relays (search, redirector, computed/projected data) answer
REQs without implementing the heavy IEventStore or hand-writing the
readFrame -> parse -> policy -> EVENT/EOSE loop.
- ReqResponder: the public Flow<Event> SPI — respond(filters): Flow<Event>
(+ a count() default). EOSE is sent when the flow completes.
- SessionBackend: the seam RelaySession now depends on (query/count/submit/
negentropy-snapshot). submit + snapshot default to reject / empty so a
responder only implements the read path. LiveEventStore implements it
(storage path unchanged); ReqResponderBackend adapts a ReqResponder.
- ReqResponderServer: storage-free dispatch engine mirroring NostrServer's
connect/serve/close, reusing RelaySession for the full wire protocol.
- RelaySession now frames backend failures as CLOSED error: <msg> (REQ) and
count failures likewise, instead of dropping the coroutine — useful for
responders doing network I/O.
- RELAY.md: Non-Storage Relays section + engine/source-map updates.
Storage path (NostrServer + IEventStore, live tail, negentropy) is unchanged;
existing server/auth/negentropy tests pass alongside the new responder tests.
https://claude.ai/code/session_016YBS2pWCBSDgAMthHzfCTr
Addresses the self-contained, low-risk items from the relay-ergonomics
request:
- NIP-50: add SearchQuery to parse Filter.search into free-text terms and
the typed key:value extensions (domain/language/sentiment/nsfw/include),
preserving unknown extensions and offering a canonical toSearchString().
- NIP-42: add a suspend IRelayPolicy.onAuthenticated(pubKey, event) hook
(chained through PolicyStack) so external-auth bridges (e.g. JWT exchange)
can live inside FullAuthPolicy instead of leaking into transport code.
RelaySession invokes it after the AUTH passes; a throw becomes OK false.
- Ergonomics: Command.fromJson/toJson and Message.fromJson/toJson mirroring
Event, plus MachineReadablePrefix + OkMessage/ClosedMessage factories for
standardized OK/CLOSED reason prefixes.
- Docs: RELAY.md sections for the external-auth bridge, NIP-50 search, and
the wire helpers.
https://claude.ai/code/session_016YBS2pWCBSDgAMthHzfCTr
Kind 34551 (CommunityRulesEvent) was missing from EventFactory.create, so
signing a community-rules template produced a generic Event. Returning it
as CommunityRulesEvent in Account.sendCommunityRules threw a
ClassCastException when publishing community rules.
Register the kind in the factory and add a regression test.
- Mark all *_search_keywords translatable="false" (English concept/protocol
index; stops Crowdin translating protocol terms and breaking locale search) [#1]
- Collapse ~23 symbol+nav rows via a local symEntry() helper [#3]
- Reword keywordsRes KDoc to match the actual word-prefix tokenization [#6]
- Add search keywords to the Legal rows (privacy_policy, child_safety) [#7]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add keyword blobs so settings resolve by concept/protocol name, not just
title — e.g. "blossom" -> Media Servers, "audio rooms" -> Nests Servers,
"negentropy" -> Event Sync, "nsec" -> Backup Keys. NIP numbers omitted by
preference.
- Collapse filterSettings' two identical lookup lambdas into one stringLookup
- Rebuild via SettingsCategory.copy() so new fields aren't silently dropped
- Memoize buildSettingsCatalog with remember(hasPrivateKey, nav, uriHandler);
onResetMarmot reads isResettingMarmot via rememberUpdatedState to avoid a
stale-capture, eliminating ~60 allocations per keystroke
feat(settings): add search box that filters settings rows by title + keywords
refactor(settings): expose legal section as legalSettingsCategory factory
feat(settings): add search placeholder, empty-state, and keyword strings
test(settings): cover filterSettings (blank/title/keyword/category/empty/danger)
refactor(settings): match category title in search; use data classes
Two related polish fixes on the collapsed sidebar:
1. The hover/active highlight on each nav item used to span the full
sidebar width (minus 8dp outer padding), producing ~12dp of empty
highlight either side of the 24dp icon. Now the highlight clips to
a 40dp square centered on the icon (24dp icon + 8dp padding on each
side), so the ripple sits tight against the glyph.
2. When the sidebar is collapsed, the label was already supplied as
`contentDescription` for screen readers but had no visual
affordance. Added a `TooltipArea` that surfaces the label on hover
(Surface + inverseSurface tonal style, matching the existing
TorStatusIndicator tooltip pattern), so mouse users can also see
what each icon means without expanding the sidebar.
Applied to both `SidebarNavItem` and `SidebarFeedItem` since both
suffer the same issue. Expanded behaviour is unchanged.
Two bugs that together caused HomeFeed to always open on Following:
1. FeedScreen was reading feedRepo.pinnedFeeds.value as the source of
truth for the first pinned feed. That's a stateIn-derived flow with
initial value persistentListOf(); the underlying _feeds StateFlow
IS loaded synchronously by FeedDefinitionRepository on construction,
but the derived pinnedFeeds doesn't reflect it until the first flow
emission propagates — which is too late for `remember` to see.
Fixed by reading feedRepo.feeds.value directly and filtering /
sorting by pinOrder ourselves.
2. DeckColumnContainer was passing initialFeedMode = FeedMode.FOLLOWING
when rendering DeckColumnType.HomeFeed, which overrode FeedScreen's
first-pinned logic entirely. Removed the hardcode so the deck's
home column inherits FeedScreen's default.
With both fixed, a user who has only Global pinned now opens to Global
on launch instead of Following.
If the user has pinned only Global (or only a custom feed), the app
should open to that on launch instead of showing Following just
because DesktopPreferences.feedMode happened to be saved as
Following. The "pinned feeds" list is the user's stated ordering;
the first item should drive the initial tab.
Resolution order (most specific wins):
1. explicit customFeedSource/customFeedId from the caller
2. explicit initialFeedMode from the caller
3. first pinned feed in feedRepo.pinnedFeeds (NEW)
4. DesktopPreferences.feedMode (last-saved, previous default)
For a pinned Filter feed, this also seeds activeFeedId and
activeFeedSource so the feed mounts in CUSTOM mode with the right
source.
Real root cause of the "stale feed on launch" perception bug: when
fresh events prepend to the desktop home feed, Compose's stable-key
diff (`items(loadedState.list, key = { it.idHex })`) preserves the
visual anchor on whatever item was already visible. The user's
previously-visible top item — once at index 0 — silently shifts to
index N as N new items are inserted above the viewport. From the
user's perspective the feed looks frozen on stale items even though
the underlying state HAS updated; switching screens unmounts
FeedScreen, recreates lazyListState at index 0, and on remount paints
from the now-current top.
Android already handles this with StickToTopOnPrepend
(amethyst/.../WatchScrollToTop.kt:133-152), but the helper lived in
the Android module and Desktop had no equivalent.
Changes:
- New commons/.../ui/feeds/StickToTopOnPrepend.kt with the same
observer + snapshotFlow trick, ported to use plain `collectAsState`
(replacing the Android-only `collectAsStateWithLifecycle` — the
effect's lifecycle is already bound to composition via
LaunchedEffect). Provides the same overloads:
* StickToTopOnPrepend(LazyListState, firstItemKey)
* StickToTopOnPrepend(LazyGridState, firstItemKey)
* StickToTopOnPrepend(FeedContentState, LazyListState)
* StickToTopOnPrepend(FeedContentState, LazyGridState)
- FeedScreen wires StickToTopOnPrepend(viewModel.feedState,
homeFeedLazyListState) at the same scope as the hoisted lazy list
state and the NewPostsChip.
Mutually exclusive with the NewPostsChip: the chip's visibility
predicate fires when isAtTop is false, the auto-snap fires when
isAtTop is true. Together they cover both cases:
* user at top → events arrive → auto-snap shows them
* user scrolled down → events arrive → chip announces them
The Android version in amethyst/.../WatchScrollToTop.kt is left in
place to avoid a wider refactor; it can be reduced to a thin delegate
in a follow-up.
Both loading splashes (the Tor-connect gate and the account-loading
screen between Tor active and LoginScreen) now show the Amethyst
icon tinted to the theme primary, anchored below the status text.
Layout pattern (status-forward, both splashes):
spinner → status text → Amethyst logo (96.dp, primary tint)
Brief research summary backing the choice:
- Apple HIG argues against splash branding, but its model assumes
near-instant launch — not applicable here where the Tor gate
can block for seconds.
- Material Design 2's branded-launch-screen pattern endorses
logo + brand color while a placeholder UI loads.
- The status-forward order keeps the dynamic info (what we're
waiting on) leading and the brand as the anchor below — the
right call when the wait is non-trivial.
Fixes the perceptual "stale feed on launch" bug: on cold launch the
desktop feed paints with whatever local cache had (up to 7 days old)
before relays catch up. The live updateFeedWith() path already prepends
fresh events silently, but users had no signal that fresh content
arrived unless they were already at the top of the feed (auto-snap via
StickToTopOnPrepend).
This adds a Twitter/Mastodon-style floating pill chip that slides down
from above the search header when fresh events have prepended AND the
user is scrolled below position 0. Tapping it smooth-scrolls to top
and slides the chip back up off-screen. Scrolling to top manually
also dismisses it.
Implementation:
- NewPostsChip + rememberNewPostsChipState in commons/commonMain so any
future feed surface (incl. Android, iOS) can adopt it. Desktop wires
it today; Android continues with the existing auto-stick + bottom-nav
dot pattern.
- Visibility predicate is pure-function and unit-tested (5 cases).
- Predicate mirrors the inverse of StickToTopOnPrepend's "at top" check
so the two systems are mutually exclusive — auto-snap when at top,
chip when not.
- Chip placement: floating Alignment.TopCenter inside FeedScreen's outer
Box, offset by the animated headerSpacerHeight (60.dp normal,
300.dp when search is expanded) so it tracks the header card.
- Hoisted lazyListState + headerSpacerHeight one level so the chip can
share scroll state with the LazyColumn. Existing viewport-aware
metadata loading is unchanged (same lazyListState reference).
- Animation: slideInVertically(tween(280, FastOutSlowInEasing)) + fadeIn
for enter; slideOutVertically(tween(220, FastOutLinearInEasing)) +
fadeOut for exit. Initial/target offset of -fullHeight-16 guarantees
the chip is fully off-screen above its rest position.
- Per-column scope by construction: each FeedScreen instance has its
own chip state (deck mode shows one chip per column).
- Resets cleanly on feed mode switch (Following ↔ Global ↔ Custom)
because rememberNewPostsChipState is keyed on FeedContentState,
which is recreated when viewModel = remember(feedMode, activeFeedId)
recomposes.
Plan: docs/plans/2026-06-02-feat-new-posts-chip-desktop-feed-plan.md
5 issues from davotoula's review on PR #3124:
- #3 (protocol): inline reply emitted a minimal e/p tag set instead of
NIP-10. Extract `commons/actions/ReplyActions.replyTo` wrapping
`TextNoteEvent.build(replyingTo=)` (which already encodes root marker,
reply marker, parent root-e-tag carry) + carry parent's p-tag chain via
`notify(...)`. Replies to deep-thread notes now thread correctly in
Damus/Primal/Coracle. Covered by `ReplyActionsTest`.
- #4 (architecture): reaction/follow/reply each inlined
`localCache.consume + relayManager.broadcastToAll` in 5 sites with
inconsistent ordering. Extract `desktopApp/cache/dispatch(...)` —
canonical local-first order — and route all 5 sites through it.
- #1 (UX): related-content section scanned the cache once via
`DisposableEffect(noteId)` and never refreshed. Switch to `produceState`
collecting `DesktopLocalCache.eventStream.newEventBundles`; re-scan only
when an arriving bundle contains a candidate (matching hashtag or
author). `LargeCache.notes` is a ConcurrentSkipListMap (weakly consistent
iterator) so the scan stays safe on the composition coroutine.
- #2 (UX): `DeckColumnContainer` re-requested focus on every
`currentOverlay` change, stealing focus from sibling columns whenever
any column mutated overlay state. Drop to `LaunchedEffect(Unit)` and
wrap the column in `key(column.id)` in `DeckLayout` so the one-shot
effect survives column reordering.
- #5 (consistency): zap totals bypassed the shared `ZapFormatter`. Wire
`RelatedContentRow`, `CommentItem`, and `NoteActions` to
`commons/util/ZapFormatter.{showAmount,toZapAmount}`; delete
`formatZapAmount` and `formatSats` desktop-local helpers.
`WalletColumnScreen.formatSats` intentionally kept — locale-aware full
precision for wallet balance is by design.
Plan: docs/plans/2026-06-02-fix-desktop-feed-review-findings-plan.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1. LocalRelayStore: use batchInsert() with per-row savepoints instead of
manual transaction — UNIQUE constraint violations skip that row instead
of failing the whole batch
2. Robohash empty hex: guard blank input in CachedRobohash.get() with a
fallback all-zeros hex key instead of passing empty string to assembler
3. GiftWrapEvent decrypt: downgrade from WARN to DEBUG — expected when
gift wraps from local relay cache aren't addressed to current user
(subscription filter is correct, but hydration doesn't filter by p-tag)
4. Relay URL %20: decode percent-encoded spaces before rejection check in
RelayUrlNormalizer.fix() — wss://relay.example.com/%20 now normalizes
to wss://relay.example.com/ instead of being rejected
5. NIP19 Parser: downgrade from ERROR/WARN to DEBUG — malformed bech32
from relay content is expected in the wild, catch+log is correct
6. VLC macOS: add --avcodec-hw=none (disables VideoToolbox that causes
CVPN chroma failures) and --reset-plugins-cache (rebuilds stale cache
on startup instead of logging hundreds of stale-cache errors)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When composing an anonymous post (tap pfp to go anon on the short-note
or comment screens), media uploads still authorized against the Blossom /
NIP-96 server with the real account's signer. The server echoes that
pubkey back in the returned media URL (e.g. Blossom's `as=<pubkey>`),
linking the real identity to the supposedly anonymous post.
Thread an optional `forcedSigner` through the upload chain
(MultiOrchestrator -> UploadOrchestrator -> NIP-96/Blossom auth). Both
ShortNotePostViewModel and CommentPostViewModel now hold a single
ephemeral signer per compose session, reused for every photo/voice
upload and for the final anonymous broadcast, so the upload auth event
and the post share one throwaway key. signAnonymouslyAndBroadcast accepts
that signer so the media author matches the post author. Non-anonymous
callers are unaffected (forcedSigner defaults to null).
The signer is reset in cancel() so each new compose session gets a fresh
anonymous identity.