looking good
This commit is contained in:
@@ -1,8 +1,10 @@
|
|||||||
package com.example.fluffytrix.ui.screens.main
|
package com.example.fluffytrix.ui.screens.main
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.slideInHorizontally
|
import androidx.compose.animation.slideInHorizontally
|
||||||
import androidx.compose.animation.slideOutHorizontally
|
import androidx.compose.animation.slideOutHorizontally
|
||||||
|
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||||
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.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
@@ -13,6 +15,7 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.zIndex
|
import androidx.compose.ui.zIndex
|
||||||
import com.example.fluffytrix.ui.screens.main.components.ChannelList
|
import com.example.fluffytrix.ui.screens.main.components.ChannelList
|
||||||
@@ -37,9 +40,34 @@ fun MainScreen(
|
|||||||
val channelName by viewModel.channelName.collectAsState()
|
val channelName by viewModel.channelName.collectAsState()
|
||||||
val isReorderMode by viewModel.isReorderMode.collectAsState()
|
val isReorderMode by viewModel.isReorderMode.collectAsState()
|
||||||
val homeUnreadStatus by viewModel.homeUnreadStatus.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 ->
|
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
|
// Main content: SpaceList + chat + members
|
||||||
Row(modifier = Modifier.fillMaxSize()) {
|
Row(modifier = Modifier.fillMaxSize()) {
|
||||||
SpaceList(
|
SpaceList(
|
||||||
@@ -60,6 +88,7 @@ fun MainScreen(
|
|||||||
onSendMessage = { viewModel.sendMessage(it) },
|
onSendMessage = { viewModel.sendMessage(it) },
|
||||||
onSendFile = { viewModel.sendFile(it) },
|
onSendFile = { viewModel.sendFile(it) },
|
||||||
onLoadMore = { viewModel.loadMoreMessages() },
|
onLoadMore = { viewModel.loadMoreMessages() },
|
||||||
|
unreadMarkerIndex = unreadMarkerIndex,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
contentPadding = padding,
|
contentPadding = padding,
|
||||||
)
|
)
|
||||||
@@ -83,11 +112,13 @@ fun MainScreen(
|
|||||||
Spacer(modifier = Modifier.width(64.dp))
|
Spacer(modifier = Modifier.width(64.dp))
|
||||||
ChannelList(
|
ChannelList(
|
||||||
channels = channels,
|
channels = channels,
|
||||||
|
sections = channelSections,
|
||||||
selectedChannel = selectedChannel,
|
selectedChannel = selectedChannel,
|
||||||
onChannelClick = {
|
onChannelClick = {
|
||||||
viewModel.selectChannel(it)
|
viewModel.selectChannel(it)
|
||||||
viewModel.toggleChannelList()
|
viewModel.toggleChannelList()
|
||||||
},
|
},
|
||||||
|
onDismiss = { viewModel.toggleChannelList() },
|
||||||
onLogout = {
|
onLogout = {
|
||||||
viewModel.logout()
|
viewModel.logout()
|
||||||
onLogout()
|
onLogout()
|
||||||
|
|||||||
@@ -69,6 +69,12 @@ data class MessageItem(
|
|||||||
val timestamp: Long,
|
val timestamp: Long,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class ChannelSection(
|
||||||
|
val spaceId: String? = null,
|
||||||
|
val spaceName: String? = null,
|
||||||
|
val channels: List<ChannelItem> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
data class MemberItem(
|
data class MemberItem(
|
||||||
val userId: String,
|
val userId: String,
|
||||||
val displayName: String,
|
val displayName: String,
|
||||||
@@ -120,11 +126,21 @@ class MainViewModel(
|
|||||||
private var cachedOrphanIds: Set<String>? = null
|
private var cachedOrphanIds: Set<String>? = null
|
||||||
|
|
||||||
private val _roomUnreadStatus = MutableStateFlow<Map<String, UnreadStatus>>(emptyMap())
|
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)
|
private val _homeUnreadStatus = MutableStateFlow(UnreadStatus.NONE)
|
||||||
val homeUnreadStatus: StateFlow<UnreadStatus> = _homeUnreadStatus
|
val homeUnreadStatus: StateFlow<UnreadStatus> = _homeUnreadStatus
|
||||||
private val _spaceChildrenMap = MutableStateFlow<Map<String, Set<String>>>(emptyMap())
|
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
|
// Per-room caches
|
||||||
private val messageCache = mutableMapOf<String, MutableList<MessageItem>>()
|
private val messageCache = mutableMapOf<String, MutableList<MessageItem>>()
|
||||||
private val messageIds = mutableMapOf<String, MutableSet<String>>()
|
private val messageIds = mutableMapOf<String, MutableSet<String>>()
|
||||||
@@ -188,6 +204,7 @@ class MainViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val syncUnread = mutableMapOf<String, UnreadStatus>()
|
val syncUnread = mutableMapOf<String, UnreadStatus>()
|
||||||
|
val syncUnreadCount = mutableMapOf<String, ULong>()
|
||||||
for (room in joinedRooms) {
|
for (room in joinedRooms) {
|
||||||
val roomId = room.id()
|
val roomId = room.id()
|
||||||
if (roomId == _selectedChannel.value) continue
|
if (roomId == _selectedChannel.value) continue
|
||||||
@@ -203,9 +220,12 @@ class MainViewModel(
|
|||||||
}
|
}
|
||||||
if (highlight > 0uL || unreadMentions > 0uL) syncUnread[roomId] = UnreadStatus.MENTIONED
|
if (highlight > 0uL || unreadMentions > 0uL) syncUnread[roomId] = UnreadStatus.MENTIONED
|
||||||
else if (notification > 0uL || unreadNotif > 0uL || unread > 0uL) syncUnread[roomId] = UnreadStatus.UNREAD
|
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) { }
|
} catch (_: Exception) { }
|
||||||
}
|
}
|
||||||
_roomUnreadStatus.value = syncUnread
|
_roomUnreadStatus.value = syncUnread
|
||||||
|
_roomUnreadCount.value = syncUnreadCount
|
||||||
|
|
||||||
// Rebuild spaces list only when the set of space IDs changes
|
// Rebuild spaces list only when the set of space IDs changes
|
||||||
try {
|
try {
|
||||||
@@ -266,13 +286,10 @@ class MainViewModel(
|
|||||||
|
|
||||||
private fun observeSpaceFiltering() {
|
private fun observeSpaceFiltering() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
combine(_allChannelRooms, _spaceChildren, _selectedSpace, _channelOrderMap, _roomUnreadStatus) { args ->
|
combine(
|
||||||
@Suppress("UNCHECKED_CAST")
|
combine(_allChannelRooms, _spaceChildren, _selectedSpace) { a, b, c -> Triple(a, b, c) },
|
||||||
val allChannels = args[0] as List<ChannelItem>
|
combine(_channelOrderMap, _roomUnreadStatus, _directChildSpaces) { a, b, c -> Triple(a, b, c) },
|
||||||
val children = args[1] as Set<String>?
|
) { (allChannels, children, spaceId), (orderMap, unreadMap, directChildren) ->
|
||||||
val spaceId = args[2] as String?
|
|
||||||
val orderMap = args[3] as Map<String, List<String>>
|
|
||||||
val unreadMap = args[4] as Map<String, UnreadStatus>
|
|
||||||
val filtered = if (children == null) allChannels
|
val filtered = if (children == null) allChannels
|
||||||
else allChannels.filter { it.id in children }
|
else allChannels.filter { it.id in children }
|
||||||
val withUnread = filtered.map { ch ->
|
val withUnread = filtered.map { ch ->
|
||||||
@@ -280,11 +297,47 @@ class MainViewModel(
|
|||||||
if (status != ch.unreadStatus) ch.copy(unreadStatus = status) else ch
|
if (status != ch.unreadStatus) ch.copy(unreadStatus = status) else ch
|
||||||
}
|
}
|
||||||
val savedOrder = spaceId?.let { orderMap[it] }
|
val savedOrder = spaceId?.let { orderMap[it] }
|
||||||
if (savedOrder != null) {
|
val sorted = if (savedOrder != null) {
|
||||||
val indexMap = savedOrder.withIndex().associate { (i, id) -> id to i }
|
val indexMap = savedOrder.withIndex().associate { (i, id) -> id to i }
|
||||||
withUnread.sortedBy { indexMap[it.id] ?: Int.MAX_VALUE }
|
withUnread.sortedBy { indexMap[it.id] ?: Int.MAX_VALUE }
|
||||||
} else withUnread
|
} 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 {
|
try {
|
||||||
val spaceService = client.spaceService()
|
val spaceService = client.spaceService()
|
||||||
val map = mutableMapOf<String, Set<String>>()
|
val map = mutableMapOf<String, Set<String>>()
|
||||||
|
val directMap = mutableMapOf<String, List<Triple<String, String, Set<String>>>>()
|
||||||
for (space in _spaces.value) {
|
for (space in _spaces.value) {
|
||||||
if (space.id in _spaceChildrenMap.value) continue
|
if (space.id in _spaceChildrenMap.value) continue
|
||||||
val allRoomIds = mutableSetOf<String>()
|
val allRoomIds = mutableSetOf<String>()
|
||||||
val childSpaceIds = mutableSetOf<String>()
|
val childSpaceIds = mutableSetOf<String>()
|
||||||
|
val directChildSpaceList = mutableListOf<Pair<String, String>>() // id, name
|
||||||
val visited = mutableSetOf(space.id)
|
val visited = mutableSetOf(space.id)
|
||||||
var currentLevel = listOf(space.id)
|
var currentLevel = listOf(space.id)
|
||||||
|
var isFirstLevel = true
|
||||||
while (currentLevel.isNotEmpty()) {
|
while (currentLevel.isNotEmpty()) {
|
||||||
val results = currentLevel.map { sid ->
|
val results = currentLevel.map { sid ->
|
||||||
async { paginateSpaceFully(spaceService, sid) }
|
async { sid to paginateSpaceFully(spaceService, sid) }
|
||||||
}.awaitAll()
|
}.awaitAll()
|
||||||
val nextLevel = mutableListOf<String>()
|
val nextLevel = mutableListOf<String>()
|
||||||
for (children in results) {
|
for ((parentSid, children) in results) {
|
||||||
for (child in children) {
|
for (child in children) {
|
||||||
allRoomIds.add(child.roomId)
|
allRoomIds.add(child.roomId)
|
||||||
if (child.roomType is org.matrix.rustcomponents.sdk.RoomType.Space) {
|
if (child.roomType is org.matrix.rustcomponents.sdk.RoomType.Space) {
|
||||||
childSpaceIds.add(child.roomId)
|
childSpaceIds.add(child.roomId)
|
||||||
if (visited.add(child.roomId)) nextLevel.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
|
currentLevel = nextLevel
|
||||||
|
isFirstLevel = false
|
||||||
}
|
}
|
||||||
map[space.id] = allRoomIds - childSpaceIds
|
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
|
_spaceChildrenMap.value = _spaceChildrenMap.value + map
|
||||||
|
_directChildSpaces.value = _directChildSpaces.value + directMap
|
||||||
updateSpaceUnreadStatus()
|
updateSpaceUnreadStatus()
|
||||||
// Recompute orphan rooms now that we have all space children
|
// Recompute orphan rooms now that we have all space children
|
||||||
computeOrphanRoomsFromCache()
|
computeOrphanRoomsFromCache()
|
||||||
@@ -826,6 +910,7 @@ class MainViewModel(
|
|||||||
// Use cache if available
|
// Use cache if available
|
||||||
_spaceChildrenMap.value[spaceId]?.let { cached ->
|
_spaceChildrenMap.value[spaceId]?.let { cached ->
|
||||||
_spaceChildren.value = cached
|
_spaceChildren.value = cached
|
||||||
|
// directChildSpaces is already cached too via the combine in observeSpaceFiltering
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val client = authRepository.getClient() ?: return
|
val client = authRepository.getClient() ?: return
|
||||||
@@ -834,25 +919,60 @@ class MainViewModel(
|
|||||||
val spaceService = client.spaceService()
|
val spaceService = client.spaceService()
|
||||||
val allRoomIds = mutableSetOf<String>()
|
val allRoomIds = mutableSetOf<String>()
|
||||||
val childSpaceIds = 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
|
// BFS with parallel loading of sibling spaces
|
||||||
val visited = mutableSetOf(spaceId)
|
val visited = mutableSetOf(spaceId)
|
||||||
var currentLevel = listOf(spaceId)
|
var currentLevel = listOf(spaceId)
|
||||||
|
var isFirstLevel = true
|
||||||
while (currentLevel.isNotEmpty()) {
|
while (currentLevel.isNotEmpty()) {
|
||||||
val results = currentLevel.map { sid ->
|
val results = currentLevel.map { sid ->
|
||||||
async { paginateSpaceFully(spaceService, sid) }
|
async { sid to paginateSpaceFully(spaceService, sid) }
|
||||||
}.awaitAll()
|
}.awaitAll()
|
||||||
val nextLevel = mutableListOf<String>()
|
val nextLevel = mutableListOf<String>()
|
||||||
for (children in results) {
|
for ((parentSid, children) in results) {
|
||||||
for (child in children) {
|
for (child in children) {
|
||||||
allRoomIds.add(child.roomId)
|
allRoomIds.add(child.roomId)
|
||||||
if (child.roomType is org.matrix.rustcomponents.sdk.RoomType.Space) {
|
if (child.roomType is org.matrix.rustcomponents.sdk.RoomType.Space) {
|
||||||
childSpaceIds.add(child.roomId)
|
childSpaceIds.add(child.roomId)
|
||||||
if (visited.add(child.roomId)) nextLevel.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
|
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
|
// Only keep actual rooms, not child spaces
|
||||||
val roomIds = allRoomIds - childSpaceIds
|
val roomIds = allRoomIds - childSpaceIds
|
||||||
_spaceChildren.value = roomIds
|
_spaceChildren.value = roomIds
|
||||||
@@ -866,11 +986,31 @@ class MainViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun selectChannel(channelId: String) {
|
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
|
_selectedChannel.value = channelId
|
||||||
if (_roomUnreadStatus.value.containsKey(channelId)) {
|
if (_roomUnreadStatus.value.containsKey(channelId)) {
|
||||||
_roomUnreadStatus.value = _roomUnreadStatus.value - channelId
|
_roomUnreadStatus.value = _roomUnreadStatus.value - channelId
|
||||||
|
_roomUnreadCount.value = _roomUnreadCount.value - channelId
|
||||||
updateSpaceUnreadStatus()
|
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() {
|
private fun updateSpaceUnreadStatus() {
|
||||||
|
|||||||
@@ -24,10 +24,13 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.Logout
|
import androidx.compose.material.icons.automirrored.filled.Logout
|
||||||
import androidx.compose.material.icons.filled.DragHandle
|
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.Lock
|
||||||
import androidx.compose.material.icons.filled.SwapVert
|
import androidx.compose.material.icons.filled.SwapVert
|
||||||
import androidx.compose.material.icons.filled.Tag
|
import androidx.compose.material.icons.filled.Tag
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -37,6 +40,7 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateMapOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberUpdatedState
|
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.clip
|
||||||
import androidx.compose.ui.draw.shadow
|
import androidx.compose.ui.draw.shadow
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
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.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.IntOffset
|
import androidx.compose.ui.unit.IntOffset
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.zIndex
|
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.ChannelItem
|
||||||
|
import com.example.fluffytrix.ui.screens.main.ChannelSection
|
||||||
import com.example.fluffytrix.ui.screens.main.UnreadStatus
|
import com.example.fluffytrix.ui.screens.main.UnreadStatus
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ChannelList(
|
fun ChannelList(
|
||||||
channels: List<ChannelItem>,
|
channels: List<ChannelItem>,
|
||||||
|
sections: List<ChannelSection>,
|
||||||
selectedChannel: String?,
|
selectedChannel: String?,
|
||||||
onChannelClick: (String) -> Unit,
|
onChannelClick: (String) -> Unit,
|
||||||
onLogout: () -> Unit,
|
onLogout: () -> Unit,
|
||||||
|
onDismiss: () -> Unit = {},
|
||||||
contentPadding: PaddingValues,
|
contentPadding: PaddingValues,
|
||||||
isReorderMode: Boolean = false,
|
isReorderMode: Boolean = false,
|
||||||
onToggleReorderMode: () -> Unit = {},
|
onToggleReorderMode: () -> Unit = {},
|
||||||
onMoveChannel: (from: Int, to: Int) -> Unit = { _, _ -> },
|
onMoveChannel: (from: Int, to: Int) -> Unit = { _, _ -> },
|
||||||
) {
|
) {
|
||||||
var showLogoutDialog by remember { mutableStateOf(false) }
|
var showLogoutDialog by remember { mutableStateOf(false) }
|
||||||
|
val collapsedSections = remember { mutableStateMapOf<String, Boolean>() }
|
||||||
|
|
||||||
// Drag state
|
// Drag state
|
||||||
var draggingIndex by remember { mutableIntStateOf(-1) }
|
var draggingIndex by remember { mutableIntStateOf(-1) }
|
||||||
@@ -92,9 +102,22 @@ fun ChannelList(
|
|||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.width(240.dp)
|
.fillMaxWidth()
|
||||||
.fillMaxHeight()
|
.fillMaxHeight()
|
||||||
.background(MaterialTheme.colorScheme.surface)
|
.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()),
|
.padding(top = contentPadding.calculateTopPadding()),
|
||||||
) {
|
) {
|
||||||
// Header
|
// Header
|
||||||
@@ -133,11 +156,52 @@ fun ChannelList(
|
|||||||
state = listState,
|
state = listState,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
|
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
|
||||||
) {
|
) {
|
||||||
itemsIndexed(channels, key = { _, ch -> ch.id }) { index, channel ->
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 isSelected = channel.id == selectedChannel
|
||||||
val isDragging = draggingIndex == index
|
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")
|
val elevation by animateDpAsState(if (isDragging) 8.dp else 0.dp, label = "elevation")
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
@@ -147,10 +211,10 @@ fun ChannelList(
|
|||||||
if (isDragging) Modifier
|
if (isDragging) Modifier
|
||||||
.zIndex(1f)
|
.zIndex(1f)
|
||||||
.offset { IntOffset(0, dragOffsetY.roundToInt()) }
|
.offset { IntOffset(0, dragOffsetY.roundToInt()) }
|
||||||
.shadow(elevation, RoundedCornerShape(4.dp))
|
.shadow(elevation, RoundedCornerShape(8.dp))
|
||||||
else Modifier
|
else Modifier
|
||||||
)
|
)
|
||||||
.clip(RoundedCornerShape(4.dp))
|
.clip(RoundedCornerShape(8.dp))
|
||||||
.background(
|
.background(
|
||||||
when {
|
when {
|
||||||
isDragging -> MaterialTheme.colorScheme.surfaceContainerHigh
|
isDragging -> MaterialTheme.colorScheme.surfaceContainerHigh
|
||||||
@@ -159,11 +223,11 @@ fun ChannelList(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
.then(if (!isReorderMode) Modifier.clickable { onChannelClick(channel.id) } else Modifier)
|
.then(if (!isReorderMode) Modifier.clickable { onChannelClick(channel.id) } else Modifier)
|
||||||
.padding(horizontal = 8.dp, vertical = 6.dp),
|
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
if (isReorderMode) {
|
if (isReorderMode) {
|
||||||
val currentIndex by rememberUpdatedState(index)
|
val currentGlobalIndex by rememberUpdatedState(globalIndex)
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.DragHandle,
|
imageVector = Icons.Default.DragHandle,
|
||||||
contentDescription = "Drag to reorder",
|
contentDescription = "Drag to reorder",
|
||||||
@@ -172,13 +236,13 @@ fun ChannelList(
|
|||||||
.pointerInput(Unit) {
|
.pointerInput(Unit) {
|
||||||
detectDragGesturesAfterLongPress(
|
detectDragGesturesAfterLongPress(
|
||||||
onDragStart = {
|
onDragStart = {
|
||||||
draggingIndex = currentIndex
|
draggingIndex = currentGlobalIndex
|
||||||
dragOffsetY = 0f
|
dragOffsetY = 0f
|
||||||
},
|
},
|
||||||
onDrag = { change, dragAmount ->
|
onDrag = { change, dragAmount ->
|
||||||
change.consume()
|
change.consume()
|
||||||
dragOffsetY += dragAmount.y
|
dragOffsetY += dragAmount.y
|
||||||
val itemHeight = 34.dp.toPx()
|
val itemHeight = 40.dp.toPx()
|
||||||
val draggedPositions = (dragOffsetY / itemHeight).roundToInt()
|
val draggedPositions = (dragOffsetY / itemHeight).roundToInt()
|
||||||
val targetIndex = (draggingIndex + draggedPositions).coerceIn(0, channels.lastIndex)
|
val targetIndex = (draggingIndex + draggedPositions).coerceIn(0, channels.lastIndex)
|
||||||
if (targetIndex != draggingIndex) {
|
if (targetIndex != draggingIndex) {
|
||||||
@@ -199,35 +263,42 @@ fun ChannelList(
|
|||||||
},
|
},
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
}
|
}
|
||||||
if (channel.unreadStatus != UnreadStatus.NONE) {
|
if (hasUnread) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(8.dp)
|
.size(8.dp)
|
||||||
.background(
|
.background(
|
||||||
if (channel.unreadStatus == UnreadStatus.MENTIONED) androidx.compose.ui.graphics.Color.Red
|
if (channel.unreadStatus == UnreadStatus.MENTIONED) MaterialTheme.colorScheme.error
|
||||||
else androidx.compose.ui.graphics.Color.Gray,
|
else MaterialTheme.colorScheme.primary,
|
||||||
androidx.compose.foundation.shape.CircleShape,
|
androidx.compose.foundation.shape.CircleShape,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
} else {
|
} else {
|
||||||
Spacer(modifier = Modifier.width(12.dp))
|
Spacer(modifier = Modifier.width(14.dp))
|
||||||
}
|
}
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = if (channel.isEncrypted) Icons.Default.Lock else Icons.Default.Tag,
|
imageVector = if (channel.isEncrypted) Icons.Default.Lock else Icons.Default.Tag,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
modifier = Modifier.height(18.dp),
|
modifier = Modifier.size(20.dp),
|
||||||
tint = if (isSelected) MaterialTheme.colorScheme.onPrimaryContainer
|
tint = when {
|
||||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
isSelected -> MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
hasUnread -> MaterialTheme.colorScheme.onSurface
|
||||||
|
else -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(10.dp))
|
||||||
Text(
|
Text(
|
||||||
text = channel.name,
|
text = channel.name,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
color = if (isSelected) MaterialTheme.colorScheme.onPrimaryContainer
|
fontWeight = if (hasUnread) FontWeight.SemiBold else FontWeight.Normal,
|
||||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
color = when {
|
||||||
|
isSelected -> MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
hasUnread -> MaterialTheme.colorScheme.onSurface
|
||||||
|
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
},
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
@@ -235,4 +306,6 @@ fun ChannelList(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ fun MessageTimeline(
|
|||||||
onSendMessage: (String) -> Unit,
|
onSendMessage: (String) -> Unit,
|
||||||
onSendFile: (Uri) -> Unit,
|
onSendFile: (Uri) -> Unit,
|
||||||
onLoadMore: () -> Unit = {},
|
onLoadMore: () -> Unit = {},
|
||||||
|
unreadMarkerIndex: Int = -1,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
contentPadding: PaddingValues = PaddingValues(),
|
contentPadding: PaddingValues = PaddingValues(),
|
||||||
) {
|
) {
|
||||||
@@ -213,12 +214,48 @@ fun MessageTimeline(
|
|||||||
val next = if (index + 1 < count) messages[index + 1] else null
|
val next = if (index + 1 < count) messages[index + 1] else null
|
||||||
val isFirstInGroup = next == null || next.senderName != message.senderName
|
val isFirstInGroup = next == null || next.senderName != message.senderName
|
||||||
|
|
||||||
|
// 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) {
|
if (isFirstInGroup) {
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
FullMessage(message)
|
FullMessage(message)
|
||||||
} else {
|
} else {
|
||||||
CompactMessage(message.content)
|
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 {
|
||||||
|
if (isFirstInGroup) {
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
FullMessage(message)
|
||||||
|
} else {
|
||||||
|
CompactMessage(message.content)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user