- Add expandedNoteId state to FeedScreen — clicking a card expands it
in-place instead of navigating to separate ThreadScreen
- AnimatedVisibility(expandVertically + fadeIn) for smooth expansion
- ExpandedNoteContent composable renders CommentsCard + RelatedContentSection
below the expanded card within the same LazyColumn item
- Auto-scroll expanded card to top of viewport
- Thread reply subscriptions start on expand, cancel on collapse
- Only one card expanded at a time — clicking another collapses current
- Search bar stays visible (floating header above LazyColumn)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Fix follow pill layout: author row uses weight(1f) so pill has room
(was invisible due to SpaceBetween squeezing)
- Fix comment metadata: observe metadataState so author info recomposes
when kind:0 arrives from relay
- Wire "View all" on related content to navigate to author profile
- Wire reply button on CommentItem to open reply compose dialog
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Create CompactNoteData @Immutable data class in commons for reuse
- Create RelatedContentSection composable with horizontal LazyRow
- Scan LocalCache for hashtag-matching + same-author notes
- Compact cards (160dp) with title, author, zap count
- Wire into ThreadScreen below reply notes
- Hidden when no related content found
- Subscriptions cancel on dispose
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Refactor ColumnNavigationState to use mutableStateListOf with direction tracking
- Add pushWithCap(maxDepth=2) — replaces top entry when cap reached
- Replace instant Surface overlay with AnimatedContent slide transitions (200ms)
- Add Esc key handler (onPreviewKeyEvent) for back navigation
- Add FocusRequester for keyboard nav to work after slide
- Apply to both DeckColumnContainer and SinglePaneLayout
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add bottomContent slot to NoteCard for actions to render inside card boundary
- Move NoteActionsRow into the slot in FeedNoteCard (both regular and repost paths)
- Add muted parameter to SidebarNavItem; mute Home when feed tabs are visible
- Resolves feedback: actions clearly belong to their card, sidebar doesn't
compete with feed tab active state
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Make the feature-UI vs cross-cutting-UI rule consistent (feature-first):
- ui/nip53LiveActivities -> nip53LiveActivities/ui
- ui/article + ui/editor -> new nip23LongContent/ui (article reader + editor)
ui/ now holds only cross-cutting composables (theme, components, layouts,
elements, markdown, signing, thread, feeds, notifications, screens, state,
text). Tighten ARCHITECTURE.md with the deciding test ('could a second
unrelated feature reuse this as-is?') and reconcile the NIP-second-axis
section so a single-NIP feature owns its UI under <feature>/ui rather than
ui/nipNN.
https://claude.ai/code/session_01KXLzsvx9Gyrm3Yz4Rims55
Rename the two single-NIP feature packages to mirror their quartz
counterparts for 1:1 traceability:
- chess -> nip64Chess
- call -> nipACWebRtcCalls
marmot and nip53LiveActivities already match quartz and are unchanged.
Document the rule in commons/ARCHITECTURE.md: layer is the primary axis,
NIP is the secondary axis (nipNN<slug> matching quartz), and commons is
deliberately NOT reorganized NIP-first at the top level. Also remove
stray markup that leaked into the end of the doc.
https://claude.ai/code/session_01KXLzsvx9Gyrm3Yz4Rims55
Document the commons module's purpose, source-set layout, and the CLI-safe vs
UI boundary in commons/ARCHITECTURE.md, then clean up the clearest package
overlaps that had accumulated:
- merge duplicate util/utils -> util (all source sets)
- unify service/services -> service (jvmAndroid)
- move data/UserMetadataCache -> model/cache
- fold compose/ into ui/ (ui/article, editor, elements, layouts, markdown,
nip53LiveActivities, and Compose helpers in ui/state + ui/text)
- move ProfileBroadcastBanner composable into profile/ui
All changes are whole-file/whole-package moves with import rewrites; no logic
changed. The chess logic/UI split is documented as deferred debt (it needs
file-level surgery, not moves). Marks docs/shared-ui-analysis.md superseded.
https://claude.ai/code/session_01KXLzsvx9Gyrm3Yz4Rims55
Opens the App Drawer (same as Cmd+K) for quick access to all
available screen types. Positioned between main nav and FEEDS section.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Revert spacer change (back to 60dp)
- Add top = 16.dp to LazyColumn contentPadding so first card has
extra spacing and slides nicely under the floating search header
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add AccountState.Loading as initial state (was LoggedOut)
- Show centered "Amethyst" + spinner while accounts load from storage
- After loadSavedAccount(): transition to LoggedIn or LoggedOut
- No more 0.5s flash of login screen when account exists
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- COLLAPSED_WIDTH 56dp → 64dp so icons aren't truncated
- Tor connected: use Security icon (filled shield) instead of Shield
- Tor off/connecting/error: keep outlined Shield icon
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Replace compact icon Row at bottom with SidebarNavItem-style items
- Tor: shows "Tor: Off/Connecting/Connected/Error" with Shield icon
- Bunker: shows "Bunker: OK" with Favorite icon (only when connected)
- Both use same shape, hover, and label pattern as other sidebar items
- Collapsed mode: icon-only with tooltip, same as nav items
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add LocalOpenFullSearch CompositionLocal (navigates to Search column)
- Provided at Main.kt level alongside LocalFeedSearchActive
- FeedScreen reads it directly — no param threading needed
- "Open full search" link in expanded header now actually opens Search column
- Removed unused onOpenSearch param from DeckColumnContainer
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- SearchPill: move hoverHighlight() inside Surface content Row so it's
clipped to the pill's 36dp height (was drawing on parent Row height)
- AccountSwitcherDropdown: reduce IconButton from 48dp to 40dp so ripple
circle fits within collapsed sidebar width
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- SidebarNavItem/SidebarFeedItem: .clip().clickable().background()
— ripple now clipped to RoundedCornerShape(8dp) bounds
- ColumnHeader: .padding() before .pointerInput() — gesture detection
respects horizontal padding
- SearchPill: .clip(pill shape) before .hoverHighlight() — hover
drawBehind rect clipped to pill shape, not parent rectangle
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sidebar onNavigate callback now sets searchActiveState.value = false,
clearing the feed search expansion and sidebar dim overlay when the
user clicks any nav item (Home, Messages, Settings, etc.)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Relay query debounce stays at 300ms (AdvancedSearchBarState default)
- Separate 1s debounce on searchText: when user stops typing for 1s,
save the query to SearchHistoryStore (assumes intent confirmed)
- LaunchedEffect(searchText.text) auto-cancels on each keystroke,
so only fires after 1s of inactivity
- No duplicates: addToHistory() deduplicates by serialized query
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Increase relay query debounce from 300ms to 1000ms for inline search
(reduces unnecessary relay load while typing)
- Save query to SearchHistoryStore when search collapses (if non-empty)
- SearchHistoryStore.addToHistory() already deduplicates by serialized query
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- LinearProgressIndicator at top of expanded card (animated in/out)
- Loading state: centered icon + "Searching N relays..."
- Empty state: "No results found" / "No search relays configured"
- Results stream in incrementally from relays
- 1s debounce for relay queries, save to search history (no dupes)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Show "Searching N relays..." with spinner while waiting for results
- Show "No search relays configured" if searchRelays is empty
- Observe isSearching, peopleResults, noteResults directly for state
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
FeedTabsHeader now creates actual NIP-50 relay subscriptions when
typing in the inline search, matching the full SearchScreen wiring:
- Collect debouncedQuery (300ms) from AdvancedSearchBarState
- Access searchRelays from LocalRelayCategories
- rememberSubscription for people search (MetadataEvent kind 0)
- rememberSubscription for note search (SearchFilterFactory filters)
- Results flow into SearchResultsList (reused from search/ package)
- 10s timeout for silent relays
- Subscriptions auto-cleanup on collapse via DisposableEffect
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1. AnimatedVisibility for search card expand/collapse (expandVertically
+ fadeIn 200ms, shrinkVertically + fadeOut 150ms)
2. Feed tab clicks now collapse the search (onSearchExpandedChange(false)
before switching feed)
3. When typing in search: reuses SearchResultsList composable from
search/ package with AdvancedSearchBarState (same as full SearchScreen)
- Empty input shows search history (recent + saved)
- Typing shows live people + note results grouped by kind
- "Open full search" link at bottom for advanced filtering
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Header card pulled OUT of ReadingColumn into outer Box layer 3
(rendered after scrim, so it floats visually above it)
- Feed content in layer 1 with spacer for header height
- Scrim in layer 2 covers only feed content
- Sidebar gets its own scrim overlay via Box wrapper in Main.kt
- Search card stays sharp/visible while everything else dims
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- FeedTabsHeader expands to full-width search card when active
- Feed tabs hidden, search input fills entire header width
- History section (recent + saved) shown below input
- "Open full search" link at bottom
- Scrim overlay dims feed content when search is active
- Cmd+F toggles search via LocalFeedSearchActive CompositionLocal
- MutableState<Boolean> provided at Window level
- FeedScreen reads it directly, no param threading needed
- SearchPill simplified back to clickable-only (no inline expansion)
- Escape or click scrim dismisses search
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- SearchPill: fix collapsed state not rendering — removed nested
Box/Surface/matchParentSize, replaced with simple if/else on
expanded state within single Surface+Row
- Sidebar: add "+ Add Feed" item below custom feeds in FEEDS section
- Dropdown menu moves outside Surface to render properly
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- FeedTabsHeader wrapped in Surface card (border + rounded corners)
- Feed tabs compact with spacedBy(4dp), removed "+ More" (in sidebar)
- SearchPill centered with weight(1f), expands inline to BasicTextField
with DropdownMenu showing recent + saved searches
- Removed Dialog-based SearchSpotlight (wrong UX — created separate AWT window)
- Cmd+F now navigates to Search column via navigateToScreen callback
- All feed tab types (Following/Global/Custom) properly handled in onClick
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- SearchSpotlight: global overlay with scrim, auto-focused input, recent/saved
searches, hashtag quick-jump, and "Open full search" link
- SearchPill: compact reusable pill component with hover highlight and shortcut hint
- Cmd+F keyboard shortcut wired in MenuBar to open/close spotlight
- SearchPill added to FeedTabsHeader (Home/Feeds column headers)
- Result selection dispatches to deckState (deck mode) or singlePaneState (single-pane)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Rename DeckSidebar -> MainSidebar (it's not deck-specific anymore)
- Hoist MainSidebar from DECK-only to shared Row in Main.kt
- Remove inline 80dp NavigationRail from SinglePaneLayout
- SinglePaneLayout now only renders content pane (sidebar is external)
- onNavigate dispatches to singlePaneState.navigate() or deckState.addColumn()
based on active layoutMode
- Active item highlighting works in both modes
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Hardcoded date/time patterns ignored the user's Locale (date order:
dd/mm/yyyy vs mm/dd/yyyy vs yyyy-mm-dd) and the system 12/24-hour
override. Replace them with locale-aware formatters that resolve order
from the active Locale via DateFormat.getBestDateTimePattern() and pick
the time-of-day pattern via DateFormat.is24HourFormat(context).
- TimeAgoFormatter (amethyst + commons): build SimpleDateFormat from
Unicode LDML skeletons (yMMMd / MMMd / yMMM) so "May 28, 2026" in
en-US becomes "28 May 2026" in en-GB, "28.05.2026" in de-DE, etc.
- CalendarTimeFormat: same skeleton approach for date pieces; time
uses DateFormat.getTimeFormat(context) so a 24-hour Android user
sees 14:32 even on a 12-hour locale.
- New LocalizedDateTimeFormat helper with formatMonthDayTime,
formatMediumDate, formatMediumDateTime — used by wallet, vanish,
attestation, namecoin, eventsync screens to replace inline
SimpleDateFormat("MMM d, HH:mm") / ("MMM dd, yyyy hh:mm a") etc.
- Material3 TimePicker callers (calendar/nest/poll/zap-poll/expiration
date pickers + vanish request) now pass is24Hour from the system
setting instead of hardcoding false.
- Desktop article/reads/highlights screens use
java.text.DateFormat.getDateInstance(MEDIUM, locale).
- Drop dead formattedDateTime() in RelayCompose (was unused).
Intentionally left alone: notification feed bucket keys ("yyyy-MM-dd"
used as Map keys), TakePicture file naming (Locale.US), iCalendar
RFC 5545 stamps, NIP-52 ISO date storage, internal logging, and
ThreadLevelCalculator sort keys — none are user-facing.
Brings Amethyst Desktop to feature parity with Android for the Namecoin
resolution backend stack landed in #3056 / #3068. Three pieces:
- DesktopNamecoinPreferences now persists backend, namecoinCoreRpc,
fallbackToCustomElectrumx and fallbackToDefaultElectrumx (KEY_BACKEND
/ KEY_CORE_RPC / KEY_FALLBACK_*), mirroring NamecoinSharedPreferences.
Mutators are non-suspend because java.util.prefs is synchronous,
unlike Android's coroutine-backed DataStore. The Jackson mapper now
rejects unknown properties on read so kotlinx `@Serializable`
computed getters (e.g. NamecoinCoreRpcConfig.isUsable) round-trip
cleanly through java.util.prefs.
- DesktopNamecoinNameService takes an optional OkHttpClient provider,
lazily constructs a NamecoinCoreRpcClient when supplied, and builds
a fresh CompositeNamecoinBackend per lookup based on current
NamecoinSettings. Same shape as AppModules#buildNamecoinBackend.
Exposes the underlying RPC client (rpcClient) and a probeCoreRpc(cfg)
helper for the Settings Test RPC button.
- NamecoinSettingsSection gains a backend radio selector, a Core RPC
subform (URL / username / password / Save / Test RPC) with a TOFU
cert-pin AlertDialog mirroring Android's NamecoinCoreRpcSection, and
a fallback toggles section. The same KEY_PINNED_CERTS list is shared
with both ElectrumXClient and NamecoinCoreRpcClient via
setDynamicCerts(...), matching Android's behaviour where both
backends consume one trust store.
- Main.kt wires DesktopHttpClient.currentClient() in as the Core RPC
HTTP provider so .onion RPC URLs flow through the existing Tor
routing without extra plumbing, and propagates the new mutators to
the Settings UI.
- Extends DesktopNamecoinPreferencesTest with 8 new cases covering
default state, backend round-trip, Core RPC URL/user/pass/pin-flag
round-trip, fallback toggles, reset clearing, and a full
multi-field round-trip across a fresh preferences instance.
Verification on the canonical workspace clone:
- ./gradlew :commons:jvmTest --tests *Namecoin* — BUILD SUCCESSFUL
- ./gradlew :amethyst:compilePlayDebugKotlin — BUILD SUCCESSFUL
- ./gradlew :desktopApp:compileKotlin :desktopApp:test — BUILD SUCCESSFUL
- ./gradlew :amethyst:testPlayDebugUnitTest --tests *Namecoin* — BUILD SUCCESSFUL
- ./gradlew :amethyst:spotlessCheck :commons:spotlessCheck
:desktopApp:spotlessCheck — BUILD SUCCESSFUL
Mirrors Android's NamecoinSharedPreferences pinned-cert API on Desktop so
user-accepted TLS pins survive process restart. Same JSON-list shape, same
distinct-append semantics, same wipe-on-reset behaviour.
What's new
- DesktopNamecoinPreferences gains addPinnedCert / loadPinnedCerts /
clearPinnedCerts (sync rather than suspend, since java.util.prefs is
synchronous). reset() now clears pinned certs too, matching Android.
- DesktopNamecoinNameService accepts a pinnedCertsProvider and pushes the
loaded list into ElectrumXClient.setDynamicCerts at init, mirroring
Android's AppModules.kt wiring. Exposes the underlying client so the
Settings UI can call testServer() and re-apply pins live.
- Desktop NamecoinSettingsSection grows an optional Test Connection + TOFU
pin sub-section: runs ElectrumXClient.testServer per active server,
collects PEM + SHA-256 fingerprint from successful TLS handshakes, and
prompts the user to pin each new cert via AlertDialog. UI hidden when
no service is wired (so existing call sites stay valid).
- Main.kt wires both halves together and updates the freshly-pinned cert
list into the live client without waiting for restart.
Persistence is plain java.util.prefs (same backing store as the rest of
DesktopNamecoinPreferences) — explicitly NOT EncryptedSharedPreferences.
Pinned cert PEMs are public material; no secrets stored.
Tests
- DesktopNamecoinPreferencesTest: +6 cases covering empty default,
persistence + reload, dedup, blank input ignored, reset wipes, and
independence from settings copies.
Verification
- ./gradlew :desktopApp:compileKotlin — BUILD SUCCESSFUL
- ./gradlew :desktopApp:test — BUILD SUCCESSFUL (16 tests, 0 failures)
- ./gradlew :amethyst:compilePlayDebugKotlin — BUILD SUCCESSFUL
- ./gradlew :amethyst:spotlessCheck :commons:spotlessCheck :desktopApp:spotlessCheck — BUILD SUCCESSFUL
Stack note
Stacked behind #3072 (merged 2026-05-27). Next: PR-C for the full
Namecoin Core RPC backend + composite fallback persistence + UI.
Pins each per-user replaceable note to the User's lifetime so weak-ref
eviction from LocalCache.addressables can't lose them — same fix the
NIP-65 / DM relay list notes already had, generalised so adding new
pinned kinds is a one-liner.
Background: LocalCache.addressables is a LargeSoftCache<Address,
AddressableNote> backed by WeakReference. Without a strong reference
somewhere, an addressable note shell (and any event loaded into it) can
be cleared on any GC cycle even though it was successfully delivered.
The User constructor already held nip65RelayListNote / dmRelayListNote
fields exactly to defeat this for kinds 10002 and 10050. kind:10019
(NutzapInfoEvent) had no such pin, so the zap picker's "does this user
accept nutzaps?" check would silently return null for an evicted note —
the chip never showed even when the recipient had actually published.
This refactor:
1. Adds `UserContext` — a one-method `fun interface` exposing
`addressableNote(addr): Note`. User holds it for life; LocalCache
implements it via a single instance bound to ::getOrCreateAddressableNoteInternal.
2. Converts the three per-user pinned notes (nip65 / dm / nutzapInfo)
to `by lazy` fields backed by the context. Each is resolved the
first time it's read and then held by the User's strong reference
until the User itself is collected. `by lazy`'s default SYNCHRONIZED
mode handles concurrent reads from the zap picker + wallet state.
3. Adds typed accessors on User: nutzapInfo(), acceptsNutzaps(),
nutzapMints(), nutzapP2pkPubkey() — mirrors the existing
authorRelayList() / dmInboxRelayList() shape.
4. CashuWalletState.peekNutzapTarget now reads via
`cache.getOrCreateUser(recipientPubKey).nutzapInfo()` instead of
touching the cache's addressable map directly.
Tradeoffs vs the eager-constructor approach:
- No upfront allocation for kinds the screen never reads.
- Adding a new pinned kind (mute list, blocked relays, bookmark list)
is one `by lazy { context.addressableNote(...) }` line in User —
no constructor-signature churn across call sites.
- User now depends on a narrow `UserContext` interface; test fakes are
a one-liner: `User(hex) { addr -> Note(addr.toValue()) }`.
Migration:
- Single User constructor call site (LocalCache.getOrCreateUser) updated.
- Two existing test fakes (NoteOnchainZapTest, SearchResultSorterTest)
switched to the SAM-lambda form.
- No external behaviour change — the public `nip65RelayListNote` /
`dmRelayListNote` fields keep the same names and types, so the few
consumers (RelayFeedViewModel, ChatNewMessageViewModel) need no edits.
https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP