The grace-period unsubscribe in LifecycleAwareKeyDataSourceSubscription ran
on the composition scope from rememberCoroutineScope(), whose dispatcher is
coupled to the UI frame clock. When the app is backgrounded the frame clock
stops ticking, so the pending unsubscribe could be starved and never fire.
Because closing the REQ is what drives the relay disconnect (via desiredRelays
-> RelayPool.updatePool), the connection could linger indefinitely. This is
most visible on the relay feed, whose dedicated one-off relay is kept alive by
nothing else.
Drive the grace timer from Lifecycle.currentStateFlow on a dedicated
Dispatchers.Default scope instead. collectLatest cancels the pending delay
automatically when the lifecycle returns to STARTED, preserving the 30s
app-switch grace while ensuring the timer fires reliably in the background.
https://claude.ai/code/session_01SesftJphLwvLtn1fJB5zx8
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>
StickToTopOnPrepend hardcoded `wasAtTop = true` on remember, so when the
user scrolled down, navigated to a post and came back,
rememberForeverLazyListState restored a non-zero offset but the helper
still believed the user was at the top. As soon as the head-of-feed key
emitted (first observation after recomposition), the LaunchedEffect
scrolled them back to 0.
Seed wasAtTop from the actual restored scroll position via an
initialAtTop lambda. The sampler's gesture-guarded false→true protection
against keyed-item shifts is unchanged.
Regression from #3088.
The module-level mutable SimpleDateFormat formatters in these files
were read concurrently — UI composition on the main thread, and
LocalCache.justVerify calling dateFormatter() from background event-
verification coroutines for failed-signature log lines. SimpleDateFormat
is not thread-safe (mutable internal Calendar), and updateFormattersIfNeeded
reassigned the field mid-format. Race produced corrupted timestamp
strings and occasionally NumberFormatException inside format().
Replace the shared-var pattern with a small LocaleAwareFormatter that
wraps a ThreadLocal<Pair<Locale, SimpleDateFormat>>. Each thread caches
its own instance and rebuilds lazily when Locale.getDefault() changes —
no locks, no contention, same allocation profile after warm-up.
Apply the same pattern to CalendarTimeFormat for consistency; today its
callers are all main-thread but the structure was identical.
The post-refactor `bestPattern(skeleton)` helper allocated a fresh
SimpleDateFormat — plus ran the ICU lookup in `getBestDateTimePattern`
— on every call. That's hot in the calendar grid: 7 weekday-header
calls per render and up to 42 `formatLongDate` cells per month render.
Restore the old caching pattern (module-level vars, rebuild on locale
change via `refreshFormattersIfLocaleChanged`), now keyed off the
locale-aware skeleton patterns. Time format still re-fetched per call
via `DateFormat.getTimeFormat(context)` since the user's 24-hour
override can change without a locale change.
Zulu's CDN (cdn.azul.com) has been returning HTTP 520 for several
hours, breaking JDK 21 setup across multiple PRs (#3075, #3095) on
every Linux runner. actions/setup-java's internal retry budget isn't
enough to ride through it.
Temurin (Adoptium) publishes its JDKs as GitHub Releases assets, so
the download path uses GitHub's own CDN — completely independent
infrastructure. Java 21 is the same upstream OpenJDK build either way,
so Gradle, Compose Multiplatform, and jpackage behave identically.
Applied to all setup-java steps in build.yml, smoke-test-desktop.yml,
and create-release.yml.
Re-add the nostr-protocol/nips reference (dropped in the cleanup) as a
one-liner in the overview, tied to the /nip command so it points at the
exact spec file rather than the bare index.
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.
Addresses 10 audit findings from the post-build review:
CRITICAL — non-functional without this:
- LocalCache.justConsume had no branches for kind 54 / 10054 / 10064 / 10154,
so every podcast event fell through to "Event Not Supported" and was
silently dropped. Added the four explicit branches (regular event for
PodcastEpisode; replaceable for the other three).
HIGH — silent invisibility / broken tap-through:
- Home, profile (newthreads + mutual), hashtag, geohash, follow-pack, and
notification feed filters didn't recognize PodcastEpisodeEvent /
PodcastMetadataEvent. Episodes were invisible everywhere outside the
dedicated tab; reactions/zaps on episodes were dropped from the
notifications feed.
- ThreadFeedView's renderer dispatch had no podcast branch, so tapping a
feed card opened a plain text-note view. Added explicit cases that call
the new RenderPodcastEpisode / RenderPodcastMetadata composables.
- The hashtag / geohash / relay / search REQ kind lists didn't include
podcast kinds, so discovery surfaces returned nothing for them.
- RelayInformationScreen kind→label map gained podcast entries +
4 new string resources (Podcast Episode, Podcast Show, Authored
Podcasts, Favorite Podcasts).
- HomeNewThreadFeedFilter.ADDRESSABLE_KINDS gained PodcastMetadataEvent so
shows surface alongside music/wiki/long-form on the home feed.
HIGH — privacy leak in Quartz:
- FavoritePodcastsListEvent.add(isPrivate=true) was passing
earlierVersion.tags through untouched, so toggling a previously-public
favorite to private left the public p-tag intact. Made both branches
symmetric: each removes the entry from the other half before adding to
its own. Two regression tests cover the round-trip.
MEDIUM — data hygiene:
- AuthorTag.parse used to accept ANY non-empty slot-2 string as a role
(rendering a stray relay-hint URL as "Role: wss://relay…"). Now
validates against the spec-defined {host, cohost, editor} allowlist;
unknown values resolve to role=null, preserving the pubkey association.
PERF:
- PodcastEpisode renderer was allocating a fresh 96-element WaveformData
and rebuilding the cover Modifier chain per visible card. Hoisted both
to top-level constants (FLAT_WAVEFORM, COVER_IMAGE_MODIFIER,
PLAYER_BORDER_MODIFIER) so the whole feed shares one instance.
CODE QUALITY:
- Extracted PodcastCoverCard as a shared composable used by both renderers
(was duplicated byte-identical across PodcastEpisode + PodcastMetadata).
- Extracted PodcastFeedLoaded so the Episodes screen and Shows screen
share one feed body (was duplicated byte-identical).
- Dropped the misleading `group = listOf(singleAssembler)` wrapper in the
two FilterAssembler files.
- Replaced `mapNotNull { … }.flatten()` with `flatMap { … }` in the
Communities sub-assembly (the lambda never returns null).
DOCUMENTED:
- PODCAST_KINDS "Following" resolution still goes through kind:3 follows,
but per NIP-F4 podcasts use their own keypairs tracked via kind:10054.
Added an inline comment naming the deferred work — proper fix needs
Account-level 10054 integration which is a separate scope.