From aac56bd92e552f68fdffc185d9e3a9acabe640dd Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Apr 2026 16:14:18 +0000 Subject: [PATCH] feat(desktop): Messages draggable divider + alignment polish + centered empty states - **Draggable list pane**: the 280dp-fixed conversation list width is now user-adjustable via a draggable divider between the list and the chat pane. Width is clamped to 220-480dp and persisted across app restarts via DesktopPreferences.messagesListWidthDp. Cursor flips to the horizontal-resize arrow on hover. - **ConversationCard alignment**: the unread indicator used to sit as an 8dp dot + 14dp spacer to the left of the avatar, which pushed every card ~14dp inward from the header row's 12dp padding. The avatar now starts flush with the header (aligned with the top-bar buttons); the unread state is drawn as a 10dp dot overlaid on the avatar's bottom-right corner (iMessage / Slack-style), with a surface-matched ring so it reads clearly against the avatar. - **Drafts empty state**: was a flush-left Text pinned 16dp below the header; now uses the shared EmptyState composable so "No drafts yet" / description is vertically + horizontally centered in the available space, consistent with Notifications / Highlights / etc. https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo --- .../amethyst/desktop/DesktopPreferences.kt | 8 +++ .../amethyst/desktop/ui/DraftsScreen.kt | 10 ++-- .../desktop/ui/chats/ConversationListPane.kt | 60 ++++++++++--------- .../desktop/ui/chats/DesktopMessagesScreen.kt | 50 +++++++++++++++- 4 files changed, 92 insertions(+), 36 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt index 11e9521bb..00d8e26fa 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt @@ -37,6 +37,7 @@ object DesktopPreferences { private const val KEY_LAST_SCREEN = "last_screen" private const val KEY_DECK_COLUMNS = "deck_columns" private const val KEY_LAYOUT_MODE = "layout_mode" + private const val KEY_MESSAGES_LIST_WIDTH = "messages_list_width_dp" var feedMode: FeedMode get() { @@ -69,6 +70,13 @@ object DesktopPreferences { prefs.put(KEY_LAYOUT_MODE, value) } + /** Width (dp) of the conversation list pane in the Messages screen. */ + var messagesListWidthDp: Float + get() = prefs.getFloat(KEY_MESSAGES_LIST_WIDTH, 280f) + set(value) { + prefs.putFloat(KEY_MESSAGES_LIST_WIDTH, value) + } + private const val KEY_WORKSPACES = "workspaces" var workspaces: String diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/DraftsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/DraftsScreen.kt index 70f904c32..0de229a37 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/DraftsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/DraftsScreen.kt @@ -53,6 +53,7 @@ import androidx.compose.ui.text.style.TextOverflow 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.components.EmptyState import com.vitorpamplona.amethyst.desktop.service.drafts.DesktopDraftStore import com.vitorpamplona.amethyst.desktop.service.drafts.DraftEntry import kotlinx.coroutines.launch @@ -94,13 +95,10 @@ fun DraftsScreen( } } - Spacer(Modifier.height(16.dp)) - if (drafts.isEmpty()) { - Text( - "No drafts yet. Click \"New Draft\" to start writing.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + EmptyState( + title = "No drafts yet", + description = "Click \"New Draft\" to start writing.", ) } else { LazyColumn( diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ConversationListPane.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ConversationListPane.kt index 3e8bd2fd4..ecb597b24 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ConversationListPane.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ConversationListPane.kt @@ -306,35 +306,39 @@ private fun ConversationCard( .padding(horizontal = 12.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically, ) { - // Unread indicator - if (item.hasUnread) { - Box( - modifier = - Modifier - .size(8.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.primary), - ) - Spacer(Modifier.width(6.dp)) - } else { - Spacer(Modifier.width(14.dp)) - } - - // Avatar + // Avatar — starts flush with the header's 12dp padding. Unread state is + // signalled by a small primary-coloured dot overlaid on the avatar's + // bottom-right corner (Slack / iMessage-style), not an inline indent + // that would push the whole card to the right of the header label. val firstUser = item.users.firstOrNull() - if (firstUser != null) { - UserAvatar( - userHex = firstUser.pubkeyHex, - pictureUrl = firstUser.profilePicture(), - size = 40.dp, - ) - } else if (item.isGroup) { - Icon( - MaterialSymbols.Group, - contentDescription = "Group", - modifier = Modifier.size(40.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) + Box { + if (firstUser != null) { + UserAvatar( + userHex = firstUser.pubkeyHex, + pictureUrl = firstUser.profilePicture(), + size = 40.dp, + ) + } else if (item.isGroup) { + Icon( + MaterialSymbols.Group, + contentDescription = "Group", + modifier = Modifier.size(40.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (item.hasUnread) { + Box( + modifier = + Modifier + .align(Alignment.BottomEnd) + .size(10.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceContainer) + .padding(1.5.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary), + ) + } } Spacer(Modifier.width(10.dp)) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt index 3bade983a..98bae6adc 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.desktop.ui.chats import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight @@ -51,6 +52,9 @@ import androidx.compose.ui.input.key.isShiftPressed 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.input.pointer.PointerIcon +import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols @@ -63,6 +67,7 @@ import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import kotlinx.coroutines.CoroutineScope +import java.awt.Cursor private val isMacOS = System.getProperty("os.name").lowercase().contains("mac") @@ -273,6 +278,15 @@ private fun SplitMessagesContent( onShowRelayPicker: () -> Unit = {}, keyHandler: Modifier, ) { + // Draggable split: the conversation list width is user-adjustable and + // persisted so it survives app restarts. + var listWidth by remember { + androidx.compose.runtime.mutableStateOf( + com.vitorpamplona.amethyst.desktop.DesktopPreferences.messagesListWidthDp.dp, + ) + } + val density = androidx.compose.ui.platform.LocalDensity.current + Row(modifier = Modifier.fillMaxSize().then(keyHandler)) { ConversationListPane( state = listState, @@ -281,10 +295,17 @@ private fun SplitMessagesContent( onNewConversation = onShowNewDm, onShowRelayPicker = onShowRelayPicker, focusRequester = listFocusRequester, - modifier = Modifier.width(280.dp), + modifier = Modifier.width(listWidth), ) - VerticalDivider(modifier = Modifier.fillMaxHeight()) + MessagesDraggableDivider( + onDrag = { deltaPx -> + val deltaDp = with(density) { deltaPx.toDp() } + val next = (listWidth + deltaDp).coerceIn(220.dp, 480.dp) + listWidth = next + com.vitorpamplona.amethyst.desktop.DesktopPreferences.messagesListWidthDp = next.value + }, + ) Box( modifier = @@ -357,3 +378,28 @@ private fun EmptyConversationState() { } } } + +/** + * Draggable vertical divider between the conversation list pane and the chat + * pane. 12dp wide hit area; cursor flips to the horizontal resize arrow on + * hover. + */ +@Composable +private fun MessagesDraggableDivider(onDrag: (Float) -> Unit) { + Box( + modifier = + Modifier + .width(12.dp) + .fillMaxHeight() + .pointerHoverIcon(PointerIcon(Cursor(Cursor.E_RESIZE_CURSOR))) + .pointerInput(Unit) { + detectDragGestures { change, dragAmount -> + change.consume() + onDrag(dragAmount.x) + } + }, + contentAlignment = Alignment.Center, + ) { + VerticalDivider(color = MaterialTheme.colorScheme.outlineVariant) + } +}