threads boiiiii

This commit is contained in:
2026-03-02 16:30:40 +00:00
parent d0311e7632
commit b58f745fbc
10 changed files with 910 additions and 164 deletions

View File

@@ -22,13 +22,9 @@ android {
buildTypes {
debug {
isMinifyEnabled = true
isShrinkResources = true
isMinifyEnabled = false
isShrinkResources = false
isDebuggable = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
release {
isMinifyEnabled = true
@@ -69,6 +65,7 @@ dependencies {
implementation(libs.navigation.compose)
implementation(libs.lifecycle.viewmodel.compose)
implementation(libs.lifecycle.runtime.compose)
implementation(libs.lifecycle.process)
debugImplementation(libs.compose.ui.tooling)
// Koin

View File

@@ -25,11 +25,12 @@ class PreferencesManager(private val context: Context) {
private val KEY_OIDC_DATA = stringPreferencesKey("oidc_data")
private val KEY_SLIDING_SYNC_VERSION = stringPreferencesKey("sliding_sync_version")
private val KEY_USERNAME = stringPreferencesKey("username")
private val KEY_PASSWORD = stringPreferencesKey("password")
private val KEY_IS_LOGGED_IN = booleanPreferencesKey("is_logged_in")
private val KEY_CHANNEL_ORDER = stringPreferencesKey("channel_order")
private val KEY_CHILD_SPACE_ORDER = stringPreferencesKey("child_space_order")
private val KEY_HIDE_SPACES_WHEN_CLOSED = booleanPreferencesKey("hide_spaces_when_closed")
private val KEY_THREAD_NAMES = stringPreferencesKey("thread_names")
private val KEY_HIDDEN_THREADS = stringPreferencesKey("hidden_threads")
}
val isLoggedIn: Flow<Boolean> = context.dataStore.data.map { prefs ->
@@ -52,10 +53,6 @@ class PreferencesManager(private val context: Context) {
prefs[KEY_USERNAME]
}
val password: Flow<String?> = context.dataStore.data.map { prefs ->
prefs[KEY_PASSWORD]
}
suspend fun saveSession(
accessToken: String,
refreshToken: String?,
@@ -128,6 +125,39 @@ class PreferencesManager(private val context: Context) {
}
}
// Thread names: key = "roomId:threadRootEventId", value = custom name
val threadNames: Flow<Map<String, String>> = context.dataStore.data.map { prefs ->
val raw = prefs[KEY_THREAD_NAMES] ?: return@map emptyMap()
try { Json.decodeFromString<Map<String, String>>(raw) } catch (_: Exception) { emptyMap() }
}
suspend fun saveThreadName(roomId: String, threadRootEventId: String, name: String) {
context.dataStore.edit { prefs ->
val existing = prefs[KEY_THREAD_NAMES]?.let {
try { Json.decodeFromString<Map<String, String>>(it) } catch (_: Exception) { emptyMap() }
} ?: emptyMap()
val key = "$roomId:$threadRootEventId"
val updated = if (name.isBlank()) existing - key else existing + (key to name)
prefs[KEY_THREAD_NAMES] = Json.encodeToString(updated)
}
}
// Hidden (removed) threads: set of "roomId:threadRootEventId"
val hiddenThreads: Flow<Set<String>> = context.dataStore.data.map { prefs ->
val raw = prefs[KEY_HIDDEN_THREADS] ?: return@map emptySet()
try { Json.decodeFromString<Set<String>>(raw) } catch (_: Exception) { emptySet() }
}
suspend fun setThreadHidden(roomId: String, threadRootEventId: String, hidden: Boolean) {
context.dataStore.edit { prefs ->
val existing = prefs[KEY_HIDDEN_THREADS]?.let {
try { Json.decodeFromString<Set<String>>(it) } catch (_: Exception) { emptySet() }
} ?: emptySet()
val key = "$roomId:$threadRootEventId"
prefs[KEY_HIDDEN_THREADS] = Json.encodeToString(if (hidden) existing + key else existing - key)
}
}
suspend fun clearSession() {
context.dataStore.edit { it.clear() }
}

View File

@@ -40,6 +40,12 @@ class AuthRepository(
fun getSyncService(): SyncService? = syncService
suspend fun restartSync(): SyncService? {
try { syncService?.stop() } catch (_: Exception) { }
syncService = null
return getOrStartSync()
}
private fun sessionDataPath(): String {
val dir = File(context.filesDir, "matrix_session_data")
dir.mkdirs()

View File

@@ -12,8 +12,9 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.dp
@@ -32,26 +33,34 @@ fun MainScreen(
onSettingsClick: () -> Unit = {},
viewModel: MainViewModel = koinViewModel(),
) {
val spaces by viewModel.spaces.collectAsState()
val channels by viewModel.channels.collectAsState()
val selectedSpace by viewModel.selectedSpace.collectAsState()
val selectedChannel by viewModel.selectedChannel.collectAsState()
val showChannelList by viewModel.showChannelList.collectAsState()
val showMemberList by viewModel.showMemberList.collectAsState()
val messages by viewModel.messages.collectAsState()
val members by viewModel.members.collectAsState()
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()
val spaces by viewModel.spaces.collectAsStateWithLifecycle()
val channels by viewModel.channels.collectAsStateWithLifecycle()
val selectedSpace by viewModel.selectedSpace.collectAsStateWithLifecycle()
val selectedChannel by viewModel.selectedChannel.collectAsStateWithLifecycle()
val showChannelList by viewModel.showChannelList.collectAsStateWithLifecycle()
val showMemberList by viewModel.showMemberList.collectAsStateWithLifecycle()
val messages by viewModel.messages.collectAsStateWithLifecycle()
val members by viewModel.members.collectAsStateWithLifecycle()
val channelName by viewModel.channelName.collectAsStateWithLifecycle()
val isReorderMode by viewModel.isReorderMode.collectAsStateWithLifecycle()
val homeUnreadStatus by viewModel.homeUnreadStatus.collectAsStateWithLifecycle()
val channelSections by viewModel.channelSections.collectAsStateWithLifecycle()
val unreadMarkerIndex by viewModel.unreadMarkerIndex.collectAsStateWithLifecycle()
val roomThreads by viewModel.roomThreads.collectAsStateWithLifecycle()
val expandedThreadRooms by viewModel.expandedThreadRooms.collectAsStateWithLifecycle()
val selectedThread by viewModel.selectedThread.collectAsStateWithLifecycle()
val threadMessages by viewModel.threadMessages.collectAsStateWithLifecycle()
val listState = viewModel.channelListState
val preferencesManager: PreferencesManager = koinInject()
val hideSpacesWhenClosed by preferencesManager.hideSpacesWhenClosed.collectAsState(initial = false)
val hideSpacesWhenClosed by preferencesManager.hideSpacesWhenClosed.collectAsStateWithLifecycle(initialValue = false)
// Back button opens channel list when in a chat, or does nothing if already open
BackHandler(enabled = selectedChannel != null && !showChannelList) {
viewModel.toggleChannelList()
// Back button: close thread first, then open channel list
BackHandler(enabled = selectedThread != null || (selectedChannel != null && !showChannelList)) {
if (selectedThread != null) {
viewModel.closeThread()
} else {
viewModel.toggleChannelList()
}
}
Scaffold { padding ->
@@ -100,6 +109,25 @@ fun MainScreen(
unreadMarkerIndex = unreadMarkerIndex,
modifier = Modifier.weight(1f),
contentPadding = padding,
selectedThread = selectedThread,
threadMessages = threadMessages,
onCloseThread = { viewModel.closeThread() },
onSendThreadMessage = { viewModel.sendThreadMessage(it) },
onOpenThread = { eventId ->
selectedChannel?.let { viewModel.selectThread(it, eventId) }
},
threadReplyCounts = remember(roomThreads, selectedChannel) {
selectedChannel?.let { roomId ->
roomThreads[roomId]?.associate { it.rootEventId to it.replyCount }
} ?: emptyMap()
},
selectedThreadName = remember(selectedThread, selectedChannel, roomThreads) {
selectedThread?.let { threadId ->
selectedChannel?.let { roomId ->
roomThreads[roomId]?.find { it.rootEventId == threadId }?.name
}
}
},
)
AnimatedVisibility(visible = showMemberList) {
@@ -137,6 +165,7 @@ fun MainScreen(
sections = channelSections,
selectedChannel = selectedChannel,
onChannelClick = {
viewModel.closeThread()
viewModel.selectChannel(it)
viewModel.toggleChannelList()
},
@@ -152,6 +181,21 @@ fun MainScreen(
onMoveChannel = { from, to -> viewModel.moveChannel(from, to) },
onMoveChannelById = { id, delta -> viewModel.moveChannelById(id, delta) },
onMoveChildSpace = { from, to -> viewModel.moveChildSpace(from, to) },
roomThreads = roomThreads,
expandedThreadRooms = expandedThreadRooms,
selectedThread = selectedThread,
onToggleRoomThreads = { viewModel.toggleRoomThreads(it) },
onThreadClick = { roomId, threadRootEventId ->
viewModel.selectChannel(roomId)
viewModel.selectThread(roomId, threadRootEventId)
viewModel.toggleChannelList()
},
onRenameThread = { roomId, threadRootEventId, name ->
viewModel.renameThread(roomId, threadRootEventId, name)
},
onRemoveThread = { roomId, threadRootEventId ->
viewModel.removeThread(roomId, threadRootEventId)
},
)
}
}

View File

@@ -4,6 +4,9 @@ import android.app.Application
import android.net.Uri
import android.provider.OpenableColumns
import androidx.compose.runtime.Immutable
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.fluffytrix.data.MxcUrlHelper
@@ -27,11 +30,17 @@ import org.matrix.rustcomponents.sdk.MessageType
import org.matrix.rustcomponents.sdk.MsgLikeKind
import org.matrix.rustcomponents.sdk.ProfileDetails
import org.matrix.rustcomponents.sdk.SyncService
import org.matrix.rustcomponents.sdk.DateDividerMode
import org.matrix.rustcomponents.sdk.TimelineConfiguration
import org.matrix.rustcomponents.sdk.TimelineDiff
import org.matrix.rustcomponents.sdk.TimelineFilter
import org.matrix.rustcomponents.sdk.TimelineFocus
import uniffi.matrix_sdk_ui.TimelineReadReceiptTracking
import org.matrix.rustcomponents.sdk.TimelineItemContent
import org.matrix.rustcomponents.sdk.TimelineListener
import org.matrix.rustcomponents.sdk.UploadParameters
import org.matrix.rustcomponents.sdk.UploadSource
import org.json.JSONObject
import org.matrix.rustcomponents.sdk.messageEventContentFromMarkdown
enum class UnreadStatus { NONE, UNREAD, MENTIONED }
@@ -77,6 +86,16 @@ data class MessageItem(
val content: MessageContent,
val timestamp: Long,
val replyTo: ReplyInfo? = null,
val threadRootEventId: String? = null,
)
data class ThreadItem(
val rootEventId: String,
val rootBody: String,
val rootSenderName: String,
val replyCount: Int,
val lastActivity: Long,
val name: String? = null,
)
data class ChannelSection(
@@ -124,6 +143,24 @@ class MainViewModel(
private val _channelName = MutableStateFlow<String?>(null)
val channelName: StateFlow<String?> = _channelName
// Thread support
private val _roomThreads = MutableStateFlow<Map<String, List<ThreadItem>>>(emptyMap())
val roomThreads: StateFlow<Map<String, List<ThreadItem>>> = _roomThreads
private val _expandedThreadRooms = MutableStateFlow<Set<String>>(emptySet())
val expandedThreadRooms: StateFlow<Set<String>> = _expandedThreadRooms
private val _selectedThread = MutableStateFlow<String?>(null)
val selectedThread: StateFlow<String?> = _selectedThread
private val _threadMessages = MutableStateFlow<List<MessageItem>>(emptyList())
val threadMessages: StateFlow<List<MessageItem>> = _threadMessages
private val _selectedThreadRoomId = MutableStateFlow<String?>(null)
private val _threadNames = MutableStateFlow<Map<String, String>>(emptyMap())
private val _hiddenThreads = MutableStateFlow<Set<String>>(emptySet())
val channelListState = LazyListState()
private val _isReorderMode = MutableStateFlow(false)
@@ -156,22 +193,27 @@ class MainViewModel(
// 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>>()
private val memberCache = mutableMapOf<String, List<MemberItem>>()
private val channelNameCache = mutableMapOf<String, String>()
private val senderAvatarCache = mutableMapOf<String, String?>()
private val senderNameCache = mutableMapOf<String, String>()
// Per-room caches — outer maps are ConcurrentHashMap to prevent structural corruption
// from concurrent access across Dispatchers.IO, Dispatchers.Default, and Main.
private val messageCache = java.util.concurrent.ConcurrentHashMap<String, MutableList<MessageItem>>()
private val messageIds = java.util.concurrent.ConcurrentHashMap<String, MutableSet<String>>()
private val memberCache = java.util.concurrent.ConcurrentHashMap<String, List<MemberItem>>()
private val channelNameCache = java.util.concurrent.ConcurrentHashMap<String, String>()
private val senderAvatarCache = java.util.concurrent.ConcurrentHashMap<String, String?>()
private val senderNameCache = java.util.concurrent.ConcurrentHashMap<String, String>()
// Thread caches: roomId -> (threadRootEventId -> list of thread reply messages)
private val threadMessageCache = java.util.concurrent.ConcurrentHashMap<String, MutableMap<String, MutableList<MessageItem>>>()
private var timelineJob: Job? = null
private var membersJob: Job? = null
private var syncService: SyncService? = null
private var activeTimeline: org.matrix.rustcomponents.sdk.Timeline? = null
private var activeThreadTimeline: org.matrix.rustcomponents.sdk.Timeline? = null
private val threadTimelineMutex = kotlinx.coroutines.sync.Mutex()
private var timelineListenerHandle: org.matrix.rustcomponents.sdk.TaskHandle? = null
private var roomPollJob: Job? = null
private var isPaginating = false
private var hitTimelineStart = false
private val hitTimelineStartByRoom = java.util.concurrent.ConcurrentHashMap<String, Boolean>()
private val baseUrl: String by lazy {
try { authRepository.getClient()?.session()?.homeserverUrl?.trimEnd('/') ?: "" }
catch (_: Exception) { "" }
@@ -180,6 +222,52 @@ class MainViewModel(
private fun avatarUrl(mxcUri: String?, size: Int = 64): String? =
MxcUrlHelper.mxcToThumbnailUrl(baseUrl, mxcUri, size)
private val lifecycleObserver = object : DefaultLifecycleObserver {
override fun onStart(owner: LifecycleOwner) {
// App returned to foreground — restart sync and re-subscribe timelines
android.util.Log.d("MainVM", "App foregrounded, restarting sync")
viewModelScope.launch(Dispatchers.IO) {
syncService = authRepository.restartSync()
// Re-subscribe to the active room timeline if one was selected
val roomId = _selectedChannel.value
if (roomId != null) {
timelineJob?.cancel()
timelineListenerHandle?.cancel()
timelineListenerHandle = null
activeTimeline = null
isPaginating = false
timelineJob = loadTimeline(roomId)
sendReadReceipt(roomId)
}
// Rebuild thread timeline if a thread is open — old native object may be stale
threadTimelineMutex.withLock {
activeThreadTimeline?.destroy()
activeThreadTimeline = null
val threadId = _selectedThread.value
val threadRoomId = _selectedThreadRoomId.value
if (threadId != null && threadRoomId != null) {
try {
val client = authRepository.getClient() ?: return@withLock
val room = client.getRoom(threadRoomId) ?: return@withLock
activeThreadTimeline = room.timelineWithConfiguration(
TimelineConfiguration(
focus = TimelineFocus.Thread(rootEventId = threadId),
filter = TimelineFilter.All,
internalIdPrefix = null,
dateDividerMode = DateDividerMode.DAILY,
trackReadReceipts = TimelineReadReceiptTracking.DISABLED,
reportUtds = false,
)
)
} catch (e: Exception) {
android.util.Log.e("MainVM", "Failed to rebuild thread timeline on resume", e)
}
}
}
}
}
}
init {
viewModelScope.launch(Dispatchers.IO) {
syncService = authRepository.getOrStartSync()
@@ -193,6 +281,13 @@ class MainViewModel(
viewModelScope.launch {
preferencesManager.childSpaceOrder.collect { _childSpaceOrderMap.value = it }
}
viewModelScope.launch {
preferencesManager.threadNames.collect { _threadNames.value = it }
}
viewModelScope.launch {
preferencesManager.hiddenThreads.collect { _hiddenThreads.value = it }
}
ProcessLifecycleOwner.get().lifecycle.addObserver(lifecycleObserver)
}
private fun loadRooms() {
@@ -380,7 +475,6 @@ class MainViewModel(
timelineListenerHandle = null
activeTimeline = null
isPaginating = false
hitTimelineStart = false
membersJob?.cancel()
if (roomId == null) {
@@ -415,6 +509,8 @@ class MainViewModel(
private fun loadTimeline(roomId: String): Job {
val client = authRepository.getClient() ?: return Job()
// Reset so the new SDK timeline can paginate fresh (each room.timeline() starts a new window)
hitTimelineStartByRoom[roomId] = false
return viewModelScope.launch(Dispatchers.IO) {
try {
val room = client.getRoom(roomId) ?: return@launch
@@ -468,12 +564,18 @@ class MainViewModel(
}
}
// Rebuild cache from SDK items
cached.clear()
ids.clear()
// Merge SDK items into persistent cache — skip events already seen.
// This preserves paginated history across room visits and avoids
// re-downloading messages when re-entering a room.
for (item in sdkItems) {
val eventItem = item.asEvent() ?: continue
processEventItem(roomId, eventItem, cached, ids)
val eventId = when (val eot = eventItem.eventOrTransactionId) {
is EventOrTransactionId.EventId -> eot.eventId
is EventOrTransactionId.TransactionId -> eot.transactionId
}
if (eventId !in ids) {
processEventItem(roomId, eventItem, cached, ids)
}
}
if (_selectedChannel.value == roomId) {
_messages.value = ArrayList(cached)
@@ -510,6 +612,7 @@ class MainViewModel(
val content = eventItem.content
var replyInfo: ReplyInfo? = null
var threadRootId: String? = null
val msgContent: MessageContent = when (content) {
is TimelineItemContent.MsgLike -> {
when (val kind = content.content.kind) {
@@ -551,6 +654,26 @@ class MainViewModel(
}
} catch (_: Exception) { }
val rawJson = try { eventItem.lazyProvider.debugInfo().originalJson } catch (_: Exception) { null }
// Detect thread relation from raw JSON
threadRootId = rawJson?.let { rj ->
try {
val json = JSONObject(rj)
val contentObj = json.optJSONObject("content")
val relatesTo = contentObj?.optJSONObject("m.relates_to")
val relType = relatesTo?.optString("rel_type")
val threadEventId = if (relType == "m.thread") relatesTo?.optString("event_id") else null
if (relatesTo != null) {
android.util.Log.d("MainVM", "Event $eventId m.relates_to: rel_type=$relType threadEventId=$threadEventId")
}
threadEventId
} catch (e: Exception) {
android.util.Log.e("MainVM", "Thread JSON parse failed for $eventId", e)
null
}
}
if (rawJson == null) {
android.util.Log.w("MainVM", "rawJson is null for event $eventId")
}
resolveMessageType(kind.content.msgType, kind.content.body, rawJson)
?: return false
}
@@ -591,9 +714,28 @@ class MainViewModel(
content = msgContent,
timestamp = eventItem.timestamp.toLong(),
replyTo = replyInfo,
threadRootEventId = threadRootId,
)
ids.add(eventId)
// Thread replies go to threadMessageCache instead of main timeline
if (threadRootId != null && threadRootId.isNotEmpty()) {
android.util.Log.d("MainVM", "Filtering thread reply $eventId -> root $threadRootId from main timeline")
val roomThreads = threadMessageCache.getOrPut(roomId) { mutableMapOf() }
val threadMsgs = roomThreads.getOrPut(threadRootId) { mutableListOf() }
val tIdx = threadMsgs.binarySearch {
msg.timestamp.compareTo(it.timestamp)
}.let { if (it < 0) -(it + 1) else it }
threadMsgs.add(tIdx, msg)
rebuildThreadList(roomId)
// Update active thread view if this thread is selected
if (_selectedThread.value == threadRootId && _selectedThreadRoomId.value == roomId) {
updateThreadMessagesView(roomId, threadRootId)
}
return true
}
// Descending order (newest at index 0) — reverseLayout shows index 0 at bottom
val insertIdx = cached.binarySearch {
msg.timestamp.compareTo(it.timestamp)
@@ -1090,14 +1232,15 @@ class MainViewModel(
fun loadMoreMessages() {
val timeline = activeTimeline ?: return
if (isPaginating || hitTimelineStart) return
val roomId = _selectedChannel.value ?: return
if (isPaginating || hitTimelineStartByRoom[roomId] == true) return
isPaginating = true
viewModelScope.launch {
viewModelScope.launch(Dispatchers.IO) {
try {
val reachedStart = timeline.paginateBackwards(50u.toUShort())
if (reachedStart) {
hitTimelineStart = true
android.util.Log.d("MainVM", "Hit timeline start")
hitTimelineStartByRoom[roomId] = true
android.util.Log.d("MainVM", "Hit timeline start for $roomId")
}
} catch (e: Exception) {
android.util.Log.e("MainVM", "paginateBackwards failed", e)
@@ -1164,10 +1307,162 @@ class MainViewModel(
}
}
// --- Thread support ---
private fun rebuildThreadList(roomId: String) {
val roomThreads = threadMessageCache[roomId] ?: return
val mainMessages = messageCache[roomId] ?: emptyList()
val hiddenSet = _hiddenThreads.value
val nameMap = _threadNames.value
val threads = roomThreads
.filter { (rootId, _) -> "$roomId:$rootId" !in hiddenSet }
.map { (rootId, replies) ->
val rootMsg = mainMessages.find { it.eventId == rootId }
val rootBody = rootMsg?.let {
when (val c = it.content) {
is MessageContent.Text -> c.body.take(80)
is MessageContent.Image -> "\uD83D\uDDBC ${c.body}"
is MessageContent.File -> "\uD83D\uDCCE ${c.body}"
else -> "Thread"
}
} ?: "Thread"
val rootSender = rootMsg?.senderName ?: ""
ThreadItem(
rootEventId = rootId,
rootBody = rootBody,
rootSenderName = rootSender,
replyCount = replies.size,
lastActivity = replies.maxOfOrNull { it.timestamp } ?: 0L,
name = nameMap["$roomId:$rootId"],
)
}.sortedByDescending { it.lastActivity }
_roomThreads.value = _roomThreads.value + (roomId to threads)
}
private fun updateThreadMessagesView(roomId: String, threadRootEventId: String) {
val replies = threadMessageCache[roomId]?.get(threadRootEventId) ?: emptyList()
val rootMsg = messageCache[roomId]?.find { it.eventId == threadRootEventId }
_threadMessages.value = if (rootMsg != null) {
// replies is already descending (newest at index 0, same as main timeline).
// rootMsg has the oldest timestamp so it belongs at the end (displayed at top
// with reverseLayout = true, matching how a thread root sits above its replies).
ArrayList(replies + listOf(rootMsg))
} else {
ArrayList(replies)
}
}
fun toggleRoomThreads(roomId: String) {
val current = _expandedThreadRooms.value
_expandedThreadRooms.value = if (roomId in current) current - roomId else current + roomId
}
fun selectThread(roomId: String, threadRootEventId: String) {
_selectedThreadRoomId.value = roomId
_selectedThread.value = threadRootEventId
updateThreadMessagesView(roomId, threadRootEventId)
// Create a thread-focused timeline for sending replies into the thread.
// All access to activeThreadTimeline is serialised through threadTimelineMutex on IO.
viewModelScope.launch(Dispatchers.IO) {
val newTimeline = try {
val client = authRepository.getClient() ?: return@launch
val room = client.getRoom(roomId) ?: return@launch
room.timelineWithConfiguration(
TimelineConfiguration(
focus = TimelineFocus.Thread(rootEventId = threadRootEventId),
filter = TimelineFilter.All,
internalIdPrefix = null,
dateDividerMode = DateDividerMode.DAILY,
trackReadReceipts = TimelineReadReceiptTracking.DISABLED,
reportUtds = false,
)
)
} catch (e: Exception) {
android.util.Log.e("MainVM", "Failed to create thread timeline for $threadRootEventId", e)
return@launch
}
threadTimelineMutex.withLock {
// Guard against rapid thread-switching: discard if the selected thread changed.
if (_selectedThread.value != threadRootEventId) {
newTimeline.destroy()
return@withLock
}
activeThreadTimeline?.destroy()
activeThreadTimeline = newTimeline
}
}
}
fun closeThread() {
_selectedThread.value = null
_selectedThreadRoomId.value = null
_threadMessages.value = emptyList()
viewModelScope.launch(Dispatchers.IO) {
threadTimelineMutex.withLock {
activeThreadTimeline?.destroy()
activeThreadTimeline = null
}
}
}
fun renameThread(roomId: String, threadRootEventId: String, name: String) {
viewModelScope.launch {
preferencesManager.saveThreadName(roomId, threadRootEventId, name)
// Update in-memory immediately
val key = "$roomId:$threadRootEventId"
_threadNames.value = if (name.isBlank()) _threadNames.value - key
else _threadNames.value + (key to name)
rebuildThreadList(roomId)
}
}
fun removeThread(roomId: String, threadRootEventId: String) {
// Close thread view if this thread is selected
if (_selectedThread.value == threadRootEventId && _selectedThreadRoomId.value == roomId) {
closeThread()
}
viewModelScope.launch {
preferencesManager.setThreadHidden(roomId, threadRootEventId, true)
_hiddenThreads.value = _hiddenThreads.value + "$roomId:$threadRootEventId"
rebuildThreadList(roomId)
}
}
fun sendThreadMessage(body: String) {
viewModelScope.launch(Dispatchers.IO) {
threadTimelineMutex.withLock {
val threadTimeline = activeThreadTimeline ?: return@withLock
try {
// Thread-focused timeline sends automatically include the m.thread relation
threadTimeline.send(messageEventContentFromMarkdown(body))
} catch (e: Exception) {
android.util.Log.e("MainVM", "Failed to send thread message", e)
}
}
}
}
fun logout() {
ProcessLifecycleOwner.get().lifecycle.removeObserver(lifecycleObserver)
viewModelScope.launch {
try { syncService?.stop() } catch (_: Exception) { }
authRepository.logout()
}
}
override fun onCleared() {
super.onCleared()
ProcessLifecycleOwner.get().lifecycle.removeObserver(lifecycleObserver)
timelineListenerHandle?.cancel()
timelineListenerHandle = null
activeTimeline = null
roomPollJob?.cancel()
viewModelScope.launch(Dispatchers.IO) {
threadTimelineMutex.withLock {
activeThreadTimeline?.destroy()
activeThreadTimeline = null
}
try { syncService?.stop() } catch (_: Exception) { }
}
}
}

View File

@@ -1,8 +1,10 @@
package com.example.fluffytrix.ui.screens.main.components
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -36,6 +38,7 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
@@ -58,6 +61,7 @@ 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.ThreadItem
import com.example.fluffytrix.ui.screens.main.UnreadStatus
import kotlin.math.roundToInt
@@ -76,8 +80,17 @@ fun ChannelList(
onMoveChannel: (from: Int, to: Int) -> Unit = { _, _ -> },
onMoveChannelById: (channelId: String, delta: Int) -> Unit = { _, _ -> },
onMoveChildSpace: (from: Int, to: Int) -> Unit = { _, _ -> },
roomThreads: Map<String, List<ThreadItem>> = emptyMap(),
expandedThreadRooms: Set<String> = emptySet(),
selectedThread: String? = null,
onToggleRoomThreads: (String) -> Unit = {},
onThreadClick: (roomId: String, threadRootEventId: String) -> Unit = { _, _ -> },
onRenameThread: (roomId: String, threadRootEventId: String, name: String) -> Unit = { _, _, _ -> },
onRemoveThread: (roomId: String, threadRootEventId: String) -> Unit = { _, _ -> },
) {
var showLogoutDialog by remember { mutableStateOf(false) }
var threadActionTarget by remember { mutableStateOf<Pair<String, ThreadItem>?>(null) }
var threadRenameText by remember { mutableStateOf("") }
val collapsedSections = remember { mutableStateMapOf<String, Boolean>() }
// Channel drag state — track by ID, no visual offset (let LazyColumn handle positioning)
@@ -106,6 +119,52 @@ fun ChannelList(
)
}
threadActionTarget?.let { (roomId, thread) ->
AlertDialog(
onDismissRequest = { threadActionTarget = null },
title = { Text("Thread") },
text = {
Column {
Text("Rename this thread:", style = MaterialTheme.typography.bodyMedium)
Spacer(modifier = Modifier.height(8.dp))
TextField(
value = threadRenameText,
onValueChange = { threadRenameText = it },
placeholder = { Text(thread.name ?: thread.rootBody) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
}
},
confirmButton = {
TextButton(onClick = {
onRenameThread(roomId, thread.rootEventId, threadRenameText.trim())
threadRenameText = ""
threadActionTarget = null
}) {
Text("Save")
}
},
dismissButton = {
Row {
TextButton(onClick = {
onRemoveThread(roomId, thread.rootEventId)
threadRenameText = ""
threadActionTarget = null
}) {
Text("Remove", color = MaterialTheme.colorScheme.error)
}
TextButton(onClick = {
threadRenameText = ""
threadActionTarget = null
}) {
Text("Cancel")
}
}
},
)
}
Column(
modifier = Modifier
.fillMaxWidth()
@@ -262,107 +321,172 @@ fun ChannelList(
val isSelected = channel.id == selectedChannel
val hasUnread = channel.unreadStatus != UnreadStatus.NONE
val isDragging = draggingChannelId == channel.id
val threads = roomThreads[channel.id] ?: emptyList()
val isThreadExpanded = channel.id in expandedThreadRooms
Row(
modifier = Modifier
.fillMaxWidth()
.then(
if (isDragging) Modifier
.zIndex(1f)
.background(MaterialTheme.colorScheme.surfaceContainerHigh)
else Modifier
)
.clip(RoundedCornerShape(8.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 = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (isReorderMode) {
val currentChannelId by rememberUpdatedState(channel.id)
val currentSectionSize by rememberUpdatedState(section.channels.size)
val currentLocalIndex by rememberUpdatedState(index)
Column {
Row(
modifier = Modifier
.fillMaxWidth()
.then(
if (isDragging) Modifier
.zIndex(1f)
.background(MaterialTheme.colorScheme.surfaceContainerHigh)
else Modifier
)
.clip(RoundedCornerShape(8.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 = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (isReorderMode) {
val currentChannelId by rememberUpdatedState(channel.id)
val currentSectionSize by rememberUpdatedState(section.channels.size)
val currentLocalIndex by rememberUpdatedState(index)
Icon(
imageVector = Icons.Default.DragHandle,
contentDescription = "Drag to reorder",
modifier = Modifier
.size(20.dp)
.pointerInput(Unit) {
detectDragGesturesAfterLongPress(
onDragStart = {
draggingChannelId = currentChannelId
dragAccumulator = 0f
},
onDrag = { change, dragAmount ->
change.consume()
dragAccumulator += dragAmount.y
val itemHeight = 56.dp.toPx()
val id = draggingChannelId ?: return@detectDragGesturesAfterLongPress
if (dragAccumulator > itemHeight * 0.6f && currentLocalIndex < currentSectionSize - 1) {
onMoveChannelById(id, 1)
dragAccumulator = 0f
} else if (dragAccumulator < -itemHeight * 0.6f && currentLocalIndex > 0) {
onMoveChannelById(id, -1)
dragAccumulator = 0f
}
},
onDragEnd = {
draggingChannelId = null
dragAccumulator = 0f
},
onDragCancel = {
draggingChannelId = null
dragAccumulator = 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 = Icons.Default.DragHandle,
contentDescription = "Drag to reorder",
modifier = Modifier
.size(20.dp)
.pointerInput(Unit) {
detectDragGesturesAfterLongPress(
onDragStart = {
draggingChannelId = currentChannelId
dragAccumulator = 0f
},
onDrag = { change, dragAmount ->
change.consume()
dragAccumulator += dragAmount.y
val itemHeight = 56.dp.toPx()
val id = draggingChannelId ?: return@detectDragGesturesAfterLongPress
if (dragAccumulator > itemHeight * 0.6f && currentLocalIndex < currentSectionSize - 1) {
onMoveChannelById(id, 1)
dragAccumulator = 0f
} else if (dragAccumulator < -itemHeight * 0.6f && currentLocalIndex > 0) {
onMoveChannelById(id, -1)
dragAccumulator = 0f
}
},
onDragEnd = {
draggingChannelId = null
dragAccumulator = 0f
},
onDragCancel = {
draggingChannelId = null
dragAccumulator = 0f
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,
modifier = Modifier.weight(1f),
)
// Thread expand/collapse chevron
if (threads.isNotEmpty() && !isReorderMode) {
Icon(
imageVector = if (isThreadExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
contentDescription = if (isThreadExpanded) "Collapse threads" else "Expand threads",
modifier = Modifier
.size(20.dp)
.clickable { onToggleRoomThreads(channel.id) },
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
)
}
}
// Thread items under channel
if (isThreadExpanded && threads.isNotEmpty()) {
for (thread in threads) {
val isThreadSelected = selectedThread == thread.rootEventId
@OptIn(ExperimentalFoundationApi::class)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 24.dp)
.clip(RoundedCornerShape(6.dp))
.background(
if (isThreadSelected) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.6f)
else MaterialTheme.colorScheme.surface
)
.combinedClickable(
onClick = { onThreadClick(channel.id, thread.rootEventId) },
onLongClick = {
threadRenameText = thread.name ?: ""
threadActionTarget = channel.id to thread
},
)
},
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.width(6.dp))
.padding(horizontal = 10.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Default.Tag,
contentDescription = null,
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = thread.name ?: thread.rootBody,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "${thread.replyCount}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
)
}
}
}
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,
)
}
}
}

View File

@@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -59,6 +60,7 @@ import com.mikepenz.markdown.m3.Markdown
import com.mikepenz.markdown.m3.markdownColor
import com.mikepenz.markdown.m3.markdownTypography
import com.mikepenz.markdown.coil3.Coil3ImageTransformerImpl
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.AttachFile
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.KeyboardArrowDown
@@ -131,6 +133,13 @@ fun MessageTimeline(
unreadMarkerIndex: Int = -1,
modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues(),
selectedThread: String? = null,
threadMessages: List<MessageItem> = emptyList(),
onCloseThread: () -> Unit = {},
onSendThreadMessage: (String) -> Unit = {},
onOpenThread: (String) -> Unit = {},
threadReplyCounts: Map<String, Int> = emptyMap(),
selectedThreadName: String? = null,
) {
var fullscreenImageUrl by remember { mutableStateOf<String?>(null) }
var fullscreenVideoUrl by remember { mutableStateOf<String?>(null) }
@@ -157,10 +166,17 @@ fun MessageTimeline(
modifier = modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.padding(top = contentPadding.calculateTopPadding()),
.padding(
top = contentPadding.calculateTopPadding(),
bottom = contentPadding.calculateBottomPadding(),
),
) {
if (selectedChannel != null) {
TopBar(channelName ?: selectedChannel, onToggleMemberList)
if (selectedThread != null) {
ThreadTopBar(selectedThreadName ?: "Thread in #${channelName ?: selectedChannel}", onCloseThread)
} else {
TopBar(channelName ?: selectedChannel, onToggleMemberList)
}
HorizontalDivider(color = MaterialTheme.colorScheme.surfaceVariant)
}
@@ -176,7 +192,13 @@ fun MessageTimeline(
)
}
} else {
val listState = rememberLazyListState()
val activeMessages = if (selectedThread != null) threadMessages else messages
val activeSend: (String) -> Unit = if (selectedThread != null) onSendThreadMessage else onSendMessage
// Separate list states so the thread view always starts at the bottom (newest)
// and doesn't inherit the main timeline's scroll position.
val mainListState = rememberLazyListState()
val threadListState = remember(selectedThread) { LazyListState() }
val listState = if (selectedThread != null) threadListState else mainListState
val scope = rememberCoroutineScope()
val isAtBottom by remember {
derivedStateOf {
@@ -188,15 +210,15 @@ fun MessageTimeline(
val shouldLoadMore by remember {
derivedStateOf {
val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
lastVisible >= messages.size - 5
lastVisible >= activeMessages.size - 5
}
}
LaunchedEffect(shouldLoadMore, messages.size) {
if (shouldLoadMore && messages.isNotEmpty()) onLoadMore()
LaunchedEffect(shouldLoadMore, activeMessages.size) {
if (shouldLoadMore && activeMessages.isNotEmpty() && selectedThread == null) onLoadMore()
}
// Auto-scroll when near bottom and new messages arrive
LaunchedEffect(messages.size) {
LaunchedEffect(activeMessages.size) {
if (listState.firstVisibleItemIndex <= 2) {
listState.animateScrollToItem(0)
}
@@ -205,7 +227,7 @@ fun MessageTimeline(
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
CompositionLocalProvider(
LocalScrollToEvent provides { eventId ->
val idx = messages.indexOfFirst { it.eventId == eventId }
val idx = activeMessages.indexOfFirst { it.eventId == eventId }
if (idx >= 0) {
scope.launch { listState.animateScrollToItem(idx) }
}
@@ -217,24 +239,23 @@ fun MessageTimeline(
reverseLayout = true,
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
) {
val count = messages.size
val count = activeMessages.size
items(
count = count,
key = { messages[it].eventId },
key = { activeMessages[it].eventId },
contentType = {
val msg = messages[it]
val next = if (it + 1 < count) messages[it + 1] else null
val msg = activeMessages[it]
val next = if (it + 1 < count) activeMessages[it + 1] else null
if (next == null || next.senderId != msg.senderId) 0 else 1
},
) { index ->
val message = messages[index]
val next = if (index + 1 < count) messages[index + 1] else null
val isFirstInGroup = next == null || next.senderName != message.senderName || message.replyTo != null
val message = activeMessages[index]
val next = if (index + 1 < count) activeMessages[index + 1] else null
val isFirstInGroup = next == null || next.senderId != message.senderId || message.replyTo != null
val effectiveUnreadMarker = if (selectedThread != null) -1 else unreadMarkerIndex
// 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) {
if (index == effectiveUnreadMarker) {
Column {
Spacer(modifier = Modifier.height(8.dp))
Row(
@@ -260,7 +281,7 @@ fun MessageTimeline(
Spacer(modifier = Modifier.height(8.dp))
if (isFirstInGroup) {
Spacer(modifier = Modifier.height(12.dp))
FullMessage(message)
FullMessage(message, onOpenThread = onOpenThread, threadReplyCount = threadReplyCounts[message.eventId] ?: 0)
} else {
CompactMessage(message)
}
@@ -268,7 +289,7 @@ fun MessageTimeline(
} else {
if (isFirstInGroup) {
Spacer(modifier = Modifier.height(12.dp))
FullMessage(message)
FullMessage(message, onOpenThread = onOpenThread)
} else {
CompactMessage(message)
}
@@ -298,7 +319,11 @@ fun MessageTimeline(
}
HorizontalDivider(color = MaterialTheme.colorScheme.surfaceVariant)
MessageInput(channelName ?: "message", onSendMessage, onSendFiles)
MessageInput(
channelName = if (selectedThread != null) "thread" else (channelName ?: "message"),
onSendMessage = activeSend,
onSendFiles = onSendFiles,
)
}
}
}
@@ -326,7 +351,27 @@ private fun TopBar(name: String, onToggleMemberList: () -> Unit) {
}
@Composable
private fun FullMessage(message: MessageItem) {
private fun ThreadTopBar(title: String, onClose: () -> Unit) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onClose) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back", tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
Spacer(Modifier.width(4.dp))
Text(
title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.weight(1f),
)
}
}
@Composable
private fun FullMessage(message: MessageItem, onOpenThread: (String) -> Unit = {}, threadReplyCount: Int = 0) {
val senderColor = remember(message.senderName) { colorForSender(message.senderName) }
val time = remember(message.timestamp) { formatTimestamp(message.timestamp) }
val reply = message.replyTo
@@ -366,6 +411,16 @@ private fun FullMessage(message: MessageItem) {
}
Spacer(Modifier.height(2.dp))
MessageContentView(message.content)
if (threadReplyCount > 0) {
Text(
text = "$threadReplyCount ${if (threadReplyCount == 1) "reply" else "replies"}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier
.clickable { onOpenThread(message.eventId) }
.padding(top = 2.dp),
)
}
}
}
}