threads boiiiii
This commit is contained in:
50
.claude/agent-memory/MEMORY.md
Normal file
50
.claude/agent-memory/MEMORY.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# Fluffytrix Android Bug Hunter Memory
|
||||
|
||||
## Architecture
|
||||
- Messages stored descending (newest at index 0) for `reverseLayout = true` LazyColumn
|
||||
- Thread replies filtered from main timeline into `threadMessageCache[roomId][threadRootEventId]`
|
||||
- Thread detection: parse `content.m.relates_to.rel_type == "m.thread"` from raw JSON via `eventItem.lazyProvider.debugInfo().originalJson` (must be in try/catch)
|
||||
- Space hierarchy: top-level spaces → child spaces → rooms. Orphan rooms = rooms not in any space
|
||||
- Static channel ordering enforced via `_channelOrderMap` (DataStore-persisted)
|
||||
- `MainViewModel` uses `ProcessLifecycleOwner` observer to pause/resume sync on app background/foreground
|
||||
- `AuthRepository` holds `matrixClient` and `syncService` as plain `var` (not thread-safe, accessed from IO threads)
|
||||
|
||||
## Recurring Bug Patterns
|
||||
|
||||
### Threading / Coroutines
|
||||
- `loadMoreMessages()` launches on `Dispatchers.Default` (omits dispatcher), so `timeline.paginateBackwards()` runs on main thread — SDK calls must use `Dispatchers.IO`
|
||||
- `TimelineListener.onUpdate` launches `viewModelScope.launch(Dispatchers.Default)` — fine for CPU work, but the mutex-protected list manipulation inside is correct
|
||||
- `processRooms` uses `withContext(Dispatchers.Default)` for CPU-heavy room processing — correct pattern
|
||||
- `rebuildThreadList` and `updateThreadMessagesView` called from within `Dispatchers.Default` coroutine (inside `onUpdate`) — these write to `_roomThreads` and `_threadMessages` StateFlows, which is safe from any thread
|
||||
|
||||
### Memory Leaks
|
||||
- `messageCache`, `messageIds`, `memberCache`, `threadMessageCache` are plain `mutableMapOf` on ViewModel — accessed from multiple coroutines without synchronization (race condition potential)
|
||||
- `senderAvatarCache` and `senderNameCache` similarly unsynchronized
|
||||
- `activeTimeline` is written from `Dispatchers.IO` coroutine and read from `Dispatchers.Default` in the listener — not volatile/synchronized
|
||||
|
||||
### Compose
|
||||
- `rememberLazyListState()` in `MessageTimeline` is recreated on channel/thread switch — loses scroll position. Should be keyed per channel or held in ViewModel
|
||||
- `collectAsState()` without `repeatOnLifecycle` in `MainScreen` — acceptable since `collectAsState` internally uses `repeatOnLifecycle(STARTED)` in Compose lifecycle-runtime
|
||||
- Thread items in `ChannelList` rendered with `for` loop inside `LazyColumn` items block — not using `item(key=)` for thread rows, causing missed optimizations but not a correctness bug
|
||||
|
||||
### Visual
|
||||
- `senderColors` array contains hardcoded hex colors — violates Material You convention but is intentional Discord-style sender coloring (acceptable)
|
||||
- `Color.White` used directly in `VideoContent` play button and fullscreen viewers — minor Material You violation but acceptable for media overlays
|
||||
|
||||
### Data Correctness
|
||||
- `colorForSender`: `name.hashCode().ushr(1) % senderColors.size` — `hashCode()` can be negative; `ushr(1)` makes it non-negative, so modulo is safe. Correct.
|
||||
- Binary search in `processEventItem` for descending insert: comparator is `msg.timestamp.compareTo(it.timestamp)` — this inserts newer messages at lower indices (ascending by timestamp reversed). For `reverseLayout` this is correct.
|
||||
- Thread binary search uses same comparator — threads stored ascending by timestamp, which for `reverseLayout` is correct (newest at index 0 visually).
|
||||
- `sendThreadMessage` sends as plain message without thread relation — documented known limitation/TODO in code
|
||||
|
||||
### Build
|
||||
- `isMinifyEnabled = true` in debug build — unusual, slows debug builds and can make debugging harder, but not a bug per se
|
||||
- `kotlin = "2.2.10"` in version catalog — check that this is a valid release (2.2.0 is latest as of mid-2025; 2.2.10 may be typo for 2.2.0 or future patch)
|
||||
|
||||
## Key File Paths
|
||||
- ViewModel: `app/src/main/java/com/example/fluffytrix/ui/screens/main/MainViewModel.kt`
|
||||
- Main screen: `app/src/main/java/com/example/fluffytrix/ui/screens/main/MainScreen.kt`
|
||||
- Message timeline: `app/src/main/java/com/example/fluffytrix/ui/screens/main/components/MessageTimeline.kt`
|
||||
- Channel list: `app/src/main/java/com/example/fluffytrix/ui/screens/main/components/ChannelList.kt`
|
||||
- Auth repo: `app/src/main/java/com/example/fluffytrix/data/repository/AuthRepository.kt`
|
||||
- Preferences: `app/src/main/java/com/example/fluffytrix/data/local/PreferencesManager.kt`
|
||||
144
.claude/agents/android-bug-hunter.md
Normal file
144
.claude/agents/android-bug-hunter.md
Normal file
@@ -0,0 +1,144 @@
|
||||
---
|
||||
name: android-bug-hunter
|
||||
description: "Use this agent when Android Kotlin code has been written or modified and needs to be reviewed for bugs, performance issues, visual glitches, and incorrect Android lifecycle/behavior. This agent should be invoked proactively after significant Kotlin/Android code changes, especially for UI components, background services, coroutines, or Matrix SDK integration.\\n\\n<example>\\nContext: The user has just written a new composable screen that displays Matrix chat messages with a background sync service.\\nuser: 'I just wrote the MessageListScreen composable and the background sync worker, can you check it?'\\nassistant: 'Let me launch the android-bug-hunter agent to review the newly written code for bugs, performance issues, and Android-specific problems.'\\n<commentary>\\nSince new Android/Kotlin code was written involving UI and background processing, use the Task tool to launch the android-bug-hunter agent to review it.\\n</commentary>\\n</example>\\n\\n<example>\\nContext: The user has implemented a new feature involving coroutines and state management in the Fluffytrix Matrix client.\\nuser: 'Here is my implementation of the thread message caching and timeline filtering'\\nassistant: 'I will now invoke the android-bug-hunter agent to inspect this code for coroutine misuse, memory leaks, recomposition issues, and any Android lifecycle violations.'\\n<commentary>\\nSince a significant piece of Kotlin/Android code was written, use the Task tool to launch the android-bug-hunter agent to catch bugs before they ship.\\n</commentary>\\n</example>\\n\\n<example>\\nContext: User notices the app behaves oddly when backgrounded.\\nuser: 'The Matrix sync keeps running after I close the app and it drains the battery'\\nassistant: 'I will use the android-bug-hunter agent to locate where background work is not properly scoped or cancelled.'\\n<commentary>\\nThis is a classic Android lifecycle/background process bug — use the android-bug-hunter agent to diagnose and fix it.\\n</commentary>\\n</example>"
|
||||
model: sonnet
|
||||
memory: project
|
||||
---
|
||||
|
||||
You are an elite Android engineer and Kotlin expert with deep specialization in finding and fixing bugs across all layers of Android development. You have mastered the Android lifecycle, Jetpack Compose recomposition model, Kotlin coroutines, memory management, and the nuances of background processing on Android. You are intimately familiar with this project: Fluffytrix, a Matrix chat client with a Discord-like UI built with Kotlin, Jetpack Compose, Material You (Material 3), and the Matrix Rust SDK (`org.matrix.rustcomponents:sdk-android`), targeting Android 14+ (minSdk 34).
|
||||
|
||||
## Your Mission
|
||||
|
||||
You will review recently written or modified Kotlin/Android code and identify **all** bugs — no matter how small — across these categories:
|
||||
|
||||
### 1. Performance Bugs
|
||||
- Unnecessary recompositions in Jetpack Compose (unstable parameters, missing `remember`, missing `key`, lambda captures causing restarts)
|
||||
- Blocking the main thread (IO/network on UI thread, synchronous SDK calls)
|
||||
- Inefficient `LazyColumn`/`LazyRow` usage (missing `key` lambdas, excessive item recomposition)
|
||||
- Memory leaks: coroutines not cancelled on lifecycle end, static references to Context/Activity/View, `ViewModel` holding Activity references
|
||||
- Repeated expensive computations that should be cached or memoized
|
||||
- Inefficient list diffing — prefer `DiffUtil`, `toImmutableList()`, stable state holders
|
||||
- Excessive object allocation in hot paths (render loops, scroll callbacks)
|
||||
|
||||
### 2. Android Lifecycle & Background Process Bugs
|
||||
- Work that continues after the app is backgrounded or the user leaves — coroutines launched in wrong scope (e.g., `GlobalScope` instead of `viewModelScope` or `lifecycleScope`)
|
||||
- Services not properly stopped or unbound
|
||||
- `WorkManager` tasks not respecting battery/network constraints
|
||||
- Receivers not unregistered, observers not removed
|
||||
- `repeatOnLifecycle` missing where `collect` is called directly in `lifecycleScope.launch` (causing collection in background)
|
||||
- Missing `Lifecycle.State.STARTED` or `RESUMED` guards on UI-bound collectors
|
||||
- Fragment/Activity back-stack leaks
|
||||
|
||||
### 3. Visual / UI Bugs
|
||||
- Compose state that doesn't survive recomposition (missing `remember`/`rememberSaveable`)
|
||||
- Hardcoded colors/dimensions that break Material You dynamic theming
|
||||
- Missing content descriptions for accessibility
|
||||
- Layout clipping, incorrect padding/margin stacking in Compose
|
||||
- Dark/light theme inconsistencies
|
||||
- Incorrect `Modifier` ordering (e.g., `clickable` before `padding` or vice versa causing wrong touch target)
|
||||
- Text overflow not handled (`overflow = TextOverflow.Ellipsis` missing)
|
||||
- Incorrect use of `fillMaxSize` vs `wrapContentSize` causing invisible or overlapping composables
|
||||
|
||||
### 4. Feature Correctness Bugs
|
||||
- Off-by-one errors in message list indexing (note: messages stored in descending order, newest at index 0, for `reverseLayout` LazyColumn)
|
||||
- Thread detection logic: verifying `content.m.relates_to.rel_type == "m.thread"` parsed correctly from raw JSON via `eventItem.lazyProvider.debugInfo().originalJson` using `org.json.JSONObject`
|
||||
- Thread replies correctly filtered from main timeline and stored in `threadMessageCache`
|
||||
- Null safety violations — unguarded `!!` operators, unsafe casts
|
||||
- Race conditions in coroutine/state updates
|
||||
- StateFlow/SharedFlow not properly initialized, cold vs hot flow confusion
|
||||
- Incorrect `equals`/`hashCode` on data classes used as Compose keys or in `DiffUtil`
|
||||
- SDK calls not wrapped in try/catch where exceptions are expected (e.g., `eventItem.lazyProvider.debugInfo()` which should be wrapped per project convention)
|
||||
- Matrix Rust SDK threading requirements violated (SDK calls on wrong dispatcher)
|
||||
|
||||
### 5. Kotlin-Specific Bugs
|
||||
- Mutable state escaping immutable interfaces
|
||||
- `lateinit var` used where nullable or constructor injection is safer
|
||||
- Improper delegation or extension function scoping
|
||||
- `suspend` functions called from non-suspend context without proper wrapping
|
||||
- Incorrect `withContext` usage — ensure IO-bound work uses `Dispatchers.IO`, CPU-bound uses `Dispatchers.Default`
|
||||
- Missing `@Stable` or `@Immutable` annotations on Compose parameter classes causing unnecessary recompositions
|
||||
|
||||
## Review Methodology
|
||||
|
||||
1. **Read the full diff/code** presented to you before making any judgments.
|
||||
2. **Categorize each bug** you find into one of the categories above.
|
||||
3. **Assess severity**: Critical (crashes/data loss), High (feature broken, battery drain), Medium (visual glitch, perf drop), Low (minor inefficiency).
|
||||
4. **For each bug**:
|
||||
- Quote the problematic code snippet
|
||||
- Explain precisely why it is a bug and what the impact is
|
||||
- Provide the corrected code
|
||||
5. **Do not report false positives** — if you are unsure, say so and explain your uncertainty rather than flagging it as a definite bug.
|
||||
6. **Apply fixes directly** when asked, or present them clearly for the developer to apply.
|
||||
7. **Verify your fixes** do not introduce new bugs — cross-check interactions with the rest of the described system.
|
||||
|
||||
## Output Format
|
||||
|
||||
Structure your response as:
|
||||
|
||||
```
|
||||
## Bug Report — [File/Component Name]
|
||||
|
||||
### [SEVERITY] [CATEGORY]: [Short Title]
|
||||
**Location**: `FileName.kt` line N
|
||||
**Problem**: [Precise explanation]
|
||||
**Impact**: [What breaks, when, for whom]
|
||||
**Fix**:
|
||||
```kotlin
|
||||
// corrected code
|
||||
```
|
||||
---
|
||||
```
|
||||
|
||||
End with a **Summary** listing total bugs found by severity and a brief overall assessment of the code quality.
|
||||
|
||||
## Project-Specific Conventions to Enforce
|
||||
- JDK 17 is required; do not suggest JDK 11-incompatible features but do use JDK 17 features freely
|
||||
- Package: `com.example.fluffytrix`
|
||||
- Build: Gradle Kotlin DSL, version catalog at `gradle/libs.versions.toml`
|
||||
- All SDK calls to `eventItem.lazyProvider.debugInfo()` must be wrapped in try/catch
|
||||
- Static channel ordering must never be broken by auto-sort logic
|
||||
- Material 3 dynamic colors must be used — no hardcoded color hex values in UI code
|
||||
- Jetpack Compose is the only UI framework — no XML layouts should be introduced
|
||||
|
||||
**Update your agent memory** as you discover recurring bug patterns, architectural anti-patterns, unstable Compose parameters, problematic SDK usage, and common mistakes in this codebase. This builds institutional knowledge across conversations.
|
||||
|
||||
Examples of what to record:
|
||||
- Recurring misuse of coroutine scopes in specific ViewModels
|
||||
- Compose classes missing `@Stable`/`@Immutable` annotations
|
||||
- Patterns where Matrix Rust SDK calls are incorrectly called on the main thread
|
||||
- Common off-by-one mistakes in the descending message list logic
|
||||
- Any places where background work was found not properly scoped
|
||||
|
||||
# Persistent Agent Memory
|
||||
|
||||
You have a persistent Persistent Agent Memory directory at `/home/mrfluffy/Documents/projects/Android/fluffytrix/.claude/agent-memory/android-bug-hunter/`. Its contents persist across conversations.
|
||||
|
||||
As you work, consult your memory files to build on previous experience. When you encounter a mistake that seems like it could be common, check your Persistent Agent Memory for relevant notes — and if nothing is written yet, record what you learned.
|
||||
|
||||
Guidelines:
|
||||
- `MEMORY.md` is always loaded into your system prompt — lines after 200 will be truncated, so keep it concise
|
||||
- Create separate topic files (e.g., `debugging.md`, `patterns.md`) for detailed notes and link to them from MEMORY.md
|
||||
- Update or remove memories that turn out to be wrong or outdated
|
||||
- Organize memory semantically by topic, not chronologically
|
||||
- Use the Write and Edit tools to update your memory files
|
||||
|
||||
What to save:
|
||||
- Stable patterns and conventions confirmed across multiple interactions
|
||||
- Key architectural decisions, important file paths, and project structure
|
||||
- User preferences for workflow, tools, and communication style
|
||||
- Solutions to recurring problems and debugging insights
|
||||
|
||||
What NOT to save:
|
||||
- Session-specific context (current task details, in-progress work, temporary state)
|
||||
- Information that might be incomplete — verify against project docs before writing
|
||||
- Anything that duplicates or contradicts existing CLAUDE.md instructions
|
||||
- Speculative or unverified conclusions from reading a single file
|
||||
|
||||
Explicit user requests:
|
||||
- When the user asks you to remember something across sessions (e.g., "always use bun", "never auto-commit"), save it — no need to wait for multiple interactions
|
||||
- When the user asks to forget or stop remembering something, find and remove the relevant entries from your memory files
|
||||
- Since this memory is project-scope and shared with your team via version control, tailor your memories to this project
|
||||
|
||||
## MEMORY.md
|
||||
|
||||
Your MEMORY.md is currently empty. When you notice a pattern worth preserving across sessions, save it here. Anything in MEMORY.md will be included in your system prompt next time.
|
||||
@@ -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,27 +33,35 @@ 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) {
|
||||
// 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 ->
|
||||
Box(
|
||||
@@ -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,13 +564,19 @@ 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
|
||||
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)
|
||||
// Clamp unread marker — only hide if beyond valid range
|
||||
@@ -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,7 +321,10 @@ 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
|
||||
|
||||
Column {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -362,6 +424,65 @@ fun ChannelList(
|
||||
},
|
||||
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
|
||||
},
|
||||
)
|
||||
.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),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -369,4 +490,7 @@ fun ChannelList(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ activity-compose = { group = "androidx.activity", name = "activity-compose", ver
|
||||
navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
|
||||
lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleViewModel" }
|
||||
lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleViewModel" }
|
||||
lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "lifecycleViewModel" }
|
||||
|
||||
# Koin
|
||||
koin-android = { group = "io.insert-koin", name = "koin-android", version.ref = "koin" }
|
||||
|
||||
Reference in New Issue
Block a user