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>
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.
Replace the literal "CashuWalletState.start() not called" duplicated across
9 call sites (8 check guards + the publish default lambda) with a single
private companion constant.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- fix(dm-share): kotlin-review fixes (alias dot-boundary match + transient feed doc)
- fix(dm-share): address code-review findings (intent consume, media helper, manifest sync)
- fix(dm-share): make the picker one-shot so backing out doesn't duplicate drafts
- fix(dm): avoid duplicate drafts on abort by rotating draft tag after the async save
TagArrayBuilder.addUniqueValueIfNew had an inverted guard:
if (tag.has(1) || tag[0].isEmpty() || tag[1].isEmpty()) return this
Since has(index) == size > index, `tag.has(1)` is true for every
well-formed tag with a value, so the function returned early and never
added it. addUniqueValueIfNew / addAllUniqueValueIfNew are used only by
the quote() / quotes() builders, so every `q` tag (naddr, nevent, note,
nembed, npub, nprofile) has been silently dropped since this file was
introduced. Restore the missing `!` and add a regression test covering an
addressable (naddr) quote plus the guard's accept/skip/dedupe semantics.
https://claude.ai/code/session_01NMavNzJ7VRLhoD3hboCCC7
- Fix like: read replyNote.event inside lambda (not captured val)
to avoid stale null reference. Consume reaction into local cache.
- Wire zap on comments: uses zapNote (now internal) with 21 sats default
via NWC connection, same flow as main action row
- Wire like/zap in both FeedScreen (inline expansion) and ThreadScreen
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Wire onLike on CommentItem: ReactionAction.reactTo + broadcast
- Related content clicks use overlay navigation (ThreadScreen) since
related notes may not be in the feed LazyColumn
- Add onNavigateToThreadOverlay param to ExpandedNoteContent
- Zap from comments deferred (requires full NWC flow)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Observe note.flow().replies so replyNotes recomputes when replies arrive
- Use loadMetadataBatched with explicit author pubkeys from reply events
- DisposableEffect for proper flow cleanup
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- 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>
The Step 2.5 git "sync-timestamp" heuristic (skip keys added before the
last Crowdin export commit) produced false negatives: a key added shortly
before an export that translators hadn't reached yet is genuinely missing,
but the filter classified it as "Crowdin already decided" and dropped real
work. Replace it with the raw on-disk diff reconciled against the Crowdin
web UI's untranslated count; source-identical entries are skipped by
inspection instead.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Catches up four locales on the missing strings/plurals for the new
Cashu (NIP-60) wallet, mint top-up/reload, NIP-61 nutzaps, NIP-32
hashtag labels, podcasts, and (pt-BR) music tracks/playlists & NIP-82
software releases. pt-BR was furthest behind (199 keys); cs/de/sv each
add 123 strings + 5 plurals. Czech plurals carry the full one/few/many/
other CLDR set. Notification-channel id calendar_reminder_channel_id
left untranslated by design.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-up to the link-preview move: HtmlParser + HtmlCharsetParser were
stuck in jvmAndroid only because they spoke java.nio.charset.Charset.
There is no common Charset type in the Kotlin stdlib, so this reshapes
the API to speak IANA charset *names* (String) and pushes the single
genuinely-platform operation — byte->String decode — behind expect/actual.
- Move HtmlParser + HtmlCharsetParser to commonMain. Charset detection
(meta-tag sniff + BOM sniff) is pure string/byte work; BOM detection no
longer needs okio (manual leading-byte compare).
- Add `expect fun decodeBytes(bytes, charsetName)`:
* jvmAndroid actual -> java.nio.charset (all JRE charsets, UTF-8 fallback)
* iosMain actual -> NSStringEncoding for the common web charsets
(UTF-8/16/32, Latin-1, CP1252, ASCII), UTF-8 fallback for the rest.
- UrlPreview (stays jvmAndroid; needs OkHttp) now reads response.body.bytes()
and passes mimeType.charset()?.name().
Verified: commons compiles for JVM AND iosSimulatorArm64, verifyKmpPurity
passes, commons jvmTest passes, amethyst play + fdroid compile.
Add a dedicated TopUpMintScreen + TopUpMintViewModel for funding a
specific mint outside the zap flow. Each mint row on the wallet screen
gets an add-funds icon that opens the screen with that mint as the fixed
target.
The screen reuses the same funding primitives as the zap-driven Reload
screen — CashuWalletState.rebalance for a mint-to-mint move and
startMintFromLightning/completeMintFromLightning for an LN top-up (NWC
or external invoice) — but drops all zap machinery: no recipient, no
shared-mint intersection, no fixed send amount/shortfall, no terminal
nutzap, no fund-then-send atomicity. This keeps the double-spend-
sensitive zap pipeline untouched.
Shares SectionHeader/SourceRow/shortMint/sats from ReloadMintScreen
(widened to internal) instead of duplicating them.
Third slice of the amethyst→commons migration. UrlPreview (OpenGraph
link-preview fetcher) and HtmlParser already wrapped the extracted
commons preview parsers (MetaTagsParser/OpenGraphParser/HtmlCharsetParser);
this consolidates the whole link-preview concern in commons.
- Move service/previews/{UrlPreview,HtmlParser} into commons jvmAndroid
service preview package. They land in jvmAndroid (not commonMain)
because UrlPreview uses OkHttp and HtmlParser uses java.nio.charset —
both JVM-only. No Android-framework or keystone coupling: the caller
injects the OkHttpClient as a lambda.
- Add explicit okhttp + okhttp-coroutines deps to commons jvmAndroid
(previously only present transitively via coil-okhttp).
- Re-point the single caller (model/UrlCachedPreviewer).
commons JVM compile + verifyKmpPurity pass; amethyst play + fdroid compile.
Second slice of the amethyst→commons migration. BroadcastTracker +
BroadcastEvent/RelayResult/BroadcastStatus are platform-agnostic relay
event-broadcast logic (no keystone coupling, no Android) that Desktop and
the CLI can reuse.
- Move service/broadcast/{BroadcastModels,BroadcastTracker} into
commons commonMain service/broadcast.
- Replace the two commonMain purity-gate violations:
System.currentTimeMillis() -> TimeUtils.now() (startedAt is only used
to sort the active-broadcast list) and java.util.UUID.randomUUID() ->
RandomInstance.randomChars(16) for the tracking id.
- Re-point the 4 Android callers (AccountViewModel + broadcast UI).
verifyKmpPurity passes; amethyst play + fdroid both compile.