threads boiiiii
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user