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_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
@@ -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(
@@ -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))
@@ -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)
}
}