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
This commit is contained in:
Claude
2026-04-24 16:14:18 +00:00
parent c5712af2de
commit aac56bd92e
4 changed files with 92 additions and 36 deletions
@@ -37,6 +37,7 @@ object DesktopPreferences {
private const val KEY_LAST_SCREEN = "last_screen" private const val KEY_LAST_SCREEN = "last_screen"
private const val KEY_DECK_COLUMNS = "deck_columns" private const val KEY_DECK_COLUMNS = "deck_columns"
private const val KEY_LAYOUT_MODE = "layout_mode" private const val KEY_LAYOUT_MODE = "layout_mode"
private const val KEY_MESSAGES_LIST_WIDTH = "messages_list_width_dp"
var feedMode: FeedMode var feedMode: FeedMode
get() { get() {
@@ -69,6 +70,13 @@ object DesktopPreferences {
prefs.put(KEY_LAYOUT_MODE, value) 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" private const val KEY_WORKSPACES = "workspaces"
var workspaces: String var workspaces: String
@@ -53,6 +53,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols 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.DesktopDraftStore
import com.vitorpamplona.amethyst.desktop.service.drafts.DraftEntry import com.vitorpamplona.amethyst.desktop.service.drafts.DraftEntry
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -94,13 +95,10 @@ fun DraftsScreen(
} }
} }
Spacer(Modifier.height(16.dp))
if (drafts.isEmpty()) { if (drafts.isEmpty()) {
Text( EmptyState(
"No drafts yet. Click \"New Draft\" to start writing.", title = "No drafts yet",
style = MaterialTheme.typography.bodyMedium, description = "Click \"New Draft\" to start writing.",
color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} else { } else {
LazyColumn( LazyColumn(
@@ -306,22 +306,12 @@ private fun ConversationCard(
.padding(horizontal = 12.dp, vertical = 10.dp), .padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
// Unread indicator // Avatar — starts flush with the header's 12dp padding. Unread state is
if (item.hasUnread) { // signalled by a small primary-coloured dot overlaid on the avatar's
Box( // bottom-right corner (Slack / iMessage-style), not an inline indent
modifier = // that would push the whole card to the right of the header label.
Modifier
.size(8.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary),
)
Spacer(Modifier.width(6.dp))
} else {
Spacer(Modifier.width(14.dp))
}
// Avatar
val firstUser = item.users.firstOrNull() val firstUser = item.users.firstOrNull()
Box {
if (firstUser != null) { if (firstUser != null) {
UserAvatar( UserAvatar(
userHex = firstUser.pubkeyHex, userHex = firstUser.pubkeyHex,
@@ -336,6 +326,20 @@ private fun ConversationCard(
tint = MaterialTheme.colorScheme.onSurfaceVariant, 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)) Spacer(Modifier.width(10.dp))
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.desktop.ui.chats package com.vitorpamplona.amethyst.desktop.ui.chats
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight 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.key
import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type 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 androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols 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.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import java.awt.Cursor
private val isMacOS = System.getProperty("os.name").lowercase().contains("mac") private val isMacOS = System.getProperty("os.name").lowercase().contains("mac")
@@ -273,6 +278,15 @@ private fun SplitMessagesContent(
onShowRelayPicker: () -> Unit = {}, onShowRelayPicker: () -> Unit = {},
keyHandler: Modifier, 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)) { Row(modifier = Modifier.fillMaxSize().then(keyHandler)) {
ConversationListPane( ConversationListPane(
state = listState, state = listState,
@@ -281,10 +295,17 @@ private fun SplitMessagesContent(
onNewConversation = onShowNewDm, onNewConversation = onShowNewDm,
onShowRelayPicker = onShowRelayPicker, onShowRelayPicker = onShowRelayPicker,
focusRequester = listFocusRequester, 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( Box(
modifier = 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)
}
}