looking good
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
package com.example.fluffytrix.ui.screens.main
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
@@ -13,6 +15,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.zIndex
|
||||
import com.example.fluffytrix.ui.screens.main.components.ChannelList
|
||||
@@ -37,9 +40,34 @@ fun MainScreen(
|
||||
val channelName by viewModel.channelName.collectAsState()
|
||||
val isReorderMode by viewModel.isReorderMode.collectAsState()
|
||||
val homeUnreadStatus by viewModel.homeUnreadStatus.collectAsState()
|
||||
val channelSections by viewModel.channelSections.collectAsState()
|
||||
val unreadMarkerIndex by viewModel.unreadMarkerIndex.collectAsState()
|
||||
|
||||
// Back button opens channel list when in a chat, or does nothing if already open
|
||||
BackHandler(enabled = selectedChannel != null && !showChannelList) {
|
||||
viewModel.toggleChannelList()
|
||||
}
|
||||
|
||||
Scaffold { padding ->
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.pointerInput(showChannelList) {
|
||||
if (!showChannelList) {
|
||||
var totalDrag = 0f
|
||||
detectHorizontalDragGestures(
|
||||
onDragStart = { totalDrag = 0f },
|
||||
onHorizontalDrag = { _, dragAmount ->
|
||||
totalDrag += dragAmount
|
||||
if (totalDrag > 60f) {
|
||||
viewModel.toggleChannelList()
|
||||
totalDrag = 0f
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
) {
|
||||
// Main content: SpaceList + chat + members
|
||||
Row(modifier = Modifier.fillMaxSize()) {
|
||||
SpaceList(
|
||||
@@ -60,6 +88,7 @@ fun MainScreen(
|
||||
onSendMessage = { viewModel.sendMessage(it) },
|
||||
onSendFile = { viewModel.sendFile(it) },
|
||||
onLoadMore = { viewModel.loadMoreMessages() },
|
||||
unreadMarkerIndex = unreadMarkerIndex,
|
||||
modifier = Modifier.weight(1f),
|
||||
contentPadding = padding,
|
||||
)
|
||||
@@ -83,11 +112,13 @@ fun MainScreen(
|
||||
Spacer(modifier = Modifier.width(64.dp))
|
||||
ChannelList(
|
||||
channels = channels,
|
||||
sections = channelSections,
|
||||
selectedChannel = selectedChannel,
|
||||
onChannelClick = {
|
||||
viewModel.selectChannel(it)
|
||||
viewModel.toggleChannelList()
|
||||
},
|
||||
onDismiss = { viewModel.toggleChannelList() },
|
||||
onLogout = {
|
||||
viewModel.logout()
|
||||
onLogout()
|
||||
|
||||
@@ -69,6 +69,12 @@ data class MessageItem(
|
||||
val timestamp: Long,
|
||||
)
|
||||
|
||||
data class ChannelSection(
|
||||
val spaceId: String? = null,
|
||||
val spaceName: String? = null,
|
||||
val channels: List<ChannelItem> = emptyList(),
|
||||
)
|
||||
|
||||
data class MemberItem(
|
||||
val userId: String,
|
||||
val displayName: String,
|
||||
@@ -120,11 +126,21 @@ class MainViewModel(
|
||||
private var cachedOrphanIds: Set<String>? = null
|
||||
|
||||
private val _roomUnreadStatus = MutableStateFlow<Map<String, UnreadStatus>>(emptyMap())
|
||||
private val _roomUnreadCount = MutableStateFlow<Map<String, ULong>>(emptyMap())
|
||||
|
||||
private val _unreadMarkerIndex = MutableStateFlow(-1)
|
||||
val unreadMarkerIndex: StateFlow<Int> = _unreadMarkerIndex
|
||||
|
||||
private val _homeUnreadStatus = MutableStateFlow(UnreadStatus.NONE)
|
||||
val homeUnreadStatus: StateFlow<UnreadStatus> = _homeUnreadStatus
|
||||
private val _spaceChildrenMap = MutableStateFlow<Map<String, Set<String>>>(emptyMap())
|
||||
|
||||
private val _channelSections = MutableStateFlow<List<ChannelSection>>(emptyList())
|
||||
val channelSections: StateFlow<List<ChannelSection>> = _channelSections
|
||||
|
||||
// Maps spaceId -> list of (childSpaceId, childSpaceName, Set<roomIds>)
|
||||
private val _directChildSpaces = MutableStateFlow<Map<String, List<Triple<String, String, Set<String>>>>>(emptyMap())
|
||||
|
||||
// Per-room caches
|
||||
private val messageCache = mutableMapOf<String, MutableList<MessageItem>>()
|
||||
private val messageIds = mutableMapOf<String, MutableSet<String>>()
|
||||
@@ -188,6 +204,7 @@ class MainViewModel(
|
||||
}
|
||||
|
||||
val syncUnread = mutableMapOf<String, UnreadStatus>()
|
||||
val syncUnreadCount = mutableMapOf<String, ULong>()
|
||||
for (room in joinedRooms) {
|
||||
val roomId = room.id()
|
||||
if (roomId == _selectedChannel.value) continue
|
||||
@@ -203,9 +220,12 @@ class MainViewModel(
|
||||
}
|
||||
if (highlight > 0uL || unreadMentions > 0uL) syncUnread[roomId] = UnreadStatus.MENTIONED
|
||||
else if (notification > 0uL || unreadNotif > 0uL || unread > 0uL) syncUnread[roomId] = UnreadStatus.UNREAD
|
||||
if (unread > 0uL) syncUnreadCount[roomId] = unread
|
||||
else if (notification > 0uL) syncUnreadCount[roomId] = notification
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
_roomUnreadStatus.value = syncUnread
|
||||
_roomUnreadCount.value = syncUnreadCount
|
||||
|
||||
// Rebuild spaces list only when the set of space IDs changes
|
||||
try {
|
||||
@@ -266,13 +286,10 @@ class MainViewModel(
|
||||
|
||||
private fun observeSpaceFiltering() {
|
||||
viewModelScope.launch {
|
||||
combine(_allChannelRooms, _spaceChildren, _selectedSpace, _channelOrderMap, _roomUnreadStatus) { args ->
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val allChannels = args[0] as List<ChannelItem>
|
||||
val children = args[1] as Set<String>?
|
||||
val spaceId = args[2] as String?
|
||||
val orderMap = args[3] as Map<String, List<String>>
|
||||
val unreadMap = args[4] as Map<String, UnreadStatus>
|
||||
combine(
|
||||
combine(_allChannelRooms, _spaceChildren, _selectedSpace) { a, b, c -> Triple(a, b, c) },
|
||||
combine(_channelOrderMap, _roomUnreadStatus, _directChildSpaces) { a, b, c -> Triple(a, b, c) },
|
||||
) { (allChannels, children, spaceId), (orderMap, unreadMap, directChildren) ->
|
||||
val filtered = if (children == null) allChannels
|
||||
else allChannels.filter { it.id in children }
|
||||
val withUnread = filtered.map { ch ->
|
||||
@@ -280,11 +297,47 @@ class MainViewModel(
|
||||
if (status != ch.unreadStatus) ch.copy(unreadStatus = status) else ch
|
||||
}
|
||||
val savedOrder = spaceId?.let { orderMap[it] }
|
||||
if (savedOrder != null) {
|
||||
val sorted = if (savedOrder != null) {
|
||||
val indexMap = savedOrder.withIndex().associate { (i, id) -> id to i }
|
||||
withUnread.sortedBy { indexMap[it.id] ?: Int.MAX_VALUE }
|
||||
} else withUnread
|
||||
}.collect { _channels.value = it }
|
||||
|
||||
// Build sections
|
||||
val childSpaces = spaceId?.let { directChildren[it] }
|
||||
val sections = if (childSpaces.isNullOrEmpty()) {
|
||||
// Home view or space with no child spaces — single flat section
|
||||
listOf(ChannelSection(channels = sorted))
|
||||
} else {
|
||||
val channelMap = sorted.associateBy { it.id }
|
||||
val usedIds = mutableSetOf<String>()
|
||||
val sectionList = mutableListOf<ChannelSection>()
|
||||
|
||||
// Direct rooms (not in any child space)
|
||||
val childSpaceRoomIds = childSpaces.flatMap { it.third }.toSet()
|
||||
val directRooms = sorted.filter { it.id !in childSpaceRoomIds }
|
||||
if (directRooms.isNotEmpty()) {
|
||||
sectionList.add(ChannelSection(channels = directRooms))
|
||||
usedIds.addAll(directRooms.map { it.id })
|
||||
}
|
||||
|
||||
// Child space sections
|
||||
for ((csId, csName, csRoomIds) in childSpaces) {
|
||||
val sectionChannels = csRoomIds.mapNotNull { channelMap[it] }
|
||||
.filter { it.id !in usedIds }
|
||||
if (sectionChannels.isNotEmpty()) {
|
||||
sectionList.add(ChannelSection(spaceId = csId, spaceName = csName, channels = sectionChannels))
|
||||
usedIds.addAll(sectionChannels.map { it.id })
|
||||
}
|
||||
}
|
||||
|
||||
sectionList
|
||||
}
|
||||
|
||||
sorted to sections
|
||||
}.collect { (sorted, sections) ->
|
||||
_channels.value = sorted
|
||||
_channelSections.value = sections
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -729,31 +782,62 @@ class MainViewModel(
|
||||
try {
|
||||
val spaceService = client.spaceService()
|
||||
val map = mutableMapOf<String, Set<String>>()
|
||||
val directMap = mutableMapOf<String, List<Triple<String, String, Set<String>>>>()
|
||||
for (space in _spaces.value) {
|
||||
if (space.id in _spaceChildrenMap.value) continue
|
||||
val allRoomIds = mutableSetOf<String>()
|
||||
val childSpaceIds = mutableSetOf<String>()
|
||||
val directChildSpaceList = mutableListOf<Pair<String, String>>() // id, name
|
||||
val visited = mutableSetOf(space.id)
|
||||
var currentLevel = listOf(space.id)
|
||||
var isFirstLevel = true
|
||||
while (currentLevel.isNotEmpty()) {
|
||||
val results = currentLevel.map { sid ->
|
||||
async { paginateSpaceFully(spaceService, sid) }
|
||||
async { sid to paginateSpaceFully(spaceService, sid) }
|
||||
}.awaitAll()
|
||||
val nextLevel = mutableListOf<String>()
|
||||
for (children in results) {
|
||||
for ((parentSid, children) in results) {
|
||||
for (child in children) {
|
||||
allRoomIds.add(child.roomId)
|
||||
if (child.roomType is org.matrix.rustcomponents.sdk.RoomType.Space) {
|
||||
childSpaceIds.add(child.roomId)
|
||||
if (visited.add(child.roomId)) nextLevel.add(child.roomId)
|
||||
if (isFirstLevel && parentSid == space.id) {
|
||||
directChildSpaceList.add(child.roomId to (child.displayName ?: child.roomId))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
currentLevel = nextLevel
|
||||
isFirstLevel = false
|
||||
}
|
||||
map[space.id] = allRoomIds - childSpaceIds
|
||||
|
||||
// Resolve rooms for each direct child space
|
||||
val resolved = directChildSpaceList.map { (csId, csName) ->
|
||||
val csRoomIds = mutableSetOf<String>()
|
||||
val csVisited = mutableSetOf(csId)
|
||||
var csLevel = listOf(csId)
|
||||
while (csLevel.isNotEmpty()) {
|
||||
val csResults = csLevel.map { sid ->
|
||||
async { paginateSpaceFully(spaceService, sid) }
|
||||
}.awaitAll()
|
||||
val csNext = mutableListOf<String>()
|
||||
for (ch in csResults.flatten()) {
|
||||
if (ch.roomType is org.matrix.rustcomponents.sdk.RoomType.Space) {
|
||||
if (csVisited.add(ch.roomId)) csNext.add(ch.roomId)
|
||||
} else {
|
||||
csRoomIds.add(ch.roomId)
|
||||
}
|
||||
}
|
||||
csLevel = csNext
|
||||
}
|
||||
Triple(csId, csName, csRoomIds as Set<String>)
|
||||
}
|
||||
directMap[space.id] = resolved
|
||||
}
|
||||
_spaceChildrenMap.value = _spaceChildrenMap.value + map
|
||||
_directChildSpaces.value = _directChildSpaces.value + directMap
|
||||
updateSpaceUnreadStatus()
|
||||
// Recompute orphan rooms now that we have all space children
|
||||
computeOrphanRoomsFromCache()
|
||||
@@ -826,6 +910,7 @@ class MainViewModel(
|
||||
// Use cache if available
|
||||
_spaceChildrenMap.value[spaceId]?.let { cached ->
|
||||
_spaceChildren.value = cached
|
||||
// directChildSpaces is already cached too via the combine in observeSpaceFiltering
|
||||
return
|
||||
}
|
||||
val client = authRepository.getClient() ?: return
|
||||
@@ -834,25 +919,60 @@ class MainViewModel(
|
||||
val spaceService = client.spaceService()
|
||||
val allRoomIds = mutableSetOf<String>()
|
||||
val childSpaceIds = mutableSetOf<String>()
|
||||
|
||||
// Track direct child spaces of the selected space
|
||||
val directChildSpaceList = mutableListOf<Triple<String, String, Set<String>>>()
|
||||
|
||||
// BFS with parallel loading of sibling spaces
|
||||
val visited = mutableSetOf(spaceId)
|
||||
var currentLevel = listOf(spaceId)
|
||||
var isFirstLevel = true
|
||||
while (currentLevel.isNotEmpty()) {
|
||||
val results = currentLevel.map { sid ->
|
||||
async { paginateSpaceFully(spaceService, sid) }
|
||||
async { sid to paginateSpaceFully(spaceService, sid) }
|
||||
}.awaitAll()
|
||||
val nextLevel = mutableListOf<String>()
|
||||
for (children in results) {
|
||||
for ((parentSid, children) in results) {
|
||||
for (child in children) {
|
||||
allRoomIds.add(child.roomId)
|
||||
if (child.roomType is org.matrix.rustcomponents.sdk.RoomType.Space) {
|
||||
childSpaceIds.add(child.roomId)
|
||||
if (visited.add(child.roomId)) nextLevel.add(child.roomId)
|
||||
// Track direct child spaces of the selected space
|
||||
if (isFirstLevel && parentSid == spaceId) {
|
||||
// Will fill rooms later
|
||||
directChildSpaceList.add(Triple(child.roomId, child.displayName ?: child.roomId, emptySet()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
currentLevel = nextLevel
|
||||
isFirstLevel = false
|
||||
}
|
||||
|
||||
// Now resolve rooms for each direct child space (recursively)
|
||||
val resolvedDirectChildren = directChildSpaceList.map { (csId, csName, _) ->
|
||||
val csRoomIds = mutableSetOf<String>()
|
||||
val csVisited = mutableSetOf(csId)
|
||||
var csLevel = listOf(csId)
|
||||
while (csLevel.isNotEmpty()) {
|
||||
val csResults = csLevel.map { sid ->
|
||||
async { paginateSpaceFully(spaceService, sid) }
|
||||
}.awaitAll()
|
||||
val csNext = mutableListOf<String>()
|
||||
for (ch in csResults.flatten()) {
|
||||
if (ch.roomType is org.matrix.rustcomponents.sdk.RoomType.Space) {
|
||||
if (csVisited.add(ch.roomId)) csNext.add(ch.roomId)
|
||||
} else {
|
||||
csRoomIds.add(ch.roomId)
|
||||
}
|
||||
}
|
||||
csLevel = csNext
|
||||
}
|
||||
Triple(csId, csName, csRoomIds as Set<String>)
|
||||
}
|
||||
_directChildSpaces.value = _directChildSpaces.value + (spaceId to resolvedDirectChildren)
|
||||
|
||||
// Only keep actual rooms, not child spaces
|
||||
val roomIds = allRoomIds - childSpaceIds
|
||||
_spaceChildren.value = roomIds
|
||||
@@ -866,11 +986,31 @@ class MainViewModel(
|
||||
}
|
||||
|
||||
fun selectChannel(channelId: String) {
|
||||
// Capture unread count before clearing
|
||||
val unreadCount = _roomUnreadCount.value[channelId]?.toInt() ?: 0
|
||||
_unreadMarkerIndex.value = if (unreadCount > 0) unreadCount - 1 else -1
|
||||
|
||||
_selectedChannel.value = channelId
|
||||
if (_roomUnreadStatus.value.containsKey(channelId)) {
|
||||
_roomUnreadStatus.value = _roomUnreadStatus.value - channelId
|
||||
_roomUnreadCount.value = _roomUnreadCount.value - channelId
|
||||
updateSpaceUnreadStatus()
|
||||
}
|
||||
|
||||
// Send read receipt to the server
|
||||
sendReadReceipt(channelId)
|
||||
}
|
||||
|
||||
private fun sendReadReceipt(roomId: String) {
|
||||
val client = authRepository.getClient() ?: return
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val room = client.getRoom(roomId) ?: return@launch
|
||||
room.markAsRead(org.matrix.rustcomponents.sdk.ReceiptType.READ)
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("MainVM", "Failed to send read receipt for $roomId", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateSpaceUnreadStatus() {
|
||||
|
||||
@@ -24,10 +24,13 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Logout
|
||||
import androidx.compose.material.icons.filled.DragHandle
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.SwapVert
|
||||
import androidx.compose.material.icons.filled.Tag
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -37,6 +40,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
@@ -46,26 +50,32 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||
import com.example.fluffytrix.ui.screens.main.ChannelItem
|
||||
import com.example.fluffytrix.ui.screens.main.ChannelSection
|
||||
import com.example.fluffytrix.ui.screens.main.UnreadStatus
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Composable
|
||||
fun ChannelList(
|
||||
channels: List<ChannelItem>,
|
||||
sections: List<ChannelSection>,
|
||||
selectedChannel: String?,
|
||||
onChannelClick: (String) -> Unit,
|
||||
onLogout: () -> Unit,
|
||||
onDismiss: () -> Unit = {},
|
||||
contentPadding: PaddingValues,
|
||||
isReorderMode: Boolean = false,
|
||||
onToggleReorderMode: () -> Unit = {},
|
||||
onMoveChannel: (from: Int, to: Int) -> Unit = { _, _ -> },
|
||||
) {
|
||||
var showLogoutDialog by remember { mutableStateOf(false) }
|
||||
val collapsedSections = remember { mutableStateMapOf<String, Boolean>() }
|
||||
|
||||
// Drag state
|
||||
var draggingIndex by remember { mutableIntStateOf(-1) }
|
||||
@@ -92,9 +102,22 @@ fun ChannelList(
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.width(240.dp)
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight()
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.pointerInput(Unit) {
|
||||
var totalDrag = 0f
|
||||
detectHorizontalDragGestures(
|
||||
onDragStart = { totalDrag = 0f },
|
||||
onHorizontalDrag = { _, dragAmount ->
|
||||
totalDrag += dragAmount
|
||||
if (totalDrag < -60f) {
|
||||
onDismiss()
|
||||
totalDrag = 0f
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
.padding(top = contentPadding.calculateTopPadding()),
|
||||
) {
|
||||
// Header
|
||||
@@ -133,104 +156,154 @@ fun ChannelList(
|
||||
state = listState,
|
||||
modifier = Modifier.weight(1f),
|
||||
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
itemsIndexed(channels, key = { _, ch -> ch.id }) { index, channel ->
|
||||
val isSelected = channel.id == selectedChannel
|
||||
val isDragging = draggingIndex == index
|
||||
val elevation by animateDpAsState(if (isDragging) 8.dp else 0.dp, label = "elevation")
|
||||
for ((sectionIdx, section) in sections.withIndex()) {
|
||||
// Section header for child spaces
|
||||
if (section.spaceName != null && section.spaceId != null) {
|
||||
item(key = "section_${section.spaceId}") {
|
||||
val isCollapsed = collapsedSections[section.spaceId] ?: false
|
||||
if (sectionIdx > 0) {
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(horizontal = 4.dp),
|
||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f),
|
||||
)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable { collapsedSections[section.spaceId] = !isCollapsed }
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (isCollapsed) Icons.Default.ExpandMore else Icons.Default.ExpandLess,
|
||||
contentDescription = if (isCollapsed) "Expand" else "Collapse",
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = section.spaceName.uppercase(),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.then(
|
||||
if (isDragging) Modifier
|
||||
.zIndex(1f)
|
||||
.offset { IntOffset(0, dragOffsetY.roundToInt()) }
|
||||
.shadow(elevation, RoundedCornerShape(4.dp))
|
||||
else Modifier
|
||||
)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(
|
||||
when {
|
||||
isDragging -> MaterialTheme.colorScheme.surfaceContainerHigh
|
||||
isSelected -> MaterialTheme.colorScheme.primaryContainer
|
||||
else -> MaterialTheme.colorScheme.surface
|
||||
}
|
||||
)
|
||||
.then(if (!isReorderMode) Modifier.clickable { onChannelClick(channel.id) } else Modifier)
|
||||
.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (isReorderMode) {
|
||||
val currentIndex by rememberUpdatedState(index)
|
||||
Icon(
|
||||
imageVector = Icons.Default.DragHandle,
|
||||
contentDescription = "Drag to reorder",
|
||||
val isCollapsed = section.spaceId?.let { collapsedSections[it] ?: false } ?: false
|
||||
if (!isCollapsed) {
|
||||
itemsIndexed(section.channels, key = { _, ch -> ch.id }) { index, channel ->
|
||||
val isSelected = channel.id == selectedChannel
|
||||
val hasUnread = channel.unreadStatus != UnreadStatus.NONE
|
||||
val globalIndex = channels.indexOfFirst { it.id == channel.id }
|
||||
val isDragging = draggingIndex == globalIndex
|
||||
val elevation by animateDpAsState(if (isDragging) 8.dp else 0.dp, label = "elevation")
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.size(20.dp)
|
||||
.pointerInput(Unit) {
|
||||
detectDragGesturesAfterLongPress(
|
||||
onDragStart = {
|
||||
draggingIndex = currentIndex
|
||||
dragOffsetY = 0f
|
||||
},
|
||||
onDrag = { change, dragAmount ->
|
||||
change.consume()
|
||||
dragOffsetY += dragAmount.y
|
||||
val itemHeight = 34.dp.toPx()
|
||||
val draggedPositions = (dragOffsetY / itemHeight).roundToInt()
|
||||
val targetIndex = (draggingIndex + draggedPositions).coerceIn(0, channels.lastIndex)
|
||||
if (targetIndex != draggingIndex) {
|
||||
onMoveChannel(draggingIndex, targetIndex)
|
||||
dragOffsetY -= (targetIndex - draggingIndex) * itemHeight
|
||||
draggingIndex = targetIndex
|
||||
}
|
||||
},
|
||||
onDragEnd = {
|
||||
draggingIndex = -1
|
||||
dragOffsetY = 0f
|
||||
},
|
||||
onDragCancel = {
|
||||
draggingIndex = -1
|
||||
dragOffsetY = 0f
|
||||
},
|
||||
)
|
||||
},
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
}
|
||||
if (channel.unreadStatus != UnreadStatus.NONE) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(8.dp)
|
||||
.fillMaxWidth()
|
||||
.then(
|
||||
if (isDragging) Modifier
|
||||
.zIndex(1f)
|
||||
.offset { IntOffset(0, dragOffsetY.roundToInt()) }
|
||||
.shadow(elevation, RoundedCornerShape(8.dp))
|
||||
else Modifier
|
||||
)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(
|
||||
if (channel.unreadStatus == UnreadStatus.MENTIONED) androidx.compose.ui.graphics.Color.Red
|
||||
else androidx.compose.ui.graphics.Color.Gray,
|
||||
androidx.compose.foundation.shape.CircleShape,
|
||||
),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
} else {
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
when {
|
||||
isDragging -> MaterialTheme.colorScheme.surfaceContainerHigh
|
||||
isSelected -> MaterialTheme.colorScheme.primaryContainer
|
||||
else -> MaterialTheme.colorScheme.surface
|
||||
}
|
||||
)
|
||||
.then(if (!isReorderMode) Modifier.clickable { onChannelClick(channel.id) } else Modifier)
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (isReorderMode) {
|
||||
val currentGlobalIndex by rememberUpdatedState(globalIndex)
|
||||
Icon(
|
||||
imageVector = Icons.Default.DragHandle,
|
||||
contentDescription = "Drag to reorder",
|
||||
modifier = Modifier
|
||||
.size(20.dp)
|
||||
.pointerInput(Unit) {
|
||||
detectDragGesturesAfterLongPress(
|
||||
onDragStart = {
|
||||
draggingIndex = currentGlobalIndex
|
||||
dragOffsetY = 0f
|
||||
},
|
||||
onDrag = { change, dragAmount ->
|
||||
change.consume()
|
||||
dragOffsetY += dragAmount.y
|
||||
val itemHeight = 40.dp.toPx()
|
||||
val draggedPositions = (dragOffsetY / itemHeight).roundToInt()
|
||||
val targetIndex = (draggingIndex + draggedPositions).coerceIn(0, channels.lastIndex)
|
||||
if (targetIndex != draggingIndex) {
|
||||
onMoveChannel(draggingIndex, targetIndex)
|
||||
dragOffsetY -= (targetIndex - draggingIndex) * itemHeight
|
||||
draggingIndex = targetIndex
|
||||
}
|
||||
},
|
||||
onDragEnd = {
|
||||
draggingIndex = -1
|
||||
dragOffsetY = 0f
|
||||
},
|
||||
onDragCancel = {
|
||||
draggingIndex = -1
|
||||
dragOffsetY = 0f
|
||||
},
|
||||
)
|
||||
},
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
}
|
||||
if (hasUnread) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(8.dp)
|
||||
.background(
|
||||
if (channel.unreadStatus == UnreadStatus.MENTIONED) MaterialTheme.colorScheme.error
|
||||
else MaterialTheme.colorScheme.primary,
|
||||
androidx.compose.foundation.shape.CircleShape,
|
||||
),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
} else {
|
||||
Spacer(modifier = Modifier.width(14.dp))
|
||||
}
|
||||
Icon(
|
||||
imageVector = if (channel.isEncrypted) Icons.Default.Lock else Icons.Default.Tag,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = when {
|
||||
isSelected -> MaterialTheme.colorScheme.onPrimaryContainer
|
||||
hasUnread -> MaterialTheme.colorScheme.onSurface
|
||||
else -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
|
||||
},
|
||||
)
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
Text(
|
||||
text = channel.name,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = if (hasUnread) FontWeight.SemiBold else FontWeight.Normal,
|
||||
color = when {
|
||||
isSelected -> MaterialTheme.colorScheme.onPrimaryContainer
|
||||
hasUnread -> MaterialTheme.colorScheme.onSurface
|
||||
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
Icon(
|
||||
imageVector = if (channel.isEncrypted) Icons.Default.Lock else Icons.Default.Tag,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.height(18.dp),
|
||||
tint = if (isSelected) MaterialTheme.colorScheme.onPrimaryContainer
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = channel.name,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (isSelected) MaterialTheme.colorScheme.onPrimaryContainer
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,6 +119,7 @@ fun MessageTimeline(
|
||||
onSendMessage: (String) -> Unit,
|
||||
onSendFile: (Uri) -> Unit,
|
||||
onLoadMore: () -> Unit = {},
|
||||
unreadMarkerIndex: Int = -1,
|
||||
modifier: Modifier = Modifier,
|
||||
contentPadding: PaddingValues = PaddingValues(),
|
||||
) {
|
||||
@@ -213,11 +214,47 @@ fun MessageTimeline(
|
||||
val next = if (index + 1 < count) messages[index + 1] else null
|
||||
val isFirstInGroup = next == null || next.senderName != message.senderName
|
||||
|
||||
if (isFirstInGroup) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
FullMessage(message)
|
||||
// Show "NEW" divider after the last unread message
|
||||
// In reverse layout, unreadMarkerIndex 0 = newest message
|
||||
// The divider goes after index unreadMarkerIndex (visually above unread block)
|
||||
if (index == unreadMarkerIndex) {
|
||||
Column {
|
||||
if (isFirstInGroup) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
FullMessage(message)
|
||||
} else {
|
||||
CompactMessage(message.content)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.weight(1f),
|
||||
color = MaterialTheme.colorScheme.error.copy(alpha = 0.6f),
|
||||
)
|
||||
Text(
|
||||
text = "NEW",
|
||||
modifier = Modifier.padding(horizontal = 8.dp),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.weight(1f),
|
||||
color = MaterialTheme.colorScheme.error.copy(alpha = 0.6f),
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
} else {
|
||||
CompactMessage(message.content)
|
||||
if (isFirstInGroup) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
FullMessage(message)
|
||||
} else {
|
||||
CompactMessage(message.content)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user