mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 08:04:45 +00:00
feat(chats): stacked messages layout in deck columns
In multi-deck mode, Messages column now uses stacked navigation instead of side-by-side split pane. Full-width contact list OR full-width chat — clicking a conversation navigates to chat, back arrow returns to list. Single-pane mode keeps the existing split layout (280dp list + flex chat). Changes: - Add compactMode param to DesktopMessagesScreen (default false) - Extract SplitMessagesContent and CompactMessagesContent composables - Add onBack callback to ChatPane with back arrow in header - Remove hardcoded 280dp from ConversationListPane (caller controls width) - Pass compactMode=true from deck RootContent Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
598256639b
commit
e06287acb3
+39
-18
@@ -39,6 +39,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material.icons.filled.AttachFile
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
@@ -141,6 +142,7 @@ fun ChatPane(
|
||||
messageState: ChatNewMessageState,
|
||||
dmBroadcastStatus: DmBroadcastStatus = DmBroadcastStatus.Idle,
|
||||
onNavigateToProfile: (String) -> Unit = {},
|
||||
onBack: (() -> Unit)? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -209,24 +211,43 @@ fun ChatPane(
|
||||
),
|
||||
) {
|
||||
// Header
|
||||
if (isGroup) {
|
||||
GroupChatroomHeader(
|
||||
users = users,
|
||||
onClick = { users.firstOrNull()?.let { onNavigateToProfile(it.pubkeyHex) } },
|
||||
)
|
||||
} else {
|
||||
users.firstOrNull()?.let { user ->
|
||||
ChatroomHeader(
|
||||
user = user,
|
||||
onClick = { onNavigateToProfile(user.pubkeyHex) },
|
||||
)
|
||||
} ?: run {
|
||||
// Fallback header with raw pubkey
|
||||
Text(
|
||||
text = roomKey.users.firstOrNull()?.take(20) ?: "Unknown",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
modifier = Modifier.padding(10.dp),
|
||||
)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
if (onBack != null) {
|
||||
IconButton(
|
||||
onClick = onBack,
|
||||
modifier = Modifier.size(40.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "Back to conversations",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
if (isGroup) {
|
||||
GroupChatroomHeader(
|
||||
users = users,
|
||||
onClick = { users.firstOrNull()?.let { onNavigateToProfile(it.pubkeyHex) } },
|
||||
)
|
||||
} else {
|
||||
users.firstOrNull()?.let { user ->
|
||||
ChatroomHeader(
|
||||
user = user,
|
||||
onClick = { onNavigateToProfile(user.pubkeyHex) },
|
||||
)
|
||||
} ?: run {
|
||||
// Fallback header with raw pubkey
|
||||
Text(
|
||||
text = roomKey.users.firstOrNull()?.take(20) ?: "Unknown",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
modifier = Modifier.padding(10.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
-1
@@ -119,7 +119,6 @@ fun ConversationListPane(
|
||||
Column(
|
||||
modifier =
|
||||
modifier
|
||||
.width(280.dp)
|
||||
.fillMaxHeight()
|
||||
.focusRequester(focusRequester)
|
||||
.focusable()
|
||||
|
||||
+150
-53
@@ -24,8 +24,10 @@ import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
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.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Email
|
||||
import androidx.compose.material3.Icon
|
||||
@@ -59,18 +61,19 @@ import com.vitorpamplona.amethyst.commons.viewmodels.ChatroomFeedViewModel
|
||||
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
||||
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
|
||||
|
||||
private val isMacOS = System.getProperty("os.name").lowercase().contains("mac")
|
||||
|
||||
/**
|
||||
* Desktop DM screen with split-pane layout.
|
||||
* Desktop DM screen with two layout modes:
|
||||
*
|
||||
* Left pane (280dp): ConversationListPane with Known/New tabs
|
||||
* Right pane (flex): ChatPane with messages + input, or empty state
|
||||
* - **Split mode** (compactMode = false): Side-by-side with conversation list (280dp) + chat pane.
|
||||
* Used in single-pane layout where there's plenty of horizontal space.
|
||||
*
|
||||
* @param account The user's IAccount for DM operations
|
||||
* @param cacheProvider ICacheProvider for user/note lookups
|
||||
* @param onNavigateToProfile Called when navigating to a user profile
|
||||
* - **Compact mode** (compactMode = true): Stacked navigation — full-width contact list OR
|
||||
* full-width chat. Used in multi-deck columns where width is limited.
|
||||
*/
|
||||
@Composable
|
||||
fun DesktopMessagesScreen(
|
||||
@@ -78,6 +81,7 @@ fun DesktopMessagesScreen(
|
||||
cacheProvider: ICacheProvider,
|
||||
relayManager: DesktopRelayConnectionManager,
|
||||
localCache: DesktopLocalCache,
|
||||
compactMode: Boolean = false,
|
||||
onNavigateToProfile: (String) -> Unit = {},
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -89,51 +93,159 @@ fun DesktopMessagesScreen(
|
||||
val listFocusRequester = remember { FocusRequester() }
|
||||
var showNewDmDialog by remember { mutableStateOf(false) }
|
||||
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.onPreviewKeyEvent { event ->
|
||||
if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
|
||||
val isModifier = if (isMacOS) event.isMetaPressed else event.isCtrlPressed
|
||||
// Shared keyboard shortcuts
|
||||
val keyHandler =
|
||||
Modifier.onPreviewKeyEvent { event ->
|
||||
if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
|
||||
val isModifier = if (isMacOS) event.isMetaPressed else event.isCtrlPressed
|
||||
|
||||
when {
|
||||
// Escape -> deselect conversation
|
||||
event.key == Key.Escape -> {
|
||||
listState.clearSelection()
|
||||
true
|
||||
}
|
||||
when {
|
||||
event.key == Key.Escape -> {
|
||||
listState.clearSelection()
|
||||
true
|
||||
}
|
||||
|
||||
// Cmd+Shift+N / Ctrl+Shift+N -> new DM
|
||||
event.key == Key.N && isModifier && event.isShiftPressed -> {
|
||||
showNewDmDialog = true
|
||||
true
|
||||
}
|
||||
event.key == Key.N && isModifier && event.isShiftPressed -> {
|
||||
showNewDmDialog = true
|
||||
true
|
||||
}
|
||||
|
||||
else -> {
|
||||
false
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
// Left pane: conversation list (280dp fixed)
|
||||
else -> {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (compactMode) {
|
||||
CompactMessagesContent(
|
||||
selectedRoom = selectedRoom,
|
||||
listState = listState,
|
||||
account = account,
|
||||
cacheProvider = cacheProvider,
|
||||
scope = scope,
|
||||
onNavigateToProfile = onNavigateToProfile,
|
||||
listFocusRequester = listFocusRequester,
|
||||
onShowNewDm = { showNewDmDialog = true },
|
||||
keyHandler = keyHandler,
|
||||
)
|
||||
} else {
|
||||
SplitMessagesContent(
|
||||
selectedRoom = selectedRoom,
|
||||
listState = listState,
|
||||
account = account,
|
||||
cacheProvider = cacheProvider,
|
||||
scope = scope,
|
||||
onNavigateToProfile = onNavigateToProfile,
|
||||
listFocusRequester = listFocusRequester,
|
||||
onShowNewDm = { showNewDmDialog = true },
|
||||
keyHandler = keyHandler,
|
||||
)
|
||||
}
|
||||
|
||||
if (showNewDmDialog) {
|
||||
NewDmDialog(
|
||||
cacheProvider = cacheProvider,
|
||||
relayManager = relayManager,
|
||||
localCache = localCache,
|
||||
onUserSelected = { roomKey ->
|
||||
listState.selectRoom(roomKey)
|
||||
showNewDmDialog = false
|
||||
},
|
||||
onDismiss = { showNewDmDialog = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact (stacked) layout for deck columns.
|
||||
* Shows either the contact list OR the chat, never both.
|
||||
*/
|
||||
@Composable
|
||||
private fun CompactMessagesContent(
|
||||
selectedRoom: ChatroomKey?,
|
||||
listState: ChatroomListState,
|
||||
account: IAccount,
|
||||
cacheProvider: ICacheProvider,
|
||||
scope: CoroutineScope,
|
||||
onNavigateToProfile: (String) -> Unit,
|
||||
listFocusRequester: FocusRequester,
|
||||
onShowNewDm: () -> Unit,
|
||||
keyHandler: Modifier,
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize().then(keyHandler)) {
|
||||
val currentRoom = selectedRoom
|
||||
if (currentRoom != null) {
|
||||
val feedViewModel =
|
||||
remember(currentRoom) {
|
||||
ChatroomFeedViewModel(currentRoom, account, cacheProvider)
|
||||
}
|
||||
val messageState =
|
||||
remember(currentRoom) {
|
||||
ChatNewMessageState(account, cacheProvider, scope)
|
||||
}
|
||||
val broadcastStatus =
|
||||
if (account is DesktopIAccount) {
|
||||
account.dmSendTracker.status
|
||||
.collectAsState()
|
||||
.value
|
||||
} else {
|
||||
DmBroadcastStatus.Idle
|
||||
}
|
||||
|
||||
ChatPane(
|
||||
roomKey = currentRoom,
|
||||
account = account,
|
||||
cacheProvider = cacheProvider,
|
||||
feedViewModel = feedViewModel,
|
||||
messageState = messageState,
|
||||
dmBroadcastStatus = broadcastStatus,
|
||||
onNavigateToProfile = onNavigateToProfile,
|
||||
onBack = { listState.clearSelection() },
|
||||
)
|
||||
} else {
|
||||
ConversationListPane(
|
||||
state = listState,
|
||||
selectedRoom = selectedRoom,
|
||||
onConversationSelected = { listState.selectRoom(it) },
|
||||
onNewConversation = onShowNewDm,
|
||||
focusRequester = listFocusRequester,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Split (side-by-side) layout for single-pane mode.
|
||||
* Contact list (280dp) + divider + chat pane (flex).
|
||||
*/
|
||||
@Composable
|
||||
private fun SplitMessagesContent(
|
||||
selectedRoom: ChatroomKey?,
|
||||
listState: ChatroomListState,
|
||||
account: IAccount,
|
||||
cacheProvider: ICacheProvider,
|
||||
scope: CoroutineScope,
|
||||
onNavigateToProfile: (String) -> Unit,
|
||||
listFocusRequester: FocusRequester,
|
||||
onShowNewDm: () -> Unit,
|
||||
keyHandler: Modifier,
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxSize().then(keyHandler)) {
|
||||
ConversationListPane(
|
||||
state = listState,
|
||||
selectedRoom = selectedRoom,
|
||||
onConversationSelected = { roomKey ->
|
||||
listState.selectRoom(roomKey)
|
||||
},
|
||||
onNewConversation = { showNewDmDialog = true },
|
||||
onConversationSelected = { listState.selectRoom(it) },
|
||||
onNewConversation = onShowNewDm,
|
||||
focusRequester = listFocusRequester,
|
||||
modifier = Modifier.width(280.dp),
|
||||
)
|
||||
|
||||
VerticalDivider(modifier = Modifier.fillMaxHeight())
|
||||
|
||||
// Right pane: chat or empty state (flex)
|
||||
Box(modifier = Modifier.weight(1f).fillMaxHeight()) {
|
||||
val currentRoom = selectedRoom
|
||||
if (currentRoom != null) {
|
||||
// Create feed VM and message state scoped to the selected room
|
||||
val feedViewModel =
|
||||
remember(currentRoom) {
|
||||
ChatroomFeedViewModel(currentRoom, account, cacheProvider)
|
||||
@@ -142,7 +254,6 @@ fun DesktopMessagesScreen(
|
||||
remember(currentRoom) {
|
||||
ChatNewMessageState(account, cacheProvider, scope)
|
||||
}
|
||||
|
||||
val broadcastStatus =
|
||||
if (account is DesktopIAccount) {
|
||||
account.dmSendTracker.status
|
||||
@@ -162,28 +273,14 @@ fun DesktopMessagesScreen(
|
||||
onNavigateToProfile = onNavigateToProfile,
|
||||
)
|
||||
} else {
|
||||
// Empty state
|
||||
EmptyConversationState()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showNewDmDialog) {
|
||||
NewDmDialog(
|
||||
cacheProvider = cacheProvider,
|
||||
relayManager = relayManager,
|
||||
localCache = localCache,
|
||||
onUserSelected = { roomKey ->
|
||||
listState.selectRoom(roomKey)
|
||||
showNewDmDialog = false
|
||||
},
|
||||
onDismiss = { showNewDmDialog = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shown when no conversation is selected in the right pane.
|
||||
* Shown when no conversation is selected in the split layout right pane.
|
||||
*/
|
||||
@Composable
|
||||
private fun EmptyConversationState() {
|
||||
|
||||
+1
@@ -209,6 +209,7 @@ internal fun RootContent(
|
||||
cacheProvider = localCache,
|
||||
relayManager = relayManager,
|
||||
localCache = localCache,
|
||||
compactMode = true,
|
||||
onNavigateToProfile = onNavigateToProfile,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
# Brainstorm: Stacked Messages Layout in Multi-Deck
|
||||
|
||||
**Date:** 2026-03-19
|
||||
**Status:** Ready for planning
|
||||
|
||||
## What We're Building
|
||||
|
||||
Replace the side-by-side split-pane Messages layout (contact list + chat) with a stacked navigation in deck columns. Clicking a conversation navigates from the contact list to the chat view; a back arrow returns to the list. Single-pane (non-deck) mode keeps the current split layout.
|
||||
|
||||
## Why This Approach
|
||||
|
||||
In multi-deck mode, columns can be 350-400dp wide. The current split layout allocates 280dp to the contact list, leaving only 70-120dp for the chat pane — unusable. A stacked layout gives the full column width to whichever view is active.
|
||||
|
||||
## Key Decisions
|
||||
|
||||
| Decision | Choice | Rationale |
|
||||
|----------|--------|-----------|
|
||||
| Layout mode | Always stacked in deck columns | Simplest, works at any column width |
|
||||
| Single-pane mode | Keep split layout | Plenty of horizontal space in single-pane |
|
||||
| Back navigation | Back arrow in chat header | Discoverable; Escape key already works |
|
||||
| State management | `selectedRoom` in `ChatroomListState` already controls this | No new state needed — just show list when null, chat when selected |
|
||||
|
||||
## Architecture
|
||||
|
||||
### Current Flow (DesktopMessagesScreen.kt)
|
||||
```
|
||||
Row {
|
||||
ConversationListPane(280dp) // always visible
|
||||
VerticalDivider
|
||||
ChatPane(flex) // or EmptyState
|
||||
}
|
||||
```
|
||||
|
||||
### New Flow (Deck Mode)
|
||||
```
|
||||
// When selectedRoom == null:
|
||||
ConversationListPane(full width)
|
||||
|
||||
// When selectedRoom != null:
|
||||
Column {
|
||||
BackArrow + ChatroomHeader
|
||||
ChatPane(full width)
|
||||
}
|
||||
```
|
||||
|
||||
### Implementation Approach
|
||||
|
||||
`DesktopMessagesScreen` already has `selectedRoom` state. The change is layout-only:
|
||||
|
||||
1. Add a `compactMode: Boolean` parameter to `DesktopMessagesScreen`
|
||||
2. In deck mode (`compactMode = true`): show either list OR chat, not both
|
||||
3. In single-pane mode (`compactMode = false`): keep current split Row
|
||||
4. Add back arrow to `ChatPane` header when in compact mode
|
||||
5. `clearSelection()` → back to list (already exists)
|
||||
|
||||
### What Changes
|
||||
|
||||
| Component | Change |
|
||||
|-----------|--------|
|
||||
| `DesktopMessagesScreen` | Add `compactMode` param; conditional layout (Row vs when/else) |
|
||||
| `ChatPane` | Add `onBack` callback + back arrow in header when provided |
|
||||
| `RootContent` | Pass `compactMode = true` to DesktopMessagesScreen |
|
||||
| `SinglePaneLayout` `RootContent` | Pass `compactMode = false` |
|
||||
| `ConversationListPane` | No changes — already works at any width |
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- **Keyboard nav**: Escape already calls `clearSelection()` — works as back
|
||||
- **New DM dialog**: Opens over whichever view is active — no change needed
|
||||
- **Receiving DM while in list**: Room appears/updates in list normally
|
||||
- **Receiving DM while in chat**: Messages appear in real-time — no change
|
||||
|
||||
## Open Questions
|
||||
|
||||
None — all decisions resolved.
|
||||
@@ -0,0 +1,343 @@
|
||||
---
|
||||
title: "feat: Stacked messages layout in deck columns"
|
||||
type: feat
|
||||
status: completed
|
||||
date: 2026-03-19
|
||||
deepened: 2026-03-19
|
||||
origin: docs/brainstorms/2026-03-19-deck-messages-stacked-layout-brainstorm.md
|
||||
---
|
||||
|
||||
# feat: Stacked Messages Layout in Deck Columns
|
||||
|
||||
## Enhancement Summary
|
||||
|
||||
**Deepened on:** 2026-03-19
|
||||
**Files to change:** 4
|
||||
**Approach:** Add `compactMode` flag, conditional layout in `DesktopMessagesScreen`
|
||||
|
||||
### Key Implementation Details
|
||||
1. `ConversationListPane` width is hardcoded at line 122 (`Modifier.width(280.dp)`) — remove it, let caller control width
|
||||
2. `ChatPane` header (lines 211-231) uses `ChatroomHeader`/`GroupChatroomHeader` — wrap with `Row` adding back arrow
|
||||
3. `DesktopMessagesScreen` already has `selectedRoom` state and `clearSelection()` — stacked nav is pure layout change
|
||||
4. Keyboard Escape handling already at line 102 calls `listState.clearSelection()` — works as-is for back nav
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Replace the side-by-side split-pane Messages layout with stacked navigation in deck columns. Full-width contact list OR full-width chat — clicking a conversation navigates to chat, back arrow returns to list. Single-pane mode keeps the current split layout.
|
||||
|
||||
## Problem Statement
|
||||
|
||||
In multi-deck mode, columns are 350-400dp wide. The current layout allocates 280dp to `ConversationListPane` (line 122) and the remaining 70-120dp to `ChatPane` — unusable.
|
||||
|
||||
(see brainstorm: `docs/brainstorms/2026-03-19-deck-messages-stacked-layout-brainstorm.md`)
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Make ConversationListPane width flexible
|
||||
|
||||
**File:** `desktopApp/.../ui/chats/ConversationListPane.kt` (line 119-123)
|
||||
|
||||
**Current code:**
|
||||
```kotlin
|
||||
Column(
|
||||
modifier =
|
||||
modifier
|
||||
.width(280.dp) // ← hardcoded, breaks compact mode
|
||||
.fillMaxHeight()
|
||||
```
|
||||
|
||||
**Change:** Remove the hardcoded width from the composable. The caller controls width via the `modifier` parameter.
|
||||
|
||||
```kotlin
|
||||
Column(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxHeight()
|
||||
```
|
||||
|
||||
**Call sites:**
|
||||
- Split mode (DesktopMessagesScreen): passes no modifier → add `Modifier.width(280.dp)` at call site
|
||||
- Compact mode: passes `Modifier.fillMaxWidth()` → uses full column width
|
||||
|
||||
### Research Insights
|
||||
|
||||
- `ConversationListPane` already accepts `modifier: Modifier = Modifier` (line 95) — just unused for width
|
||||
- The keyboard nav (`onPreviewKeyEvent` at line 126) and `LazyColumn` (line 243) work at any width
|
||||
- `ConversationCard` (line 268) uses `Modifier.fillMaxWidth()` — adapts automatically
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Add `onBack` to ChatPane header
|
||||
|
||||
**File:** `desktopApp/.../ui/chats/ChatPane.kt` (lines 136-144, 211-231)
|
||||
|
||||
**Current signature:**
|
||||
```kotlin
|
||||
fun ChatPane(
|
||||
roomKey: ChatroomKey,
|
||||
account: IAccount,
|
||||
cacheProvider: ICacheProvider,
|
||||
feedViewModel: ChatroomFeedViewModel,
|
||||
messageState: ChatNewMessageState,
|
||||
dmBroadcastStatus: DmBroadcastStatus = DmBroadcastStatus.Idle,
|
||||
onNavigateToProfile: (String) -> Unit = {},
|
||||
modifier: Modifier = Modifier,
|
||||
)
|
||||
```
|
||||
|
||||
**Add:** `onBack: (() -> Unit)? = null` parameter.
|
||||
|
||||
**Current header (lines 211-231):**
|
||||
```kotlin
|
||||
// Header
|
||||
if (isGroup) {
|
||||
GroupChatroomHeader(
|
||||
users = users,
|
||||
onClick = { users.firstOrNull()?.let { onNavigateToProfile(it.pubkeyHex) } },
|
||||
)
|
||||
} else {
|
||||
users.firstOrNull()?.let { user ->
|
||||
ChatroomHeader(
|
||||
user = user,
|
||||
onClick = { onNavigateToProfile(user.pubkeyHex) },
|
||||
)
|
||||
} ?: run {
|
||||
Text(
|
||||
text = roomKey.users.firstOrNull()?.take(20) ?: "Unknown",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
modifier = Modifier.padding(10.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**New header:** Wrap in a `Row` with conditional back arrow:
|
||||
|
||||
```kotlin
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
if (onBack != null) {
|
||||
IconButton(
|
||||
onClick = onBack,
|
||||
modifier = Modifier.size(40.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "Back to conversations",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
// Existing header content (ChatroomHeader / GroupChatroomHeader / fallback)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Research Insights
|
||||
|
||||
- `Icons.AutoMirrored.Filled.ArrowBack` is already available in material-icons-extended
|
||||
- `ChatroomHeader` and `GroupChatroomHeader` are shared composables from commons — don't modify them
|
||||
- The `Row` wrapper doesn't affect the existing divider at line 233 (`HorizontalDivider()`)
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Refactor DesktopMessagesScreen layout
|
||||
|
||||
**File:** `desktopApp/.../ui/chats/DesktopMessagesScreen.kt`
|
||||
|
||||
**Current:** Single `Row` layout (lines 92-168) with `ConversationListPane` + `VerticalDivider` + `ChatPane`/`EmptyState`.
|
||||
|
||||
**Change:** Add `compactMode: Boolean = false` parameter. Extract existing Row into a private `SplitMessagesContent` composable. Add a new `CompactMessagesContent` for stacked mode.
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun DesktopMessagesScreen(
|
||||
account: IAccount,
|
||||
cacheProvider: ICacheProvider,
|
||||
relayManager: DesktopRelayConnectionManager,
|
||||
localCache: DesktopLocalCache,
|
||||
compactMode: Boolean = false, // NEW
|
||||
onNavigateToProfile: (String) -> Unit = {},
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val listState = remember(account) {
|
||||
ChatroomListState(account, cacheProvider, relayManager, localCache, scope)
|
||||
}
|
||||
val selectedRoom by listState.selectedRoom.collectAsState()
|
||||
val listFocusRequester = remember { FocusRequester() }
|
||||
var showNewDmDialog by remember { mutableStateOf(false) }
|
||||
|
||||
// Keyboard shortcuts (shared between modes)
|
||||
val keyHandler = Modifier.onPreviewKeyEvent { event ->
|
||||
if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
|
||||
val isModifier = if (isMacOS) event.isMetaPressed else event.isCtrlPressed
|
||||
when {
|
||||
event.key == Key.Escape -> {
|
||||
listState.clearSelection()
|
||||
true
|
||||
}
|
||||
event.key == Key.N && isModifier && event.isShiftPressed -> {
|
||||
showNewDmDialog = true
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
if (compactMode) {
|
||||
CompactMessagesContent(
|
||||
selectedRoom = selectedRoom,
|
||||
listState = listState,
|
||||
account = account,
|
||||
cacheProvider = cacheProvider,
|
||||
scope = scope,
|
||||
onNavigateToProfile = onNavigateToProfile,
|
||||
listFocusRequester = listFocusRequester,
|
||||
showNewDmDialog = showNewDmDialog,
|
||||
onShowNewDm = { showNewDmDialog = true },
|
||||
keyHandler = keyHandler,
|
||||
)
|
||||
} else {
|
||||
SplitMessagesContent(
|
||||
selectedRoom = selectedRoom,
|
||||
listState = listState,
|
||||
account = account,
|
||||
cacheProvider = cacheProvider,
|
||||
scope = scope,
|
||||
onNavigateToProfile = onNavigateToProfile,
|
||||
listFocusRequester = listFocusRequester,
|
||||
keyHandler = keyHandler,
|
||||
onShowNewDm = { showNewDmDialog = true },
|
||||
)
|
||||
}
|
||||
|
||||
// New DM dialog (shared)
|
||||
if (showNewDmDialog) { /* existing NewDmDialog code */ }
|
||||
}
|
||||
```
|
||||
|
||||
**CompactMessagesContent:**
|
||||
```kotlin
|
||||
@Composable
|
||||
private fun CompactMessagesContent(
|
||||
selectedRoom: ChatroomKey?,
|
||||
listState: ChatroomListState,
|
||||
account: IAccount,
|
||||
cacheProvider: ICacheProvider,
|
||||
scope: CoroutineScope,
|
||||
onNavigateToProfile: (String) -> Unit,
|
||||
listFocusRequester: FocusRequester,
|
||||
showNewDmDialog: Boolean,
|
||||
onShowNewDm: () -> Unit,
|
||||
keyHandler: Modifier,
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize().then(keyHandler)) {
|
||||
val currentRoom = selectedRoom
|
||||
if (currentRoom != null) {
|
||||
// Full-width chat with back arrow
|
||||
val feedViewModel = remember(currentRoom) {
|
||||
ChatroomFeedViewModel(currentRoom, account, cacheProvider)
|
||||
}
|
||||
val messageState = remember(currentRoom) {
|
||||
ChatNewMessageState(account, cacheProvider, scope)
|
||||
}
|
||||
val broadcastStatus = if (account is DesktopIAccount) {
|
||||
account.dmSendTracker.status.collectAsState().value
|
||||
} else DmBroadcastStatus.Idle
|
||||
|
||||
ChatPane(
|
||||
roomKey = currentRoom,
|
||||
account = account,
|
||||
cacheProvider = cacheProvider,
|
||||
feedViewModel = feedViewModel,
|
||||
messageState = messageState,
|
||||
dmBroadcastStatus = broadcastStatus,
|
||||
onNavigateToProfile = onNavigateToProfile,
|
||||
onBack = { listState.clearSelection() },
|
||||
)
|
||||
} else {
|
||||
// Full-width contact list
|
||||
ConversationListPane(
|
||||
state = listState,
|
||||
selectedRoom = selectedRoom,
|
||||
onConversationSelected = { listState.selectRoom(it) },
|
||||
onNewConversation = onShowNewDm,
|
||||
focusRequester = listFocusRequester,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**SplitMessagesContent:** Extract existing `Row` code verbatim from current `DesktopMessagesScreen`, adding `Modifier.width(280.dp)` to the `ConversationListPane` call.
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Wire compactMode from deck
|
||||
|
||||
**File:** `desktopApp/.../ui/deck/DeckColumnContainer.kt` (line 206-214)
|
||||
|
||||
```kotlin
|
||||
DeckColumnType.Messages -> {
|
||||
DesktopMessagesScreen(
|
||||
account = iAccount,
|
||||
cacheProvider = localCache,
|
||||
relayManager = relayManager,
|
||||
localCache = localCache,
|
||||
compactMode = true, // ← ADD THIS
|
||||
onNavigateToProfile = onNavigateToProfile,
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
`SinglePaneLayout` — no change needed, `compactMode` defaults to `false`.
|
||||
|
||||
---
|
||||
|
||||
## Edge Cases
|
||||
|
||||
| Scenario | Behavior | Verified by |
|
||||
|----------|----------|-------------|
|
||||
| Escape in chat | `clearSelection()` → back to list | Existing keyboard handler (line 102) |
|
||||
| Escape in list | No-op (already no selection) | Same handler, `clearSelection()` on null is safe |
|
||||
| New DM dialog in compact chat | Dialog opens over chat, selecting user switches to that chat | `showNewDmDialog` is shared state |
|
||||
| Receiving DM while viewing list | Chatroom list updates via 2s polling | `ChatroomListState.refreshRooms()` |
|
||||
| Receiving DM while in chat | Messages appear real-time via `ChatroomFeedViewModel` | No change needed |
|
||||
| Back arrow + drag-drop | Drag-drop zone is on the ChatPane Column, unaffected by back arrow Row | Separate modifier chain |
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [x] Deck Messages column shows full-width contact list (no split)
|
||||
- [x] Clicking conversation navigates to full-width chat view
|
||||
- [x] Back arrow visible in chat header (compact mode only)
|
||||
- [x] Clicking back arrow returns to contact list
|
||||
- [x] Escape key still returns to contact list
|
||||
- [x] Single-pane mode unchanged (split layout preserved)
|
||||
- [x] Keyboard navigation (up/down/enter) still works in contact list
|
||||
- [x] New DM dialog still works in both modes
|
||||
- [x] ConversationListPane uses full width in compact mode
|
||||
|
||||
## Files Changed
|
||||
|
||||
| File | Change | Lines affected |
|
||||
|------|--------|---------------|
|
||||
| `ConversationListPane.kt` | Remove hardcoded `width(280.dp)` | Line 122 |
|
||||
| `ChatPane.kt` | Add `onBack` param, wrap header in Row with back arrow | Lines 136-144, 211-231 |
|
||||
| `DesktopMessagesScreen.kt` | Add `compactMode`, extract `SplitMessagesContent`/`CompactMessagesContent` | Major refactor |
|
||||
| `DeckColumnContainer.kt` | Pass `compactMode = true` | Line ~208 |
|
||||
|
||||
## Sources
|
||||
|
||||
- **Brainstorm:** `docs/brainstorms/2026-03-19-deck-messages-stacked-layout-brainstorm.md`
|
||||
- `DesktopMessagesScreen.kt:75-213` — current split-pane layout
|
||||
- `ChatPane.kt:136-144` — current signature; `211-231` — header section
|
||||
- `ConversationListPane.kt:119-122` — hardcoded width; `95` — modifier param
|
||||
- `DeckColumnContainer.kt:206-214` — Messages deck routing
|
||||
Reference in New Issue
Block a user