Merge pull request #3098 from nrobi144/feat/desktop-visual-personality

feat(desktop): Visual Personality Overhaul — Unified Theme, Sidebar, Search, Cards
This commit is contained in:
Vitor Pamplona
2026-05-29 05:13:00 -04:00
committed by GitHub
58 changed files with 4076 additions and 863 deletions
@@ -0,0 +1,96 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.ui.components
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.unit.dp
@Composable
fun ShimmerPlaceholder(modifier: Modifier = Modifier) {
val transition = rememberInfiniteTransition(label = "shimmer")
val translateAnim by transition.animateFloat(
initialValue = 0f,
targetValue = 1000f,
animationSpec =
infiniteRepeatable(
animation = tween(durationMillis = 1200, easing = LinearEasing),
repeatMode = RepeatMode.Restart,
),
label = "shimmerTranslate",
)
val shimmerColors =
listOf(
MaterialTheme.colorScheme.surfaceContainerHigh,
MaterialTheme.colorScheme.surfaceContainer,
MaterialTheme.colorScheme.surfaceContainerHigh,
)
val brush =
Brush.linearGradient(
colors = shimmerColors,
start = Offset(translateAnim - 200f, translateAnim - 200f),
end = Offset(translateAnim, translateAnim),
)
Box(modifier.background(brush, MaterialTheme.shapes.small))
}
@Composable
fun NoteCardSkeleton(modifier: Modifier = Modifier) {
Column(
modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
ShimmerPlaceholder(Modifier.size(32.dp).clip(CircleShape))
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
ShimmerPlaceholder(Modifier.width(120.dp).height(12.dp))
ShimmerPlaceholder(Modifier.width(60.dp).height(10.dp))
}
}
ShimmerPlaceholder(Modifier.fillMaxWidth().height(14.dp))
ShimmerPlaceholder(Modifier.fillMaxWidth(0.7f).height(14.dp))
ShimmerPlaceholder(Modifier.fillMaxWidth().height(180.dp))
}
}
@@ -23,6 +23,10 @@ package com.vitorpamplona.amethyst.commons.ui.theme
import androidx.compose.ui.graphics.Color
// Primary brand colors
val AmethystBlue = Color(0xFF0096FF)
val AmethystBlueDark = Color(0xFF4DB8FF)
val AmethystPurple = Color(0xFF9A82DB)
val Primary50 = Color(red = 127, green = 103, blue = 190)
val Primary60 = Color(red = 154, green = 130, blue = 219)
val Primary70 = Color(red = 182, green = 157, blue = 248)
@@ -71,6 +75,15 @@ val DarkWarningColorOnSecondSurface = Color(0xFFE1C419)
val LightAllGoodColor = Color(0xFF339900)
val DarkAllGoodColor = Color(0xFF99cc33)
// Semantic status colors for desktop
val StatusGreen = Color(0xFF4CAF50)
val StatusGreenDark = Color(0xFF81C784)
val StatusRed = Color(0xFFF44336)
val StatusRedDark = Color(0xFFEF9A9A)
val StatusAmber = Color(0xFFFFB300)
val StatusAmberDark = Color(0xFFFFD54F)
val StatusBlue = Color(0xFF2196F3)
// Fundraiser colors
val LightFundraiserProgressColor = Color(0xFF3DB601)
val DarkFundraiserProgressColor = Color(0xFF61A229)
@@ -105,4 +105,12 @@ object DesktopPreferences {
val preferredBlossomServer: String
get() = blossomServers.firstOrNull() ?: DEFAULT_BLOSSOM_SERVER
private const val KEY_SIDEBAR_COLLAPSED = "sidebar_collapsed"
var sidebarCollapsed: Boolean
get() = prefs.getBoolean(KEY_SIDEBAR_COLLAPSED, false)
set(value) {
prefs.putBoolean(KEY_SIDEBAR_COLLAPSED, value)
}
}
@@ -32,6 +32,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
@@ -104,8 +105,8 @@ import com.vitorpamplona.amethyst.desktop.ui.chats.DmSendTracker
import com.vitorpamplona.amethyst.desktop.ui.deck.AppDrawer
import com.vitorpamplona.amethyst.desktop.ui.deck.DeckColumnType
import com.vitorpamplona.amethyst.desktop.ui.deck.DeckLayout
import com.vitorpamplona.amethyst.desktop.ui.deck.DeckSidebar
import com.vitorpamplona.amethyst.desktop.ui.deck.DeckState
import com.vitorpamplona.amethyst.desktop.ui.deck.MainSidebar
import com.vitorpamplona.amethyst.desktop.ui.deck.PinnedNavBarState
import com.vitorpamplona.amethyst.desktop.ui.deck.SinglePaneLayout
import com.vitorpamplona.amethyst.desktop.ui.deck.SinglePaneState
@@ -263,6 +264,7 @@ fun main() {
var showAppDrawer by remember { mutableStateOf(false) }
var showAddColumnDialog by remember { mutableStateOf(false) }
var showImportFollowListDialog by remember { mutableStateOf(false) }
val feedSearchActiveState = remember { mutableStateOf(false) }
// Tor state at Window level — survives key() app rebuild
var torSettings by remember {
@@ -443,6 +445,18 @@ fun main() {
)
}
Menu("View") {
Item(
"Search",
shortcut =
if (isMacOS) {
KeyShortcut(Key.F, meta = true)
} else {
KeyShortcut(Key.F, ctrl = true)
},
onClick = {
feedSearchActiveState.value = !feedSearchActiveState.value
},
)
Item(
"App Drawer",
shortcut =
@@ -607,39 +621,46 @@ fun main() {
LocalIsImmersiveFullscreen provides immersiveFullscreenState,
) {
key(appRestartKey) {
App(
layoutMode = layoutMode,
onLayoutModeChange = { newMode ->
layoutMode = newMode
DesktopPreferences.layoutMode = newMode.name
CompositionLocalProvider(
com.vitorpamplona.amethyst.desktop.ui.theme.LocalFeedSearchActive provides feedSearchActiveState,
com.vitorpamplona.amethyst.desktop.ui.theme.LocalOpenFullSearch provides {
navigateToScreen?.invoke(DeckColumnType.Search)
},
deckState = deckState,
workspaceManager = workspaceManager,
accountManager = accountManager,
showComposeDialog = showComposeDialog,
showAppDrawer = showAppDrawer,
onShowComposeDialog = { showComposeDialog = true },
onShowReplyDialog = { event ->
replyToNote = event
showComposeDialog = true
},
onDismissComposeDialog = {
showComposeDialog = false
replyToNote = null
},
onDismissAppDrawer = { showAppDrawer = false },
onShowAppDrawer = { showAppDrawer = true },
replyToNote = replyToNote,
showImportFollowListDialog = showImportFollowListDialog,
onShowImportFollowListDialog = { showImportFollowListDialog = true },
onDismissImportFollowListDialog = { showImportFollowListDialog = false },
onRestartApp = { appRestartKey++ },
torManager = torManager,
torTypeFlow = torTypeFlow,
externalPortFlow = externalPortFlow,
initialTorSettings = torSettings,
onNavigateToScreen = { navigateToScreen = it },
)
) {
App(
layoutMode = layoutMode,
onLayoutModeChange = { newMode ->
layoutMode = newMode
DesktopPreferences.layoutMode = newMode.name
},
deckState = deckState,
workspaceManager = workspaceManager,
accountManager = accountManager,
showComposeDialog = showComposeDialog,
showAppDrawer = showAppDrawer,
onShowComposeDialog = { showComposeDialog = true },
onShowReplyDialog = { event ->
replyToNote = event
showComposeDialog = true
},
onDismissComposeDialog = {
showComposeDialog = false
replyToNote = null
},
onDismissAppDrawer = { showAppDrawer = false },
onShowAppDrawer = { showAppDrawer = true },
replyToNote = replyToNote,
showImportFollowListDialog = showImportFollowListDialog,
onShowImportFollowListDialog = { showImportFollowListDialog = true },
onDismissImportFollowListDialog = { showImportFollowListDialog = false },
onRestartApp = { appRestartKey++ },
torManager = torManager,
torTypeFlow = torTypeFlow,
externalPortFlow = externalPortFlow,
initialTorSettings = torSettings,
onNavigateToScreen = { navigateToScreen = it },
)
}
}
}
}
@@ -844,6 +865,7 @@ fun App(
}
is AccountState.ConnectingRelays -> {}
is AccountState.Loading -> {}
}
}
@@ -904,8 +926,10 @@ fun App(
if (current != null) {
accountManager.loadNwcConnection(current.npub)
}
} else {
// No saved account found → show login screen
accountManager.setLoggedOut()
}
// If failure: state remains LoggedOut → login screen shows automatically
}
onDispose {
@@ -936,6 +960,28 @@ fun App(
com.vitorpamplona.amethyst.desktop.ui.deck.LocalLocalRelayStore provides localRelayStore,
) {
when (accountState) {
is AccountState.Loading -> {
// Branded loading screen while accounts load from storage
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
androidx.compose.material3.CircularProgressIndicator(
modifier = Modifier.size(32.dp),
color = MaterialTheme.colorScheme.primary,
strokeWidth = 3.dp,
)
Spacer(Modifier.height(16.dp))
Text(
"Amethyst",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onBackground,
)
}
}
}
is AccountState.LoggedOut -> {
LoginScreen(
accountManager = accountManager,
@@ -1346,6 +1392,106 @@ fun MainContent(
Box(Modifier.fillMaxSize()) {
Column(Modifier.fillMaxSize()) {
Row(Modifier.fillMaxSize().weight(1f)) {
// Shared sidebar for both layout modes
if (!isImmersive) {
val allAccountsState by accountManager.allAccounts.collectAsState()
val searchActiveState = com.vitorpamplona.amethyst.desktop.ui.theme.LocalFeedSearchActive.current
var showAddAccountDialog by remember { mutableStateOf(false) }
// Derive active column type based on layout mode
val activeColumnType =
when (layoutMode) {
LayoutMode.DECK -> {
val deckColumns by deckState.columns.collectAsState()
val focusedIdx by deckState.focusedColumnIndex.collectAsState()
deckColumns.getOrNull(focusedIdx)?.type
}
LayoutMode.SINGLE_PANE -> {
val currentScreen by singlePaneState.currentScreen.collectAsState()
currentScreen
}
}
Box {
MainSidebar(
activeNpub = accountManager.currentAccount()?.npub,
allAccounts = allAccountsState,
localCache = localCache,
onSwitchAccount = { npub ->
scope.launch(Dispatchers.IO) {
accountManager.switchAccount(npub)
}
},
onAddAccount = { showAddAccountDialog = true },
onRemoveAccount = { npub ->
scope.launch(Dispatchers.IO) {
accountManager.removeAccountFromStorage(npub)
}
},
onAddColumn = onShowAppDrawer,
onOpenSettings = {
when (layoutMode) {
LayoutMode.DECK -> {
if (deckState.hasColumnOfType(DeckColumnType.Settings)) {
deckState.focusExistingColumn(DeckColumnType.Settings)
} else {
deckState.addColumn(DeckColumnType.Settings)
}
}
LayoutMode.SINGLE_PANE -> {
singlePaneState.navigate(DeckColumnType.Settings)
}
}
},
onNavigate = { type ->
searchActiveState.value = false
when (layoutMode) {
LayoutMode.DECK -> {
if (deckState.hasColumnOfType(type)) {
deckState.focusExistingColumn(type)
} else {
deckState.addColumn(type)
}
}
LayoutMode.SINGLE_PANE -> {
singlePaneState.navigate(type)
}
}
},
activeColumnType = activeColumnType,
onShowImportFollowListDialog = onShowImportFollowListDialog,
signerConnectionState = signerConnectionState,
lastPingTimeSec = lastPingTimeSec,
torStatus = torStatus,
)
// Dim sidebar when feed search is active
if (com.vitorpamplona.amethyst.desktop.ui.theme.LocalFeedSearchActive.current.value) {
Box(
modifier =
Modifier
.matchParentSize()
.background(Color.Black.copy(alpha = 0.3f)),
)
}
}
VerticalDivider()
if (showAddAccountDialog) {
com.vitorpamplona.amethyst.desktop.ui.account.AddAccountDialog(
accountManager = accountManager,
onDismiss = { showAddAccountDialog = false },
onAccountAdded = {
showAddAccountDialog = false
scope.launch(Dispatchers.IO) {
accountManager.refreshAccountList()
}
},
)
}
}
when (layoutMode) {
LayoutMode.SINGLE_PANE -> {
val lastRelayEvent by subscriptionsCoordinator.lastEventAt.collectAsState()
@@ -1377,55 +1523,6 @@ fun MainContent(
}
LayoutMode.DECK -> {
if (!isImmersive) {
val allAccountsState by accountManager.allAccounts.collectAsState()
var showAddAccountDialog by remember { mutableStateOf(false) }
DeckSidebar(
activeNpub = accountManager.currentAccount()?.npub,
allAccounts = allAccountsState,
localCache = localCache,
onSwitchAccount = { npub ->
scope.launch(Dispatchers.IO) {
accountManager.switchAccount(npub)
}
},
onAddAccount = { showAddAccountDialog = true },
onRemoveAccount = { npub ->
scope.launch(Dispatchers.IO) {
accountManager.removeAccountFromStorage(npub)
}
},
onAddColumn = onShowAppDrawer,
onOpenSettings = {
if (deckState.hasColumnOfType(DeckColumnType.Settings)) {
deckState.focusExistingColumn(DeckColumnType.Settings)
} else {
deckState.addColumn(DeckColumnType.Settings)
}
},
onShowImportFollowListDialog = onShowImportFollowListDialog,
signerConnectionState = signerConnectionState,
lastPingTimeSec = lastPingTimeSec,
torStatus = torStatus,
)
VerticalDivider()
if (showAddAccountDialog) {
com.vitorpamplona.amethyst.desktop.ui.account.AddAccountDialog(
accountManager = accountManager,
onDismiss = { showAddAccountDialog = false },
onAccountAdded = {
showAddAccountDialog = false
scope.launch(Dispatchers.IO) {
accountManager.refreshAccountList()
}
},
)
}
}
DeckLayout(
deckState = deckState,
relayManager = relayManager,
@@ -69,6 +69,8 @@ import kotlinx.coroutines.withTimeout
import java.io.File
sealed class AccountState {
data object Loading : AccountState()
data object LoggedOut : AccountState()
data object ConnectingRelays : AccountState()
@@ -123,7 +125,7 @@ class AccountManager internal constructor(
private val _allAccounts = MutableStateFlow<ImmutableList<AccountInfo>>(persistentListOf())
val allAccounts: StateFlow<ImmutableList<AccountInfo>> = _allAccounts.asStateFlow()
private val _accountState = MutableStateFlow<AccountState>(AccountState.LoggedOut)
private val _accountState = MutableStateFlow<AccountState>(AccountState.Loading)
val accountState: StateFlow<AccountState> = _accountState.asStateFlow()
private val _nwcConnection = MutableStateFlow<Nip47WalletConnect.Nip47URINorm?>(null)
@@ -463,6 +465,10 @@ class AccountManager internal constructor(
accountStorage.setCurrentAccount(npub)
}
fun setLoggedOut() {
_accountState.value = AccountState.LoggedOut
}
fun setConnectingRelays() {
_accountState.value = AccountState.ConnectingRelays
}
@@ -24,272 +24,92 @@ import androidx.compose.material3.ColorScheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
import com.vitorpamplona.amethyst.commons.ui.theme.AmethystBlue
import com.vitorpamplona.amethyst.commons.ui.theme.AmethystBlueDark
import com.vitorpamplona.amethyst.commons.ui.theme.AmethystPurple
/**
* Per-OS Material3 [ColorScheme]s tuned to match each platform's native surface
* tones. The OS accent (resolved via [PlatformAccent]) is plumbed in as `primary`
* so links, focus rings, and selection highlights match the rest of the user's
* system.
*
* Surface tones come from each OS's reference palettes:
* - macOS dark: NSWindowBackgroundColor / NSAlternateSelectedControlColor.
* - macOS light: NSColor systemBackgroundColor (#FFFFFF) / window background (#ECECEC).
* - GNOME dark: libadwaita `@window_bg_color` (#242424) / `@view_bg_color` (#1E1E1E).
* - GNOME light: libadwaita `@window_bg_color` (#FAFAFA) / `@view_bg_color` (#FFFFFF).
* - KDE Breeze dark/light defaults.
* - Windows 11 dark/light: WinUI mica reference values.
* Unified Amethyst brand [ColorScheme] — consistent visual identity across all
* platforms. Uses cyan/blue (#0096FF) as primary accent, with Amethyst purple
* as tertiary heritage color.
*/
object PlatformColorScheme {
fun resolve(
dark: Boolean,
accent: Color,
): ColorScheme =
when (PlatformInfo.current) {
Platform.MACOS -> if (dark) macOSDark(accent) else macOSLight(accent)
Platform.GNOME -> if (dark) gnomeDark(accent) else gnomeLight(accent)
Platform.KDE -> if (dark) kdeDark(accent) else kdeLight(accent)
Platform.WINDOWS -> if (dark) windowsDark(accent) else windowsLight(accent)
else -> if (dark) genericDark(accent) else genericLight(accent)
}
fun resolve(dark: Boolean): ColorScheme = if (dark) amethystDark() else amethystLight()
// ── macOS ─────────────────────────────────────────────────────────────────
private fun amethystLight() =
lightColorScheme(
primary = AmethystBlue,
onPrimary = Color.White,
primaryContainer = AmethystBlue.copy(alpha = 0.12f).compositeOver(Color.White),
onPrimaryContainer = AmethystBlue,
secondary = Color(0xFF5E8FAD),
onSecondary = Color.White,
secondaryContainer = Color(0xFF5E8FAD).copy(alpha = 0.12f).compositeOver(Color.White),
onSecondaryContainer = Color(0xFF5E8FAD),
tertiary = AmethystPurple,
onTertiary = Color.White,
tertiaryContainer = AmethystPurple.copy(alpha = 0.12f).compositeOver(Color.White),
onTertiaryContainer = AmethystPurple,
background = Color(0xFFF2F2F7),
onBackground = Color(0xFF1C1C1E),
surface = Color.White,
onSurface = Color(0xFF1C1C1E),
surfaceVariant = Color(0xFFF0F0F5),
onSurfaceVariant = Color(0xFF6E6E73),
surfaceContainer = Color(0xFFF7F7FA),
surfaceContainerHigh = Color(0xFFEEEEF2),
surfaceContainerHighest = Color(0xFFE5E5EA),
surfaceContainerLow = Color(0xFFFAFAFC),
surfaceContainerLowest = Color.White,
surfaceDim = Color(0xFFE8E8ED),
surfaceBright = Color.White,
outline = Color(0xFFE0E0E0),
outlineVariant = Color(0xFFEBEBEB),
inverseSurface = Color(0xFF2C2C2E),
inverseOnSurface = Color(0xFFF2F2F7),
inversePrimary = AmethystBlueDark,
error = Color(0xFFBA1A1A),
onError = Color.White,
errorContainer = Color(0xFFFFDAD6),
onErrorContainer = Color(0xFF410002),
)
private fun macOSDark(accent: Color) =
private fun amethystDark() =
darkColorScheme(
primary = accent,
onPrimary = onAccent(accent),
secondary = accent,
tertiary = accent,
background = Color(0xFF1E1E1E),
onBackground = Color(0xFFE5E5E5),
primary = AmethystBlueDark,
onPrimary = Color.White,
primaryContainer = AmethystBlueDark.copy(alpha = 0.16f).compositeOver(Color(0xFF1E1E1E)),
onPrimaryContainer = AmethystBlueDark,
secondary = Color(0xFF7EAEC8),
onSecondary = Color.White,
secondaryContainer = Color(0xFF7EAEC8).copy(alpha = 0.16f).compositeOver(Color(0xFF1E1E1E)),
onSecondaryContainer = Color(0xFF7EAEC8),
tertiary = Color(0xFFB6A0E0),
onTertiary = Color.White,
tertiaryContainer = Color(0xFFB6A0E0).copy(alpha = 0.16f).compositeOver(Color(0xFF1E1E1E)),
onTertiaryContainer = Color(0xFFB6A0E0),
background = Color(0xFF121212),
onBackground = Color(0xFFE5E5EA),
surface = Color(0xFF1E1E1E),
onSurface = Color(0xFFE5E5E5),
onSurface = Color(0xFFE5E5EA),
surfaceVariant = Color(0xFF2A2A2A),
onSurfaceVariant = Color(0xFFB8B8B8),
onSurfaceVariant = Color(0xFF9E9EA3),
surfaceContainer = Color(0xFF252525),
surfaceContainerHigh = Color(0xFF2D2D2D),
surfaceContainerHighest = Color(0xFF353535),
surfaceContainerHigh = Color(0xFF2E2E2E),
surfaceContainerHighest = Color(0xFF383838),
surfaceContainerLow = Color(0xFF1A1A1A),
surfaceContainerLowest = Color(0xFF141414),
surfaceDim = Color(0xFF1A1A1A),
surfaceBright = Color(0xFF353535),
outline = Color(0xFF6A6A6A),
outlineVariant = Color(0xFF3A3A3A),
surfaceBright = Color(0xFF383838),
outline = Color(0xFF3A3A3A),
outlineVariant = Color(0xFF2E2E2E),
inverseSurface = Color(0xFFE5E5EA),
inverseOnSurface = Color(0xFF1C1C1E),
inversePrimary = AmethystBlue,
error = Color(0xFFFFB4AB),
onError = Color(0xFF690005),
errorContainer = Color(0xFF93000A),
onErrorContainer = Color(0xFFFFDAD6),
)
private fun macOSLight(accent: Color): ColorScheme {
// Apple's accent palette includes Yellow (#F8BA00) and Graphite (#8C8C8C)
// which have terrible contrast on any light surface. Darken high-luminance
// accents so the primary color stays readable without disabling the user's
// preference — the hue is preserved, just pulled toward a usable lightness.
val readable = darkenForLight(accent)
return lightColorScheme(
primary = readable,
onPrimary = onAccent(readable),
secondary = readable,
tertiary = readable,
// Apple's light-mode main content background is near-white (#F5F5F7 /
// Apple's secondarySystemBackgroundColor); the #ECECEC gray is only
// used behind the title bar chrome — which we map to surfaceContainer.
background = Color(0xFFF5F5F7),
onBackground = Color(0xFF1A1A1A),
surface = Color(0xFFFFFFFF),
onSurface = Color(0xFF1A1A1A),
surfaceVariant = Color(0xFFECECEC),
onSurfaceVariant = Color(0xFF555555),
surfaceContainer = Color(0xFFECECEC),
surfaceContainerHigh = Color(0xFFE5E5E5),
surfaceContainerHighest = Color(0xFFDDDDDD),
surfaceContainerLow = Color(0xFFF0F0F0),
surfaceContainerLowest = Color(0xFFFFFFFF),
outline = Color(0xFFB0B0B0),
outlineVariant = Color(0xFFD8D8D8),
)
}
/**
* Scales an accent color toward black until its relative luminance is at
* most [maxLuminance]. Preserves hue (all RGB channels scale by the same
* factor). Only applied on light surfaces — on dark surfaces the raw accent
* already has good contrast.
*
* 0.38 was picked empirically: it keeps a saturated blue visible without
* darkening it, while pulling yellow/graphite down to a readable amber/gray.
*/
private fun darkenForLight(
c: Color,
maxLuminance: Float = 0.38f,
): Color {
val lum = 0.2126f * c.red + 0.7152f * c.green + 0.0722f * c.blue
if (lum <= maxLuminance) return c
val scale = maxLuminance / lum
return Color(
red = (c.red * scale).coerceIn(0f, 1f),
green = (c.green * scale).coerceIn(0f, 1f),
blue = (c.blue * scale).coerceIn(0f, 1f),
alpha = c.alpha,
)
}
// ── GNOME (libadwaita) ────────────────────────────────────────────────────
private fun gnomeDark(accent: Color) =
darkColorScheme(
primary = accent,
onPrimary = onAccent(accent),
secondary = accent,
tertiary = accent,
background = Color(0xFF242424),
onBackground = Color(0xFFFFFFFF),
surface = Color(0xFF1E1E1E),
onSurface = Color(0xFFFFFFFF),
surfaceVariant = Color(0xFF323232),
onSurfaceVariant = Color(0xFFCCCCCC),
surfaceContainer = Color(0xFF2C2C2C),
surfaceContainerHigh = Color(0xFF383838),
surfaceContainerHighest = Color(0xFF424242),
surfaceContainerLow = Color(0xFF222222),
surfaceContainerLowest = Color(0xFF1A1A1A),
outline = Color(0xFF5E5E5E),
outlineVariant = Color(0xFF3A3A3A),
)
private fun gnomeLight(accent: Color) =
lightColorScheme(
primary = accent,
onPrimary = onAccent(accent),
secondary = accent,
tertiary = accent,
background = Color(0xFFFAFAFA),
onBackground = Color(0xFF1A1A1A),
surface = Color(0xFFFFFFFF),
onSurface = Color(0xFF1A1A1A),
surfaceVariant = Color(0xFFF0F0F0),
onSurfaceVariant = Color(0xFF5E5E5E),
surfaceContainer = Color(0xFFF4F4F4),
surfaceContainerHigh = Color(0xFFEDEDED),
surfaceContainerHighest = Color(0xFFE5E5E5),
surfaceContainerLow = Color(0xFFF9F9F9),
surfaceContainerLowest = Color(0xFFFFFFFF),
outline = Color(0xFFB0B0B0),
outlineVariant = Color(0xFFD4D4D4),
)
// ── KDE Plasma (Breeze) ───────────────────────────────────────────────────
private fun kdeDark(accent: Color) =
darkColorScheme(
primary = accent,
onPrimary = onAccent(accent),
secondary = accent,
tertiary = accent,
background = Color(0xFF1B1E20),
onBackground = Color(0xFFFCFCFC),
surface = Color(0xFF232629),
onSurface = Color(0xFFFCFCFC),
surfaceVariant = Color(0xFF2A2E32),
onSurfaceVariant = Color(0xFFBDC3C7),
surfaceContainer = Color(0xFF272A2E),
surfaceContainerHigh = Color(0xFF31353A),
surfaceContainerHighest = Color(0xFF3B4045),
surfaceContainerLow = Color(0xFF1F2225),
surfaceContainerLowest = Color(0xFF18191B),
outline = Color(0xFF4D5258),
outlineVariant = Color(0xFF34383C),
)
private fun kdeLight(accent: Color) =
lightColorScheme(
primary = accent,
onPrimary = onAccent(accent),
secondary = accent,
tertiary = accent,
background = Color(0xFFEFF0F1),
onBackground = Color(0xFF232629),
surface = Color(0xFFFCFCFC),
onSurface = Color(0xFF232629),
surfaceVariant = Color(0xFFE5E9EC),
onSurfaceVariant = Color(0xFF4D4D4D),
surfaceContainer = Color(0xFFF2F3F4),
surfaceContainerHigh = Color(0xFFEAECEE),
surfaceContainerHighest = Color(0xFFE0E3E5),
surfaceContainerLow = Color(0xFFF7F8F9),
surfaceContainerLowest = Color(0xFFFFFFFF),
outline = Color(0xFFBABEC2),
outlineVariant = Color(0xFFD9DCDF),
)
// ── Windows 11 (WinUI 3 / Mica) ───────────────────────────────────────────
private fun windowsDark(accent: Color) =
darkColorScheme(
primary = accent,
onPrimary = onAccent(accent),
secondary = accent,
tertiary = accent,
background = Color(0xFF202020),
onBackground = Color(0xFFFFFFFF),
surface = Color(0xFF2B2B2B),
onSurface = Color(0xFFFFFFFF),
surfaceVariant = Color(0xFF323232),
onSurfaceVariant = Color(0xFFCCCCCC),
surfaceContainer = Color(0xFF272727),
surfaceContainerHigh = Color(0xFF323232),
surfaceContainerHighest = Color(0xFF3D3D3D),
surfaceContainerLow = Color(0xFF1F1F1F),
surfaceContainerLowest = Color(0xFF1A1A1A),
outline = Color(0xFF5E5E5E),
outlineVariant = Color(0xFF3A3A3A),
)
private fun windowsLight(accent: Color) =
lightColorScheme(
primary = accent,
onPrimary = onAccent(accent),
secondary = accent,
tertiary = accent,
background = Color(0xFFF3F3F3),
onBackground = Color(0xFF1A1A1A),
surface = Color(0xFFFBFBFB),
onSurface = Color(0xFF1A1A1A),
surfaceVariant = Color(0xFFEDEDED),
onSurfaceVariant = Color(0xFF555555),
surfaceContainer = Color(0xFFF6F6F6),
surfaceContainerHigh = Color(0xFFEDEDED),
surfaceContainerHighest = Color(0xFFE5E5E5),
surfaceContainerLow = Color(0xFFFAFAFA),
surfaceContainerLowest = Color(0xFFFFFFFF),
outline = Color(0xFFB0B0B0),
outlineVariant = Color(0xFFD8D8D8),
)
// ── Generic (other Linux DEs / Unknown) ──────────────────────────────────
private fun genericDark(accent: Color) =
darkColorScheme(
primary = accent,
onPrimary = onAccent(accent),
secondary = accent,
tertiary = accent,
background = Color(0xFF1E1E1E),
surface = Color(0xFF1E1E1E),
surfaceVariant = Color(0xFF2A2A2A),
)
private fun genericLight(accent: Color) =
lightColorScheme(
primary = accent,
onPrimary = onAccent(accent),
secondary = accent,
tertiary = accent,
)
/**
* Picks readable text color (white or black) on top of the given accent based on
* its perceived luminance (Rec. 709 weights).
*/
private fun onAccent(accent: Color): Color {
val luminance = 0.2126f * accent.red + 0.7152f * accent.green + 0.0722f * accent.blue
return if (luminance > 0.55f) Color.Black else Color.White
}
}
@@ -25,67 +25,15 @@ import androidx.compose.material3.Shapes
import androidx.compose.ui.unit.dp
/**
* Per-OS Material3 [Shapes] tuned to match each platform's native rounding language.
* Material's defaults (4 / 4 / 0 dp) read as Android — these values match what users
* see in their OS's first-party apps.
*
* - macOS (Sonoma+): ~10 / 12 / 16 dp continuous-style corners.
* - GNOME (libadwaita): 9 / 12 / 16 dp — adw_dialog / adw_card baseline.
* - KDE (Breeze): 6 / 8 / 12 dp — Breeze prefers tighter rounding than libadwaita.
* - Windows (WinUI 3): 4 / 8 / 8 dp — WinUI's mica surfaces use modest rounding.
* Unified Amethyst brand [Shapes] — consistent rounding across all platforms.
*/
object PlatformShapes {
val current: Shapes by lazy {
when (PlatformInfo.current) {
Platform.MACOS -> {
Shapes(
extraSmall = RoundedCornerShape(6.dp),
small = RoundedCornerShape(8.dp),
medium = RoundedCornerShape(10.dp),
large = RoundedCornerShape(14.dp),
extraLarge = RoundedCornerShape(20.dp),
)
}
Platform.GNOME -> {
Shapes(
extraSmall = RoundedCornerShape(6.dp),
small = RoundedCornerShape(9.dp),
medium = RoundedCornerShape(12.dp),
large = RoundedCornerShape(16.dp),
extraLarge = RoundedCornerShape(24.dp),
)
}
Platform.KDE -> {
Shapes(
extraSmall = RoundedCornerShape(4.dp),
small = RoundedCornerShape(6.dp),
medium = RoundedCornerShape(8.dp),
large = RoundedCornerShape(12.dp),
extraLarge = RoundedCornerShape(16.dp),
)
}
Platform.WINDOWS -> {
Shapes(
extraSmall = RoundedCornerShape(4.dp),
small = RoundedCornerShape(4.dp),
medium = RoundedCornerShape(8.dp),
large = RoundedCornerShape(8.dp),
extraLarge = RoundedCornerShape(12.dp),
)
}
Platform.LINUX_OTHER, Platform.UNKNOWN -> {
Shapes(
extraSmall = RoundedCornerShape(6.dp),
small = RoundedCornerShape(8.dp),
medium = RoundedCornerShape(10.dp),
large = RoundedCornerShape(14.dp),
extraLarge = RoundedCornerShape(20.dp),
)
}
}
}
val current: Shapes =
Shapes(
extraSmall = RoundedCornerShape(6.dp),
small = RoundedCornerShape(8.dp),
medium = RoundedCornerShape(12.dp),
large = RoundedCornerShape(16.dp),
extraLarge = RoundedCornerShape(24.dp),
)
}
@@ -22,10 +22,14 @@ package com.vitorpamplona.amethyst.desktop.platform
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.FrameWindowScope
import com.vitorpamplona.amethyst.desktop.ui.theme.AmethystSpacing
import com.vitorpamplona.amethyst.desktop.ui.theme.LocalIsDarkTheme
import com.vitorpamplona.amethyst.desktop.ui.theme.LocalSpacing
/**
* Wraps content in a [MaterialTheme] tuned for the host OS: native fonts, accent
@@ -41,14 +45,18 @@ fun PlatformMaterialTheme(
isDark: Boolean,
content: @Composable () -> Unit,
) {
val accent = remember { PlatformAccent.systemAccent() }
val colorScheme = remember(isDark, accent) { PlatformColorScheme.resolve(isDark, accent) }
MaterialTheme(
colorScheme = colorScheme,
typography = PlatformTypography.current,
shapes = PlatformShapes.current,
content = content,
)
val colorScheme = remember(isDark) { PlatformColorScheme.resolve(isDark) }
CompositionLocalProvider(
LocalSpacing provides AmethystSpacing(),
LocalIsDarkTheme provides isDark,
) {
MaterialTheme(
colorScheme = colorScheme,
typography = PlatformTypography.current,
shapes = PlatformShapes.current,
content = content,
)
}
}
/**
@@ -40,19 +40,7 @@ object PlatformTypography {
val current: Typography by lazy { build(PlatformFonts.ui) }
private fun build(family: FontFamily): Typography {
// Per-OS letter spacing offset applied to display/headline styles.
val tightening: TextUnit =
when (PlatformInfo.current) {
Platform.MACOS -> (-0.4).sp
// SF Pro is set tight at large sizes
Platform.GNOME -> (-0.2).sp
// Adwaita Sans is mildly tight
Platform.KDE, Platform.WINDOWS -> 0.sp
else -> 0.sp
}
val tightening: TextUnit = (-0.3).sp
fun ts(
size: Int,
@@ -68,9 +56,9 @@ object PlatformTypography {
)
return Typography(
displayLarge = ts(57, 64, FontWeight.Normal, tightening),
displayMedium = ts(45, 52, FontWeight.Normal, tightening),
displaySmall = ts(36, 44, FontWeight.Normal, tightening),
displayLarge = ts(57, 64, FontWeight.Light, tightening),
displayMedium = ts(45, 52, FontWeight.Light, tightening),
displaySmall = ts(36, 44, FontWeight.Light, tightening),
headlineLarge = ts(32, 40, FontWeight.SemiBold, tightening),
headlineMedium = ts(28, 36, FontWeight.SemiBold, tightening),
headlineSmall = ts(24, 32, FontWeight.SemiBold, tightening),
@@ -36,7 +36,6 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.Button
import androidx.compose.material3.Card
@@ -220,7 +219,7 @@ fun ComposeNoteDialog(
.dragAndDropTarget(shouldStartDragAndDrop = { true }, target = dropTarget)
.then(
if (isDragOver) {
Modifier.border(2.dp, MaterialTheme.colorScheme.primary, RoundedCornerShape(12.dp))
Modifier.border(2.dp, MaterialTheme.colorScheme.primary, MaterialTheme.shapes.medium)
} else {
Modifier
},
@@ -43,12 +43,14 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.ui.theme.StatusAmber
import com.vitorpamplona.amethyst.commons.ui.theme.StatusGreen
import com.vitorpamplona.amethyst.commons.ui.theme.StatusRed
import com.vitorpamplona.amethyst.desktop.account.AccountState
import java.awt.Toolkit
import java.awt.datatransfer.StringSelection
@@ -70,11 +72,11 @@ fun DevSettingsSection(
.fillMaxWidth()
.border(
width = 2.dp,
color = Color(0xFFFF9800), // Orange warning color
shape = RoundedCornerShape(8.dp),
color = StatusAmber,
shape = MaterialTheme.shapes.small,
).background(
color = Color(0xFF332200), // Dark orange tint
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
shape = MaterialTheme.shapes.small,
).padding(16.dp),
) {
// Warning header
@@ -85,13 +87,13 @@ fun DevSettingsSection(
Icon(
MaterialSymbols.Warning,
contentDescription = "Warning",
tint = Color(0xFFFF9800),
tint = StatusAmber,
)
Text(
"Developer Settings",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = Color(0xFFFF9800),
color = StatusAmber,
)
}
@@ -105,7 +107,7 @@ fun DevSettingsSection(
Spacer(Modifier.height(16.dp))
HorizontalDivider(color = Color(0xFFFF9800).copy(alpha = 0.3f))
HorizontalDivider(color = StatusAmber.copy(alpha = 0.3f))
Spacer(Modifier.height(16.dp))
@@ -114,7 +116,7 @@ fun DevSettingsSection(
onClick = { showKeys = !showKeys },
colors =
ButtonDefaults.outlinedButtonColors(
contentColor = Color(0xFFFF9800),
contentColor = StatusAmber,
),
) {
Text(if (showKeys) "Hide Keys" else "Show Keys")
@@ -180,7 +182,7 @@ private fun KeyRow(
style = MaterialTheme.typography.labelMedium,
color =
if (isSensitive) {
Color(0xFFFF5252)
StatusRed
} else {
MaterialTheme.colorScheme.onSurface
},
@@ -196,7 +198,7 @@ private fun KeyRow(
ButtonDefaults.buttonColors(
containerColor =
if (copiedRecently) {
Color(0xFF4CAF50)
StatusGreen
} else {
MaterialTheme.colorScheme.primaryContainer
},
@@ -222,7 +224,7 @@ private fun KeyRow(
),
color =
if (isSensitive) {
Color(0xFFFF5252)
StatusRed
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
@@ -230,7 +232,7 @@ private fun KeyRow(
Modifier
.fillMaxWidth()
.background(
color = Color(0xFF1A1A1A),
color = MaterialTheme.colorScheme.onBackground,
shape = RoundedCornerShape(4.dp),
).padding(8.dp),
)
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.desktop.ui
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
@@ -33,10 +34,10 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
@@ -148,14 +149,15 @@ private fun DraftCard(
onClick: () -> Unit,
onDelete: () -> Unit,
) {
Card(
OutlinedCard(
onClick = onClick,
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
CardDefaults.outlinedCardColors(
containerColor = MaterialTheme.colorScheme.surface,
),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
shape = MaterialTheme.shapes.medium,
) {
Row(
modifier = Modifier.padding(16.dp).fillMaxWidth(),
@@ -20,8 +20,19 @@
*/
package com.vitorpamplona.amethyst.desktop.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.TooltipArea
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -31,13 +42,18 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
@@ -49,44 +65,66 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.chess.RelaySyncStatus
import com.vitorpamplona.amethyst.commons.compose.elements.BoostedMark
import com.vitorpamplona.amethyst.commons.compose.layouts.GenericRepostLayout
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.richtext.UrlParser
import com.vitorpamplona.amethyst.commons.search.AdvancedSearchBarState
import com.vitorpamplona.amethyst.commons.search.QuerySerializer
import com.vitorpamplona.amethyst.commons.search.SearchResultFilter
import com.vitorpamplona.amethyst.commons.ui.components.EmptyState
import com.vitorpamplona.amethyst.commons.ui.components.LoadingState
import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.desktop.DesktopPreferences
import com.vitorpamplona.amethyst.desktop.SearchHistoryStore
import com.vitorpamplona.amethyst.desktop.account.AccountState
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.feeds.DesktopCustomFeedFilter
import com.vitorpamplona.amethyst.desktop.feeds.DesktopFollowingFeedFilter
import com.vitorpamplona.amethyst.desktop.feeds.DesktopGlobalFeedFilter
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.platform.PlatformInfo
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
import com.vitorpamplona.amethyst.desktop.subscriptions.FeedMode
import com.vitorpamplona.amethyst.desktop.subscriptions.FilterBuilders
import com.vitorpamplona.amethyst.desktop.subscriptions.SearchFilterFactory
import com.vitorpamplona.amethyst.desktop.subscriptions.SubscriptionConfig
import com.vitorpamplona.amethyst.desktop.subscriptions.createContactListSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.createCustomFeedSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.createFollowingFeedSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.createGlobalFeedSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.createSearchPeopleSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.generateSubId
import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
import com.vitorpamplona.amethyst.desktop.ui.media.LightboxOverlay
import com.vitorpamplona.amethyst.desktop.ui.note.NoteCard
import com.vitorpamplona.amethyst.desktop.ui.relay.LocalRelayCategories
import com.vitorpamplona.amethyst.desktop.ui.relay.Nip65RelayEditor
import com.vitorpamplona.amethyst.desktop.ui.search.SearchResultsList
import com.vitorpamplona.amethyst.desktop.viewmodels.DesktopFeedViewModel
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
@@ -192,6 +230,7 @@ fun FeedNoteCard(
val displayData = remember(originalEvent, metadataState) { originalEvent.toNoteDisplayData(localCache) }
NoteCard(
note = displayData,
modifier = Modifier.fillMaxWidth(),
localCache = localCache,
onClick = { onNavigateToThread(originalEvent.id) },
onAuthorClick = onNavigateToProfile,
@@ -245,6 +284,7 @@ fun FeedNoteCard(
val displayData = remember(event, metadataState) { event.toNoteDisplayData(localCache) }
NoteCard(
note = displayData,
modifier = Modifier.fillMaxWidth(),
localCache = localCache,
onClick = { onNavigateToThread(event.id) },
onAuthorClick = onNavigateToProfile,
@@ -297,7 +337,12 @@ fun FeedScreen(
onNavigateToThread: (String) -> Unit = {},
onZapFeedback: (ZapFeedback) -> Unit = {},
onNavigateToRelays: () -> Unit = {},
onSearchClick: () -> Unit = {},
) {
val feedSearchActiveState = com.vitorpamplona.amethyst.desktop.ui.theme.LocalFeedSearchActive.current
var searchActive by feedSearchActiveState
val onSearchActiveChange: (Boolean) -> Unit = { searchActive = it }
val openFullSearch = com.vitorpamplona.amethyst.desktop.ui.theme.LocalOpenFullSearch.current
val relayStatuses by relayManager.relayStatuses.collectAsState()
val connectedRelays by relayManager.connectedRelays.collectAsState()
val followedUsers by localCache.followedUsers.collectAsState()
@@ -557,32 +602,16 @@ fun FeedScreen(
}
Box(modifier = Modifier.fillMaxSize()) {
// Layer 1: Feed content (scrollable, behind scrim)
ReadingColumn {
// Header: pinned feed tabs + "Show More +"
FeedTabsHeader(
feedMode = feedMode,
activeFeedId = activeFeedId,
onFeedModeChange = { mode ->
feedMode = mode
activeFeedId = null
activeFeedSource = null
if (mode != FeedMode.CUSTOM) {
DesktopPreferences.feedMode = mode
}
},
onNavigateToFeed = { feed ->
val source = feed.source
if (source is com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource.Filter) {
activeFeedId = feed.id
activeFeedSource = source
feedMode = FeedMode.CUSTOM
}
},
onOpenFeedsDrawer = onOpenFeedsDrawer,
onCompose = onCompose,
// Reserve space for the header card that floats above
// Reserve space for the header card that floats above.
// When search is expanded, the card grows — add more margin.
val headerSpacerHeight by animateDpAsState(
targetValue = if (searchActive) 300.dp else 60.dp,
animationSpec = tween(200),
)
Spacer(Modifier.height(8.dp))
Spacer(Modifier.height(headerSpacerHeight))
// Feed content based on FeedState
when (val state = feedState) {
@@ -653,7 +682,7 @@ fun FeedScreen(
val sidePadding = LocalReadingSidePadding.current
LazyColumn(
state = lazyListState,
contentPadding = PaddingValues(horizontal = sidePadding + 12.dp),
contentPadding = PaddingValues(start = sidePadding + 12.dp, end = sidePadding + 12.dp, top = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(loadedState.list, key = { it.idHex }) { note ->
@@ -721,6 +750,53 @@ fun FeedScreen(
)
}
// Layer 2: Search scrim — dims feed content when search is expanded
if (searchActive) {
Box(
modifier =
Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.3f))
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
) {
onSearchActiveChange(false)
},
)
}
// Layer 3: Header card — rendered AFTER scrim so it floats above it
FeedTabsHeader(
feedMode = feedMode,
activeFeedId = activeFeedId,
searchExpanded = searchActive,
onSearchExpandedChange = onSearchActiveChange,
onFeedModeChange = { mode ->
feedMode = mode
activeFeedId = null
activeFeedSource = null
if (mode != FeedMode.CUSTOM) {
DesktopPreferences.feedMode = mode
}
},
onNavigateToFeed = { feed ->
val source = feed.source
if (source is com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource.Filter) {
activeFeedId = feed.id
activeFeedSource = source
feedMode = FeedMode.CUSTOM
}
},
onOpenFeedsDrawer = onOpenFeedsDrawer,
onCompose = onCompose,
onSearchClick = openFullSearch,
relayManager = relayManager,
localCache = localCache,
onNavigateToProfile = onNavigateToProfile,
onNavigateToThread = onNavigateToThread,
)
// Lightbox overlay
lightboxState?.let { state ->
LightboxOverlay(
@@ -747,77 +823,447 @@ fun FeedScreen(
private fun FeedTabsHeader(
feedMode: FeedMode,
activeFeedId: String? = null,
searchExpanded: Boolean = false,
onSearchExpandedChange: (Boolean) -> Unit = {},
onFeedModeChange: (FeedMode) -> Unit,
onNavigateToFeed: (com.vitorpamplona.amethyst.commons.feeds.custom.FeedDefinition) -> Unit = {},
onOpenFeedsDrawer: () -> Unit,
onCompose: () -> Unit,
onSearchClick: () -> Unit = {},
relayManager: DesktopRelayConnectionManager? = null,
localCache: DesktopLocalCache? = null,
onNavigateToProfile: (String) -> Unit = {},
onNavigateToThread: (String) -> Unit = {},
) {
val feedRepo = com.vitorpamplona.amethyst.desktop.ui.deck.LocalFeedRepository.current
val pinnedFeeds by feedRepo.pinnedFeeds.collectAsState()
val sidePadding = LocalReadingSidePadding.current
val scope = rememberCoroutineScope()
val searchState = remember { AdvancedSearchBarState(scope) }
var searchText by remember { mutableStateOf(TextFieldValue("")) }
val focusRequester = remember { FocusRequester() }
val focusManager = LocalFocusManager.current
val debouncedQuery by searchState.debouncedQuery.collectAsState()
val relayCategories = LocalRelayCategories.current
val searchRelays by relayCategories.searchRelays.collectAsState()
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = sidePadding + 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
// Pinned feed tabs
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
pinnedFeeds.forEach { feed ->
val isSelected =
when (feed.source) {
is com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource.Following -> {
feedMode == FeedMode.FOLLOWING
}
// Sync search text to AdvancedSearchBarState
LaunchedEffect(searchText.text) {
searchState.updateFromText(searchText.text)
}
is com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource.Global -> {
feedMode == FeedMode.GLOBAL
}
// Auto-save to history after 1s of no typing (separate from relay debounce)
LaunchedEffect(searchText.text) {
if (searchText.text.isNotBlank()) {
kotlinx.coroutines.delay(1000L)
val query = searchState.query.value
if (!query.isEmpty) {
SearchHistoryStore.addToHistory(query)
}
}
}
else -> {
activeFeedId == feed.id
// Clear on collapse
LaunchedEffect(searchExpanded) {
if (!searchExpanded) {
searchText = TextFieldValue("")
searchState.clearSearch()
}
}
// Auto-focus when expanded
LaunchedEffect(searchExpanded) {
if (searchExpanded) {
focusRequester.requestFocus()
}
}
// Start/stop relay search when debounced query changes
LaunchedEffect(debouncedQuery) {
if (!debouncedQuery.isEmpty) {
searchState.clearResults()
searchState.initRelayStates(searchRelays)
searchState.startSearching("people-search")
searchState.startSearching("adv-search")
kotlinx.coroutines.delay(10_000L)
searchState.timeoutWaitingRelays()
}
}
// NIP-50 people search subscription
if (relayManager != null) {
rememberSubscription(searchRelays, debouncedQuery, relayManager = relayManager) {
if (searchRelays.isEmpty() || debouncedQuery.isEmpty) {
return@rememberSubscription null
}
createSearchPeopleSubscription(
relays = searchRelays,
searchQuery = debouncedQuery.text.ifBlank { QuerySerializer.serialize(debouncedQuery) },
limit = 10,
onEvent = { event, _, relay, _ ->
if (searchState.trackRelayEvent(relay.url, event.id)) {
if (event is MetadataEvent) {
localCache?.consumeMetadata(event)
val user = localCache?.getUserIfExists(event.pubKey)
if (user != null) {
searchState.addPeopleResult(user)
}
}
}
FilterChip(
selected = isSelected,
onClick = {
when (feed.source) {
is com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource.Following -> {
onFeedModeChange(FeedMode.FOLLOWING)
}
is com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource.Global -> {
onFeedModeChange(FeedMode.GLOBAL)
}
else -> {
onNavigateToFeed(feed)
}
}
},
label = { Text("${feed.emoji} ${feed.name}") },
)
}
// "Show More +" button
FilterChip(
selected = false,
onClick = onOpenFeedsDrawer,
label = { Text("+ More") },
},
onEose = { relay, _ ->
searchState.updateRelayState(relay.url, RelaySyncStatus.EOSE_RECEIVED)
searchState.stopSearching("people-search")
},
onClosed = { relay, _, _ ->
searchState.updateRelayState(relay.url, RelaySyncStatus.FAILED)
searchState.stopSearching("people-search")
},
)
}
// Compose button
IconButton(onClick = onCompose) {
Icon(
MaterialSymbols.Edit,
contentDescription = "Compose",
modifier = Modifier.size(20.dp),
// NIP-50 note search subscription
rememberSubscription(searchRelays, debouncedQuery, relayManager = relayManager) {
if (searchRelays.isEmpty() || debouncedQuery.isEmpty) {
return@rememberSubscription null
}
val filters = SearchFilterFactory.createFilters(debouncedQuery)
if (filters.isEmpty()) return@rememberSubscription null
SubscriptionConfig(
subId = generateSubId("inline-search"),
filters = filters,
relays = searchRelays,
onEvent = { event, _, relay, _ ->
if (event.kind == MetadataEvent.KIND) return@SubscriptionConfig
if (searchState.trackRelayEvent(relay.url, event.id)) {
val filtered = SearchResultFilter.filter(listOf(event), debouncedQuery)
if (filtered.isNotEmpty()) {
searchState.addNoteResults(filtered)
}
}
},
onEose = { relay, _ ->
searchState.updateRelayState(relay.url, RelaySyncStatus.EOSE_RECEIVED)
searchState.stopSearching("adv-search")
},
onClosed = { relay, _, _ ->
searchState.updateRelayState(relay.url, RelaySyncStatus.FAILED)
searchState.stopSearching("adv-search")
},
)
}
}
Surface(
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.surface,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = sidePadding + 8.dp, vertical = 8.dp),
) {
Column {
// Always-visible header row: tabs + search + compose
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
// Feed tabs — always visible. Clicking collapses search.
pinnedFeeds.forEach { feed ->
val isSelected =
when (feed.source) {
is com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource.Following ->
feedMode == FeedMode.FOLLOWING
is com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource.Global ->
feedMode == FeedMode.GLOBAL
else -> activeFeedId == feed.id
}
FilterChip(
selected = isSelected,
onClick = {
onSearchExpandedChange(false)
when (feed.source) {
is com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource.Following ->
onFeedModeChange(FeedMode.FOLLOWING)
is com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource.Global ->
onFeedModeChange(FeedMode.GLOBAL)
else -> onNavigateToFeed(feed)
}
},
label = {
Text(
"${feed.emoji} ${feed.name}",
maxLines = 1,
style = MaterialTheme.typography.labelMedium,
)
},
)
}
// Search: pill when collapsed, active input when expanded
if (searchExpanded) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.weight(1f),
) {
Icon(
MaterialSymbols.Search,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.width(8.dp))
BasicTextField(
value = searchText,
onValueChange = { searchText = it },
modifier =
Modifier
.weight(1f)
.focusRequester(focusRequester)
.onPreviewKeyEvent { event ->
if (event.type == KeyEventType.KeyDown && event.key == Key.Escape) {
onSearchExpandedChange(false)
focusManager.clearFocus()
true
} else {
false
}
},
textStyle =
MaterialTheme.typography.bodyMedium.copy(
color = MaterialTheme.colorScheme.onSurface,
),
singleLine = true,
decorationBox = { innerTextField ->
Box(contentAlignment = Alignment.CenterStart) {
if (searchText.text.isEmpty()) {
Text(
"Search notes, profiles, hashtags...",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
)
}
innerTextField()
}
},
)
Spacer(Modifier.width(8.dp))
Text(
if (PlatformInfo.isMacOS) "\u2318F" else "Ctrl+F",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
)
}
} else {
com.vitorpamplona.amethyst.desktop.ui.search.SearchPill(
onClick = { onSearchExpandedChange(true) },
modifier = Modifier.weight(1f),
)
}
// Compose button — always visible
IconButton(onClick = onCompose, modifier = Modifier.size(32.dp)) {
Icon(
MaterialSymbols.Edit,
contentDescription = "Compose",
modifier = Modifier.size(18.dp),
)
}
}
// Animated expanding section: history or search results
AnimatedVisibility(
visible = searchExpanded,
enter = expandVertically(animationSpec = tween(200)) + fadeIn(tween(200)),
exit = shrinkVertically(animationSpec = tween(150)) + fadeOut(tween(100)),
) {
Column {
val hasQuery = searchText.text.isNotBlank()
val isSearching by searchState.isSearching.collectAsState()
val people by searchState.peopleResults.collectAsState()
val notes by searchState.noteResults.collectAsState()
val hasResults = people.isNotEmpty() || notes.isNotEmpty()
// Linear progress bar at the top — visible while searching
AnimatedVisibility(
visible = isSearching || (hasQuery && !hasResults),
enter = expandVertically(expandFrom = Alignment.Top) + fadeIn(),
exit = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut(),
) {
LinearProgressIndicator(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.primary,
trackColor = MaterialTheme.colorScheme.surfaceVariant,
)
}
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
if (hasQuery) {
// Results stream in as they arrive
if (hasResults) {
SearchResultsList(
state = searchState,
onNavigateToProfile = { pubkey ->
onSearchExpandedChange(false)
onNavigateToProfile(pubkey)
},
onNavigateToThread = { noteId ->
onSearchExpandedChange(false)
onNavigateToThread(noteId)
},
localCache = localCache,
modifier = Modifier.heightIn(max = 400.dp).fillMaxWidth(),
)
} else if (isSearching) {
// Loading — centered in results area
Column(
modifier = Modifier.fillMaxWidth().padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(
MaterialSymbols.Search,
contentDescription = null,
modifier = Modifier.size(32.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f),
)
Spacer(Modifier.height(8.dp))
Text(
"Searching ${searchRelays.size} relays...",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else {
// Search complete, no results
Column(
modifier = Modifier.fillMaxWidth().padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(
MaterialSymbols.Search,
contentDescription = null,
modifier = Modifier.size(32.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f),
)
Spacer(Modifier.height(8.dp))
Text(
if (searchRelays.isEmpty()) "No search relays configured" else "No results found",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
// "Open full search" — always visible when there's a query
HorizontalDivider(
color = MaterialTheme.colorScheme.outlineVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.fillMaxWidth()
.clickable {
onSearchExpandedChange(false)
onSearchClick()
}.padding(horizontal = 16.dp, vertical = 8.dp),
) {
Icon(
MaterialSymbols.AutoMirrored.OpenInNew,
null,
Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.width(10.dp))
Text("Open full search", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary)
}
} else {
// Show search history when empty
SearchHistorySection(
onOpenFullSearch = {
onSearchExpandedChange(false)
onSearchClick()
},
onHistoryItemClick = { text ->
searchText = TextFieldValue(text, TextRange(text.length))
},
)
}
}
}
}
}
}
@Composable
private fun SearchHistorySection(
onOpenFullSearch: () -> Unit,
onHistoryItemClick: (String) -> Unit = { },
) {
val history by SearchHistoryStore.history.collectAsState()
val savedSearches by SearchHistoryStore.savedSearches.collectAsState()
Column(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
) {
if (history.isNotEmpty()) {
Text(
"Recent",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp),
)
history.take(5).forEach { query ->
val text =
com.vitorpamplona.amethyst.commons.search.QuerySerializer
.serialize(query)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.fillMaxWidth()
.clickable { onHistoryItemClick(text) }
.padding(horizontal = 16.dp, vertical = 8.dp),
) {
Icon(MaterialSymbols.History, null, Modifier.size(16.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(Modifier.width(10.dp))
Text(text, style = MaterialTheme.typography.bodySmall, maxLines = 1)
}
}
}
if (savedSearches.isNotEmpty()) {
if (history.isNotEmpty()) {
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant, modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp))
}
Text("Saved", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp))
savedSearches.take(5).forEach { saved ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().clickable { onOpenFullSearch() }.padding(horizontal = 16.dp, vertical = 8.dp),
) {
Icon(MaterialSymbols.Bookmark, null, Modifier.size(16.dp), tint = MaterialTheme.colorScheme.primary)
Spacer(Modifier.width(10.dp))
Text(saved.label, style = MaterialTheme.typography.bodySmall, maxLines = 1)
}
}
}
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant, modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().clickable { onOpenFullSearch() }.padding(horizontal = 16.dp, vertical = 8.dp),
) {
Icon(MaterialSymbols.AutoMirrored.OpenInNew, null, Modifier.size(16.dp), tint = MaterialTheme.colorScheme.primary)
Spacer(Modifier.width(10.dp))
Text("Open full search", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary)
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@@ -44,13 +44,13 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.login_subtitle_desktop
import com.vitorpamplona.amethyst.commons.resources.login_title
import com.vitorpamplona.amethyst.commons.ui.theme.StatusGreen
import com.vitorpamplona.amethyst.desktop.account.AccountManager
import com.vitorpamplona.amethyst.desktop.account.AccountState
import com.vitorpamplona.amethyst.desktop.network.RelayStatus
@@ -203,7 +203,7 @@ fun ConnectingRelaysScreen(
Icon(
MaterialSymbols.Check,
contentDescription = null,
tint = Color(0xFF4CAF50),
tint = StatusGreen,
modifier = Modifier.size(14.dp),
)
}
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.desktop.ui
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -32,12 +33,12 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -96,14 +97,15 @@ fun LongFormCard(
val authorName = author.toBestDisplayName()
val publishedAt = event.publishedAt() ?: event.createdAt
Card(
OutlinedCard(
onClick = onClick,
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
CardDefaults.outlinedCardColors(
containerColor = MaterialTheme.colorScheme.surface,
),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
shape = MaterialTheme.shapes.medium,
) {
Column(modifier = Modifier.padding(16.dp)) {
// Title
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.desktop.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@@ -48,6 +49,7 @@ import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.PrimaryTabRow
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
@@ -1139,13 +1141,14 @@ private fun PublishedHighlightCard(
val articleAddress = highlight.inPostAddress()
val articleTitle = articleAddress?.let { "Article" } ?: "Unknown source"
Card(
OutlinedCard(
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp),
colors =
CardDefaults.cardColors(
CardDefaults.outlinedCardColors(
containerColor = MaterialTheme.colorScheme.surface,
),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
shape = MaterialTheme.shapes.medium,
) {
Column(modifier = Modifier.padding(16.dp)) {
// Quoted highlight text
@@ -99,7 +99,7 @@ fun AccountSwitcherDropdown(
Box(modifier = modifier) {
IconButton(
onClick = { expanded = true },
modifier = Modifier.size(48.dp),
modifier = Modifier.size(40.dp),
) {
Icon(
MaterialSymbols.Person,
@@ -29,7 +29,6 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.Button
import androidx.compose.material3.Card
@@ -107,7 +106,7 @@ fun LoginCard(
@Suppress("DEPRECATION")
PrimaryTabRow(
selectedTabIndex = selectedTab,
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp)),
modifier = Modifier.fillMaxWidth().clip(MaterialTheme.shapes.small),
) {
tabs.forEachIndexed { index, title ->
Tab(
@@ -34,10 +34,10 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.ui.theme.StatusGreen
import com.vitorpamplona.amethyst.desktop.account.LoginProgress
import com.vitorpamplona.amethyst.desktop.account.RelayLoginStatus
@@ -111,7 +111,7 @@ private fun StepRow(step: StepInfo) {
Icon(
MaterialSymbols.Check,
contentDescription = null,
tint = Color(0xFF4CAF50),
tint = StatusGreen,
modifier = Modifier.size(16.dp),
)
}
@@ -133,7 +133,7 @@ private fun StepRow(step: StepInfo) {
style = MaterialTheme.typography.bodySmall,
color =
when (step.state) {
StepState.DONE -> Color(0xFF4CAF50)
StepState.DONE -> StatusGreen
StepState.ACTIVE -> MaterialTheme.colorScheme.onSurface
StepState.PENDING -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
},
@@ -155,7 +155,7 @@ private fun RelayRow(
Icon(
MaterialSymbols.Check,
contentDescription = null,
tint = Color(0xFF2196F3),
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(12.dp),
)
}
@@ -164,7 +164,7 @@ private fun RelayRow(
Icon(
MaterialSymbols.Check,
contentDescription = null,
tint = Color(0xFF4CAF50),
tint = StatusGreen,
modifier = Modifier.size(12.dp),
)
}
@@ -206,7 +206,7 @@ private fun RelayRow(
}
RelayLoginStatus.EVENT_SENT -> {
Color(0xFF2196F3)
MaterialTheme.colorScheme.primary
}
else -> {
@@ -33,7 +33,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@@ -76,7 +75,7 @@ fun NewKeyWarningCard(
Text(
stringResource(Res.string.new_key_warning_title),
style = MaterialTheme.typography.titleMedium,
color = Color.Red,
color = MaterialTheme.colorScheme.error,
)
Spacer(Modifier.height(16.dp))
@@ -102,7 +101,7 @@ fun NewKeyWarningCard(
Text(
stringResource(Res.string.new_key_secret_label),
style = MaterialTheme.typography.labelMedium,
color = Color.Red,
color = MaterialTheme.colorScheme.error,
)
SelectableKeyText(secretKey)
}
@@ -31,7 +31,6 @@ import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
@@ -170,7 +169,7 @@ fun ChatFileAttachment(
Modifier
.fillMaxWidth()
.heightIn(max = 300.dp)
.clip(RoundedCornerShape(8.dp)),
.clip(MaterialTheme.shapes.small),
contentScale = ContentScale.FillWidth,
)
}
@@ -203,7 +203,7 @@ fun ChatPane(
target = dropTarget,
).then(
if (isDragOver) {
Modifier.border(2.dp, MaterialTheme.colorScheme.primary, RoundedCornerShape(8.dp))
Modifier.border(2.dp, MaterialTheme.colorScheme.primary, MaterialTheme.shapes.small)
} else {
Modifier
},
@@ -657,7 +657,7 @@ private suspend fun sendWrappedReaction(
@Composable
private fun ReactionBar(onReaction: (String) -> Unit) {
Surface(
shape = RoundedCornerShape(16.dp),
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surfaceVariant,
shadowElevation = 2.dp,
tonalElevation = 2.dp,
@@ -398,7 +398,7 @@ fun AppDrawer(
interactionSource = remember { MutableInteractionSource() },
indication = null,
) { /* consume click — prevent propagation to scrim */ },
shape = RoundedCornerShape(16.dp),
shape = MaterialTheme.shapes.large,
tonalElevation = 8.dp,
) {
Column {
@@ -579,7 +579,7 @@ private fun DrawerScreenCard(
showMenu = true
}
},
shape = RoundedCornerShape(12.dp),
shape = MaterialTheme.shapes.medium,
tonalElevation = if (isSelected) 8.dp else 2.dp,
color =
if (isSelected) {
@@ -792,7 +792,7 @@ private fun WorkspaceCard(
.fillMaxWidth()
.padding(vertical = 4.dp)
.clickable(onClick = onSwitch),
shape = RoundedCornerShape(12.dp),
shape = MaterialTheme.shapes.medium,
tonalElevation = if (isActive) 8.dp else 2.dp,
color =
if (isActive) {
@@ -869,7 +869,7 @@ private fun AddWorkspaceCard(
.fillMaxWidth()
.padding(vertical = 4.dp)
.clickable(enabled = enabled, onClick = onClick),
shape = RoundedCornerShape(12.dp),
shape = MaterialTheme.shapes.medium,
tonalElevation = 1.dp,
) {
Row(
@@ -935,7 +935,7 @@ private fun WorkspaceEditorDialog(
WorkspaceIcons.availableNames.forEach { iName ->
Surface(
modifier = Modifier.size(40.dp).clickable { iconName = iName },
shape = RoundedCornerShape(8.dp),
shape = MaterialTheme.shapes.small,
color =
if (isSelected(iName)) {
MaterialTheme.colorScheme.primaryContainer
@@ -56,13 +56,14 @@ fun ColumnHeader(
modifier =
modifier
.fillMaxWidth()
.height(40.dp)
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
.height(48.dp)
.background(MaterialTheme.colorScheme.surfaceContainer)
.padding(horizontal = 12.dp)
.pointerInput(Unit) {
detectTapGestures(
onDoubleTap = { onDoubleClick() },
)
}.padding(horizontal = 8.dp),
},
verticalAlignment = Alignment.CenterVertically,
) {
if (hasBackStack) {
@@ -35,6 +35,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.PointerIcon
import androidx.compose.ui.input.pointer.pointerHoverIcon
@@ -51,6 +52,8 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscription
import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.debounce
import java.awt.Cursor
@Composable
@@ -80,14 +83,23 @@ fun DeckLayout(
val availableWidthDp = with(density) { constraints.maxWidth.toDp().value }
deckState.setAvailableWidth(availableWidthDp)
// Auto-fit columns on first composition or when available width changes significantly
LaunchedEffect(availableWidthDp, columns.size) {
val dividers = (columns.size - 1) * DeckState.DIVIDER_WIDTH
val totalColumnWidth = columns.sumOf { it.width.toDouble() }.toFloat()
val diff = kotlin.math.abs(totalColumnWidth + dividers - availableWidthDp)
if (diff > 20f && columns.isNotEmpty()) {
deckState.fitColumnsToWidth(availableWidthDp)
}
// Auto-fit columns when available width changes (debounced to avoid per-frame
// recomposition during sidebar expand/collapse animation)
@OptIn(FlowPreview::class)
LaunchedEffect(Unit) {
snapshotFlow { availableWidthDp to columns.size }
.debounce(300)
.collect { (width, size) ->
val dividers = (size - 1) * DeckState.DIVIDER_WIDTH
val totalColumnWidth =
deckState.columns.value
.sumOf { it.width.toDouble() }
.toFloat()
val diff = kotlin.math.abs(totalColumnWidth + dividers - width)
if (diff > 20f && size > 0) {
deckState.fitColumnsToWidth(width)
}
}
}
Row(
@@ -20,34 +20,89 @@
*/
package com.vitorpamplona.amethyst.desktop.ui.deck
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.IconButton
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.onPointerEvent
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.domain.nip46.SignerConnectionState
import com.vitorpamplona.amethyst.commons.feeds.custom.FeedDefinition
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.account.AccountInfo
import com.vitorpamplona.amethyst.commons.tor.TorServiceStatus
import com.vitorpamplona.amethyst.commons.ui.components.BunkerHeartbeatIndicator
import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar
import com.vitorpamplona.amethyst.desktop.DesktopPreferences
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.platform.titleBarInsetTop
import com.vitorpamplona.amethyst.desktop.ui.account.AccountSwitcherDropdown
import com.vitorpamplona.amethyst.desktop.ui.tor.TorStatusIndicator
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
import kotlinx.collections.immutable.ImmutableList
private val EXPANDED_WIDTH = 240.dp
private val COLLAPSED_WIDTH = 64.dp
/**
* Data class for a sidebar navigation item.
*/
private data class NavItem(
val type: DeckColumnType,
val label: String,
val icon: MaterialSymbol,
)
private val NAV_ITEMS =
listOf(
NavItem(DeckColumnType.HomeFeed, "Home", MaterialSymbols.Home),
NavItem(DeckColumnType.Search, "Search", MaterialSymbols.Search),
NavItem(DeckColumnType.Messages, "Messages", MaterialSymbols.Mail),
NavItem(DeckColumnType.Wallet, "Wallet", MaterialSymbols.AccountBalanceWallet),
NavItem(DeckColumnType.Bookmarks, "Bookmarks", MaterialSymbols.Bookmark),
NavItem(DeckColumnType.Settings, "Settings", MaterialSymbols.Settings),
)
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun DeckSidebar(
fun MainSidebar(
activeNpub: String?,
allAccounts: ImmutableList<AccountInfo>,
localCache: DesktopLocalCache?,
@@ -56,67 +111,490 @@ fun DeckSidebar(
onRemoveAccount: (String) -> Unit,
onAddColumn: () -> Unit,
onOpenSettings: () -> Unit,
onNavigate: (DeckColumnType) -> Unit,
activeColumnType: DeckColumnType?,
onShowImportFollowListDialog: () -> Unit = {},
signerConnectionState: SignerConnectionState,
lastPingTimeSec: Long?,
torStatus: TorServiceStatus,
modifier: Modifier = Modifier,
) {
var expanded by remember { mutableStateOf(true) }
val animatedWidth by animateDpAsState(
targetValue = if (expanded) EXPANDED_WIDTH else COLLAPSED_WIDTH,
animationSpec = tween(300, easing = FastOutSlowInEasing),
)
// Observe metadata to recompose when display names/pictures load
@Suppress("UNUSED_VARIABLE")
val metadataVersion by localCache?.metadataVersion?.collectAsState()
?: remember { mutableStateOf(0L) }
// Resolve user info for avatar header
val pubkeyHex = remember(activeNpub) { activeNpub?.let { decodePublicKeyAsHexOrNull(it) } }
val user =
remember(pubkeyHex, metadataVersion) {
pubkeyHex?.let { localCache?.getUserIfExists(it) }
}
val displayName =
remember(user, metadataVersion) {
user?.let {
val name = it.toBestDisplayName()
name.takeIf { n -> n != it.pubkeyDisplayHex() }
}
}
val avatarUrl = remember(user, metadataVersion) { user?.profilePicture() }
// Custom feeds from repository
val feedRepo = LocalFeedRepository.current
val allFeeds by feedRepo.feeds.collectAsState()
Column(
modifier =
modifier
.width(56.dp)
.width(animatedWidth)
.fillMaxHeight()
.background(MaterialTheme.colorScheme.surfaceContainer)
.clipToBounds()
.background(MaterialTheme.colorScheme.surface)
.padding(top = 8.dp + titleBarInsetTop, bottom = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top,
) {
AccountSwitcherDropdown(
// -- Avatar + Account header --
SidebarAccountHeader(
activeNpub = activeNpub,
allAccounts = allAccounts,
localCache = localCache,
displayName = displayName,
avatarUrl = avatarUrl,
pubkeyHex = pubkeyHex,
expanded = expanded,
onSwitchAccount = onSwitchAccount,
onAddAccount = onAddAccount,
onRemoveAccount = onRemoveAccount,
)
Spacer(Modifier.size(16.dp))
Spacer(Modifier.height(8.dp))
HorizontalDivider(modifier = Modifier.padding(horizontal = 12.dp))
Spacer(Modifier.height(8.dp))
IconButton(onClick = onAddColumn) {
Icon(
MaterialSymbols.Add,
contentDescription = "Add Column",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
// -- Nav items (scrollable) --
Column(
modifier = Modifier.weight(1f).verticalScroll(rememberScrollState()),
) {
NAV_ITEMS.forEach { item ->
val isActive = activeColumnType?.typeKey() == item.type.typeKey()
SidebarNavItem(
icon = item.icon,
label = item.label,
isActive = isActive,
expanded = expanded,
onClick = { onNavigate(item.type) },
)
}
// -- All Screens --
Spacer(Modifier.height(8.dp))
HorizontalDivider(modifier = Modifier.padding(horizontal = 12.dp))
Spacer(Modifier.height(8.dp))
SidebarNavItem(
icon = MaterialSymbols.Apps,
label = "All Screens",
isActive = false,
expanded = expanded,
onClick = onAddColumn,
)
// -- Custom feeds section --
if (allFeeds.isNotEmpty()) {
Spacer(Modifier.height(4.dp))
AnimatedVisibility(
visible = expanded,
enter = fadeIn(tween(200, delayMillis = 100)),
exit = fadeOut(tween(100)),
) {
Text(
text = "FEEDS",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
)
}
allFeeds.forEach { feed ->
val isActive =
activeColumnType is DeckColumnType.CustomFeed &&
activeColumnType.feedId == feed.id
SidebarFeedItem(
feed = feed,
isActive = isActive,
expanded = expanded,
onClick = {
onNavigate(
DeckColumnType.CustomFeed(
feedId = feed.id,
feedName = feed.name,
feedEmoji = feed.emoji,
),
)
},
)
}
// "+ Add Feed" button
SidebarNavItem(
icon = MaterialSymbols.Add,
label = "Add Feed",
isActive = false,
expanded = expanded,
onClick = onAddColumn,
)
}
}
IconButton(onClick = onShowImportFollowListDialog) {
Icon(
MaterialSymbols.PersonAdd,
contentDescription = "Import Follow List",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// -- Bottom section: status indicators + collapse toggle --
HorizontalDivider(modifier = Modifier.padding(horizontal = 12.dp))
Spacer(Modifier.height(4.dp))
Spacer(Modifier.weight(1f))
BunkerHeartbeatIndicator(
signerConnectionState = signerConnectionState,
lastPingTimeSec = lastPingTimeSec,
// Tor status — same style as nav items
val torLabel =
when (torStatus) {
is TorServiceStatus.Off -> "Tor: Off"
is TorServiceStatus.Connecting -> "Tor: Connecting"
is TorServiceStatus.Active -> "Tor: Connected"
is TorServiceStatus.Error -> "Tor: Error"
}
val torIcon =
if (torStatus is TorServiceStatus.Active) MaterialSymbols.Security else MaterialSymbols.Shield
SidebarNavItem(
icon = torIcon,
label = torLabel,
isActive = false,
expanded = expanded,
onClick = onOpenSettings,
)
Spacer(Modifier.size(4.dp))
TorStatusIndicator(status = torStatus, onClick = onOpenSettings)
// Bunker heartbeat — same style as nav items (only show when connected)
if (signerConnectionState is SignerConnectionState.Connected) {
SidebarNavItem(
icon = MaterialSymbols.Favorite,
label = "Bunker: OK",
isActive = false,
expanded = expanded,
onClick = onOpenSettings,
)
}
Spacer(Modifier.size(4.dp))
Spacer(Modifier.height(4.dp))
IconButton(onClick = onOpenSettings) {
Icon(
MaterialSymbols.Settings,
contentDescription = "Settings",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
// Collapse toggle
SidebarNavItem(
icon = if (expanded) MaterialSymbols.AutoMirrored.KeyboardArrowLeft else MaterialSymbols.ChevronRight,
label = if (expanded) "Collapse" else "Expand",
isActive = false,
expanded = expanded,
onClick = {
expanded = !expanded
DesktopPreferences.sidebarCollapsed = !expanded
},
)
}
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
private fun SidebarAccountHeader(
activeNpub: String?,
allAccounts: ImmutableList<AccountInfo>,
localCache: DesktopLocalCache?,
displayName: String?,
avatarUrl: String?,
pubkeyHex: String?,
expanded: Boolean,
onSwitchAccount: (String) -> Unit,
onAddAccount: () -> Unit,
onRemoveAccount: (String) -> Unit,
) {
var showDropdown by remember { mutableStateOf(false) }
var isHovered by remember { mutableStateOf(false) }
val hoverBg =
if (isHovered) {
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f)
} else {
Color.Transparent
}
Box {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 2.dp)
.clip(MaterialTheme.shapes.small)
.clickable { showDropdown = true }
.background(hoverBg)
.onPointerEvent(PointerEventType.Enter) { isHovered = true }
.onPointerEvent(PointerEventType.Exit) { isHovered = false }
.padding(horizontal = 8.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
// Avatar
if (pubkeyHex != null) {
UserAvatar(
userHex = pubkeyHex,
pictureUrl = avatarUrl,
size = if (expanded) 32.dp else 28.dp,
contentDescription = "Account avatar",
)
} else {
Box(
modifier =
Modifier
.size(if (expanded) 32.dp else 28.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center,
) {
Icon(
MaterialSymbols.Person,
contentDescription = "Account",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
}
}
if (expanded) {
Spacer(Modifier.width(10.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = displayName ?: "Account",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (activeNpub != null) {
Text(
text = npubShortForSidebar(activeNpub),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
// Account switcher dropdown menu
DropdownMenu(
expanded = showDropdown,
onDismissRequest = { showDropdown = false },
offset = DpOffset(x = if (expanded) 240.dp else 56.dp, y = 0.dp),
) {
allAccounts.forEach { account ->
val isActive = account.npub == activeNpub
DropdownMenuItem(
text = {
Text(
text = resolveDisplayNameForSidebar(account.npub, localCache) ?: npubShortForSidebar(account.npub),
fontWeight = if (isActive) FontWeight.SemiBold else FontWeight.Normal,
)
},
onClick = {
showDropdown = false
onSwitchAccount(account.npub)
},
trailingIcon = {
if (isActive) {
Icon(MaterialSymbols.Check, contentDescription = "Active", modifier = Modifier.size(16.dp))
}
},
)
}
HorizontalDivider()
DropdownMenuItem(
text = { Text("Add Account") },
onClick = {
showDropdown = false
onAddAccount()
},
leadingIcon = { Icon(MaterialSymbols.Add, contentDescription = null, modifier = Modifier.size(16.dp)) },
)
}
}
}
/**
* A single navigation item row with icon + optional label.
*/
@OptIn(ExperimentalComposeUiApi::class)
@Composable
private fun SidebarNavItem(
icon: MaterialSymbol,
label: String,
isActive: Boolean,
expanded: Boolean,
onClick: () -> Unit,
) {
var isHovered by remember { mutableStateOf(false) }
val backgroundColor =
when {
isActive -> MaterialTheme.colorScheme.primaryContainer
isHovered -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f)
else -> MaterialTheme.colorScheme.surface
}
val iconTint =
if (isActive) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
val textColor =
if (isActive) {
MaterialTheme.colorScheme.onPrimaryContainer
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 2.dp)
.clip(MaterialTheme.shapes.small)
.clickable(onClick = onClick)
.background(backgroundColor)
.onPointerEvent(PointerEventType.Enter) { isHovered = true }
.onPointerEvent(PointerEventType.Exit) { isHovered = false }
.padding(horizontal = 8.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
icon,
contentDescription = if (!expanded) label else null,
tint = iconTint,
modifier = Modifier.size(24.dp),
)
AnimatedVisibility(
visible = expanded,
enter = fadeIn(tween(200, delayMillis = 100)),
exit = fadeOut(tween(100)),
) {
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
fontWeight = if (isActive) FontWeight.SemiBold else FontWeight.Normal,
color = textColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(start = 12.dp),
)
}
}
}
/**
* A custom feed item in the sidebar.
*/
@OptIn(ExperimentalComposeUiApi::class)
@Composable
private fun SidebarFeedItem(
feed: FeedDefinition,
isActive: Boolean,
expanded: Boolean,
onClick: () -> Unit,
) {
var isHovered by remember { mutableStateOf(false) }
val backgroundColor =
when {
isActive -> MaterialTheme.colorScheme.primaryContainer
isHovered -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f)
else -> MaterialTheme.colorScheme.surface
}
val iconTint =
if (isActive) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
val textColor =
if (isActive) {
MaterialTheme.colorScheme.onPrimaryContainer
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 2.dp)
.clip(MaterialTheme.shapes.small)
.clickable(onClick = onClick)
.background(backgroundColor)
.onPointerEvent(PointerEventType.Enter) { isHovered = true }
.onPointerEvent(PointerEventType.Exit) { isHovered = false }
.padding(horizontal = 8.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (feed.emoji.isNotEmpty() && expanded) {
Text(
text = feed.emoji,
modifier = Modifier.size(24.dp),
style = MaterialTheme.typography.titleMedium,
)
} else {
Icon(
MaterialSymbols.AutoMirrored.Feed,
contentDescription = if (!expanded) feed.name else null,
tint = iconTint,
modifier = Modifier.size(24.dp),
)
}
AnimatedVisibility(
visible = expanded,
enter = fadeIn(tween(200, delayMillis = 100)),
exit = fadeOut(tween(100)),
) {
Text(
text = feed.name.ifEmpty { "Feed" },
style = MaterialTheme.typography.bodyMedium,
fontWeight = if (isActive) FontWeight.SemiBold else FontWeight.Normal,
color = textColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(start = 12.dp),
)
}
}
}
private fun npubShortForSidebar(npub: String): String {
if (npub.length <= 20) return npub
return "${npub.take(10)}...${npub.takeLast(6)}"
}
private fun resolveDisplayNameForSidebar(
npub: String,
localCache: DesktopLocalCache?,
): String? {
if (localCache == null) return null
val pubkeyHex =
com.vitorpamplona.quartz.nip19Bech32
.decodePublicKeyAsHexOrNull(npub) ?: return null
val user = localCache.getUserIfExists(pubkeyHex) ?: return null
val name = user.toBestDisplayName()
return name.takeIf { it != user.pubkeyDisplayHex() }
}
@@ -33,7 +33,6 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -302,7 +301,7 @@ private fun FeedRow(
Modifier
.fillMaxWidth()
.clickable(onClick = onSelect),
shape = RoundedCornerShape(8.dp),
shape = MaterialTheme.shapes.small,
tonalElevation = 1.dp,
) {
Row(
@@ -21,60 +21,30 @@
package com.vitorpamplona.amethyst.desktop.ui.deck
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationRailItem
import androidx.compose.material3.NavigationRailItemDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.domain.nip46.SignerConnectionState
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.ui.components.BunkerHeartbeatIndicator
import com.vitorpamplona.amethyst.desktop.DesktopScreen
import com.vitorpamplona.amethyst.desktop.account.AccountManager
import com.vitorpamplona.amethyst.desktop.account.AccountState
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.network.Nip11Fetcher
import com.vitorpamplona.amethyst.desktop.platform.titleBarInsetTop
import com.vitorpamplona.amethyst.desktop.service.highlights.DesktopHighlightStore
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback
import com.vitorpamplona.amethyst.desktop.ui.account.AccountSwitcherDropdown
import com.vitorpamplona.amethyst.desktop.ui.account.AddAccountDialog
import com.vitorpamplona.amethyst.desktop.ui.components.RelayHealthIndicator
import com.vitorpamplona.amethyst.desktop.ui.media.LocalIsImmersiveFullscreen
import com.vitorpamplona.amethyst.desktop.ui.tor.LocalTorState
import com.vitorpamplona.amethyst.desktop.ui.tor.TorStatusIndicator
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
fun SinglePaneLayout(
@@ -107,185 +77,10 @@ fun SinglePaneLayout(
val navStack by navState.stack.collectAsState()
val currentOverlay = navStack.lastOrNull()
val isImmersive by LocalIsImmersiveFullscreen.current
Row(modifier = modifier.fillMaxSize()) {
if (!isImmersive) {
// Custom navigation rail with a scrollable items area so all pinned
// screens (Home, Reads, Notifications, ...) remain reachable when
// the window is short. Bottom status indicators stay anchored.
//
// We don't use Material3 `NavigationRail` directly here because its
// internal `Column` is not scrollable — when there are more pinned
// items than fit vertically, items at the bottom of the list (and
// the "More" button) get clipped on small windows. We replicate the
// rail's container styling via NavigationRailItemDefaults so item
// visuals are unchanged.
val railScrollState = rememberScrollState()
val pinnedScreens by pinnedNavBarState.pinnedScreens.collectAsState()
val torState = LocalTorState.current
val allAccountsState by accountManager.allAccounts.collectAsState()
val singlePaneScope = rememberCoroutineScope()
var showAddAccountDialog by remember { mutableStateOf(false) }
Column(
modifier =
Modifier
.width(80.dp)
.fillMaxHeight()
.background(MaterialTheme.colorScheme.surfaceContainer),
horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally,
) {
// macOS: push rail items below the traffic lights.
Spacer(Modifier.height(titleBarInsetTop))
// Scrollable region for pinned screens + "More" launcher.
// Takes all remaining space above the fixed bottom controls
// (weight(1f)) and scrolls vertically when items overflow.
Column(
modifier =
Modifier
.weight(1f)
.fillMaxWidth()
.verticalScroll(railScrollState),
horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top,
) {
pinnedScreens.forEach { screenType ->
// Rename "Home" to "Feeds" in the nav rail
val label = if (screenType == DeckColumnType.HomeFeed) "Feeds" else screenType.title()
NavigationRailItem(
selected = currentColumnType == screenType && navStack.isEmpty(),
onClick = {
singlePaneState.navigate(screenType)
navState.clear()
},
icon = {
Icon(
screenType.icon(),
contentDescription = label,
modifier = Modifier.size(22.dp),
)
},
label = {
Text(
label,
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
colors = NavigationRailItemDefaults.colors(),
)
}
NavigationRailItem(
selected = false,
onClick = onShowImportFollowListDialog,
icon = {
Icon(
MaterialSymbols.PersonAdd,
contentDescription = "Import Follow List",
modifier = Modifier.size(22.dp),
)
},
label = {
Text(
"Import",
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
)
},
colors = NavigationRailItemDefaults.colors(),
)
NavigationRailItem(
selected = false,
onClick = onOpenAppDrawer,
icon = {
Icon(
MaterialSymbols.Apps,
contentDescription = "App Drawer",
modifier = Modifier.size(22.dp),
)
},
label = {
Text(
"More",
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
)
},
colors = NavigationRailItemDefaults.colors(),
)
}
// Fixed bottom controls — always visible regardless of how
// many items are in the scrollable region above.
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally,
) {
// Relay health — shows elapsed time since last event (hidden when <30s)
RelayHealthIndicator(
lastEventReceivedAt = lastRelayEventAt,
modifier = Modifier.padding(bottom = 4.dp),
)
BunkerHeartbeatIndicator(
signerConnectionState = signerConnectionState,
lastPingTimeSec = lastPingTimeSec,
modifier = Modifier.padding(bottom = 4.dp),
)
TorStatusIndicator(
status = torState.status,
onClick = {
singlePaneState.navigate(DeckColumnType.Settings)
navState.clear()
},
modifier = Modifier.padding(bottom = 4.dp),
)
AccountSwitcherDropdown(
activeNpub = accountManager.currentAccount()?.npub,
allAccounts = allAccountsState,
localCache = localCache,
onSwitchAccount = { npub ->
singlePaneScope.launch(Dispatchers.IO) {
accountManager.switchAccount(npub)
}
},
onAddAccount = { showAddAccountDialog = true },
onRemoveAccount = { npub ->
singlePaneScope.launch(Dispatchers.IO) {
accountManager.removeAccountFromStorage(npub)
}
},
modifier = Modifier.padding(bottom = 8.dp),
)
}
}
if (showAddAccountDialog) {
AddAccountDialog(
accountManager = accountManager,
onDismiss = { showAddAccountDialog = false },
onAccountAdded = {
showAddAccountDialog = false
singlePaneScope.launch(Dispatchers.IO) {
accountManager.refreshAccountList()
}
},
)
}
}
if (!isImmersive) {
VerticalDivider()
}
Column(modifier = Modifier.weight(1f).fillMaxHeight()) {
// Sidebar is now provided by Main.kt (shared MainSidebar for both layout modes).
// SinglePaneLayout only renders the content pane.
Box(modifier = modifier.fillMaxSize()) {
Column(modifier = Modifier.fillMaxSize()) {
// Offline banner — shows when no remote relays connected
val connectedRelays by relayManager.connectedRelays.collectAsState()
val localRelay = LocalLocalRelayStore.current
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.desktop.ui.highlights
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -34,10 +35,10 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
@@ -175,13 +176,14 @@ private fun HighlightCard(
highlight: HighlightData,
onDelete: () -> Unit,
) {
Card(
OutlinedCard(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
CardDefaults.outlinedCardColors(
containerColor = MaterialTheme.colorScheme.surface,
),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
shape = MaterialTheme.shapes.medium,
) {
Row(
modifier = Modifier.padding(16.dp).fillMaxWidth(),
@@ -26,7 +26,6 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
@@ -59,7 +58,7 @@ fun AudioPlayer(
modifier =
modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.clip(MaterialTheme.shapes.small)
.background(MaterialTheme.colorScheme.surfaceContainerHigh)
.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
@@ -111,7 +111,7 @@ fun DesktopVideoPlayer(
.height(constrainedHeight)
.background(
MaterialTheme.colorScheme.surfaceContainerHigh,
RoundedCornerShape(8.dp),
MaterialTheme.shapes.small,
),
contentAlignment = Alignment.Center,
) {
@@ -123,7 +123,7 @@ fun DesktopVideoPlayer(
modifier =
Modifier
.fillMaxSize()
.clip(RoundedCornerShape(8.dp)),
.clip(MaterialTheme.shapes.small),
contentScale = ContentScale.Fit,
)
}
@@ -37,7 +37,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.IconButton
@@ -421,7 +420,7 @@ fun LightboxOverlay(
Modifier
.align(Alignment.BottomCenter)
.padding(16.dp)
.background(Color.Black.copy(alpha = 0.5f), RoundedCornerShape(16.dp))
.background(Color.Black.copy(alpha = 0.5f), MaterialTheme.shapes.large)
.padding(horizontal = 16.dp, vertical = 6.dp),
)
}
@@ -72,7 +72,7 @@ fun PictureDisplay(
.heightIn(max = 500.dp)
.clip(
if (index == 0 && title == null && description.isBlank()) {
RoundedCornerShape(8.dp)
MaterialTheme.shapes.small
} else if (index == 0) {
RoundedCornerShape(topStart = 8.dp, topEnd = 8.dp)
} else {
@@ -33,7 +33,6 @@ import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
@@ -171,7 +170,7 @@ fun DesktopRichTextViewer(
Modifier
.weight(1f)
.heightIn(max = 300.dp)
.clip(RoundedCornerShape(8.dp))
.clip(MaterialTheme.shapes.small)
.then(
if (callbacks.onImageClick != null) {
Modifier.clickable { callbacks.onImageClick.invoke(urls, index) }
@@ -349,7 +348,7 @@ private fun RenderSegment(
Modifier
.fillMaxWidth()
.heightIn(max = 300.dp)
.clip(RoundedCornerShape(8.dp)),
.clip(MaterialTheme.shapes.small),
contentScale = ContentScale.Fit,
)
}
@@ -362,7 +361,7 @@ private fun RenderSegment(
Modifier
.fillMaxWidth()
.heightIn(max = 300.dp)
.clip(RoundedCornerShape(8.dp)),
.clip(MaterialTheme.shapes.small),
contentScale = ContentScale.Fit,
)
}
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.desktop.ui.note
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -36,6 +37,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
@@ -147,10 +149,15 @@ fun NoteCard(
// the NoteActionsRow have their own clickables that consume the click before
// it reaches the Card's handler, so tapping an action still fires only that
// action.
val cardColors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
val cardElevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
val outlineVariant = MaterialTheme.colorScheme.outlineVariant
val cardBorder =
remember(outlineVariant) {
BorderStroke(1.dp, outlineVariant)
}
val cardColors = CardDefaults.outlinedCardColors(containerColor = MaterialTheme.colorScheme.surface)
val cardShape = MaterialTheme.shapes.medium
val cardBody: @Composable ColumnScope.() -> Unit = {
Column(modifier = Modifier.padding(12.dp)) {
Column(modifier = Modifier.padding(16.dp)) {
Column {
Row(
modifier = Modifier.fillMaxWidth(),
@@ -230,7 +237,7 @@ fun NoteCard(
Modifier
.fillMaxWidth()
.heightIn(max = maxMediaHeight)
.clip(RoundedCornerShape(8.dp))
.clip(MaterialTheme.shapes.small)
.then(
if (onImageClick != null) {
Modifier.clickable { onImageClick(imageUrls, index) }
@@ -302,18 +309,20 @@ fun NoteCard(
}
if (onClick != null) {
Card(
OutlinedCard(
onClick = onClick,
modifier = modifier.fillMaxWidth(),
modifier = modifier,
colors = cardColors,
elevation = cardElevation,
border = cardBorder,
shape = cardShape,
content = cardBody,
)
} else {
Card(
modifier = modifier.fillMaxWidth(),
OutlinedCard(
modifier = modifier,
colors = cardColors,
elevation = cardElevation,
border = cardBorder,
shape = cardShape,
content = cardBody,
)
}
@@ -37,7 +37,6 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
@@ -528,7 +527,7 @@ fun EditProfileContent(
Modifier
.fillMaxWidth()
.height(120.dp)
.clip(RoundedCornerShape(8.dp))
.clip(MaterialTheme.shapes.small)
.border(
width = if (isBannerDragOver) 2.dp else 1.dp,
color =
@@ -537,7 +536,7 @@ fun EditProfileContent(
} else {
MaterialTheme.colorScheme.outline
},
shape = RoundedCornerShape(8.dp),
shape = MaterialTheme.shapes.small,
).clickable { onPickBanner() }
.dragAndDropTarget(
shouldStartDragAndDrop = { true },
@@ -31,8 +31,8 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.ui.theme.StatusAmber
/**
* Card displaying Nostr account key information.
@@ -57,7 +57,7 @@ fun ProfileInfoCard(
Text(
"Read-only mode",
style = MaterialTheme.typography.labelMedium,
color = Color.Yellow,
color = StatusAmber,
)
Spacer(Modifier.height(8.dp))
}
@@ -35,11 +35,12 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.tor.TorServiceStatus
import com.vitorpamplona.amethyst.commons.ui.theme.StatusGreen
import com.vitorpamplona.amethyst.commons.ui.theme.StatusRed
import com.vitorpamplona.amethyst.desktop.network.RelayStatus
import com.vitorpamplona.amethyst.desktop.ui.tor.LocalTorState
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isOnion
@@ -73,9 +74,9 @@ fun RelayStatusCard(
) {
val statusColor =
when {
status.connected -> Color.Green
status.error != null -> Color.Red
else -> Color.Gray
status.connected -> StatusGreen
status.error != null -> StatusRed
else -> MaterialTheme.colorScheme.onSurfaceVariant
}
if (status.connected) {
@@ -114,9 +115,9 @@ fun RelayStatusCard(
val torState = LocalTorState.current
val badgeColor =
if (torState.status is TorServiceStatus.Off) {
Color(0xFFF44336) // Red — Tor required but off
StatusRed // Tor required but off
} else {
Color(0xFF4CAF50) // Green — routed via Tor
StatusGreen // routed via Tor
}
val badgeText =
if (torState.status is TorServiceStatus.Off) "Requires Tor" else "via Tor"
@@ -138,7 +139,7 @@ fun RelayStatusCard(
Text(
error,
style = MaterialTheme.typography.bodySmall,
color = Color.Red.copy(alpha = 0.8f),
color = MaterialTheme.colorScheme.error,
)
}
}
@@ -30,7 +30,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
@@ -79,7 +78,7 @@ fun AdvancedSearchPanel(
) {
Card(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
shape = MaterialTheme.shapes.medium,
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
@@ -0,0 +1,79 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.ui.search
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.desktop.platform.PlatformInfo
import com.vitorpamplona.amethyst.desktop.ui.theme.hoverHighlight
@Composable
fun SearchPill(
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val shortcutHint = if (PlatformInfo.isMacOS) "\u2318F" else "Ctrl+F"
Surface(
onClick = onClick,
shape = RoundedCornerShape(999.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
modifier = modifier.height(36.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.hoverHighlight().padding(horizontal = 12.dp),
) {
Icon(
MaterialSymbols.Search,
contentDescription = "Search",
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.width(8.dp))
Text(
"Search...",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
Spacer(Modifier.width(8.dp))
Text(
shortcutHint,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
)
}
}
}
@@ -0,0 +1,305 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.ui.search
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.search.QuerySerializer
import com.vitorpamplona.amethyst.desktop.SearchHistoryStore
import com.vitorpamplona.amethyst.desktop.ui.theme.hoverHighlight
@Composable
fun SearchSpotlight(
onSelectProfile: (String) -> Unit,
onSelectNote: (String) -> Unit,
onSelectHashtag: (String) -> Unit,
onOpenFullSearch: (String) -> Unit,
onDismiss: () -> Unit,
) {
val scope = rememberCoroutineScope()
val focusRequester = remember { FocusRequester() }
var textFieldValue by remember { mutableStateOf(TextFieldValue("")) }
val history by SearchHistoryStore.history.collectAsState()
val savedSearches by SearchHistoryStore.savedSearches.collectAsState()
val hasQuery = textFieldValue.text.isNotBlank()
Dialog(onDismissRequest = onDismiss) {
// Full-screen scrim + centered card
Box(
modifier =
Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.5f))
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
) {
onDismiss()
},
contentAlignment = Alignment.TopCenter,
) {
// Search card — offset 15% from top
Surface(
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surface,
shadowElevation = 8.dp,
modifier =
Modifier
.padding(top = 80.dp)
.widthIn(max = 600.dp)
.fillMaxWidth(0.9f)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
) {
// consume clicks so they don't dismiss
},
) {
Column(
modifier =
Modifier
.onKeyEvent { event ->
if (event.type == KeyEventType.KeyDown && event.key == Key.Escape) {
onDismiss()
true
} else {
false
}
},
) {
// Search input
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(16.dp),
) {
Icon(
MaterialSymbols.Search,
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.width(12.dp))
BasicTextField(
value = textFieldValue,
onValueChange = { textFieldValue = it },
modifier =
Modifier
.weight(1f)
.focusRequester(focusRequester),
textStyle =
MaterialTheme.typography.bodyLarge.copy(
color = MaterialTheme.colorScheme.onSurface,
),
singleLine = true,
decorationBox = { innerTextField ->
Box {
if (textFieldValue.text.isEmpty()) {
Text(
"Search notes, profiles, hashtags...",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
)
}
innerTextField()
}
},
)
}
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
// Results or history
LazyColumn(
modifier = Modifier.heightIn(max = 400.dp).fillMaxWidth(),
) {
if (!hasQuery) {
// Recent searches
if (history.isNotEmpty()) {
item {
Text(
"Recent",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(16.dp, 12.dp, 16.dp, 4.dp),
)
}
items(history.take(5)) { query ->
val text = QuerySerializer.serialize(query)
SpotlightRow(
icon = { Icon(MaterialSymbols.History, null, Modifier.size(20.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) },
text = text,
onClick = {
textFieldValue = TextFieldValue(text, TextRange(text.length))
},
)
}
}
// Saved searches
if (savedSearches.isNotEmpty()) {
item {
Text(
"Saved",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(16.dp, 12.dp, 16.dp, 4.dp),
)
}
items(savedSearches.take(5)) { saved ->
SpotlightRow(
icon = { Icon(MaterialSymbols.Bookmark, null, Modifier.size(20.dp), tint = MaterialTheme.colorScheme.primary) },
text = saved.label,
onClick = { onOpenFullSearch(QuerySerializer.serialize(saved.query)) },
)
}
}
if (history.isEmpty() && savedSearches.isEmpty()) {
item {
Text(
"Type to search notes, profiles, and hashtags",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(16.dp),
)
}
}
} else {
// Placeholder for live results — Phase 2 will wire AdvancedSearchBarState
item {
SpotlightRow(
icon = { Icon(MaterialSymbols.Search, null, Modifier.size(20.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) },
text = "Search for \"${textFieldValue.text}\"",
onClick = { onOpenFullSearch(textFieldValue.text) },
)
}
item {
SpotlightRow(
icon = { Icon(MaterialSymbols.Tag, null, Modifier.size(20.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) },
text = "#${textFieldValue.text.removePrefix("#")}",
onClick = { onSelectHashtag(textFieldValue.text.removePrefix("#")) },
)
}
// "Open full search" at bottom
item {
HorizontalDivider(
color = MaterialTheme.colorScheme.outlineVariant,
modifier = Modifier.padding(vertical = 4.dp),
)
SpotlightRow(
icon = {
Icon(
MaterialSymbols.AutoMirrored.OpenInNew,
null,
Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.primary,
)
},
text = "Open full search",
textColor = MaterialTheme.colorScheme.primary,
onClick = { onOpenFullSearch(textFieldValue.text) },
)
}
}
}
}
}
}
}
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
}
@Composable
private fun SpotlightRow(
icon: @Composable () -> Unit,
text: String,
textColor: Color = MaterialTheme.colorScheme.onSurface,
onClick: () -> Unit,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.hoverHighlight()
.padding(horizontal = 16.dp, vertical = 10.dp),
) {
icon()
Spacer(Modifier.width(12.dp))
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = textColor,
maxLines = 1,
)
}
}
@@ -57,10 +57,11 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.ui.theme.StatusGreen
import com.vitorpamplona.amethyst.commons.ui.theme.StatusRed
import com.vitorpamplona.amethyst.desktop.service.media.ServerHealthCheck
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
@@ -263,9 +264,9 @@ private fun ServerRow(
shape = CircleShape,
color =
when (status) {
ServerHealthCheck.ServerStatus.ONLINE -> Color(0xFF4CAF50)
ServerHealthCheck.ServerStatus.OFFLINE -> Color(0xFFF44336)
ServerHealthCheck.ServerStatus.UNKNOWN -> Color(0xFF9E9E9E)
ServerHealthCheck.ServerStatus.ONLINE -> StatusGreen
ServerHealthCheck.ServerStatus.OFFLINE -> StatusRed
ServerHealthCheck.ServerStatus.UNKNOWN -> MaterialTheme.colorScheme.onSurfaceVariant
},
) {}
}
@@ -0,0 +1,61 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.ui.theme
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@Immutable
data class AmethystSpacing(
val xxs: Dp = 2.dp,
val xs: Dp = 4.dp,
val sm: Dp = 8.dp,
val md: Dp = 12.dp,
val lg: Dp = 16.dp,
val xl: Dp = 24.dp,
val xxl: Dp = 32.dp,
val cardPadding: Dp = 16.dp,
val cardGap: Dp = 8.dp,
val sidebarExpandedWidth: Dp = 240.dp,
val sidebarCollapsedWidth: Dp = 56.dp,
val columnHeaderHeight: Dp = 48.dp,
)
val LocalSpacing = staticCompositionLocalOf { AmethystSpacing() }
val LocalIsDarkTheme = staticCompositionLocalOf { false }
/** Mutable state for Cmd+F feed search. Provided at Main.kt level. */
val LocalFeedSearchActive = compositionLocalOf { mutableStateOf(false) }
/** Callback to open the full Search column/screen. Provided at Main.kt level. */
val LocalOpenFullSearch = compositionLocalOf<() -> Unit> { {} }
val MaterialTheme.spacing: AmethystSpacing
@Composable @ReadOnlyComposable
get() = LocalSpacing.current
@@ -0,0 +1,47 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.ui.theme
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.onPointerEvent
/**
* Adds a subtle background highlight on hover. The state read is deferred to
* the draw phase via [drawBehind], so hover enter/exit only invalidates draw —
* no recomposition of the host composable.
*/
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun Modifier.hoverHighlight(hoverColor: Color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f)): Modifier {
val color = remember { mutableStateOf(Color.Transparent) }
return this
.onPointerEvent(PointerEventType.Enter) { color.value = hoverColor }
.onPointerEvent(PointerEventType.Exit) { color.value = Color.Transparent }
.drawBehind { drawRect(color.value) }
}
@@ -33,12 +33,14 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.tor.TorServiceStatus
import com.vitorpamplona.amethyst.commons.ui.theme.StatusAmber
import com.vitorpamplona.amethyst.commons.ui.theme.StatusGreen
import com.vitorpamplona.amethyst.commons.ui.theme.StatusRed
/**
* Small shield icon showing Tor connection status in the sidebar footer.
@@ -54,19 +56,19 @@ fun TorStatusIndicator(
val (icon, tint, tooltip) =
when (status) {
is TorServiceStatus.Off -> {
Triple(MaterialSymbols.Shield, Color.Gray, "Tor: Off")
Triple(MaterialSymbols.Shield, MaterialTheme.colorScheme.onSurfaceVariant, "Tor: Off")
}
is TorServiceStatus.Connecting -> {
Triple(MaterialSymbols.Shield, Color(0xFFFFB300), "Tor: Connecting...")
Triple(MaterialSymbols.Shield, StatusAmber, "Tor: Connecting...")
}
is TorServiceStatus.Active -> {
Triple(MaterialSymbols.Shield, Color(0xFF4CAF50), "Tor: Connected")
Triple(MaterialSymbols.Shield, StatusGreen, "Tor: Connected")
}
is TorServiceStatus.Error -> {
Triple(MaterialSymbols.Shield, Color(0xFFF44336), "Tor: ${status.message}")
Triple(MaterialSymbols.Shield, StatusRed, "Tor: ${status.message}")
}
}
@@ -33,7 +33,6 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
@@ -631,7 +630,7 @@ private fun SendDialog(
Dialog(onDismissRequest = { if (!isLoading) onDismiss() }) {
Card(
modifier = Modifier.width(480.dp),
shape = RoundedCornerShape(16.dp),
shape = MaterialTheme.shapes.large,
) {
Column(modifier = Modifier.padding(24.dp)) {
// Header
@@ -849,7 +848,7 @@ private fun ReceiveDialog(
Dialog(onDismissRequest = { if (!isGenerating) onDismiss() }) {
Card(
modifier = Modifier.width(400.dp),
shape = RoundedCornerShape(16.dp),
shape = MaterialTheme.shapes.large,
) {
Column(modifier = Modifier.padding(24.dp)) {
// Header: title + close X
@@ -94,12 +94,12 @@ class AccountManagerStateTransitionTest {
advanceUntilIdle()
// Should see: LoggedOut → ConnectingRelays → LoggedIn
// Should see: Loading → ConnectingRelays → LoggedIn
assertTrue(
states.size >= 3,
"Expected at least 3 state transitions, got ${states.size}: $states",
)
assertIs<AccountState.LoggedOut>(states[0])
assertIs<AccountState.Loading>(states[0])
assertIs<AccountState.ConnectingRelays>(states[1])
assertIs<AccountState.LoggedIn>(states[2])
@@ -125,12 +125,12 @@ class AccountManagerStateTransitionTest {
advanceUntilIdle()
// Should see: LoggedOut → ConnectingRelays → LoggedOut
// Should see: Loading → ConnectingRelays → LoggedOut
assertTrue(
states.size >= 3,
"Expected at least 3 state transitions, got ${states.size}: $states",
)
assertIs<AccountState.LoggedOut>(states[0])
assertIs<AccountState.Loading>(states[0])
assertIs<AccountState.ConnectingRelays>(states[1])
assertIs<AccountState.LoggedOut>(states[2])
@@ -0,0 +1,220 @@
---
title: "fix: macOS bundled VLC discovery broken by versioned setenv symbol"
type: fix
status: active
date: 2026-05-18
deepened: 2026-05-18
origin: docs/brainstorms/2026-05-18-fix-macos-vlc-bundling-brainstorm.md
---
# fix: macOS bundled VLC discovery broken by versioned setenv symbol
## Enhancement Summary
**Deepened on:** 2026-05-18
**Research agents used:** VLC --plugin-path audit, macOS JNA symbol research, VlcjPlayerPool init flow audit
### Key Improvements from Research
1. `--plugin-path` factory arg is unreliable — VLC needs `VLC_PLUGIN_PATH` env var set before `libvlc_new()`
2. `--reset-plugins-cache` is VLC CLI-only, not available via libvlc/vlcj factory args
3. Simpler fix: replace `LibC.INSTANCE.setenv()` with JNA-free env var setting
## Overview
Bundled VLC video playback is broken on macOS release builds.
`MacOsVlcDiscoverer.setPluginPath()` calls `LibC.INSTANCE.setenv()` via JNA,
but macOS 13+ uses versioned C symbols (`setenv$3b99ba0d`) that JNA's `dlsym`
cannot resolve. Without system VLC installed, video is completely unavailable.
(See brainstorm: `docs/brainstorms/2026-05-18-fix-macos-vlc-bundling-brainstorm.md`)
## Problem Statement
```
VLC: bundled discovery threw Error looking up function 'setenv$3b99ba0d':
dlsym(0x..., setenv$3b99ba0d): symbol not found
VLC: init failed — ...DiscoveryDirectoryProvider: Provider ...not found
```
- Affects all macOS DMG users without system VLC.app installed
- Audio playback works (separate factory, no plugin path needed for audio codecs)
- Graceful degradation exists (shows "Install VLC" message) but shouldn't be needed
## Proposed Solution (Updated from Research)
**Replace `LibC.INSTANCE.setenv()` with a JNA-free approach to set the env var.**
VLC requires `VLC_PLUGIN_PATH` as a process-level environment variable BEFORE
`libvlc_new()` (called by `MediaPlayerFactory`). The `--plugin-path` factory arg
is NOT reliably forwarded to the plugin scanner. So we must set the actual env var,
just without using JNA's broken `LibC.setenv` binding.
### Approach: Use JNA's lower-level Native.getLibrary to call setenv directly
Instead of `LibC.INSTANCE.setenv()` which goes through vlcj's `LibC` interface
binding (which triggers the versioned symbol lookup), use JNA's `Function.getFunction`
to call `setenv` from libc directly without the problematic interface mapping:
```kotlin
override fun setPluginPath(pluginPath: String?): Boolean {
if (pluginPath == null) return false
return try {
// Call setenv directly via JNA Function API, bypassing LibC interface
// which fails on macOS 13+ due to versioned symbol lookup
val setenv = com.sun.jna.Function.getFunction("c", "setenv")
setenv.invokeInt(arrayOf(PLUGIN_ENV_NAME, pluginPath, 1)) == 0
} catch (e: Throwable) {
// Fallback: set as JVM system property for factory arg approach
System.setProperty("vlc.plugin.path", pluginPath)
true
}
}
```
**If that still hits the symbol issue**, the alternative fallback is:
```kotlin
override fun setPluginPath(pluginPath: String?): Boolean {
if (pluginPath == null) return false
// Store for VlcjPlayerPool to pass as --plugin-path factory arg
discoveredPluginPath = pluginPath
return true
}
```
Then in `VlcjPlayerPool.init()`, pass `--plugin-path` to both factories as belt-and-suspenders.
### Plugin Cache Fix
Since `--reset-plugins-cache` is VLC CLI-only (not available via libvlc), fix stale
cache by deleting the cache file before factory creation:
```kotlin
// Delete stale VLC plugin cache before factory init
val cacheDir = File(System.getProperty("user.home"), "Library/Caches/org.videolan.vlc")
cacheDir.listFiles()?.filter { it.name.startsWith("plugins") }?.forEach { it.delete() }
```
## Implementation
### File 1: `MacOsVlcDiscoverer.kt`
**Current** (line 55):
```kotlin
override fun setPluginPath(pluginPath: String?): Boolean =
LibC.INSTANCE.setenv(PLUGIN_ENV_NAME, pluginPath, 1) == 0
```
**Change to:**
```kotlin
var discoveredPluginPath: String? = null
private set
override fun setPluginPath(pluginPath: String?): Boolean {
if (pluginPath == null) return false
discoveredPluginPath = pluginPath
return try {
// Direct JNA Function call bypasses vlcj's LibC interface binding
// which fails on macOS 13+ (versioned symbol setenv$3b99ba0d)
val setenv = com.sun.jna.Function.getFunction("c", "setenv")
setenv.invokeInt(arrayOf(PLUGIN_ENV_NAME, pluginPath, 1)) == 0
} catch (_: Throwable) {
// If JNA call fails, store path for --plugin-path fallback
false
}
}
```
Remove import: `uk.co.caprica.vlcj.binding.lib.LibC`
### File 2: `VlcjPlayerPool.kt`
**Changes to `init()` (lines 77-113):**
1. Hold reference to `MacOsVlcDiscoverer` for plugin path access:
```kotlin
val macOsDiscoverer = MacOsVlcDiscoverer()
val nd = NativeDiscovery(BundledVlcDiscoverer(), macOsDiscoverer)
```
2. Build factory args with plugin path fallback:
```kotlin
val factoryArgs = mutableListOf("--no-xlib")
// If setenv failed, pass --plugin-path as fallback
val pluginPath = macOsDiscoverer.discoveredPluginPath
?: System.getProperty("vlc.plugin.path")
?: VlcResourceResolver.findVlcDir()?.let { "${it.absolutePath}/plugins" }
if (pluginPath != null && !envVarSetSuccessfully) {
factoryArgs += "--plugin-path=$pluginPath"
}
val f = MediaPlayerFactory(*factoryArgs.toTypedArray())
```
3. Delete stale plugin cache before factory creation (macOS only):
```kotlin
if ("mac" in System.getProperty("os.name").lowercase()) {
val cacheDir = File(System.getProperty("user.home"), "Library/Caches/org.videolan.vlc")
cacheDir.listFiles()?.filter { it.name.startsWith("plugins") }?.forEach { it.delete() }
}
```
4. Audio factory also gets plugin path if env var wasn't set:
```kotlin
val audioArgs = mutableListOf("--no-video", "--no-xlib")
if (pluginPath != null && !envVarSetSuccessfully) {
audioArgs += "--plugin-path=$pluginPath"
}
MediaPlayerFactory(*audioArgs.toTypedArray()).also { audioFactory = it }
```
### File 3: `desktopApp/build.gradle.kts`
Add JVM property as ultimate fallback:
```kotlin
jvmArgs += "-Dvlc.plugin.path=\$APPDIR/resources/vlc/plugins"
```
## Edge Cases from Research
| Edge Case | Handling |
|-----------|----------|
| `Function.getFunction("c", "setenv")` also fails on macOS | Caught by try/catch, falls back to `--plugin-path` factory arg |
| `--plugin-path` not honored by VLC 3.0.20 | JVM property `-Dvlc.plugin.path` as ultimate fallback |
| Audio factory created later without plugin path | Audio factory also receives `--plugin-path` if env var wasn't set |
| `findVlcDir()` returns root, not plugins dir | Append `/plugins` when building `--plugin-path` value |
| VLC plugin cache stale after VLC update | Cache deleted on startup before factory creation |
| NativeDiscovery swallows MacOsVlcDiscoverer ref | Restructured to hold ref before passing to NativeDiscovery |
## Acceptance Criteria
- [ ] Video plays in release DMG on macOS without system VLC installed
- [ ] No `setenv` symbol errors in console output
- [ ] No stale plugin cache warnings on launch
- [ ] Audio playback still works
- [ ] Linux/Windows builds unaffected
- [ ] `./gradlew :desktopApp:run` (debug) still works
- [ ] Fallback chain works: direct setenv → --plugin-path → JVM property
## Risks
| Risk | Severity | Mitigation |
|------|----------|------------|
| `Function.getFunction("c", "setenv")` may also hit versioned symbol | Medium | Triple fallback: direct call → --plugin-path → JVM property |
| `--plugin-path` ignored by some VLC builds | Low | Env var approach is primary, --plugin-path is fallback only |
| Plugin cache deletion too aggressive | Low | Only deletes `plugins*` files in VLC cache dir, not other data |
## Sources
- **Origin brainstorm:** [docs/brainstorms/2026-05-18-fix-macos-vlc-bundling-brainstorm.md](../brainstorms/2026-05-18-fix-macos-vlc-bundling-brainstorm.md)
- **Research:** VLC requires `VLC_PLUGIN_PATH` env var before `libvlc_new()``--plugin-path` not reliably forwarded
- **Research:** `--reset-plugins-cache` is CLI-only, not available via libvlc/vlcj
- **Research:** `setPluginPath()` called DURING `nd.discover()`, before factory creation
- **Research:** macOS 13+ versioned symbols affect JNA's `LibC` interface, but `Function.getFunction` may bypass it
- `MacOsVlcDiscoverer.kt:55` — failing `setenv` call
- `VlcjPlayerPool.kt:68-114` — init flow
- `VlcResourceResolver.kt` — returns VLC root dir (not plugins subdir)
- [JNA Issue #1423](https://github.com/java-native-access/jna/issues/1423) — macOS symbol resolution changes
- [Guardsquare/proguard#460](https://github.com/Guardsquare/proguard/issues/460) — related ProGuard bytecode rewriting
@@ -0,0 +1,299 @@
---
title: "feat: Desktop note action bar — long-press details + right-click customize"
type: feat
status: active
date: 2026-05-22
origin: docs/brainstorms/2026-05-21-note-action-bar-ux-brainstorm.md
deepened: 2026-05-22
---
# Desktop Note Action Bar — Long-Press Details + Right-Click Customize
## Enhancement Summary
**Deepened on:** 2026-05-22
**Sections enhanced:** 4
**Research agents used:** compose-expert, desktop-expert, compose-modifier-and-layout-style, compose-side-effects
### Key Improvements from Research
1. Use `sealed class ActivePopup` for mutually exclusive popup state
2. Use `DropdownMenu` for simple option lists (emoji picker, repost), `Popup` for rich content (zap/reaction details)
3. Preserve ripple by explicitly passing `indication = ripple(bounded = false, radius = 16.dp)` when replacing `IconButton`
4. Mark `ZapReceipt` as `@Immutable`, consider `ImmutableList` for list params
### New Considerations Discovered
- `Popup` creates a separate AWT window on JVM — always pass `PopupProperties(focusable = true)` for dismiss-on-click-outside
- Modifier chain order: `combinedClickable` before `onPointerEvent` (ripple wraps full area)
- Skip popup animations on desktop — instant feels right, `fadeIn(tween(100))` at most
- Metadata loading in popup: key `LaunchedEffect` on note ID, not popup visibility
## Overview
Add long-press popups and right-click customization to the desktop note action bar. Currently click = action and right-click = custom zap dialog. After this change, every action icon supports three gestures: click (action), long-press (view details), right-click (customize).
(see brainstorm: docs/brainstorms/2026-05-21-note-action-bar-ux-brainstorm.md)
## Interaction Model
| Action | Click | Long Press (~500ms) | Right-Click |
|--------|-------|---------------------|-------------|
| Reply | Open reply composer | Open thread | — |
| Like | React with + | Floating popup: who reacted + emoji | Emoji picker (DropdownMenu) |
| Repost | Repost | — | Quote/Fork (DropdownMenu) |
| Zap | Quick zap 21 sats | Floating popup: zap receipts | Custom zap dialog (existing) |
| Bookmark | Public/private dialog | — | — |
## Technical Approach
### Phase 1: Long-Press Detection + Zap Receipts Popup
**Goal:** Add long-press to zap icon showing floating zap receipts popup.
#### Step 1.1: Pass `Note` to NoteActionsRow
Currently `NoteActionsRow` receives `zapReceipts: List<ZapReceipt> = emptyList()` — callers pass empty lists. The actual zap/reaction data lives on the `Note` object.
**Change:** Add `note: Note? = null` parameter to `NoteActionsRow`.
**Files:**
- `desktopApp/src/jvmMain/.../ui/NoteActions.kt` — add `note: Note? = null` param
- `desktopApp/src/jvmMain/.../ui/FeedScreen.kt` — pass `note` (already available)
- Others keep default null (BookmarksScreen, ReadsScreen, ArticleReaderScreen)
#### Step 1.2: Popup State as Sealed Class
Replace individual boolean states with a single sealed class to ensure mutual exclusivity:
```kotlin
sealed class ActivePopup {
data object None : ActivePopup()
data object ZapReceipts : ActivePopup()
data object Reactions : ActivePopup()
data object EmojiPicker : ActivePopup()
data object RepostOptions : ActivePopup()
}
var activePopup by remember { mutableStateOf<ActivePopup>(ActivePopup.None) }
```
#### Step 1.3: Replace `IconButton` with `combinedClickable` Box
Replace the zap `IconButton` with `Box` + `combinedClickable`. Preserve ripple explicitly.
```kotlin
Box(
modifier = Modifier
.size(32.dp)
.combinedClickable(
onClick = { /* quick zap */ },
onLongClick = { activePopup = ActivePopup.ZapReceipts },
indication = ripple(bounded = false, radius = 16.dp),
interactionSource = remember { MutableInteractionSource() },
)
.onPointerEvent(PointerEventType.Press) { pointerEvent ->
if (pointerEvent.buttons.isSecondaryPressed) {
showZapDialog = true
}
},
contentAlignment = Alignment.Center,
) {
Icon(Zap, ...)
}
```
**Research insight:** `combinedClickable` before `onPointerEvent` in chain — ripple wraps full area, right-click handler sits inside.
#### Step 1.4: Zap Receipts Floating Popup
New composable using `Popup` + `ElevatedCard` (rich scrollable content):
```kotlin
@Composable
fun ZapReceiptsPopup(
note: Note,
localCache: DesktopLocalCache,
onDismiss: () -> Unit,
) {
Popup(
alignment = Alignment.TopStart,
offset = IntOffset(0, -popupHeightPx),
onDismissRequest = onDismiss,
properties = PopupProperties(focusable = true), // required for desktop dismiss
) {
ElevatedCard(
modifier = Modifier.widthIn(max = 280.dp),
) {
Column(
Modifier
.verticalScroll(rememberScrollState())
.heightIn(max = 300.dp)
.padding(12.dp),
) {
// Header: total sats
// Sorted receipts: sender name + amount + message
}
}
}
}
```
**Data access from Note:**
```kotlin
val zapEntries = note.zaps.mapNotNull { (request, receipt) ->
val sender = request.author?.toBestDisplayName()
?: request.event?.pubKey?.take(8)
?: return@mapNotNull null
val amount = (receipt?.event as? LnZapEvent)?.amount?.toLong()
?: return@mapNotNull null
Triple(sender, amount, request.event?.content?.ifBlank { null })
}.sortedByDescending { it.second }
```
**Metadata loading:** Use `LaunchedEffect(note.idHex)` to fetch unknown sender metadata. Coroutine auto-cancels when popup leaves composition.
**Empty state:** If `zapEntries` is empty, show "No zaps yet" text.
### Phase 2: Reactions Popup
#### Step 2.1: `combinedClickable` for Like Icon
Same pattern as zap — click = react, long-press = `activePopup = ActivePopup.Reactions`.
#### Step 2.2: Reactions Floating Popup
Same `Popup` + `ElevatedCard` pattern. Content grouped by emoji:
```kotlin
@Composable
fun ReactionsPopup(
note: Note,
onDismiss: () -> Unit,
) {
// note.reactions: Map<String, List<Note>>
Popup(
onDismissRequest = onDismiss,
properties = PopupProperties(focusable = true),
) {
ElevatedCard {
Column(Modifier.verticalScroll(rememberScrollState()).heightIn(max = 300.dp)) {
// Header: total count
note.reactions.forEach { (emoji, reactionNotes) ->
// Section: emoji + list of sender names
}
}
}
}
}
```
**Research insight:** Use `derivedStateOf` for the grouped reaction view to avoid recomposition on every upstream emission.
### Phase 3: Right-Click Emoji Picker
#### Step 3.1: Add right-click to Like Icon
Add `onPointerEvent` secondary press → `activePopup = ActivePopup.EmojiPicker`.
#### Step 3.2: Emoji Picker as DropdownMenu
Use `DropdownMenu` (not raw `Popup`) — matches existing `AccountSwitcherDropdown` pattern. Simple option list.
```kotlin
DropdownMenu(
expanded = activePopup is ActivePopup.EmojiPicker,
onDismissRequest = { activePopup = ActivePopup.None },
) {
listOf("+", "\u2764\ufe0f", "\ud83e\udd19", "\ud83d\udd25", "\ud83d\udc40", "\ud83d\ude02").forEach { emoji ->
DropdownMenuItem(
text = { Text(emoji, fontSize = 20.sp) },
onClick = {
activePopup = ActivePopup.None
onReact(emoji)
},
)
}
}
```
### Phase 4: Right-Click Repost Options
#### Step 4.1: Repost DropdownMenu
Right-click on repost icon → `DropdownMenu` with Quote / Fork options:
```kotlin
DropdownMenu(
expanded = activePopup is ActivePopup.RepostOptions,
onDismissRequest = { activePopup = ActivePopup.None },
) {
DropdownMenuItem(text = { Text("Repost") }, onClick = { /* repost */ })
DropdownMenuItem(text = { Text("Quote") }, onClick = { /* quote */ })
}
```
---
## File Changes Summary
| File | Changes |
|------|---------|
| `NoteActions.kt` | Add `note: Note?` param, `ActivePopup` sealed class, replace `IconButton` with `combinedClickable` for zap/like/repost, add `ZapReceiptsPopup`, `ReactionsPopup`, emoji picker, repost options. Mark `ZapReceipt` as `@Immutable`. |
| `FeedScreen.kt` | Pass `note` to `NoteActionsRow` |
## Acceptance Criteria
- [ ] Long-press (~500ms) on zap icon shows floating popup with zap receipts (sender, amount, message)
- [ ] Long-press on like icon shows floating popup with reactions grouped by emoji
- [ ] Long-press on reply icon opens thread (same as click)
- [ ] Right-click on zap icon opens custom zap dialog (existing behavior preserved)
- [ ] Right-click on like icon opens emoji picker (DropdownMenu)
- [ ] Right-click on repost icon shows Quote/Fork options (DropdownMenu)
- [ ] Single click still works for all actions (zap, react, repost, reply, bookmark)
- [ ] Popups are mutually exclusive (opening one closes others)
- [ ] Popups dismiss on click outside (`PopupProperties(focusable = true)`)
- [ ] Popups are scrollable when content exceeds 300dp
- [ ] No crash when long-pressing on notes with 0 zaps/reactions (empty state)
- [ ] Ripple preserved on all action icons
- [ ] Compiles, spotless clean, tests pass
## Implementation Order
1. **Phase 1** — Zap long-press popup (highest value, proves the pattern)
2. **Phase 2** — Reactions popup (same pattern, different data)
3. **Phase 3** — Emoji picker (small addition)
4. **Phase 4** — Repost options (nice-to-have)
## Technical Notes from Research
### Modifier Chain Order
```
Modifier
.size(32.dp) // identity: fixed touch target
.combinedClickable(...) // identity: click + long-press
.onPointerEvent(Press) { ... } // identity: right-click
```
`combinedClickable` first (provides ripple/indication), `onPointerEvent` after.
### Popup vs DropdownMenu Decision
| Content Type | API |
|-------------|-----|
| Rich scrollable content (zap receipts, reactions) | `Popup` + `ElevatedCard` |
| Simple option list (emoji picker, repost options) | `DropdownMenu` + `DropdownMenuItem` |
### Desktop-Specific
- `PopupProperties(focusable = true)` is **required** on JVM desktop for click-outside dismiss
- `Popup` creates separate AWT window — can extend beyond parent window bounds (good)
- Skip animations or use `fadeIn(tween(100))` at most — desktop users expect crisp/instant
### Side Effects in Popups
- Metadata loading: `LaunchedEffect(note.idHex)` — auto-cancels when popup leaves composition
- No `DisposableEffect` needed unless opening relay subscriptions
- Use `derivedStateOf` for grouped reaction view
## Sources
- **Origin brainstorm:** [docs/brainstorms/2026-05-21-note-action-bar-ux-brainstorm.md](docs/brainstorms/2026-05-21-note-action-bar-ux-brainstorm.md) — interaction model, popup style, per-action behavior
- Android `ReactionsRow.kt``combinedClickable`, `Popup`, animation patterns
- Android `Note.kt``reactions: Map<String, List<Note>>`, `zaps: Map<Note, Note?>`
- Desktop `NoteActions.kt``onPointerEvent` right-click, `ZapReceiptsDialog`
- Desktop `AccountSwitcherDropdown.kt``DropdownMenu` scroll/height pattern
- Desktop `ChatBubbleLayout.kt:139``combinedClickable` proven on JVM desktop
@@ -0,0 +1,136 @@
---
title: "fix: Desktop note action counters, quote boost, and boost detail popup"
type: fix
status: active
date: 2026-05-22
---
# Fix Desktop Note Action Counters, Quote Boost, and Boost Detail Popup
## Problems
### 1. Counters show zero for reactions, zaps, replies, reposts
Counts are passed as **static Int/Long parameters** to `NoteActionsRow`. They're read once at render time from `note.countReactions()`, `note.zaps.size`, etc. When new events arrive via relay subscriptions, the FlowSet invalidates — triggering recomposition of FeedNoteCard — but the counts are re-read from the same snapshot. The actual issue is **timing**: interaction subscriptions (`requestInteractions()`) fire for visible notes, but events may arrive after the initial render.
Additionally, **reply subscriptions are missing**`DesktopRelaySubscriptionsCoordinator.requestInteractions()` subscribes for kinds 7, 9735, 6 but NOT kind 1 (replies).
### 2. Quote boost does nothing
The "Quote" `DropdownMenuItem` onClick just copies a note link to clipboard. It doesn't open a compose dialog. `ComposeNoteDialog` has no `quote` parameter.
### 3. Boost long-press has no popup
Repost icon has no `onLongClick` handler — long-press does nothing. Should show who boosted.
## Technical Approach
### Phase 1: Fix counters — make them reactive
**Root cause:** FeedNoteCard reads counts once as vals, passes them as params. Even though FlowSet observations are collected, the count vals are re-read from Note's mutable properties which DO update — but only if the recomposition actually re-reads them.
**Fix:** The FlowSet observation pattern is actually correct — collecting `flowSet.reactions.stateFlow` triggers recomposition which re-reads `note.countReactions()`. The problem may be that interaction events haven't arrived yet.
**Step 1.1: Add kind 1 (replies) to interaction subscriptions**
File: `desktopApp/src/jvmMain/.../subscriptions/DesktopRelaySubscriptionsCoordinator.kt`
In `requestInteractions()`, add kind 1 to the filter list:
```kotlin
Filter(kinds = listOf(1), tags = mapOf("e" to noteIds)), // Replies
```
**Step 1.2: Verify flow collection triggers recomposition**
In `FeedScreen.kt`, the pattern is:
```kotlin
val reactionsState by flowSet.reactions.stateFlow.collectAsState()
// ...
val reactionCount = note.countReactions()
```
This should work — `collectAsState()` triggers recomposition, which re-reads `countReactions()`. If it's not working, add `reactionsState` as a key to `remember`:
```kotlin
val reactionCount = remember(reactionsState) { note.countReactions() }
```
### Phase 2: Fix quote boost
**Step 2.1: Add `quoteOf` parameter to ComposeNoteDialog**
File: `desktopApp/src/jvmMain/.../ui/ComposeNoteDialog.kt`
Add `quoteOf: Event? = null` parameter. When set, embed `nostr:${NEvent.create(event.id, event.pubKey, event.kind, relays)}` in the initial text and add "q" tag.
**Step 2.2: Wire Quote menu item to compose dialog**
File: `desktopApp/src/jvmMain/.../ui/NoteActions.kt`
Replace clipboard copy with opening ComposeNoteDialog:
```kotlin
var quoteEvent by remember { mutableStateOf<Event?>(null) }
// In Quote DropdownMenuItem onClick:
quoteEvent = event
activePopup = ActivePopup.None
// Render dialog:
if (quoteEvent != null) {
ComposeNoteDialog(quoteOf = quoteEvent, onDismiss = { quoteEvent = null }, ...)
}
```
**Step 2.3: Build quote event with "q" tag**
When composing, use TextNoteEvent builder with "q" tag:
```kotlin
TextNoteEvent.build(
message = "$userMessage\nnostr:${NEvent.create(quotedEvent.id, ...)}",
tags = arrayOf(arrayOf("q", quotedEvent.id, relayHint ?: "", quotedEvent.pubKey)),
signer = signer,
)
```
### Phase 3: Add boost detail popup
**Step 3.1: Add long-press to repost icon**
Same `combinedClickable` pattern. Long-press sets `activePopup = ActivePopup.Boosts`.
**Step 3.2: Create BoostsPopup**
New composable showing who boosted:
```kotlin
@Composable
fun BoostsPopup(note: Note, onDismiss: () -> Unit) {
// note.boosts: List<Note>
// Each boost Note has .author → display name
Popup(properties = PopupProperties(focusable = true)) {
ElevatedCard { ... }
}
}
```
Add `Boosts` to the `ActivePopup` sealed class.
## File Changes
| File | Changes |
|------|---------|
| `DesktopRelaySubscriptionsCoordinator.kt` | Add kind 1 to `requestInteractions()` filter |
| `FeedScreen.kt` | Ensure count reads are keyed on flow state |
| `NoteActions.kt` | Wire quote to compose dialog, add boost long-press popup, add `ActivePopup.Boosts` |
| `ComposeNoteDialog.kt` | Add `quoteOf: Event?` param, embed quote in text + "q" tag |
## Acceptance Criteria
- [ ] Reaction count updates when new reactions arrive via relay
- [ ] Zap count/amount updates when new zaps arrive
- [ ] Reply count shows and updates
- [ ] Repost count shows and updates
- [ ] Kind 1 replies subscribed in interaction filters
- [ ] Quote menu item opens ComposeNoteDialog with quoted note
- [ ] Quote posts include "q" tag and embedded nostr: URI
- [ ] Long-press repost icon shows popup with who boosted
- [ ] Empty state for boosts popup: "No reposts yet"
- [ ] Compiles, spotless, tests pass
@@ -0,0 +1,246 @@
---
title: "feat: Desktop Search Spotlight + Unified Feed Header Bar"
type: feat
status: active
date: 2026-05-28
origin: docs/brainstorms/2026-05-28-feat-desktop-search-spotlight-brainstorm.md
---
# feat: Desktop Search Spotlight + Unified Feed Header Bar
## Overview
Add a global search spotlight overlay (Cmd+F) and a unified feed header bar that combines feed tabs with a search pill. Also fix the bug where "+ Add more" feeds only works from the Home feed by moving it to the sidebar.
Three components:
1. **SearchSpotlight** — global overlay with scrim, auto-focus, recent/saved searches, live results
2. **FeedHeaderBar** — unified feed tabs (My Feed | Global | pinned custom) + search pill, used on every feed column
3. **Sidebar "+ Add Feed"** — move from broken feed header to always-accessible sidebar
(see brainstorm: docs/brainstorms/2026-05-28-feat-desktop-search-spotlight-brainstorm.md)
## Problem Statement
1. **No quick search** — searching requires opening a full Search column. No spotlight/command-palette UX.
2. **Feed tabs disconnected from search** — the screenshot reference shows tabs + search in a unified bar, but current feed columns have separate headers with no search pill.
3. **"+ Add more" bug** — the button to add custom feeds only works on the Home feed; from Global or custom feeds it's broken/invisible.
## Proposed Solution
### Search Spotlight (Cmd+F)
Global overlay that dims content (50% scrim), shows a centered search card (~600dp wide, 20% from top):
- **Empty state:** Recent searches + saved searches from `SearchHistoryStore`
- **Typing:** Live people + note results (reuse `AdvancedSearchBarState` with 300ms debounce)
- **Result selection:** Opens new column (deck) or navigates (single-pane)
- **"Open full search"** link at bottom → opens Search column with current query
- **Keyboard:** Escape closes, arrow keys navigate results, Enter selects
### Unified Feed Header Bar
Replaces the current `ColumnHeader` on feed-type columns:
```
┌──────────────────────────────────────────────┐
│ [My Feed] [Global] [Custom1] 🔍 Search.. ⌘F │
└──────────────────────────────────────────────┘
```
- Left: feed tabs (Following + Global always, up to 3 pinned custom feeds)
- Right: compact SearchPill that opens the spotlight
- Active tab: `primary` color indicator
- 48dp height, `surfaceContainer` background
### Sidebar "+ Add Feed"
Move from feed column header to MainSidebar's FEEDS section — always accessible.
## Technical Approach
### Implementation Phases
#### Phase 1: SearchSpotlight Composable
New file: `desktopApp/.../ui/search/SearchSpotlight.kt`
**Architecture:**
- `Dialog` with custom `Surface` (not `AlertDialog` — need full layout control)
- Scrim: `Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.5f)).clickable { onDismiss() })`
- Search card: `Surface(shape = shapes.large, color = surface)` centered with `600.dp` max width
- Input: `BasicTextField` with pill decoration (matching SearchScreen pattern), auto-focused via `FocusRequester`
- State: Create `AdvancedSearchBarState(scope)` scoped to spotlight lifecycle. `DisposableEffect` stops subscriptions on close.
- Results: `LazyColumn` with sections (People max 5, Notes max 5), each item clickable
- History: Read from `SearchHistoryStore` on open
**Key composables:**
```kotlin
@Composable
fun SearchSpotlight(
localCache: DesktopLocalCache,
relayManager: DesktopRelayConnectionManager,
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator,
account: AccountState.LoggedIn,
searchHistoryStore: SearchHistoryStore,
onSelectProfile: (String) -> Unit, // pubkey hex
onSelectNote: (String) -> Unit, // note id hex
onSelectHashtag: (String) -> Unit, // tag
onOpenFullSearch: (String) -> Unit, // query text
onDismiss: () -> Unit,
)
```
**Success criteria:**
- [ ] Spotlight opens centered with scrim dimming background
- [ ] Input auto-focused on open
- [ ] Recent + saved searches shown before typing
- [ ] Live people/note results appear while typing (300ms debounce)
- [ ] Selecting result calls appropriate callback and closes
- [ ] Escape closes spotlight
- [ ] Arrow key navigation through results
- [ ] "Open full search" opens Search column with query
#### Phase 2: SearchPill Composable
New file: `desktopApp/.../ui/search/SearchPill.kt`
Small reusable pill:
```kotlin
@Composable
fun SearchPill(
onClick: () -> Unit,
modifier: Modifier = Modifier,
)
```
- Pill shape (999dp corners), `surfaceContainerHigh` background
- Search icon + "Search..." + "⌘F" hint
- Height: 36dp
- Hover highlight via `hoverHighlight()` modifier
**Success criteria:**
- [ ] Renders as compact pill with search icon and shortcut hint
- [ ] Click opens spotlight
- [ ] Hover highlights
#### Phase 3: FeedHeaderBar Composable
New file: `desktopApp/.../ui/search/FeedHeaderBar.kt`
Unified header for feed columns:
```kotlin
@Composable
fun FeedHeaderBar(
feedTabs: ImmutableList<FeedTab>,
activeFeedId: String,
onTabClick: (String) -> Unit,
onSearchClick: () -> Unit,
modifier: Modifier = Modifier,
)
data class FeedTab(
val id: String,
val label: String,
val isBuiltIn: Boolean = false, // Following/Global are built-in
)
```
**Layout:**
- `Row` with `surfaceContainer` background, 48dp height
- Left: scrollable `Row` of tab chips/buttons
- Right: `SearchPill` with fixed width
- Active tab: filled with `primary`, others `onSurfaceVariant`
- Divider at bottom: 1dp `outlineVariant`
**Feed tabs source:**
- "My Feed" (Following) — always first, `id = "following"`
- "Global" — always second, `id = "global"`
- Pinned custom feeds from `LocalFeedRepository.current` (max 3)
**Integration:** Replace `ColumnHeader` for `DeckColumnType.HomeFeed`, `DeckColumnType.GlobalFeed`, and `DeckColumnType.CustomFeed` in `DeckColumnContainer.kt`.
**Success criteria:**
- [ ] Feed tabs render with Following + Global + up to 3 pinned
- [ ] Active tab highlighted with primary color
- [ ] Tab click switches feed
- [ ] Search pill visible on right side
- [ ] Shown on all feed-type columns
#### Phase 4: Sidebar "+ Add Feed" + Keyboard Shortcut
**MainSidebar (DeckSidebar.kt):**
- Add "+ Add Feed" item at bottom of FEEDS section
- Click opens feed builder/drawer (existing `onOpenFeedsDrawer` callback)
- Styled as subtle text link with `+` icon
**Main.kt:**
- Add `Cmd+F` keyboard shortcut in MenuBar
- Add `showSearchSpotlight` state
- Render `SearchSpotlight` when `showSearchSpotlight` is true
- Wire result callbacks to `deckState.addColumn()` / `singlePaneState.navigate()`
**FeedScreen.kt:**
- Remove inline "+ Add more" button (now in sidebar)
**Success criteria:**
- [ ] Cmd+F opens spotlight from anywhere in the app
- [ ] "+ Add Feed" visible in sidebar FEEDS section
- [ ] "+ Add Feed" works regardless of current feed view
- [ ] Old "+ Add more" button removed from feed headers
## Acceptance Criteria
### Functional Requirements
- [ ] Cmd+F opens search spotlight overlay with scrim
- [ ] Spotlight shows recent + saved searches on open
- [ ] Live search results (people + notes) with 300ms debounce
- [ ] Selecting a profile opens it (new column in deck, navigate in single-pane)
- [ ] Selecting a note opens thread
- [ ] "Open full search" opens Search column with query
- [ ] Escape closes spotlight
- [ ] FeedHeaderBar shows feed tabs + search pill on all feed columns
- [ ] Tab switching works (Following ↔ Global ↔ Custom feeds)
- [ ] "+ Add Feed" in sidebar works from any screen
- [ ] Keyboard arrow navigation through spotlight results
### Non-Functional Requirements
- [ ] Spotlight opens in <100ms (no relay calls until user types)
- [ ] Scrim renders at 60fps
- [ ] Search results appear within 300ms of typing pause
- [ ] Compiles on all platforms (macOS, Windows, Linux)
- [ ] Spotless clean
## Dependencies
- `AdvancedSearchBarState` (commons/) — reuse, no changes needed
- `SearchHistoryStore` (desktop) — reuse, no changes needed
- `SearchFilterFactory` (desktop) — reuse for relay subscriptions
- `FeedDefinitionRepository` (commons/) — read pinned feeds for tabs
- `MainSidebar` — add "+ Add Feed" item
- `hoverHighlight()` modifier — already created in visual personality PR
## Files Affected
| File | Action |
|------|--------|
| New: `desktopApp/.../ui/search/SearchSpotlight.kt` | Spotlight overlay |
| New: `desktopApp/.../ui/search/SearchPill.kt` | Compact pill component |
| New: `desktopApp/.../ui/search/FeedHeaderBar.kt` | Unified feed tabs + search |
| `desktopApp/.../Main.kt` | Cmd+F shortcut, spotlight state, render, result callbacks |
| `desktopApp/.../ui/deck/DeckColumnContainer.kt` | Use FeedHeaderBar for feed columns |
| `desktopApp/.../ui/deck/DeckSidebar.kt` | Add "+ Add Feed" to FEEDS section |
| `desktopApp/.../ui/FeedScreen.kt` | Remove "+ Add more" if present, adapt header |
| `desktopApp/.../ui/deck/ColumnHeader.kt` | May need trailing slot for non-feed columns |
## Sources & References
### Origin
- **Brainstorm:** [docs/brainstorms/2026-05-28-feat-desktop-search-spotlight-brainstorm.md](docs/brainstorms/2026-05-28-feat-desktop-search-spotlight-brainstorm.md) — Key decisions: Cmd+F spotlight, unified FeedHeaderBar, sidebar "+ Add Feed"
### Internal References
- `SearchScreen.kt` — existing full search column (keep as-is)
- `AdvancedSearchBarState.kt` — reusable search state management
- `SearchHistoryStore.kt` — recent/saved search persistence
- `SearchFilterFactory.kt` — NIP-50 relay filter construction
- `FeedDefinitionRepository.kt` — pinned custom feeds source
- `MainSidebar (DeckSidebar.kt)` — sidebar where "+ Add Feed" moves to
@@ -0,0 +1,751 @@
---
title: "feat: Desktop Visual Personality Overhaul"
type: feat
status: active
date: 2026-05-28
origin: docs/brainstorms/2026-05-27-desktop-visual-personality-brainstorm.md
---
# feat: Desktop Visual Personality Overhaul
## Enhancement Summary
**Deepened on:** 2026-05-28
**Research agents used:** compose-expert, desktop-expert, compose-stability-diagnostics, compose-modifier-and-layout-style, best-practices-researcher, color-audit-explorer
### Critical Fixes (from research)
1. **`ColorScheme.isLight` doesn't exist in M3** — use `LocalIsDarkTheme` CompositionLocal instead
2. **Hover modifier won't compile**`@Composable` lambda can't invoke in `drawBehind`; `shape.topStart` invalid on generic Shape. Rewrite using `onPointerEvent` (codebase convention)
3. **Sidebar animation thrashes column widths**`fitColumnsToWidth()` fires every frame during 240→56dp transition. Add 300ms debounce
4. **NoteCard/NoteCardSkeleton missing `modifier` parameter** — required for reusable composables
5. **AccountSwitcher dropdown offset hardcoded to 48dp** — needs dynamic offset based on sidebar width
### Key Improvements
- Use `onPointerEvent` for hover (project convention, not `composed{}`)
- Add `clipToBounds()` on sidebar during animation
- Add keyboard shortcuts: `Cmd/Ctrl+B` (sidebar toggle), `Cmd/Ctrl+K` (search focus)
- Gate SegmentedButton on column width > 400dp
- `remember` BorderStroke to avoid instance churn defeating skip optimization
- ShimmerPlaceholder in `commons/commonMain` (pure M3, no platform APIs)
- Use `LocalScrollbarStyle` for scrollbar customization (built-in API)
- Add `LocalIsDarkTheme` CompositionLocal alongside `LocalSpacing`
- SegmentedButton confirmed available and stable (already used in AppDrawer.kt)
- 32 inline `Color()` literals to migrate across 10 files
### Color Audit Results
- **32 inline Color() constructors** across 10 desktop files (status indicators: green/red/amber)
- **48 `.copy(alpha=)` patterns** across 18 files (standardize with alpha constants)
- **Key files:** DevSettingsSection (10), LoginProgressSteps (5), NamecoinSettingsSection (4), TorStatusIndicator (3)
## Overview
Transform Amethyst Desktop from an OS-native-adaptive look into a distinctly branded experience. Replace per-OS color schemes with a unified Amethyst identity (cyan/blue accent), redesign the sidebar to 240dp with labels, restyle cards to flat+border, add hover effects, skeleton loading, and polish all UI components.
Desktop-only scope — Android unchanged. (see brainstorm: Decision #5)
## Problem Statement
The current desktop theme adapts to each OS (macOS, GNOME, KDE, Windows) with per-OS colors, shapes, and fonts. While technically impressive, this makes the app visually neutral — it doesn't feel like "Amethyst." The 56dp icon-only sidebar wastes desktop screen space and hurts discoverability. Cards use shadow elevation that renders inconsistently on desktop JVM/Skia.
## Proposed Solution
A 6-phase implementation that progresses from theme foundation → spacing → sidebar → cards → column headers → polish. Each phase builds on the previous, and the app remains functional throughout.
## Technical Approach
### Architecture
The overhaul touches 3 layers:
1. **Theme layer** (`desktopApp/.../platform/`) — ColorScheme, Typography, Shapes
2. **Layout layer** (`desktopApp/.../ui/deck/`) — Sidebar, ColumnHeader, DeckLayout
3. **Component layer** (`desktopApp/.../ui/note/`, shared composables) — NoteCard, action bar, dialogs
New additions:
- `AmethystSpacing` CompositionLocal for design tokens
- `Modifier.hoverHighlight()` shared hover utility
- `ShimmerPlaceholder` composable for loading states
### Implementation Phases
---
#### Phase 1: Theme Foundation
**Goal:** Replace all per-OS color schemes with unified Amethyst brand. Unified shapes and typography weights.
**Files:**
| File | Action |
|------|--------|
| `desktopApp/.../platform/PlatformColorScheme.kt` | Replace 10 per-OS functions with `amethystLight()` + `amethystDark()` |
| `desktopApp/.../platform/PlatformShapes.kt` | Replace per-OS shapes with unified tokens |
| `desktopApp/.../platform/PlatformTypography.kt` | Standardize weight scale across all OS |
| `commons/.../ui/theme/Colors.kt` | Add cyan/blue brand constants |
**Color Scheme (see brainstorm: Layer 1):**
```kotlin
// commons/ui/theme/Colors.kt — new constants
val AmethystBlue = Color(0xFF0096FF) // light primary
val AmethystBlueDark = Color(0xFF4DB8FF) // dark primary
val AmethystPurple = Color(0xFF9A82DB) // tertiary (heritage)
// PlatformColorScheme.kt — amethystLight()
fun amethystLight() = lightColorScheme(
primary = AmethystBlue, // #0096FF
onPrimary = Color.White,
primaryContainer = AmethystBlue.copy(alpha = 0.12f).compositeOver(Color.White),
onPrimaryContainer = AmethystBlue,
secondary = Color(0xFF5E8FAD), // desaturated blue
onSecondary = Color.White,
tertiary = AmethystPurple, // heritage purple
onTertiary = Color.White,
background = Color(0xFFF2F2F7), // light gray
onBackground = Color(0xFF1C1C1E),
surface = Color.White,
onSurface = Color(0xFF1C1C1E),
surfaceVariant = Color(0xFFF0F0F5),
onSurfaceVariant = Color(0xFF6E6E73),
surfaceContainer = Color(0xFFF7F7FA),
surfaceContainerHigh = Color(0xFFEEEEF2),
surfaceContainerHighest = Color(0xFFE5E5EA),
surfaceContainerLow = Color(0xFFFAFAFC),
outline = Color(0xFFE0E0E0),
outlineVariant = Color(0xFFEBEBEB),
error = Color(0xFFBA1A1A),
onError = Color.White,
errorContainer = Color(0xFFFFDAD6),
onErrorContainer = Color(0xFF410002),
)
// amethystDark() — same structure with dark values
fun amethystDark() = darkColorScheme(
primary = AmethystBlueDark, // #4DB8FF
onPrimary = Color.White,
primaryContainer = AmethystBlueDark.copy(alpha = 0.16f).compositeOver(Color(0xFF1E1E1E)),
onPrimaryContainer = AmethystBlueDark,
secondary = Color(0xFF7EAEC8),
onSecondary = Color.White,
tertiary = Color(0xFFB6A0E0), // lighter purple
onTertiary = Color.White,
background = Color(0xFF121212),
onBackground = Color(0xFFE5E5EA),
surface = Color(0xFF1E1E1E),
onSurface = Color(0xFFE5E5EA),
surfaceVariant = Color(0xFF2A2A2A),
onSurfaceVariant = Color(0xFF9E9EA3),
surfaceContainer = Color(0xFF252525),
surfaceContainerHigh = Color(0xFF2E2E2E),
surfaceContainerHighest = Color(0xFF383838),
surfaceContainerLow = Color(0xFF1A1A1A),
outline = Color(0xFF3A3A3A),
outlineVariant = Color(0xFF2E2E2E),
error = Color(0xFFFFB4AB),
onError = Color(0xFF690005),
errorContainer = Color(0xFF93000A),
onErrorContainer = Color(0xFFFFDAD6),
)
```
**`resolve()` simplification:**
```kotlin
fun resolve(isDark: Boolean): ColorScheme =
if (isDark) amethystDark() else amethystLight()
```
Remove: `macOsLight()`, `macOsDark()`, `gnomeLight()`, `gnomeDark()`, `kdeLight()`, `kdeDark()`, `windowsLight()`, `windowsDark()`, `genericLight()`, `genericDark()`, `darkenForLight()`, accent parameter from `resolve()`. Keep `onAccent()` only if still needed elsewhere.
**Shapes (unified):**
```kotlin
// PlatformShapes.kt
val current = Shapes(
extraSmall = RoundedCornerShape(6.dp),
small = RoundedCornerShape(8.dp),
medium = RoundedCornerShape(12.dp),
large = RoundedCornerShape(16.dp),
extraLarge = RoundedCornerShape(24.dp),
)
```
Remove all per-OS shape functions.
**Typography weights:**
- Display: `FontWeight.Light` (300)
- Headline: `FontWeight.SemiBold` (600) — keep
- Title: `FontWeight.SemiBold` / `FontWeight.Medium` — keep
- Body: `FontWeight.Normal` — keep
- Label: `FontWeight.Medium` — keep
- Standardize letter spacing to `-0.3sp` for display/headline across all OS
Keep per-OS font family detection — fonts ARE platform-specific.
**PlatformTheme.kt changes:**
- Remove accent resolution (`PlatformAccent.systemAccent()`)
- Simplify to `PlatformColorScheme.resolve(isDark)` (no accent param)
- Keep `titleBarInsetTop` and `applyNativeWindowChrome()`
**Migration:** Grep `desktopApp/` for inline `Color(0xFF...)` constructors and replace with `MaterialTheme.colorScheme.*` references where possible.
**Success criteria:**
- [ ] App compiles with unified color scheme on all OS
- [ ] Dark/light mode toggle works correctly
- [ ] No per-OS color/shape variance remains
- [ ] Typography weights follow standardized scale
---
#### Phase 2: Spacing System
**Goal:** Create a design token system for consistent spacing across all desktop UI.
**Files:**
| File | Action |
|------|--------|
| New: `desktopApp/.../ui/theme/AmethystSpacing.kt` | Create spacing tokens + CompositionLocal |
| `desktopApp/.../platform/PlatformTheme.kt` | Provide `LocalSpacing` |
**Implementation:**
```kotlin
// AmethystSpacing.kt
@Immutable
data class AmethystSpacing(
val xxs: Dp = 2.dp,
val xs: Dp = 4.dp,
val sm: Dp = 8.dp,
val md: Dp = 12.dp,
val lg: Dp = 16.dp,
val xl: Dp = 24.dp,
val xxl: Dp = 32.dp,
val cardPadding: Dp = 16.dp,
val cardGap: Dp = 8.dp,
val sidebarExpandedWidth: Dp = 240.dp,
val sidebarCollapsedWidth: Dp = 56.dp,
val columnHeaderHeight: Dp = 48.dp,
)
val LocalSpacing = staticCompositionLocalOf { AmethystSpacing() }
val MaterialTheme.spacing: AmethystSpacing
@Composable @ReadOnlyComposable
get() = LocalSpacing.current
```
**In PlatformTheme.kt:**
```kotlin
val LocalIsDarkTheme = staticCompositionLocalOf { false }
// In PlatformMaterialTheme:
CompositionLocalProvider(
LocalSpacing provides AmethystSpacing(),
LocalIsDarkTheme provides isDark,
) {
MaterialTheme(colorScheme, typography, shapes, content)
}
```
> **Research insight:** `ColorScheme.isLight` does not exist in Material3 Compose (M2 only). Provide `LocalIsDarkTheme` alongside `LocalSpacing` so dialogs/components can detect dark mode. `staticCompositionLocalOf` is correct — matches `LocalDesktopCache`, `LocalRelayManager` pattern in Main.kt.
**Success criteria:**
- [ ] `MaterialTheme.spacing.*` accessible throughout desktop app
- [ ] `LocalIsDarkTheme.current` available for dark mode detection
- [ ] No functional changes — this is infrastructure for subsequent phases
---
#### Phase 3: Sidebar Redesign
**Goal:** Transform the 56dp icon-only sidebar into a 240dp labeled navigation with avatar, custom feeds, and animated collapse.
**Files:**
| File | Action |
|------|--------|
| `desktopApp/.../ui/deck/DeckSidebar.kt` | Major rewrite — wide layout, labels, sections |
| `desktopApp/.../ui/account/AccountSwitcherDropdown.kt` | Move avatar to sidebar top, restyle |
| `desktopApp/.../ui/deck/DeckLayout.kt` | Adjust sidebar width to use animated state |
**Current sidebar structure (DeckSidebar.kt):**
- 56.dp wide Column
- AccountSwitcherDropdown (person icon) → Add Column → Import → Spacer → Bunker/Tor → Settings
- No labels, no active state indicator, no feeds section
**New sidebar structure:**
```
┌─────────────────────────┐
│ [Avatar 40dp] Username │ ← tappable, opens account switcher
│ @npub... │
├─────────────────────────┤
│ 🏠 Home │ ← main nav items
│ 🔍 Search │
│ ✉️ Messages │
│ 💰 Wallet │
│ 🔖 Bookmarks │
│ ⚙️ Settings │
├─────────────────────────┤
│ FEEDS │ ← section header
│ 📷 Photography │ ← from FeedDefinitionRepository
│ 🏡 Homestead │
│ 🏛️ Architecture │
├─────────────────────────┤
│ │ ← spacer
│ [Bunker] [Tor] │ ← status indicators
│ ◀ Collapse │ ← collapse toggle
└─────────────────────────┘
```
**Key implementation details:**
1. **Animated width:** `animateDpAsState(if (expanded) 240.dp else 56.dp, tween(300, easing = FastOutSlowInEasing))` + `Modifier.clipToBounds()` on sidebar container
2. **Column width debounce:** DeckLayout's `fitColumnsToWidth()` fires via `LaunchedEffect(availableWidthDp)`. During sidebar animation, this thrashes every frame. Add `snapshotFlow { availableWidthDp }.debounce(300)` or gate on animation completion.
3. **Collapse persistence:** `java.util.prefs.Preferences` key `sidebar_collapsed`
3. **Nav items:** Map `DeckColumnType` to sidebar entries with icon + label
4. **Active state:** `primaryContainer` background pill with `primary` tinted icon + `SemiBold` text
5. **Hover state:** `onSurface.copy(alpha = 0.08f)` background on hover (see Phase 6 for shared utility)
6. **Feeds section:** Read from `LocalFeedRepository.current``groupedFeeds.pinned + myFeeds`
7. **Feed icons:** Each `FeedDefinition` gets an icon field (Material Symbol codepoint). Default to a generic feed icon.
8. **Avatar:** 40dp circular, loaded from `LocalCache` user metadata. Fallback to `Person` icon.
9. **Account switcher:** Tap avatar opens existing `AccountSwitcherDropdown` (restyled with rounded corners). **Fix dropdown offset:** current hardcoded `DpOffset(x = 48.dp)` must become dynamic based on sidebar width.
10. **Collapsed mode:** Only icons shown, no labels, tooltip on hover. Width animates to 56dp. **Accessibility:** Add `contentDescription` to icons when labels are hidden.
11. **Keyboard shortcut:** `Cmd/Ctrl+B` to toggle sidebar collapse (add to MenuBar in Main.kt).
**Label fade animation:**
```kotlin
AnimatedVisibility(
visible = expanded,
enter = fadeIn(tween(200, delayMillis = 100)),
exit = fadeOut(tween(100)),
) {
Text(label, style = MaterialTheme.typography.labelLarge)
}
```
**Success criteria:**
- [ ] Sidebar shows 240dp with avatar + nav items + feeds
- [ ] Active item has cyan pill indicator
- [ ] Collapse toggle animates smoothly
- [ ] Collapse state persists across restarts
- [ ] Custom feeds appear in FEEDS section
- [ ] Account switcher works from avatar tap
---
#### Phase 4: Card & Action Bar Refinement
**Goal:** Restyle NoteCard to flat+border, update action bar to match screenshot, add image corner treatment.
**Files:**
| File | Action |
|------|--------|
| `desktopApp/.../ui/note/NoteCard.kt` | Flat + border, 16dp padding, action bar, image corners |
**Card changes:**
> **Research fix:** `BorderStroke` creates new instance each recomposition, defeating skip. Remember it. NoteCard must accept `modifier` parameter.
```kotlin
@Composable
fun NoteCard(
// ... other params,
modifier: Modifier = Modifier,
) {
val border = remember(MaterialTheme.colorScheme.outlineVariant) {
BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)
}
OutlinedCard(
modifier = modifier,
colors = CardDefaults.outlinedCardColors(containerColor = MaterialTheme.colorScheme.surface),
border = border,
shape = MaterialTheme.shapes.medium, // 12dp
) {
Column(modifier = Modifier.padding(MaterialTheme.spacing.cardPadding)) { ... }
}
}
```
> **Scope note:** 10+ files use `CardDefaults.cardColors` across desktop (NotificationsScreen, ChessScreen, ProfileInfoCard, WalletColumnScreen, etc.). All need consistent conversion to OutlinedCard.
**Action bar redesign:**
- Icon size: 20dp (down from current)
- Layout: `Row` with `Arrangement.spacedBy(16.dp)`
- Each action: Icon + count `Text(labelSmall)` in a `Row(spacing = 4.dp)`
- Active states: filled icon + `primary` color (liked heart, zapped lightning, reposted)
- Icons: comment, heart, zap (lightning bolt), repost, share
**Image treatment:**
- All inline images: `clip(RoundedCornerShape(8.dp))`
- Images get `padding(top = 8.dp)` for spacing from text above
- Already partially implemented (8.dp corners exist) — ensure consistency
**Card gaps:**
- Remove any `HorizontalDivider` between cards in feed lists
- Use `Arrangement.spacedBy(MaterialTheme.spacing.cardGap)` (8dp) in `LazyColumn`
**Success criteria:**
- [ ] Cards show flat white surface with subtle border
- [ ] 16dp internal padding
- [ ] Action bar uses smaller icons with count badges
- [ ] Images have rounded corners
- [ ] No divider lines between cards
---
#### Phase 5: Column Headers & Search
**Goal:** Restyle per-column headers to match screenshot aesthetic, add rounded pill search bar, segmented feed toggles.
**Files:**
| File | Action |
|------|--------|
| `desktopApp/.../ui/deck/ColumnHeader.kt` | Restyle: height, background, typography |
| `desktopApp/.../ui/deck/FeedScreen.kt` (or equivalent) | Add SegmentedButton for My Feed / Global toggle |
| Search composable | Rounded pill styling |
**ColumnHeader changes:**
```kotlin
// Before: surfaceVariant.copy(alpha = 0.5f), height = 40.dp
// After:
Surface(
color = MaterialTheme.colorScheme.surfaceContainer,
modifier = Modifier.height(MaterialTheme.spacing.columnHeaderHeight) // 48dp
) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
// icon + title with titleMedium style
}
}
```
**Search bar (rounded pill):**
```kotlin
TextField(
modifier = Modifier
.clip(RoundedCornerShape(999.dp))
.background(MaterialTheme.colorScheme.surfaceContainerHigh),
colors = TextFieldDefaults.colors(
unfocusedContainerColor = Color.Transparent,
focusedContainerColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
),
leadingIcon = { Icon(Search) },
trailingIcon = { Text("⌘K", style = labelSmall, color = onSurfaceVariant) },
placeholder = { Text("Search notes, profiles, hashtags...") },
)
```
**Feed toggles (SegmentedButton):**
```kotlin
SingleChoiceSegmentedButtonRow {
SegmentedButton(selected = isMine, onClick = { ... }, shape = SegmentedButtonDefaults.itemShape(0, 2)) {
Text("My Feed")
}
SegmentedButton(selected = !isMine, onClick = { ... }, shape = SegmentedButtonDefaults.itemShape(1, 2)) {
Text("Global")
}
}
```
> **Research insight:** SegmentedButton confirmed available and stable in CMP 1.10.3 (already used in AppDrawer.kt). At `MIN_COLUMN_WIDTH` (300dp), SegmentedButton + icon + close button is tight. Gate visibility on column width > 400dp.
> **Keyboard shortcut:** Wire `Cmd/Ctrl+K` to focus the search column's text field in MenuBar (Main.kt).
**Success criteria:**
- [ ] Column headers have consistent 48dp height with surfaceContainer background
- [ ] Search bar is rounded pill with keyboard shortcut hint (Cmd+K wired)
- [ ] Feed toggle uses M3 SegmentedButton (hidden in narrow columns < 400dp)
---
#### Phase 6: Polish & Micro-interactions
**Goal:** Add hover effects, skeleton loading, styled tooltips/dialogs/context menus/scrollbars/snackbars.
**Files:**
| File | Action |
|------|--------|
| New: `desktopApp/.../ui/theme/HoverModifiers.kt` | `Modifier.hoverHighlight()` (desktop-only, `onPointerEvent`) |
| New: `commons/.../ui/components/ShimmerPlaceholder.kt` | Skeleton shimmer (pure M3, shared) |
| `desktopApp/.../ui/note/NoteCard.kt` | Add hover effect |
| `desktopApp/.../ui/deck/DeckSidebar.kt` | Add hover to nav items |
| Dialog composables | Surface color in dark mode |
| DropdownMenu usages | Styled corners + border |
**Hover utility (uses `onPointerEvent` — codebase convention from AppDrawer.kt, ChatPane.kt):**
> **Research fix:** Original plan used `composed{}` + `@Composable` lambda in `drawBehind` — won't compile. `shape.topStart` is invalid on generic `Shape`. Rewritten to match project patterns.
```kotlin
// HoverModifiers.kt (desktopApp/jvmMain — desktop-only, uses ExperimentalComposeUiApi)
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun Modifier.hoverHighlight(
hoverColor: Color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f),
shape: Shape = MaterialTheme.shapes.medium,
): Modifier {
val color = remember { mutableStateOf(Color.Transparent) }
return this
.onPointerEvent(PointerEventType.Enter) { color.value = hoverColor }
.onPointerEvent(PointerEventType.Exit) { color.value = Color.Transparent }
.drawBehind { drawRect(color.value) }
// drawBehind reads color.value in draw phase only — no recomposition on hover
}
```
> **Performance note:** State read deferred to draw phase via `drawBehind`. No recomposition on hover enter/exit — only draw invalidation. Safe for LazyColumn with 50+ cards.
**Skeleton shimmer:**
```kotlin
// ShimmerPlaceholder.kt
@Composable
fun ShimmerPlaceholder(modifier: Modifier = Modifier) {
val transition = rememberInfiniteTransition()
val translateAnim by transition.animateFloat(
initialValue = 0f,
targetValue = 1000f,
animationSpec = infiniteRepeatable(tween(1200, easing = LinearEasing)),
)
val brush = Brush.linearGradient(
colors = listOf(
MaterialTheme.colorScheme.surfaceContainerHigh,
MaterialTheme.colorScheme.surfaceContainer,
MaterialTheme.colorScheme.surfaceContainerHigh,
),
start = Offset(translateAnim - 500f, 0f),
end = Offset(translateAnim, 0f),
)
Box(modifier.background(brush, MaterialTheme.shapes.medium))
}
@Composable
fun NoteCardSkeleton() {
OutlinedCard(
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
shape = MaterialTheme.shapes.medium,
) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
ShimmerPlaceholder(Modifier.size(32.dp).clip(CircleShape)) // avatar
Column(Arrangement.spacedBy(4.dp)) {
ShimmerPlaceholder(Modifier.width(120.dp).height(12.dp)) // name
ShimmerPlaceholder(Modifier.width(60.dp).height(10.dp)) // timestamp
}
}
ShimmerPlaceholder(Modifier.fillMaxWidth().height(14.dp)) // text line 1
ShimmerPlaceholder(Modifier.fillMaxWidth(0.7f).height(14.dp)) // text line 2
ShimmerPlaceholder(Modifier.fillMaxWidth().height(180.dp)) // image
}
}
}
```
**Dialog styling (dark mode):**
> **Research fix:** `ColorScheme.isLight` does not exist in M3. Use `LocalIsDarkTheme` from Phase 2.
```kotlin
// Wrap existing Dialog composables
Dialog(onDismissRequest = ...) {
Surface(
color = if (LocalIsDarkTheme.current)
MaterialTheme.colorScheme.surfaceContainerHighest
else
MaterialTheme.colorScheme.surface,
shape = MaterialTheme.shapes.large, // 16dp
tonalElevation = 6.dp,
) { ... }
}
```
**Styled context menus:**
```kotlin
DropdownMenu(
modifier = Modifier
.border(1.dp, MaterialTheme.colorScheme.outlineVariant, MaterialTheme.shapes.medium)
.clip(MaterialTheme.shapes.medium),
...
)
```
**Styled tooltips:**
```kotlin
TooltipBox(
tooltip = {
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHighest,
shape = MaterialTheme.shapes.small,
shadowElevation = 2.dp,
) {
Text(text, modifier = Modifier.padding(8.dp, 4.dp), style = labelSmall)
}
},
...
)
```
**Snackbar (extract shared composable — two SnackbarHost instances exist: Main.kt and ChatPane.kt):**
```kotlin
// commons/commonMain — pure M3, no platform APIs
@Composable
fun AmethystSnackbarHost(
hostState: SnackbarHostState,
modifier: Modifier = Modifier,
) {
SnackbarHost(hostState, modifier) { data ->
Snackbar(
snackbarData = data,
shape = MaterialTheme.shapes.medium,
containerColor = MaterialTheme.colorScheme.inverseSurface,
contentColor = MaterialTheme.colorScheme.inverseOnSurface,
actionColor = MaterialTheme.colorScheme.primary,
)
}
}
```
> **Research insight:** Plan originally used `surfaceContainerHighest` — may lack WCAG AA contrast in light mode. `inverseSurface`/`inverseOnSurface` provides high contrast in both modes (M3 standard snackbar pattern).
**Scrollbar styling (via built-in `LocalScrollbarStyle`):**
```kotlin
// Provide at theme root in PlatformMaterialTheme
CompositionLocalProvider(
LocalScrollbarStyle provides ScrollbarStyle(
minimalHeight = 48.dp, thickness = 4.dp,
shape = RoundedCornerShape(2.dp), hoverDurationMillis = 300,
unhoverColor = Color.Black.copy(alpha = 0.12f),
hoverColor = Color.Black.copy(alpha = 0.50f),
)
) { ... }
```
**Success criteria:**
- [ ] Cards highlight on hover
- [ ] Sidebar items highlight on hover
- [ ] Skeleton shimmer shown during feed loading
- [ ] Dialogs use elevated surface in dark mode
- [ ] Context menus have rounded corners + border
- [ ] Tooltips are styled
- [ ] Snackbar matches brand
---
## System-Wide Impact
### Interaction Graph
Theme changes cascade through `PlatformMaterialTheme` → all `MaterialTheme.colorScheme.*` / `.shapes.*` / `.typography.*` consumers. No callbacks or middleware — purely declarative recomposition.
### Error Propagation
No new error paths. Theme resolution is pure function (no IO). Sidebar collapse preference uses `java.util.prefs.Preferences` (already used for custom feeds — failure falls back to expanded).
### State Lifecycle Risks
- Sidebar collapse state: stored in `Preferences`, read once at composition. No partial state risk.
- Feed definitions: already managed by `FeedDefinitionRepository` with `StateFlow`. Sidebar reads same flow.
- Account avatar: loaded from `LocalCache` metadata — may be null initially (fallback to icon).
### API Surface Parity
No external API changes. All changes are internal UI.
### Integration Test Scenarios
1. **Dark/light toggle:** Switch modes mid-session → all colors update (no cached old scheme)
2. **Sidebar collapse + window resize:** Collapse sidebar, resize window narrow → columns should not clip
3. **Custom feed CRUD:** Create/delete feed → sidebar FEEDS section updates live
4. **Account switch:** Switch account → avatar + username in sidebar update
5. **Feed loading:** Navigate to a feed → skeleton shimmer shows → cards render
## Acceptance Criteria
### Functional Requirements
- [ ] Unified Amethyst color scheme (cyan/blue accent) on all platforms
- [ ] Dark/light mode fully functional with proper contrast
- [ ] 240dp sidebar with icon + label navigation items
- [ ] Sidebar collapse/expand with smooth animation
- [ ] Collapse state persists across app restarts
- [ ] Avatar + username at top of sidebar with account switcher
- [ ] Custom feeds section in sidebar reading from FeedDefinitionRepository
- [ ] Flat cards with subtle border (no shadow)
- [ ] 16dp card padding, 12dp card corners
- [ ] Action bar with smaller icons and count badges
- [ ] Rounded images with 8dp corners
- [ ] Per-column headers with consistent 48dp height
- [ ] Rounded pill search bar with keyboard shortcut hint
- [ ] M3 SegmentedButton for feed toggles
- [ ] Hover effects on cards and sidebar items
- [ ] Skeleton shimmer during feed loading
- [ ] Styled tooltips, context menus, dialogs, snackbars
### Non-Functional Requirements
- [ ] App compiles and runs on macOS, Windows, Linux
- [ ] No regression in existing functionality
- [ ] Font rendering quality maintained (per-OS fonts preserved)
- [ ] Smooth animations (sidebar collapse, hover) at 60fps
### Quality Gates
- [ ] `./gradlew :desktopApp:compileKotlin` passes
- [ ] `./gradlew spotlessApply` clean
- [ ] Manual visual QA in dark and light modes
- [ ] Sidebar collapse/expand tested
## Dependencies & Prerequisites
- No external library additions — everything uses existing Material3 + Compose APIs
- `FeedDefinitionRepository` and custom feeds infrastructure already exist
- `AccountSwitcherDropdown` already exists — needs restyling, not rewriting
- Material Symbols font subset may need new icons for feed types (run `./tools/material-symbols-subset/subset.sh`)
## Risk Analysis & Mitigation
| Risk | Likelihood | Impact | Mitigation |
|------|-----------|--------|-----------|
| Color contrast issues in dark mode | Medium | Medium | Test with accessibility contrast checker |
| Sidebar animation jank on Linux | Low | Low | Use `tween(250)` easing, test on GNOME |
| Breaking existing hover behavior | Low | Medium | Audit all `pointerInput` / `onPointerEvent` usages first |
| Missing Material Symbol icons | Medium | Low | Run subset script, add needed codepoints |
| Inline Color() literals bypassing theme | Medium | Low | Grep audit + replace in Phase 1 |
## Migration Checklist
- [ ] Remove per-OS color scheme functions (macOsLight, gnomeDark, etc.)
- [ ] Remove `PlatformAccent` accent resolution (no longer needed)
- [ ] Remove per-OS shape variants
- [ ] Grep for `Color(0xFF` in `desktopApp/` — replace with theme references
- [ ] Remove `darkenForLight()` helper
- [ ] Update any tests referencing old color values
## Sources & References
### Origin
- **Brainstorm document:** [docs/brainstorms/2026-05-27-desktop-visual-personality-brainstorm.md](docs/brainstorms/2026-05-27-desktop-visual-personality-brainstorm.md) — Key decisions: cyan/blue accent (#0096FF), flat+border cards, 240dp collapsible sidebar, Amethyst-branded over OS-native
### Internal References
- `PlatformColorScheme.kt` — current per-OS scheme implementation
- `PlatformTheme.kt` — theme composition entry point
- `DeckSidebar.kt` — current 56dp icon sidebar
- `NoteCard.kt` — current card styling
- `ColumnHeader.kt` — current column header (40dp, surfaceVariant)
- `AccountSwitcherDropdown.kt` — existing account switcher
- `FeedDefinitionRepository.kt` — custom feed data source
- `FeedsDrawerTab.kt` — current feed browsing UI
### External References
- [Material Design 3 Color System](https://m3.material.io/styles/color/system/overview)
- [Material Design 3 Elevation](https://m3.material.io/styles/elevation/applying-elevation)
- [Custom Design Systems in Compose](https://developer.android.com/develop/ui/compose/designsystems/custom)
@@ -0,0 +1,175 @@
---
title: "fix: Feed Header Bar Layout + Inline Search UX"
type: fix
status: active
date: 2026-05-28
origin: docs/brainstorms/2026-05-28-feat-desktop-search-spotlight-brainstorm.md
---
# fix: Feed Header Bar Layout + Inline Search UX
## Overview
Fix 5 issues with the feed header bar and search experience introduced in the visual personality overhaul.
## Problems
| # | Issue | Root Cause |
|---|-------|-----------|
| 1 | Feed tabs take too much space, search not centered | `Row` with `SpaceBetween` pushes search to far right. Tabs + "+ More" consume all left space |
| 2 | Clicking search pill doesn't open search | `onSearchClick` callback not wired from `DeckColumnContainer``FeedScreen``FeedTabsHeader` |
| 3 | Search opens as Dialog overlay (wrong UX) | `SearchSpotlight` uses `Dialog()` which creates separate AWT window. Should be inline expansion of the pill with dropdown + background blur |
| 4 | Follow tabs can't be clicked | FilterChip `onClick` calls `onNavigateToFeed` for custom feeds, but the callback only handles `FeedSource.Filter` — other source types silently ignored |
| 5 | Header doesn't match screenshot card aesthetic | Header is a plain `Row` with padding. Should be a card-like `Surface` with rounded corners and visual separation |
## Proposed Solution
### Fix 1: Redesign FeedTabsHeader layout
**File:** `FeedScreen.kt``FeedTabsHeader`
New layout — compact tabs on left, search pill takes center weight, compose on right:
```
┌─────────────────────────────────────────────────────┐
│ [Following][Global][+] 🔍 Search notes... ⌘F ✏️ │
└─────────────────────────────────────────────────────┘
```
- Feed tabs: compact `FilterChip` with just emoji+name, `Arrangement.spacedBy(4.dp)`
- Remove "+ More" chip (moved to sidebar already)
- SearchPill: `Modifier.weight(1f)` so it fills remaining center space
- Compose button: stays on far right
- Wrap entire header in `Surface(shape = shapes.medium, color = surfaceContainer)` with 12dp padding
### Fix 2: Wire onSearchClick callback
**File:** `DeckColumnContainer.kt`
Pass `onSearchClick = { showSearchSpotlight = true }` through `FeedScreen` call sites. The `showSearchSpotlight` state is already at Main.kt level — need to pass it down as callback.
Simplest approach: add `onSearchClick` param to `MainContent` → column container → FeedScreen.
### Fix 3: Replace Dialog with inline search expansion
**File:** `SearchSpotlight.kt` → Delete. Replace with inline expansion in `SearchPill.kt`
Instead of a Dialog overlay, the SearchPill itself expands:
**Collapsed (default):**
```
🔍 Search notes, profiles... ⌘F
```
**Expanded (on click or Cmd+F):**
```
┌────────────────────────────────────────┐
│ 🔍 [typing here...] ⌘F │
├────────────────────────────────────────┤
│ Recent │
│ 📝 bitcoin lightning │
│ 📝 @fiatjaf │
├────────────────────────────────────────┤
│ Saved │
│ ⭐ Nostr development │
└────────────────────────────────────────┘
+ background blur/dim behind dropdown
```
Implementation:
- `SearchPill` gains `expanded: Boolean` state
- When expanded: pill becomes `BasicTextField` with same shape, a `DropdownMenu` or `Popup` appears below with history/results
- Background: `Box(Modifier.fillMaxSize().background(Color.Black.copy(0.3f)))` rendered at the parent level when expanded
- Clicking outside or pressing Escape collapses
- `Cmd+F` sets expanded = true on the active feed column's SearchPill
### Fix 4: Fix follow tab click handlers
**File:** `FeedScreen.kt``FeedTabsHeader`
Current `onNavigateToFeed` callback only handles `FeedSource.Filter`. Need to also handle `FeedSource.Following` and `FeedSource.Global` by calling `onFeedModeChange` directly in the chip onClick (already done for the `when` branches — the bug is that custom feed chips with non-Filter sources silently do nothing).
Check: is `feed.source` ever something other than `Following`, `Global`, or `Filter`? If so, add handling.
### Fix 5: Card-based header design
**File:** `FeedScreen.kt``FeedTabsHeader`
Wrap the header Row in a `Surface`:
```kotlin
Surface(
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.surface,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
modifier = Modifier.fillMaxWidth().padding(horizontal = sidePadding, vertical = 8.dp),
) {
Row(
modifier = Modifier.padding(8.dp),
...
) { ... }
}
```
This gives it the same card treatment as feed items — white surface, subtle border, rounded corners.
## Implementation Phases
### Phase 1: Fix header layout + card design (Fixes 1, 5)
**FeedScreen.kt — rewrite FeedTabsHeader:**
- Wrap in `Surface` with `OutlinedCard` styling
- Compact tabs (remove "+ More")
- SearchPill with `weight(1f)` in center
- 48dp height, consistent padding
### Phase 2: Inline search expansion (Fixes 2, 3)
**Delete SearchSpotlight.kt** — replace with expanded state in SearchPill.
**New SearchPill.kt:**
```kotlin
@Composable
fun SearchPill(
expanded: Boolean,
onExpandedChange: (Boolean) -> Unit,
onOpenFullSearch: (String) -> Unit,
modifier: Modifier = Modifier,
)
```
- Collapsed: clickable Surface pill (current design)
- Expanded: `BasicTextField` in same pill shape + `Popup`/`DropdownMenu` below with history
- Scrim: parent renders a semi-transparent overlay when `expanded = true`
**Main.kt:**
- `Cmd+F` sets a `searchExpanded` state that's passed down to the active feed
- Remove `SearchSpotlight` rendering
### Phase 3: Fix tab click handlers (Fix 4)
**FeedScreen.kt:** Audit `onNavigateToFeed` callback path. Ensure all `FeedSource` types are handled.
## Acceptance Criteria
- [ ] Feed tabs are compact, search pill fills remaining center space
- [ ] Header wrapped in card-like Surface matching screenshot aesthetic
- [ ] Clicking SearchPill expands it into an input with dropdown below
- [ ] Cmd+F expands the search pill (no separate overlay)
- [ ] Typing in expanded search shows recent + saved searches
- [ ] Clicking outside or pressing Escape collapses search
- [ ] All feed tabs (Following, Global, custom) are clickable and switch feeds
- [ ] "+ More" removed from header (already in sidebar)
- [ ] Compiles, spotless clean
## Files Affected
| File | Action |
|------|--------|
| `desktopApp/.../ui/FeedScreen.kt` | Rewrite `FeedTabsHeader` — layout, card design, tab fix |
| `desktopApp/.../ui/search/SearchPill.kt` | Add expanded state, inline BasicTextField, dropdown |
| `desktopApp/.../ui/search/SearchSpotlight.kt` | Delete (replaced by inline expansion) |
| `desktopApp/.../Main.kt` | Remove SearchSpotlight rendering, wire Cmd+F to feed search expansion |
## Sources
- Brainstorm: `docs/brainstorms/2026-05-28-feat-desktop-search-spotlight-brainstorm.md`
- Deepen research: Dialog creates separate AWT window on desktop — use Box overlay or inline instead
- Existing pattern: `LightboxOverlay.kt` uses Box overlay (not Dialog)
@@ -0,0 +1,34 @@
---
status: pending
priority: p2
issue_id: "001"
tags: [code-review, ui, desktop, search]
---
# Feed content needs more margin below search bar
## Problem Statement
When the search bar is expanded, feed items below get partially hidden by the expanded search card. The spacer height (60dp) that reserves space for the header is not enough when the search card expands with history/results.
## Findings
- FeedScreen.kt line 587: `Spacer(Modifier.height(60.dp))` reserves space for the collapsed header only
- When search expands, the card grows downward but the feed content doesn't shift
- Feed items near the top get obscured by the expanded search card + scrim
## Proposed Solutions
**Option A: Dynamic spacer based on search state**
- When `searchActive`, increase spacer to match expanded card height (~300dp)
- Pros: exact spacing. Cons: needs to track card height dynamically.
**Option B: Add extra bottom padding to expanded card**
- Feed items already have their own padding. Just ensure the search card's expanded area doesn't overlap.
- Pros: simple. Cons: may not cover all cases.
## Acceptance Criteria
- [ ] Feed items below the search bar are fully visible when search is collapsed
- [ ] When search expands, feed content is not obscured by the expanded card
- [ ] Smooth transition when expanding/collapsing
@@ -0,0 +1,34 @@
---
status: pending
priority: p2
issue_id: "002"
tags: [code-review, ui, desktop, search]
---
# Tapping search history should populate input and search
## Problem Statement
Clicking a recent search item in the expanded search history currently just calls `onOpenFullSearch()` (opens full Search column). It should instead populate the search input with that query text and immediately start searching.
## Findings
- FeedScreen.kt SearchHistorySection: all history rows call `onOpenFullSearch()` on click
- Should instead: set `searchText` to the history item's text, which triggers `updateFromText()` and relay subscriptions
## Proposed Solutions
**Option A: Pass `onHistoryItemClick: (String) -> Unit` to SearchHistorySection**
- Callback sets `searchText = TextFieldValue(text)` in parent
- `LaunchedEffect(searchText.text)` triggers `updateFromText()` automatically
- Pros: clean separation. Cons: needs callback threading.
**Option B: Pass `searchText` MutableState directly**
- SearchHistorySection writes to the state directly
- Pros: simple. Cons: tight coupling.
## Acceptance Criteria
- [ ] Clicking a recent search item populates the search input with that text
- [ ] Search results start loading immediately after populating
- [ ] The search bar stays expanded (doesn't collapse)
@@ -0,0 +1,35 @@
---
status: pending
priority: p2
issue_id: "003"
tags: [code-review, ui, desktop, search, keyboard]
---
# Cmd+F should be context-aware: inline on feeds, full search elsewhere
## Problem Statement
Cmd+F currently toggles `feedSearchActiveState` which only works on the Home/Feeds screen. When on Messages, Settings, Bookmarks, or other non-feed screens, Cmd+F should open the full Search column instead.
## Findings
- Main.kt MenuBar: Cmd+F sets `feedSearchActiveState.value = !feedSearchActiveState.value`
- FeedScreen reads `LocalFeedSearchActive.current` — only works when FeedScreen is visible
- On other screens (Messages, Settings, etc.), the state changes but nothing happens visually
## Proposed Solutions
**Option A: Check current screen type in Cmd+F handler**
- Read `activeColumnType` from deck/single-pane state
- If HomeFeed/GlobalFeed/CustomFeed → toggle inline search
- Otherwise → navigate to Search column
**Option B: Use two shortcuts**
- Cmd+F → always opens inline search on feed (no-op on other screens)
- Cmd+Shift+F → always opens full Search column
## Acceptance Criteria
- [ ] Cmd+F on Home/Feeds screen → inline search expands
- [ ] Cmd+F on any other screen → opens full Search column/screen
- [ ] Cmd+F on already-expanded search → collapses it
@@ -0,0 +1,36 @@
---
status: pending
priority: p3
issue_id: "004"
tags: [code-review, theming, desktop]
---
# Hardcoded status colors should use theme tokens
## Problem Statement
32 inline Color() constructors across 10 desktop files use hardcoded RGB values for status indicators (green/red/amber). These don't adapt to dark/light mode properly.
## Findings
Key files: RelayStatusCard.kt, TorStatusIndicator.kt, LoginProgressSteps.kt, MediaServerSettings.kt, DevSettingsSection.kt, NewKeyWarningCard.kt, ProfileInfoCard.kt
Common hardcoded values:
- `Color(0xFF4CAF50)` green — should use a semantic success color
- `Color(0xFFF44336)` red — should use `MaterialTheme.colorScheme.error`
- `Color(0xFFFFB300)` amber — should use a semantic warning color
- `Color.Red` / `Color.Green` — bare Material colors
## Proposed Solutions
Extract semantic status colors as ColorScheme extensions:
```kotlin
val ColorScheme.statusSuccess: Color get() = if (isLight) Color(0xFF339900) else Color(0xFF99cc33)
val ColorScheme.statusError: Color get() = error
val ColorScheme.statusWarning: Color get() = if (isLight) Color(0xFFC09B14) else Color(0xFFE1C419)
```
## Acceptance Criteria
- [ ] No bare Color.Red/Green/Yellow in desktop UI files
- [ ] Status colors adapt properly to dark/light mode
@@ -0,0 +1,26 @@
---
status: pending
priority: p3
issue_id: "005"
tags: [code-review, theming, desktop]
---
# ~40 inline RoundedCornerShape() calls should use MaterialTheme.shapes
## Problem Statement
The desktopApp module has ~40 inline `RoundedCornerShape()` calls with values 4dp, 8dp, 10dp, 12dp, 16dp. These should map to `MaterialTheme.shapes.*` tokens for consistency with the unified Amethyst shape system.
## Findings
Common clusters:
- `8.dp` (~20 occurrences) → `MaterialTheme.shapes.small`
- `12.dp` (~5 occurrences) → `MaterialTheme.shapes.medium`
- `16.dp` (~4 occurrences) → `MaterialTheme.shapes.large`
- `100.dp` / `999.dp` → pill shapes (acceptable as-is)
## Acceptance Criteria
- [ ] All 8dp corner shapes use MaterialTheme.shapes.small
- [ ] All 12dp corner shapes use MaterialTheme.shapes.medium
- [ ] All 16dp corner shapes use MaterialTheme.shapes.large