Account switcher dropdown improvements:
- Two-row display: Display Name on top, npub (middle-truncated) below
e.g. 'Alice' / 'npub1abc...wxyz · Bunker'
- Middle-truncation for npub: shows first 10 + last 6 chars
- Resolves display names from DesktopLocalCache user metadata
- Confirmation dialog also shows display name
- npub-only (view-only) accounts now persist to encrypted storage
(ensureCurrentAccountInStorage called in onLoginSuccess)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Root cause of 'follows at 0 after 2+ switches':
DesktopLocalCache.clear() reset _followedUsers to empty but did NOT
reset lastContactListCreatedAt. On re-subscribe after account switch,
the contact list event arrived with the same timestamp, was rejected
by the 'event.createdAt <= lastContactListCreatedAt' guard, and
followedUsers stayed empty.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When switching accounts, the feed showed stale data from the previous
account because subscriptions and LocalCache weren't cleared.
Now tracks previousAccountPubKey and when it changes:
1. Clears all relay subscriptions (subscriptionsCoordinator.clear())
2. Clears local event cache (localCache.clear())
3. Restarts subscriptions coordinator
Feed composables then resubscribe with the new account's pubkey
via their existing rememberSubscription(account, ...) keys.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
loadSavedAccount() reads from legacy files (last_account.txt) but never
wrote to the encrypted multi-account storage, so the account switcher
dropdown was always empty after restart.
Now calls ensureCurrentAccountInStorage() + refreshAccountList() after
successful load so the current account appears in the dropdown.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- ensureCurrentAccountInStorage(): saves current account metadata to
encrypted storage before switching to new account via AddAccountDialog
- AddAccountDialog calls ensureCurrentAccountInStorage() before each
login method to prevent losing the previous account
- LoginScreen onLoginSuccess now calls refreshAccountList() so the
switcher dropdown picks up the initial account immediately
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Account switcher now appears at bottom of NavigationRail in single-pane mode
(after tor indicator), matching placement in deck sidebar
- AddAccountDialog wired in single-pane mode too
- Dropdown scrollable with 400dp max height for many accounts
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- AddAccountDialog: DialogWindow (480×600) wrapping existing LoginCard
with back button, "Import Account" title. Supports nsec, npub, bunker,
nostrconnect. New account becomes active immediately after login.
- Per-account logout: ✕ button on each account row in dropdown, with
AlertDialog confirmation before removal.
- Wired both into DeckSidebar and Main.kt.
Matches Android's AddAccountDialog pattern (full-screen dialog with
LoginOrSignupScreen) adapted for desktop (DialogWindow).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Migration from single-account files didn't work reliably.
Removed migrateFromLegacyFiles() and migrateAndLoadAccounts().
Account list now populated only via saveCurrentAccount/saveBunkerAccount
when user logs in — no automatic migration from old format.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Fixes from code-simplicity and architecture reviews:
Critical fixes:
- Per-account bunker ephemeral key alias (bunker_ephemeral_<npub>) instead of
shared alias. Prevents wrong-identity handshake when switching bunker accounts.
Falls back to legacy alias for existing single-account setups.
- Resource cleanup in switchAccount(): closes NIP-46 subscription, stops
heartbeat, disconnects NIP-46 client before transitioning to new account.
- Manages signer connection state on switch (Connected for bunker, NotRemote otherwise).
Simplifications:
- Removed unused createWithStorage() factory (zero callers)
- Removed loadViewOnlyAccount() dead code (no UI path to create view-only accounts)
- Removed unused allAccounts collection at Window scope (duplicated in MainContent)
- Files.move with REPLACE_EXISTING for cross-platform atomic rename
- Remote signer with empty bunkerUri falls back to Internal (prevents corrupt state)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Phase 4: account switcher UI.
- AccountSwitcherDropdown composable (person icon → dropdown menu)
- Shows all accounts with active checkmark and signer type label
- "Add Account" button at bottom with divider
- DropdownMenu with DpOffset(48dp) to expand right of sidebar
- DeckSidebar now takes account params (activeNpub, allAccounts, callbacks)
- Replaces "A" logo text with account switcher button
- Migration called on startup to populate account list
- Account switch wired through to AccountManager.switchAccount()
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Phase 3: wire multi-account lifecycle into desktop AccountManager.
- Add DesktopAccountStorage field and allAccounts StateFlow
- switchAccount(): loads new account BEFORE cancelling old state
(prevents unrecoverable partial failure per coroutines review)
- addAccountToStorage / removeAccountFromStorage for CRUD
- loadViewOnlyAccount() for npub-only accounts
- migrateAndLoadAccounts() for legacy file migration on startup
- saveCurrentAccount / saveBunkerAccount now save to encrypted storage
- Updated test mock setup to support SecureKeyStorage key store
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Phase 1 of multi-account support. Move account types to commons/commonMain
so both Android and Desktop share the same data model:
- SignerType sealed class (Internal, Remote, ViewOnly)
- AccountInfo data class (npub, signerType, isTransient)
- AccountStorage interface (loadAccounts, saveAccount, etc.)
Desktop AccountManager now imports SignerType from commons instead of
defining its own. All existing tests updated with new import path.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The notification card BadgeCompose rendered the awarded badge via
BadgeDisplay (definition-only) and stopped there, so a recipient
viewing a fresh badge in their inbox had no way to add it to their
profile without navigating to the award's thread first.
Expose AcceptBadgeControls (was private inside Badge.kt) and call it
from BadgeCompose under the BadgeDisplay row when the underlying note
is a BadgeAwardEvent.
Other surfaces are unchanged:
- RenderBadgeAward (Badge feed / threads) keeps its own call to the
same composable; behaviour is identical because the controls were
already package-private.
- BadgeCompose has only one caller (CardFeedView, the notifications
feed), so the new row only appears there.
The FAB now opens the new-badge dialog directly. The dialog renders a
big bordered "Upload an image" placeholder where the picture will go;
tapping it opens the gallery. Once the user picks an image, the
placeholder is replaced by the existing ShowImageUploadGallery preview
and tapping the preview lets them pick a different image.
Lets the user see the whole form (name, description, server, quality,
strip-metadata) immediately instead of being thrown into the picker
the moment they hit the FAB.
Replace the form-first create screen with a picker-first media pipeline
that matches the other upload screens in the app.
- NewBadgeButton (FAB, AddPhotoAlternate icon) opens GallerySelect
directly.
- After the image is picked, NewBadgeDialog mirrors the NewMediaView /
ImageVideoPost pattern: a thumbnail strip, name + description fields,
server picker, compression-quality slider, and strip-metadata switch.
- NewBadgeModel drives the upload through the shared MultiOrchestrator.
Only on a successful upload does it reach into Account.sendBadgeDefinition
with an auto-generated UUID d-tag, the uploaded URL + dimensions, and
the uploaded URL reused as the NIP-58 thumb (a separate thumbnail
upload can land as a follow-up).
The Cancel / Post buttons use the CreatingTopBar so "Create" reads
right on the primary action. Submit is disabled until the name is
non-empty, an image is staged, and a server is selected.
Drops the old route-based NewBadgeScreen / NewBadgeViewModel and the
Route.NewBadge entry.
The accepted-first sort was keyed on acceptedAwardIds, so every toggle
changed that set and the list jumped around. Key the remember on a
"loaded" flag (first time either the 10008 or the legacy 30008 event
arrives) plus the existing bundle tick; acceptedAwardIds is read from
the enclosing scope at the moment of recomputation but isn't part of
the key.
Result: the list loads with accepted badges on top, new awards flowing
in during the session trigger a re-sort, but plain Switch toggles leave
the visual order intact.
The list event only stores content-discovery feeds (kind-5300 DVMs),
not every DVM, so the old name was misleading. Rename the wire-level
type to reflect what's actually in it.
- Quartz: package nip51Lists.favoriteDvmList -> favoriteAlgoFeedsList;
class FavoriteDvmListEvent -> FavoriteAlgoFeedsListEvent.
- DSL helpers renamed: favoriteDvm/favoriteDvms builder extensions ->
favoriteAlgoFeed/favoriteAlgoFeeds; TagArray.favoriteDvmList/Set ->
favoriteAlgoFeedsList/Set.
- create/add/remove parameter names dvm -> feed, publicDvms/privateDvms
-> publicFeeds/privateFeeds; public/private accessors
publicFavoriteDvms/privateFavoriteDvms -> publicFavoriteAlgoFeeds/
privateFavoriteAlgoFeeds. ALT string updated.
- EventFactory + LocalCache dispatch branches + AccountSettings backup
field type + FavoriteDvmListState + FavoriteDvmListDecryptionCache all
import the new type. Internal amethyst-side classes
(FavoriteDvmListState, FavoriteDvmListDecryptionCache), the top-nav
filter classes (FavoriteDvm*, AllFavoriteDvms*) and the orchestrator
keep their names — they still deal with content-discovery DVMs
specifically, and the narrower rename here is scoped to the Nostr
wire format the user was asking about.
- Quartz test renamed + rewired. Build + tests green on both modules.
Two interacting bugs were still eating badges when the user toggled
switches in the profile-badges settings page:
1. TimeUtils.now() returns seconds, and LocalCache.consumeBaseReplaceable
only accepts an update whose createdAt is strictly greater than the
one already stored. Two toggles within the same second produced
equal-timestamp events, so the second was silently dropped from
the cache — the UI reverted after a beat and the change looked
like it had never happened.
2. launchSigner coroutines run on Dispatchers.IO so two concurrent
toggles could both read the same pre-state, each write its own
fragment, and whichever landed last clobbered the other.
Wrap the read-modify-write with a Mutex and bump the outgoing
createdAt to maxOf(now, latestCachedCreatedAt + 1) so every write is
strictly newer than whatever sits in cache. The sign + local consume
happen under the lock; network publish stays outside it.
PdfRenderer.Page.render() only accepts ARGB_8888 bitmaps; RGB_565 is
silently rejected (the renderer produces no output), which is why the
preview card and viewer both stopped showing anything. Go back to
ARGB_8888 and update the memory estimates in the comments. The other
perf wins from 49e913b (FilterQuality.Medium, 4096 hi-res cap, 1.5x
threshold, remembered ImageBitmap wrapper) are unaffected and stay.
Three fixes to the Profile-badges flow:
1. TagArrayBuilder groups tags by name, so calling acceptedBadges() on
the builder scrambled [a1, e1, a2, e2] into [a1, a2, e1, e2] when
serializing. AcceptedBadge.parseAll expects adjacent (a, e) pairs,
so only one mangled badge survived, making each Accept toggle
appear to replace the previous one. Rewrite the build() of both
ProfileBadgesEvent (kind 10008) and AcceptedBadgeSetEvent (kind
30008) to collect the prefix tags (d / alt / initializer) via the
builder, then append AcceptedBadge.assemble(...) verbatim to keep
the pair order intact.
2. Rows in ProfileBadgesScreen were not clickable. Thread nav through
AwardRow / StaticAwardRow and wrap the row body in Modifier.clickable
that routes to the badge definition's thread. The Switch keeps
consuming its own clicks, so toggling still works.
3. Sort accepted badges to the top of the list, then the rest by
createdAt desc, so the user can see at a glance which badges are
currently shown on the profile.
The main cost during a live pan/zoom is the GPU re-sampling the page
bitmap every frame under the zoomable modifier's transform. Three
changes bring that cost down substantially:
- Bitmap.Config.RGB_565 instead of ARGB_8888. PDFs are opaque, so the
alpha channel is unused. Halves texture memory and GPU upload
bandwidth; visually identical.
- HI_RES_MAX_DIM_PX lowered from 6144 to 4096. 4096 stays within the
common GPU texture-size limit, keeping rendering hardware-accelerated
on mid-range devices. Peak bitmap is now ~24 MB (A4, RGB_565) instead
of ~107 MB (ARGB_8888, 6144).
- FilterQuality.Medium (bilinear) instead of High (bicubic/Mitchell).
High is recomputed per frame during pan/zoom and is the main source
of jitter on multi-megapixel sources. At 3072-4096 px base resolution
bilinear is effectively indistinguishable.
- HI_RES_ZOOM_THRESHOLD raised from 1.2 to 1.5 so we don't trigger a
costly re-render for marginal zoom levels where the base is fine.
- Cache the ImageBitmap wrapper via remember to avoid per-recomposition
allocations.
PdfRenderer.Page.getWidth()/getHeight() return the page size in
PostScript points (1/72"), so for a standard A4 that's 595 x 842
"pixels" passed to cappedRenderSize. The old early-return kept native
dimensions whenever longest <= targetDim (which was always), so the
base bitmap was 595 x 842 regardless of what we asked for — effectively
72 DPI. Zoom-aware re-renders hit the same early return and also
stayed tiny, which is why double-tap produced no visible improvement.
Remove the early return: since PDFs are vector, always rescale the
point dimensions so the longest side equals targetDim. Base renders
now produce ~3072 px bitmaps and the hi-res path actually goes to
6144 px when zoomed.
- User-facing rename. All user-visible strings switch from "favourite"
to "favorite" (American spelling) and from "DVM(s)" to "Feed
Algorithm(s)": settings entry, spinner group label, empty state,
banner messages, menu accessibility labels. Code identifiers still
reference NIP-90's "DVM" protocol name since that's the wire spec.
- Cold-start race. When the app relaunches with a persisted
TopFilter.FavoriteDvm, the AppDefinitionEvent may not be in cache
yet. mergeInterests was filtering out any favorite whose
AppDefinitionEvent didn't yet advertise kind 5300, so the spinner
didn't include a chip matching the persisted selection — it
showed "Select an option" while the orchestrator quietly fired the
RPC with no UI to ground it. Always include every favorite; the
5300 check at add time (in FavoriteDvmToggle) is enough.
SubcomposeAsyncImage was being called with just the URL (or local
File), so Coil auto-sized the decode target to the composable's
layout bounds. In the feed that's desirable — a small thumbnail
decode — but in the zoomable dialog the composable is fillMaxWidth
(~screen width), and pinch-zooming then GPU-upscales the already
downsampled bitmap, producing the same blurriness the PDF viewer
had before.
Add a fullResolution: Boolean = false flag to UrlImageView and
LocalImageView. When true, swap the model for an ImageRequest with
size(Size.ORIGINAL) so Coil decodes at the image's native
dimensions. Feed call sites keep the default (fast, sampled). Both
dialog call sites in ZoomableContentDialog now pass
fullResolution = true, so pinch-zoom shows native pixels.
The existing blurhash/aspect-ratio placeholder path already covers
the brief high-res load window in the dialog.
Revert of 93cf9f1 restored the BadgeCard chrome that works well when
embedded inside another NoteCompose (e.g. a BadgeAwardEvent's body).
But the badge feed was still bypassing NoteCompose's author header and
ReactionsRow because BadgeDefinitionEvent was hoisted out of
CheckNewAndRenderNote up at the top of NoteCompose.
Move BadgeDefinitionEvent into RenderNoteRow next to BadgeAwardEvent
so it flows through the same chrome path. Feed items now get:
- the author avatar / name / timestamp header
- the existing BadgeDisplay card body
- the standard ReactionsRow (reply / repost / zap / like)
Other call sites of BadgeDisplay are untouched:
- RenderBadgeAward still embeds BadgeDisplay as a child card
- BadgeCompose notifications still embed BadgeDisplay
- DisplayBadges profile strip uses BadgeThumb, not BadgeDisplay
The zoomable library's onDoubleTap callback is not wired by default,
so double-tapping the PDF page did nothing — no scale change, no
hi-res re-render. Pass an onDoubleTap handler that calls
zoomState.toggleScale(2.5x, tapPosition). The scale animation runs
through the same Animatable snapshotFlow already observes, so the
debounced hi-res render kicks in automatically once the animation
settles.
Putting HomeDvmStatusBanner inside the top-bar Column meant the
SecondaryTabRow shifted up/down every time the banner appeared or
disappeared (filter selection, refresh, response arrival). Annoying.
Move the banner into a Box that wraps the HorizontalPager and align
it to TopCenter so it floats over the feed:
- topBar Column is back to just HomeTopBar + SecondaryTabRow; tabs
no longer reflow when the banner toggles.
- BannerCard takes a Modifier and gets tonalElevation + shadowElevation
+ a slightly stronger surface tint so it reads as a floating overlay
rather than part of the scaffold chrome.
Base bitmap stays at 3072 px for the initial render. When the user
zooms past 1.2x and scale settles for 200 ms, asynchronously re-render
the current page at (VIEWER_MAX_DIM_PX * scale) capped at 6144 px
(~100 MB peak for an A4 page). Swap the hi-res bitmap in while zoomed;
revert to base when scale drops back under threshold or the page
leaves composition. Only the currently-focused page gets the hi-res
treatment.
Also apply FilterQuality.High to the Image composable in both the
viewer and the preview card so any residual GPU upscaling uses
bicubic-ish sampling instead of bilinear.
Factored the render path into a shared renderPageCatching() helper.
Each badge was wrapped in its own rounded OutlinedCard, which reads as
an out-of-place boxed widget in the feed since every other feed item
is a flat row separated by the standard HorizontalDivider drawn by
FeedLoaded.
Replace the Card with a plain Column + clickable + padding. The
feed's own divider handles item separation and the UI now matches
Notes, Articles, Pictures, etc.
Selecting a DVM with a long name (or any long-named filter) made
the Text in the top-nav spinner wrap to two lines, pushing the
ExpandMore icon out of the visible area. The Column holding the
Text had no width constraint and the Text itself had no maxLines.
- Constrain the Column with `Modifier.weight(1f, fill = false)` so
it reserves space for the icon instead of consuming it.
- Add `maxLines = 1, overflow = TextOverflow.Ellipsis` to every
text path in the spinner (primary label, geohash city-loading,
AroundMe location labels). Single-line + ellipsis for all filter
types, icon always visible.
- Wrap pager + controls Row in a Box(fillMaxSize) and align the Row to
TopCenter so the back/share buttons sit at the top of the screen,
matching ZoomableContentDialog's layout. They were previously
vertically centered.
- Raise VIEWER_MAX_DIM_PX from 2048 to 3072 so pinch-zoomed pages stay
legible. A4-sized pages now render at ~26 MB each (ARGB_8888).
- Replace the unbounded mutableStateMapOf page cache with a tiny
LinkedHashMap-based LRU (PAGE_CACHE_SIZE = 3) to keep total bitmap
memory around 80 MB regardless of PDF length. The cache only feeds
produceState's initial value, so it doesn't need to be a snapshot
state.
ProfileBadgesScreen used to compute the received-awards list once via a
plain remember and never refresh it; new awards landing in LocalCache
were invisible until the user navigated away and back. There was also
no relay subscription dedicated to back-filling award history — we
relied on the always-on notifications subscription bounded by `since`,
so older awards never arrived.
- New ProfileBadgesFilterAssembler / SubAssembler / Subscription that,
while the screen is mounted, queries kind 8 with `#p`=me on the
user's notification relays (limit 500, no since) so the full history
flows in.
- Registered as `profileBadges` in RelaySubscriptionsCoordinator.
- ProfileBadgesScreen now collects LocalCache.live.newEventBundles in
a LaunchedEffect, bumping a tick whenever a bundle contains a
BadgeAwardEvent for me. The receivedAwards remember keys on that
tick, so newly arrived awards appear without leaving the screen.
- AwardRow now resolves the badge definition via LoadAddressableNote +
observeNoteEvent + EventFinderFilterAssemblerSubscription so each
row re-renders when the linked kind 30009 lands in cache (and asks
relays for it if missing). The Switch is disabled until the
definition is available.
The award screen now collects awardees the same way other selection
screens do (AddMemberScreen pattern):
- A search OutlinedTextField wired into UserSuggestionState +
ShowUserSuggestionList. Typing >2 chars triggers the existing user
search pipeline.
- Selecting a suggestion adds the user to a header list of selected
recipients (avatar, display name, NIP-05 / pubkey), each with a
Remove button.
- Submit button disables until at least one recipient is picked.
ViewModel reduced to definition + sendPost(awardees: List<User>);
parsedPubKeys / awardeesText state removed.