From 6cdcbbfd4c258cb01377701af28e7460ee0d99ad Mon Sep 17 00:00:00 2001 From: mrfluffy Date: Tue, 24 Feb 2026 13:34:56 +0000 Subject: [PATCH] looking good --- .../fluffytrix/ui/screens/main/MainScreen.kt | 33 ++- .../ui/screens/main/MainViewModel.kt | 166 ++++++++++- .../ui/screens/main/components/ChannelList.kt | 261 +++++++++++------- .../main/components/MessageTimeline.kt | 45 ++- 4 files changed, 393 insertions(+), 112 deletions(-) diff --git a/app/src/main/java/com/example/fluffytrix/ui/screens/main/MainScreen.kt b/app/src/main/java/com/example/fluffytrix/ui/screens/main/MainScreen.kt index fa2ce68..d097b7d 100644 --- a/app/src/main/java/com/example/fluffytrix/ui/screens/main/MainScreen.kt +++ b/app/src/main/java/com/example/fluffytrix/ui/screens/main/MainScreen.kt @@ -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() diff --git a/app/src/main/java/com/example/fluffytrix/ui/screens/main/MainViewModel.kt b/app/src/main/java/com/example/fluffytrix/ui/screens/main/MainViewModel.kt index 6c623fe..c1f7387 100644 --- a/app/src/main/java/com/example/fluffytrix/ui/screens/main/MainViewModel.kt +++ b/app/src/main/java/com/example/fluffytrix/ui/screens/main/MainViewModel.kt @@ -69,6 +69,12 @@ data class MessageItem( val timestamp: Long, ) +data class ChannelSection( + val spaceId: String? = null, + val spaceName: String? = null, + val channels: List = emptyList(), +) + data class MemberItem( val userId: String, val displayName: String, @@ -120,11 +126,21 @@ class MainViewModel( private var cachedOrphanIds: Set? = null private val _roomUnreadStatus = MutableStateFlow>(emptyMap()) + private val _roomUnreadCount = MutableStateFlow>(emptyMap()) + + private val _unreadMarkerIndex = MutableStateFlow(-1) + val unreadMarkerIndex: StateFlow = _unreadMarkerIndex private val _homeUnreadStatus = MutableStateFlow(UnreadStatus.NONE) val homeUnreadStatus: StateFlow = _homeUnreadStatus private val _spaceChildrenMap = MutableStateFlow>>(emptyMap()) + private val _channelSections = MutableStateFlow>(emptyList()) + val channelSections: StateFlow> = _channelSections + + // Maps spaceId -> list of (childSpaceId, childSpaceName, Set) + private val _directChildSpaces = MutableStateFlow>>>>(emptyMap()) + // Per-room caches private val messageCache = mutableMapOf>() private val messageIds = mutableMapOf>() @@ -188,6 +204,7 @@ class MainViewModel( } val syncUnread = mutableMapOf() + val syncUnreadCount = mutableMapOf() 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 - val children = args[1] as Set? - val spaceId = args[2] as String? - val orderMap = args[3] as Map> - val unreadMap = args[4] as Map + 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() + val sectionList = mutableListOf() + + // 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>() + val directMap = mutableMapOf>>>() for (space in _spaces.value) { if (space.id in _spaceChildrenMap.value) continue val allRoomIds = mutableSetOf() val childSpaceIds = mutableSetOf() + val directChildSpaceList = mutableListOf>() // 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() - 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() + val csVisited = mutableSetOf(csId) + var csLevel = listOf(csId) + while (csLevel.isNotEmpty()) { + val csResults = csLevel.map { sid -> + async { paginateSpaceFully(spaceService, sid) } + }.awaitAll() + val csNext = mutableListOf() + 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) + } + 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() val childSpaceIds = mutableSetOf() + + // Track direct child spaces of the selected space + val directChildSpaceList = mutableListOf>>() + // 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() - 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() + val csVisited = mutableSetOf(csId) + var csLevel = listOf(csId) + while (csLevel.isNotEmpty()) { + val csResults = csLevel.map { sid -> + async { paginateSpaceFully(spaceService, sid) } + }.awaitAll() + val csNext = mutableListOf() + 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) + } + _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() { diff --git a/app/src/main/java/com/example/fluffytrix/ui/screens/main/components/ChannelList.kt b/app/src/main/java/com/example/fluffytrix/ui/screens/main/components/ChannelList.kt index 4256e9c..8d05bdf 100644 --- a/app/src/main/java/com/example/fluffytrix/ui/screens/main/components/ChannelList.kt +++ b/app/src/main/java/com/example/fluffytrix/ui/screens/main/components/ChannelList.kt @@ -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, + sections: List, 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() } // 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, - ) } } } diff --git a/app/src/main/java/com/example/fluffytrix/ui/screens/main/components/MessageTimeline.kt b/app/src/main/java/com/example/fluffytrix/ui/screens/main/components/MessageTimeline.kt index 56b3b59..933224c 100644 --- a/app/src/main/java/com/example/fluffytrix/ui/screens/main/components/MessageTimeline.kt +++ b/app/src/main/java/com/example/fluffytrix/ui/screens/main/components/MessageTimeline.kt @@ -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) + } } } }