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:
@@ -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(
|
||||||
|
|||||||
+19
-15
@@ -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))
|
||||||
|
|
||||||
|
|||||||
+48
-2
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user