The search bar was using resolve() which lets
NamecoinLookupException.ServersUnreachable propagate as an exception,
causing 'servers unreachable' to appear immediately instead of waiting
for the actual lookup to complete.
Switch to resolveDetailed() which catches exceptions internally and
returns typed outcomes (Success/NameNotFound/NoNostrField/
ServersUnreachable/InvalidIdentifier/Timeout). The search screen now
shows Loading for the full duration of the attempt (up to 20s) and
only shows the error after all servers have actually been tried.
The dialog was created but never accessible from the UI. Now:
- Added 'Import Follow List…' menu item in File menu (Shift+Cmd+I / Shift+Ctrl+I)
- showImportFollowListDialog state threaded through App composable
- Dialog rendered alongside ComposeNoteDialog and AddColumnDialog
Add censorship-resistant NIP-05 verification using the Namecoin blockchain
to the Desktop (JVM) app, porting functionality from PRs #1734, #1771,
New files:
- DesktopNamecoinNameService: app-level service wrapping the Quartz
NamecoinNameResolver with caching, custom server support, and live
state flows. Uses plain JVM sockets (no Tor support on Desktop yet).
- DesktopNamecoinPreferences: Java Preferences API-backed persistence
for Namecoin settings (enabled toggle + custom ElectrumX servers).
- NamecoinSettings: Desktop copy of the settings data class (no Android
dependencies).
- LocalNamecoin: CompositionLocals for threading Namecoin service/prefs
through the compose tree.
- NamecoinSettingsSection: Compose Desktop UI for configuring ElectrumX
servers — toggle, active server display with DEFAULT/CUSTOM badge,
add/remove custom servers, reset to defaults. Uses onPreviewKeyEvent
for Enter-to-submit instead of Android KeyboardActions.
- ImportFollowListDialog: Dialog for importing follow lists via npub,
hex, NIP-05, or Namecoin identifiers. Resolves identifiers to pubkeys
with Namecoin blockchain support.
Modified:
- Main.kt: Instantiate DesktopNamecoinPreferences and
DesktopNamecoinNameService in App composable, provide via
CompositionLocals, wire into RelaySettingsScreen.
- SearchScreen.kt: Detect Namecoin identifiers (.bit, d/, id/) in the
search bar, resolve via DesktopNamecoinNameService with loading/error
states, display resolved user above standard results. Uses
LaunchedEffect with key-based cancellation for stale lookups.
- DeckColumnContainer.kt: Pass namecoinPreferences to RelaySettingsScreen
from CompositionLocal.
Tests:
- DesktopNamecoinPreferencesTest: round-trip persistence, add/remove
servers, enable/disable, reset, duplicate handling.
- NamecoinSettingsTest: server string parsing/formatting, round-trips,
edge cases, toElectrumxServers conversion.
The Quartz KMP library (commonMain + jvmAndroid) already contains the
core Namecoin resolution code (ElectrumXClient, NamecoinNameResolver,
NamecoinLookupCache) shared across Android and Desktop.
Several user-visible error messages on Desktop are rendered with plain
`Text` composables, which means they can't be selected or copied. That
makes it awkward to share an error in a bug report or paste a hex error
string into a search.
Wrap the error text in `SelectionContainer` at the canonical sites:
- `commons.ui.components.LoadingState`: wrap the description in
`EmptyState` and the message in `ErrorState`. `EmptyState` is reused
as the in-feed error renderer (e.g. 'Error loading feed' with the
underlying error in `description`), so this covers feed/loading
errors across screens that use these helpers.
- `ComposeNoteDialog`: wrap the validation error and the upload error
in the compose-note dialog.
- `auth/LoginCard` (Nostr Connect): wrap the connection error.
- `auth/KeyInputField`: wrap the supporting-text error so the inline
message under the nsec input field can be copied.
No visual changes \u2014 `SelectionContainer` does not affect layout or
styling. Selection works on Compose Desktop (mouse drag) and on Android
(long-press) without further changes.
Replace manual relay subscribe + Channel approach with the proven
SearchBarState + rememberSubscription pattern (same as NewDmDialog).
This properly handles relay connection lifecycle and NIP-50 search,
returning results from all connected relays.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Hoist author search state to FeedsDrawerTab (AlertDialog can't run LaunchedEffect)
- NIP-50 relay search via connected relays with Channel-based result streaming
- 28dp UserAvatar in author suggestion rows
- "No users found" empty state
- 32dp dialog margins, 300dp max results height, 30 result limit
- FindUsersTest: 8 tests verifying cache search with/without metadata
- Fix: use connectedRelays instead of unconnected DEFAULT_SEARCH_RELAYS
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- UserSearchEngine in commons (reusable, platform-agnostic) with RelayUserSearchDelegate interface
- DesktopRelayUserSearchDelegate using direct relay manager subscribe
- LocalDesktopCache + LocalRelayManager composition locals
- Provide cache/relay at App() level so AppDrawer and dialogs can access them
- FeedsDrawerTab reads LocalDesktopCache for author display names
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- FeedDefinition data model with FeedSource sealed interface (Filter, Following, Global, DVM, PeopleList, InterestSet, SingleRelay)
- FeedDefinitionRepository with StateFlow, groupedFeeds, pin/unpin/reorder (max 3 pinned)
- JSON serialization via Jackson with round-trip tests (18 unit tests)
- SearchQuery.toFeedDefinition() bridge for creating feeds from search
- FeedBuilderDialog with author search (cache lookup + npub decode), hashtag/relay inputs, kind filter checkboxes, exclude authors/keywords, Cmd+S save, Enter to add
- FeedsDrawerTab in app drawer with edit/delete/pin/unpin actions
- Feeds tab in top header (FilterChips) with inline mode switching
- CustomFeedScreen + DesktopCustomFeedFilter + createCustomFeedSubscription for relay-based custom feed content
- FeedDefinitionEvent (kind 31890) in quartz for future publish/import
- Local persistence via java.util.prefs.Preferences (auto-save on change)
- DeckColumnType.CustomFeed for navigation integration
- Renamed Home to Feeds in nav rail
- Fix: AnimatedGifImage race condition (coerceIn on frame index)
- Fix: search results margins (sidePadding applied consistently)
- Fix: app drawer search includes feeds in results
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Two issues fixed:
1. BunkerResponseDeserializer produces generic BunkerResponse for decrypt/encrypt
results (plain strings that aren't pubkeys or JSON). The response parsers only
matched typed subtypes (BunkerResponseDecrypt/Encrypt), causing all decrypt and
encrypt operations through NIP-46 bunker to fail with ReceivedButCouldNotPerform.
Fix: parsers now fall back to extracting response.result directly.
2. RemoteSignerManager timeout was 30s, shorter than Amber's 60s approval window.
Fix: increased to 65s with safe retry (republishes same request, preserving UUID
so bunker responses always match).
3. Desktop ChatPane showed raw ciphertext on decrypt failure instead of an error
message. Fix: shows "Could not decrypt the message" in italic.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Merge upstream main into feat/desktop-multi-account, resolving:
- Main.kt: Surface wrapper + CompositionLocalProvider + nip11Fetcher from upstream, multi-account DeckSidebar/LoginScreen from HEAD
- FeedScreen.kt: viewport-aware scroll from HEAD + contentPadding from upstream
- DeckSidebar.kt: merged imports (multi-account + titleBarInsetTop + MaterialSymbols)
- Migrated AccountSwitcherDropdown + AddAccountDialog from material.icons to MaterialSymbols
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
removeAccountFromStorage was calling loadSavedAccount() which reads
from legacy last_account.txt — didn't trigger feed/relay reload.
Now calls switchAccount(nextNpub) for proper relay reconnection and
feed reload, or logout(deleteKey=true) if no accounts remain.
Also cleans up bunker ephemeral keys on removal.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replaced DialogWindow (OS native window with title bar, minimize/maximize)
with Dialog composable (in-app overlay with scrim). Matches the
ComposeNoteDialog pattern used elsewhere in the desktop app.
- Card wrapper with 480dp width, 24dp padding
- Title row with 'Add Account' + Close (✕) icon
- No OS window chrome — clean in-app feel
- Click outside or ✕ to dismiss
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
consumeMetadata() was looping ALL notes in cache (O(N)) to find notes
by the author whose metadata changed. With hundreds of cached notes,
this caused lag on every metadata event.
Added notesByAuthor index (ConcurrentHashMap<HexKey, MutableSet<Note>>)
populated at loadEvent time. consumeMetadata now looks up notes by
author in O(1) instead of scanning the entire cache.
Before: ~500 notes × per metadata event = lag
After: ~3 notes per author × per metadata event = instant
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Root cause: toNoteDisplayData(localCache) wasn't keyed on metadataState,
so Compose could skip recomputing it when metadata arrived for items
that were composed before metadata was available (first ~2 items).
Fix: remember(event, metadataState) { event.toNoteDisplayData(localCache) }
ensures display data is recomputed when the note's metadata flow
invalidates. Applied to both regular and repost note paths.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Pre-existing bug: regular (non-repost) notes in the feed never observed
note.flow().metadata.stateFlow. When metadata arrived from relays and
invalidateData() was called, the composable didn't recompose — author
names and avatars stayed as hex fallbacks.
The repost path already observed metadata correctly (line 130). Added
the same observation to the regular note path (line 211).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Display names weren't showing because metadata was never requested for
account pubkeys. Now:
1. LaunchedEffect triggers loadMetadataForPubkeys() for all accounts
in the switcher once relays connect
2. metadataVersion collector persists display names for ALL accounts
(not just active one) when metadata arrives from relays
3. Names available immediately on next dropdown open
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
saveCurrentAccount() previously returned failure for read-only and
bunker accounts, preventing them from being added to multi-account
storage. Now:
- Read-only (npub): skips private key save, still saves to storage
- Bunker: saves to storage (private key already saved during login)
- Internal (nsec): saves private key + storage (unchanged)
This was the root cause of npub-only accounts not appearing in the
switcher and accounts being lost when adding via AddAccountDialog.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
DesktopAccountStorage now caches AccountMetadata in memory after first
read. Subsequent loadAccounts/saveAccount/setCurrentAccount serve from
cache, only hitting disk on writes. Also caches AES encryption key to
avoid repeated OS keychain lookups.
Before: 3 decrypts + 1 encrypt + 4 keychain calls per login/switch
After: 1 decrypt + 1 keychain call on first access, then memory only
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Root cause of accounts not persisting:
ensureCurrentAccountInStorage() was fire-and-forget (scope.launch),
so loginWithKey() ran before the old account was saved. The old account
was lost. Now all steps run sequentially in one coroutine.
UI hanging fix:
metadataVersion LaunchedEffect fired on every metadata event, doing
encrypted file I/O each time. Now debounced with 2s delay so it only
writes once after a batch of metadata settles.
Also:
- loadInternalAccount falls back to read-only (test updated)
- bunker/nostrconnect paths also await ensureCurrentAccountInStorage
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Three fixes:
1. Display names now stored in AccountInfo/AccountInfoDto and persisted
in encrypted storage. Populated when metadata arrives from relays via
metadataVersion LaunchedEffect. Available immediately on next open.
2. loadInternalAccount falls back to loadReadOnlyAccount when no private
key found in SecureKeyStorage — fixes npub-only accounts that were
saved as SignerType.Internal from earlier sessions.
3. Dropdown uses persisted displayName first, cache lookup as fallback.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Root cause of npub-only switch failure:
loginWithKey() with pubkey created AccountState with signerType=Internal
(default). switchAccount then called loadInternalAccount which requires
a private key from SecureKeyStorage → failed. Now sets ViewOnly.
Display names not showing:
- resolveDisplayName() ran at composition time but metadata hadn't
loaded from relays yet. Dropdown never recomposed when it arrived.
- Added metadataVersion counter to DesktopLocalCache, incremented on
each consumeMetadata(). Dropdown collects it to trigger recomposition
when user names become available.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add loadReadOnlyAccount() for ViewOnly signer type switching
(was mapping to loadInternalAccount which requires private key)
- Remove remember() from display name resolution — now re-evaluates
each recomposition so names appear once metadata loads from relays
- Only show subtitle (npub row) when display name is available;
if no display name, show npub as title only
- Signer type badge shown as subtitle when no display name
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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>
Cases removed:
- Trivial Modifier allocations (Modifier.weight/padding/size) — slot table
cost dominates the cost of building a fresh Modifier each recomposition.
- Map.keys views over relayStatuses on desktop screens — .keys is a
property read on the same map, no need to memoize.
- coerceIn() arithmetic on AudioWaveform Dp/Float params — two compares
are cheaper than the slot table read+compare.
- fadeIn()/fadeOut() in AnimatedVisibility — small EnterTransition
allocations that don't justify the slot table overhead.
Audit-only changes; no behavior changes.
https://claude.ai/code/session_011Ea2pVjwvCEx7X4izwryV4
Conversation cards had the name (bodyMedium) and last-message preview
(bodySmall) stacked flush against each other with very similar color
weight, so they blurred into one visual block. Now:
- Name: titleSmall (14sp Medium) — the focal top line.
- Preview: bodySmall at onSurfaceVariant.alpha = 0.7 with a 2dp spacer
above so it sits as a clearly secondary line.
- Timestamp: labelSmall at alpha 0.6, with an 8dp gutter before it so
long names don't collide with the time.
- Group badge: onSurfaceVariant.alpha = 0.5 instead of primary-70% so
it doesn't compete with the name for attention.
The name now wins the eye first; the preview reads as a subtitle.
https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo
Split the 12dp draggable divider into two 6dp halves: left half takes
surfaceContainer (matching the conversation list pane's fill), right
half takes surface (matching the chat pane's fill). The divider now
reads as the shared edge between the two panes instead of a solid
contrasting stripe cutting down the middle.
https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo
- **Draggable list pane**: the 280dp-fixed conversation list width is
now user-adjustable via a draggable divider between the list and the
chat pane. Width is clamped to 220-480dp and persisted across app
restarts via DesktopPreferences.messagesListWidthDp. Cursor flips to
the horizontal-resize arrow on hover.
- **ConversationCard alignment**: the unread indicator used to sit as
an 8dp dot + 14dp spacer to the left of the avatar, which pushed
every card ~14dp inward from the header row's 12dp padding. The
avatar now starts flush with the header (aligned with the top-bar
buttons); the unread state is drawn as a 10dp dot overlaid on the
avatar's bottom-right corner (iMessage / Slack-style), with a
surface-matched ring so it reads clearly against the avatar.
- **Drafts empty state**: was a flush-left Text pinned 16dp below the
header; now uses the shared EmptyState composable so "No drafts
yet" / description is vertically + horizontally centered in the
available space, consistent with Notifications / Highlights / etc.
https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo
Every file under commons/src/commonMain/kotlin/.../commons/ui/chat/
(ChatBubbleLayout, ChatMessageCompose, ChatroomHeader,
DmBroadcastBanner, DmBroadcastStatus, UserDisplayNameLayout) was
imported only by desktopApp. Android has its own parallel
implementation at amethyst/.../chats/feed/, so nothing shared.
Moved the six files into desktopApp/.../ui/chats/ alongside
ChatPane.kt, DesktopMessagesScreen.kt, etc. — same package as every
existing caller, which lets us drop six `import` lines from
ChatPane.kt + DmSendTracker.kt + DesktopMessagesScreen.kt. Empty
commons/ui/chat/ directory removed.
https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo
- Dropped the "ID: <hex>…" debug footer and the HorizontalDivider that
sat above it. Cards read as finished content now, not dev output.
- The previous design had an inner header+text Column carrying the
clickable modifier, which meant the hover ring only covered part of
the card (a rectangle inside). Switched to Material3 Card(onClick =
onClick, …) so the whole card is the click-surface and the ripple is
clipped to the card's rounded shape by M3 itself.
- Content moved into a `cardBody` lambda reused by both branches of
the `if (onClick != null)` check — non-clickable fallback exists
for in-thread quoted note wrappers.
- Action buttons inside NoteActionsRow have their own clickables that
consume taps before they reach the Card's handler, so tapping
reply / repost / zap still fires only that action, not the Card.
https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo
M3 OutlinedTextField has ~32dp of vertical contentPadding baked in,
so forcing .height(40-44dp) clipped the bodyMedium (14sp, 20dp line-
height) placeholder vertically.
Refactored both Search and Chat inputs to BasicTextField wrapped in
OutlinedTextFieldDefaults.DecorationBox, which lets us override
contentPadding to (12.dp horizontal, 8.dp vertical). The field now
renders at a true 40dp tall with the placeholder centered and no
clipping. Border, focus states, placeholder, and leading/trailing
icons still come from M3 so the look stays consistent.
https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo
"Edit Profile" was an OutlinedButton with icon + text which rendered
at ~40dp tall — taller than the 32dp IconButtons every other screen
uses in its header row, so it was pushing the Profile title down and
inflating the top border of the whole page.
- Edit Profile → IconButton(size = 32.dp) with Edit icon (size = 20.dp)
tinted primary. Matches the + / refresh / relays pattern on Home,
Reads, Notifications, etc.
- Follow / Unfollow button (when viewing someone else's profile) —
kept as a labelled Button for clarity but compacted to height 32.dp
with contentPadding(horizontal = 12.dp, vertical = 0.dp) so it
matches the header row's scale.
https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo
NoteCard hover (header+text inner clickable + avatar/name chip):
- Clip(RoundedCornerShape(8.dp)) before .clickable on the header+text
Column so the ripple rounds instead of cutting a hard rectangle.
- Avatar+name chip gets a stadium clip (RoundedCornerShape(100.dp))
before its clickable — matches how Slack / Notion / Linear render
user-pill hover states.
Chat bubble hover (ChatBubbleLayout):
- combinedClickable was applied outside the Surface's shape clipping,
so the ripple ignored ChatBubbleShapeMe/Them. Moved the shape into a
named val and added Modifier.clip(bubbleShape) ahead of
combinedClickable. Hover now rounds with the bubble.
Chat reaction icon (ChatPane detailRow):
- On hover the "add reaction" icon used to conditionally appear and
shift every other element in the detail row, causing the whole list
to reflow up or down. Wrapped in a Box with Modifier.alpha that
fades in/out — the icon is always laid out so the row stays fixed.
Button is disabled when not hovered so it's click-through when
invisible.
Search text field:
- Now padded by LocalReadingSidePadding so it stays inside the 720dp
reading column on wide displays (previously the search Row's
fillMaxWidth spanned the whole window after the ReadingColumn
refactor).
- Bumped height from 40dp → 44dp. M3 OutlinedTextField has ~16dp of
internal vertical content padding that was clipping the bodyMedium
placeholder at 40dp. Same change for the chat input.
https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo
The widthIn cap confined every scrollable element to a 720dp column in
the middle of the window. Anywhere the mouse hovered outside that
column, the scroll wheel did nothing — scroll events only reached the
LazyColumn when the cursor was inside.
Refactored ReadingColumn to use BoxWithConstraints + a CompositionLocal
(LocalReadingSidePadding) instead of a width-capping Box wrapper. The
outer Column now fills the whole window so scroll gestures land on the
scrollable wherever the mouse is. Each screen reads the side padding
from the CompositionLocal and applies it to:
- its header Row as `horizontal = readingHorizontalPadding()`
- its LazyColumn as `contentPadding = PaddingValues(horizontal = readingHorizontalPadding())`
Items still appear centered at DefaultReadingWidth (720dp), but the
scrollable surface now spans the full window width.
Also:
- Search placeholder shortened ("Search people, tags, notes…") so it
stops getting cut off in the compact 40dp field.
- UserProfile's floating header AnimatedVisibility call needed to be
fully-qualified (androidx.compose.animation.AnimatedVisibility) to
avoid the compiler picking the ColumnScope overload inherited from
ReadingColumn's outer ColumnScope — the inner BoxScope's
Modifier.align(Alignment.TopCenter) required the non-scoped variant.
https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo
Text fields:
- Chat input (ChatPane) and Search field (SearchScreen) both used
M3 OutlinedTextField defaults, which has a 56dp min-height tuned
for mobile touch. On desktop that reads as "way too tall" — roughly
double what Slack / Raycast / VS Code show. Overrode with
Modifier.height(40.dp) / heightIn(min = 40.dp), paired with
bodyMedium (14sp) textStyle and slightly smaller leading/trailing
icons so the field sits at a desktop-native ~40dp when empty, still
grows with content in the chat case.
Settings section headers — normalized to titleSmall (14sp) so every
section sits consistently under the "Settings" titleMedium (16sp)
screen title:
- "Wallet Connect (NWC)" (was titleLarge, already fixed)
- "Relay Settings" (was titleLarge, already fixed)
- "Tor" (was titleLarge, now titleSmall)
- "Media Servers (Blossom)" (was titleMedium, now titleSmall)
MediaServerSettings extra border removed — the parent
RelaySettingsScreen already provides a 12dp horizontal gutter, but
MediaServerSettings was adding another 16dp all-sides padding on top,
showing as a visible inner frame. Dropped that wrapper padding.
https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo