Compare commits

..

6 Commits

Author SHA1 Message Date
6a87a33ea0 custom emojis 2026-03-02 23:14:44 +00:00
2b554dc227 better emoji reactions 2026-03-02 22:21:23 +00:00
2169f28632 version bump 2026-03-02 18:21:36 +00:00
21aed4f682 sync fix and reactions 2026-03-02 18:20:07 +00:00
b58f745fbc threads boiiiii 2026-03-02 16:30:40 +00:00
d0311e7632 better threading 2026-02-25 16:19:24 +00:00
21 changed files with 2386 additions and 327 deletions

View 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`

View 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.

View File

@@ -13,7 +13,9 @@
"WebFetch(domain:raw.githubusercontent.com)", "WebFetch(domain:raw.githubusercontent.com)",
"Bash(export TERM=dumb:*)", "Bash(export TERM=dumb:*)",
"Bash(grep:*)", "Bash(grep:*)",
"Bash(jar tf:*)" "Bash(jar tf:*)",
"Bash(javap:*)",
"Bash(jar xf:*)"
] ]
} }
} }

1
.idea/.name generated Normal file
View File

@@ -0,0 +1 @@
Fluffytrix

2
.idea/gradle.xml generated
View File

@@ -6,7 +6,7 @@
<GradleProjectSettings> <GradleProjectSettings>
<option name="testRunner" value="CHOOSE_PER_TEST" /> <option name="testRunner" value="CHOOSE_PER_TEST" />
<option name="externalProjectPath" value="$PROJECT_DIR$" /> <option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" /> <option name="gradleJvm" value="#JAVA_HOME" />
<option name="modules"> <option name="modules">
<set> <set>
<option value="$PROJECT_DIR$" /> <option value="$PROJECT_DIR$" />

6
.idea/studiobot.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="StudioBotProjectSettings">
<option name="shareContext" value="OptedIn" />
</component>
</project>

161
AGENTS.md
View File

@@ -6,7 +6,7 @@ Fluffytrix is an Android Matrix chat client with a Discord-like UI. Built with K
**Package**: `com.example.fluffytrix` **Package**: `com.example.fluffytrix`
**Build system**: Gradle with KotlinDSL, version catalog at `gradle/libs.versions.toml` **Build system**: Gradle with KotlinDSL, version catalog at `gradle/libs.versions.toml`
**Container/DI**: Koin **DI**: Koin
**State management**: Jetpack Compose StateFlow, ViewModel **State management**: Jetpack Compose StateFlow, ViewModel
**UI framework**: Jetpack Compose with Material 3 (Dynamic Colors) **UI framework**: Jetpack Compose with Material 3 (Dynamic Colors)
**Protocol**: Trixnity SDK for Matrix **Protocol**: Trixnity SDK for Matrix
@@ -20,28 +20,22 @@ Fluffytrix is an Android Matrix chat client with a Discord-like UI. Built with K
```bash ```bash
./gradlew assembleDebug # Build debug APK (minified for performance) ./gradlew assembleDebug # Build debug APK (minified for performance)
./gradlew assembleRelease # Build release APK ./gradlew assembleRelease # Build release APK
./gradlew test # Run unit tests ./gradlew test # Run all unit tests
./gradlew connectedAndroidTest # Run instrumented tests on device/emulator ./gradlew connectedAndroidTest # Run instrumented tests on device
./gradlew testDebugUnitTest --tests "com.example.fluffytrix.ExampleUnitTest" # Run single test ./gradlew testDebugUnitTest --tests "com.example.fluffytrix.*" # Run single test class
./gradlew app:testDebugUnitTest --tests "LoginViewModelTest" # Run specific test
``` ```
**Notes**: - Debug builds use R8 with `isDebuggable = true` for balanced speed/debuggability
- Debug builds use R8 with `isDebuggable = true` but strip material-icons-extended and optimize Compose for performance
- Use `--tests` with Gradle test tasks to run a single test class
- Instrumented tests require a connected device or emulator - Instrumented tests require a connected device or emulator
--- ---
## Testing ## Testing
**Unit tests**: Located in `app/src/test/java/`, run with `./gradlew test` **Unit tests**: `app/src/test/java/`
**Instrumented tests**: Located in `app/src/androidTest/java/`, run with `./gradlew connectedAndroidTest` **Instrumented tests**: `app/src/androidTest/java/`
**Single test**: `./gradlew testDebugUnitTest --tests "com.example.fluffytrix.ui.main.LoginViewModelTest"`
**Single test execution**:
```bash
./gradlew testDebugUnitTest --tests "com.example.fluffytrix.*"
./gradlew app:testDebugUnitTest --tests "ExampleUnitTest"
```
--- ---
@@ -49,17 +43,17 @@ Fluffytrix is an Android Matrix chat client with a Discord-like UI. Built with K
### Kotlin Conventions ### Kotlin Conventions
- **Android Official Kotlin style** (`kotlin.code.style=official` in gradle.properties) - **Android Official Kotlin style** (`kotlin.code.style=official`)
- File naming: `PascalCase.kt` (e.g., `MainViewModel.kt`) - File naming: `PascalCase.kt` (e.g., `MainViewModel.kt`)
- Class naming: `PascalCase` (e.g., `MainViewModel`, `AuthRepository`) - Class naming: `PascalCase` (e.g., `AuthRepository`, `MainViewModel`)
- Function/property naming: `camelCase` (e.g., `sendMessage`, `selectedChannel`) - Function/property naming: `camelCase` (e.g., `sendMessage`, `selectedChannel`)
- Constant naming: `PascalCase` for top-level constants - Constants: `PascalCase` for top-level constants
- **Never use underscores in variable names** - **Never use underscores in variable names**
### Imports ### Imports
- Explicit imports only (no wildcard imports) - Explicit imports only (no wildcards)
- Group imports: Android/X → Kotlin → Javax/Java → Third-party → Same package - Group: Android/X → Kotlin → Javax/Java → Third-party → Same package
- Example: - Example:
```kotlin ```kotlin
import android.os.Bundle import android.os.Bundle
@@ -69,59 +63,37 @@ Fluffytrix is an Android Matrix chat client with a Discord-like UI. Built with K
import com.example.fluffytrix.ui.theme.FluffytrixTheme import com.example.fluffytrix.ui.theme.FluffytrixTheme
``` ```
### Types ### Types & Error Handling
- Prefer `val` over `var` (immutable data) - Prefer `val` over `var` (immutable data)
- Use `StateFlow<T>` for observable state in ViewModels - Use `StateFlow<T>` for observable ViewModel state, `Flow<T>` for read-only streams
- Use `Flow<T>` for read-only data streams - Use `suspend` for async operations, `Result<T>` for failing operations
- Use `suspend` functions for async operations - `try-catch` with `catch (_: Exception) { }` for graceful degradation in ViewModels
- Use `Result<T>` for operations that can fail - Use `?:` operators when appropriate, **never crash on recoverable errors**
### Error Handling ### Compose & ViewModels
- Prefer `try-catch` with silent failure or `?` operators where appropriate - Use `@Composable` for all UI functions; use `MaterialTheme` for consistent theming
- In ViewModels, use `catch (_: Exception) { }` or `?:` for graceful degradation - Discord-like layout: space sidebar → channel list → message area → member list
- Expose error state via `StateFlow<AuthState>` where users need feedback - Use `Modifier.padding()`, `wrapContentWidth()`, `fillMaxWidth()` appropriately
- **Never crash the app on recoverable errors** - Inject deps via constructor with Koin; use `viewModelScope` for coroutines
- Cache expensive operations (e.g., `messageCache`, `memberCache`)
### Compose UI ### Coroutines & Data Layer
- Use `@Composable` for all UI functions - `Dispatchers.Default` for CPU work, `Dispatchers.IO` for I/O operations
- Follow Discord-like layout: space sidebar → channel list → message area → member list - Cancel jobs on ViewModel cleanup: `job?.cancel()`
- Use `MaterialTheme` for consistent theming - Use `Room` for persistent storage; `DataStore Preferences` for small key-value data
- Prefer `Modifier.padding()` over nested `Box` with margins - Prefer Flow-based APIs; cache in ViewModels
- Use `wrapContentWidth()` and `fillMaxWidth()` appropriately
- For columnar layouts: `Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp))`
### ViewModels
- Inject dependencies via constructor (Koin)
- Use `viewModelScope` for coroutines
- Expose state via `StateFlow` (e.g., `val messages: StateFlow<List<MessageItem>>`)
- Use `MutableStateFlow` for internal state, expose as read-only `StateFlow`
### Coroutines
- Use `Dispatchers.Default` for CPU-intensive work (parsing, filtering)
- Use `Dispatchers.IO` for file/network operations
- Always cancel jobs on ViewModel cleanup: `job?.cancel()`
- Use `withContext(Dispatchers.Default)` to switch threads explicitly
### Data Layer
- Use Room for persistent storage (Trixnity uses Room internally)
- Use DataStore Preferences for small key-value data
- Prefer Flow-based APIs for reactive data streams
- Cache expensive operations in ViewModel (e.g., `messageCache`, `memberCache`)
### Naming Conventions ### Naming Conventions
- State Flow properties: `_name` (private) / `name` (public) - State Flow: `_state` (private) / `state` (public)
- Repository class: `AuthService`, `AuthRepository` - Repositories: `AuthRepository`, `MessageRepository`
- ViewModel class: `MainViewModel`, `LoginViewModel` - ViewModels: `MainViewModel`, `LoginViewModel`
- UI composable: `MainScreen`, `ChannelList`, `MessageItem` - UI composables: `MainScreen`, `ChannelList`, `MessageItem`
- Model data class: `MessageItem`, `ChannelItem`, `SpaceItem` - Models: `MessageItem`, `ChannelItem`, `SpaceItem`
- Use `full` property for Matrix IDs (e.g., `userId.full`, `roomId.full`) - Matrix IDs: Use `RoomId`, `UserId` types; access `.full` for strings
--- ---
@@ -130,62 +102,19 @@ Fluffytrix is an Android Matrix chat client with a Discord-like UI. Built with K
**Layered Architecture**: **Layered Architecture**:
``` ```
ui/ — ViewModels, Screens, Navigation ui/ — ViewModels, Screens, Navigation
data/ — Repositories, local storage, models data/ — Repositories, storage, models
di/ — Koin modules di/ — Koin modules
ui/theme/ — Material 3 Theme (colors, typography) ui/theme/ — Material 3 Theme
``` ```
**Dependency Injection**: Koin with two modules: **Koin DI**:
- `appModule`: ViewModels (`viewModel { MainViewModel(...) }`) - `appModule`: ViewModel injection
- `dataModule`: singleton services (`single { AuthRepository(...) }`) - `dataModule`: Singleton repositories/UI Flow: NavHost in `MainActivity` with `FluffytrixTheme`, ViewModels expose StateFlow, screens observe with `collectAsState()`
**UI Flow**:
1. `FluffytrixNavigation` handles nav graph and session restoration
2. `MainActivity` hosts the NavHost with `FluffytrixTheme`
3. ViewModels expose state via StateFlow
4. Screens observe state with `collectAsState()`
--- ---
## Key Dependencies (from libs.versions.toml) ## Key Dependencies
- **Compose BOM**: `2025.06.00` - **Compose BOM**: `2025.06.00`, **Kotlin**: `2.2.10`, **AGP**: `9.0.1`
- **Kotlin**: `2.2.10` - **Koin**: `4.1.1`, **Trixnity**: `4.22.7`, **Ktor**: `3.3.0`
- **AGP**: `9.0.1` - **Coroutines**: `1.10.2`, **DataStore**: `1.1.7`, **Coil**: `3.2.0`
- **Koin**: `4.1.1`
- **Trixnity**: `4.22.7`
- **Ktor**: `3.3.0`
- **Coroutines**: `1.10.2`
- **DataStore**: `1.1.7`
- **Coil**: `3.2.0`
- **Media3**: `1.6.0`
---
## Special Notes
1. **Matrix IDs**: Use `RoomId`, `UserId` types from Trixnity; access `.full` for string representation
2. **MXC URLs**: Convert with `MxcUrlHelper.mxcToDownloadUrl()` and `mxcToThumbnailUrl()`
3. **Build Performance**: Debug builds use R8 minification with `isDebuggable = true` to balance speed and debuggability
4. **Channel Reordering**: Channel order is saved per-space in DataStore and restored on navigation
5. **Encrypted Rooms**: Handle decryption state—messages may appear as `Unable to decrypt` until keys arrive
6. **Theme**: Material 3 with Discord-inspired color scheme (primary: `#5865F2`)
---
## Running Lint/Checks
```bash
./gradlew lintDebug
./gradlew spotlessCheck
```
## CI/CD
- All builds use Gradle wrapper (`./gradlew`)
- No manual Gradle installation required (project uses Gradle 9.1.0)

View File

@@ -15,20 +15,16 @@ android {
minSdk = 34 minSdk = 34
targetSdk = 36 targetSdk = 36
versionCode = 1 versionCode = 1
versionName = "1.0" versionName = "1.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
buildTypes { buildTypes {
debug { debug {
isMinifyEnabled = true isMinifyEnabled = false
isShrinkResources = true isShrinkResources = false
isDebuggable = true isDebuggable = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
} }
release { release {
isMinifyEnabled = true isMinifyEnabled = true
@@ -69,6 +65,7 @@ dependencies {
implementation(libs.navigation.compose) implementation(libs.navigation.compose)
implementation(libs.lifecycle.viewmodel.compose) implementation(libs.lifecycle.viewmodel.compose)
implementation(libs.lifecycle.runtime.compose) implementation(libs.lifecycle.runtime.compose)
implementation(libs.lifecycle.process)
debugImplementation(libs.compose.ui.tooling) debugImplementation(libs.compose.ui.tooling)
// Koin // Koin
@@ -104,6 +101,9 @@ dependencies {
implementation(libs.markdown.renderer.code) implementation(libs.markdown.renderer.code)
implementation(libs.markdown.renderer.coil3) implementation(libs.markdown.renderer.coil3)
// Jetpack Emoji Picker
implementation(libs.emoji.picker)
testImplementation(libs.junit) testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.espresso.core)

View File

@@ -25,11 +25,12 @@ class PreferencesManager(private val context: Context) {
private val KEY_OIDC_DATA = stringPreferencesKey("oidc_data") private val KEY_OIDC_DATA = stringPreferencesKey("oidc_data")
private val KEY_SLIDING_SYNC_VERSION = stringPreferencesKey("sliding_sync_version") private val KEY_SLIDING_SYNC_VERSION = stringPreferencesKey("sliding_sync_version")
private val KEY_USERNAME = stringPreferencesKey("username") 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_IS_LOGGED_IN = booleanPreferencesKey("is_logged_in")
private val KEY_CHANNEL_ORDER = stringPreferencesKey("channel_order") private val KEY_CHANNEL_ORDER = stringPreferencesKey("channel_order")
private val KEY_CHILD_SPACE_ORDER = stringPreferencesKey("child_space_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_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 -> val isLoggedIn: Flow<Boolean> = context.dataStore.data.map { prefs ->
@@ -52,10 +53,6 @@ class PreferencesManager(private val context: Context) {
prefs[KEY_USERNAME] prefs[KEY_USERNAME]
} }
val password: Flow<String?> = context.dataStore.data.map { prefs ->
prefs[KEY_PASSWORD]
}
suspend fun saveSession( suspend fun saveSession(
accessToken: String, accessToken: String,
refreshToken: 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() { suspend fun clearSession() {
context.dataStore.edit { it.clear() } context.dataStore.edit { it.clear() }
} }

View File

@@ -0,0 +1,45 @@
package com.example.fluffytrix.data.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class CustomEmoji(
val url: String,
val body: String? = null,
val usage: List<String> = listOf("emoticon"),
)
@Serializable
data class EmojiPackInfo(
@SerialName("display_name") val displayName: String? = null,
@SerialName("avatar_url") val avatarUrl: String? = null,
)
@Serializable
data class EmojiPackData(
val pack: EmojiPackInfo = EmojiPackInfo(),
val images: Map<String, CustomEmoji> = emptyMap(),
)
@Serializable
data class UserEmojiAccountData(
val packs: Map<String, EmojiPackData> = emptyMap(),
)
// UI-facing (resolved URLs)
data class EmojiEntry(
val shortcode: String,
val mxcUrl: String,
val displayName: String,
val resolvedUrl: String,
)
data class EmojiPack(
val packId: String,
val displayName: String,
val avatarMxcUrl: String?,
val emojis: List<EmojiEntry>,
val isRoomPack: Boolean = false,
val roomId: String? = null,
)

View File

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

View File

@@ -0,0 +1,85 @@
package com.example.fluffytrix.data.repository
import com.example.fluffytrix.data.MxcUrlHelper
import com.example.fluffytrix.data.model.CustomEmoji
import com.example.fluffytrix.data.model.EmojiEntry
import com.example.fluffytrix.data.model.EmojiPack
import com.example.fluffytrix.data.model.EmojiPackData
import com.example.fluffytrix.data.model.EmojiPackInfo
import com.example.fluffytrix.data.model.UserEmojiAccountData
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
private val json = Json { ignoreUnknownKeys = true }
class EmojiPackRepository(
private val authRepository: AuthRepository,
) {
private val baseUrl: String
get() = try {
authRepository.getClient()?.session()?.homeserverUrl?.trimEnd('/') ?: ""
} catch (_: Exception) { "" }
suspend fun loadUserPacks(): List<EmojiPack> {
val client = authRepository.getClient() ?: return emptyList()
return try {
val raw = client.accountData("im.ponies.user_emojis") ?: return emptyList()
val data = json.decodeFromString<UserEmojiAccountData>(raw)
data.packs.map { (packId, packData) ->
packData.toEmojiPack(packId, baseUrl)
}
} catch (e: Exception) {
android.util.Log.e("EmojiPackRepo", "Failed to load user packs", e)
emptyList()
}
}
suspend fun saveUserPacks(packs: List<EmojiPack>) {
val client = authRepository.getClient() ?: return
try {
val packsMap = packs.associate { pack ->
pack.packId to EmojiPackData(
pack = EmojiPackInfo(displayName = pack.displayName, avatarUrl = pack.avatarMxcUrl),
images = pack.emojis.associate { entry ->
entry.shortcode to CustomEmoji(url = entry.mxcUrl, body = entry.displayName.ifBlank { null })
},
)
}
val data = UserEmojiAccountData(packs = packsMap)
client.setAccountData("im.ponies.user_emojis", json.encodeToString(data))
} catch (e: Exception) {
android.util.Log.e("EmojiPackRepo", "Failed to save user packs", e)
}
}
suspend fun uploadImage(mimeType: String, data: ByteArray): String? {
val client = authRepository.getClient() ?: return null
return try {
client.uploadMedia(mimeType, data, null)
} catch (e: Exception) {
android.util.Log.e("EmojiPackRepo", "Failed to upload emoji image", e)
null
}
}
suspend fun loadAllPacks(roomId: String? = null): List<EmojiPack> {
return loadUserPacks()
}
}
private fun EmojiPackData.toEmojiPack(packId: String, baseUrl: String): EmojiPack {
val emojis = images.map { (shortcode, emoji) ->
EmojiEntry(
shortcode = shortcode,
mxcUrl = emoji.url,
displayName = emoji.body ?: shortcode,
resolvedUrl = MxcUrlHelper.mxcToDownloadUrl(baseUrl, emoji.url) ?: emoji.url,
)
}
return EmojiPack(
packId = packId,
displayName = pack.displayName ?: packId,
avatarMxcUrl = pack.avatarUrl,
emojis = emojis,
)
}

View File

@@ -3,6 +3,7 @@ package com.example.fluffytrix.di
import android.app.Application import android.app.Application
import com.example.fluffytrix.data.local.PreferencesManager import com.example.fluffytrix.data.local.PreferencesManager
import com.example.fluffytrix.data.repository.AuthRepository import com.example.fluffytrix.data.repository.AuthRepository
import com.example.fluffytrix.data.repository.EmojiPackRepository
import com.example.fluffytrix.ui.screens.login.LoginViewModel import com.example.fluffytrix.ui.screens.login.LoginViewModel
import com.example.fluffytrix.ui.screens.main.MainViewModel import com.example.fluffytrix.ui.screens.main.MainViewModel
import com.example.fluffytrix.ui.screens.verification.VerificationViewModel import com.example.fluffytrix.ui.screens.verification.VerificationViewModel
@@ -11,7 +12,7 @@ import org.koin.core.module.dsl.viewModel
import org.koin.dsl.module import org.koin.dsl.module
val appModule = module { val appModule = module {
viewModel { MainViewModel(androidApplication(), get(), get()) } viewModel { MainViewModel(androidApplication(), get(), get(), get()) }
viewModel { LoginViewModel(get()) } viewModel { LoginViewModel(get()) }
viewModel { VerificationViewModel(get()) } viewModel { VerificationViewModel(get()) }
} }
@@ -19,4 +20,5 @@ val appModule = module {
val dataModule = module { val dataModule = module {
single { PreferencesManager(get()) } single { PreferencesManager(get()) }
single { AuthRepository(get(), get()) } single { AuthRepository(get(), get()) }
single { EmojiPackRepository(get()) }
} }

View File

@@ -27,6 +27,9 @@ import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import com.example.fluffytrix.data.local.PreferencesManager import com.example.fluffytrix.data.local.PreferencesManager
import com.example.fluffytrix.data.repository.AuthRepository import com.example.fluffytrix.data.repository.AuthRepository
import androidx.navigation.NavType
import androidx.navigation.navArgument
import com.example.fluffytrix.ui.screens.emoji.EmojiPackManagementScreen
import com.example.fluffytrix.ui.screens.login.LoginScreen import com.example.fluffytrix.ui.screens.login.LoginScreen
import com.example.fluffytrix.ui.screens.main.MainScreen import com.example.fluffytrix.ui.screens.main.MainScreen
import com.example.fluffytrix.ui.screens.settings.SettingsScreen import com.example.fluffytrix.ui.screens.settings.SettingsScreen
@@ -148,6 +151,19 @@ fun FluffytrixNavigation() {
onSettingsClick = { onSettingsClick = {
navController.navigate(Screen.Settings.route) navController.navigate(Screen.Settings.route)
}, },
onEmojiPackManagement = { roomId ->
navController.navigate(Screen.EmojiPackManagement.route(roomId))
},
)
}
composable(
route = Screen.EmojiPackManagement.routeWithArgs,
arguments = listOf(navArgument("roomId") { type = NavType.StringType; nullable = true; defaultValue = null }),
) { backStackEntry ->
val roomId = backStackEntry.arguments?.getString("roomId")
EmojiPackManagementScreen(
roomId = roomId,
onBack = { navController.popBackStack() },
) )
} }
composable(Screen.Settings.route) { composable(Screen.Settings.route) {

View File

@@ -5,4 +5,8 @@ sealed class Screen(val route: String) {
data object Verification : Screen("verification") data object Verification : Screen("verification")
data object Main : Screen("main") data object Main : Screen("main")
data object Settings : Screen("settings") data object Settings : Screen("settings")
data object EmojiPackManagement : Screen("emoji_packs") {
fun route(roomId: String? = null) = if (roomId != null) "emoji_packs?roomId=$roomId" else "emoji_packs"
const val routeWithArgs = "emoji_packs?roomId={roomId}"
}
} }

View File

@@ -0,0 +1,379 @@
package com.example.fluffytrix.ui.screens.emoji
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
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.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.compose.AsyncImage
import com.example.fluffytrix.data.model.EmojiEntry
import com.example.fluffytrix.data.model.EmojiPack
import com.example.fluffytrix.ui.screens.main.MainViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koin.androidx.compose.koinViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EmojiPackManagementScreen(
roomId: String?,
onBack: () -> Unit,
viewModel: MainViewModel = koinViewModel(),
) {
val emojiPacks by viewModel.emojiPacks.collectAsStateWithLifecycle()
val userPacks = emojiPacks.filter { !it.isRoomPack }
var showCreateDialog by remember { mutableStateOf(false) }
var editingPack by remember { mutableStateOf<EmojiPack?>(null) }
if (showCreateDialog) {
CreatePackDialog(
onDismiss = { showCreateDialog = false },
onCreate = { name ->
val newPack = EmojiPack(
packId = name.lowercase().replace(Regex("[^a-z0-9_]"), "_"),
displayName = name,
avatarMxcUrl = null,
emojis = emptyList(),
)
val updated = userPacks + newPack
viewModel.saveUserEmojiPacks(updated)
showCreateDialog = false
},
)
}
editingPack?.let { pack ->
EditPackDialog(
pack = pack,
viewModel = viewModel,
onDismiss = { editingPack = null },
onSave = { updatedPack ->
val updated = userPacks.map { if (it.packId == updatedPack.packId) updatedPack else it }
viewModel.saveUserEmojiPacks(updated)
editingPack = null
},
)
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Emoji Packs") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back")
}
},
)
},
floatingActionButton = {
ExtendedFloatingActionButton(
onClick = { showCreateDialog = true },
icon = { Icon(Icons.Default.Add, null) },
text = { Text("Create Pack") },
)
},
) { padding ->
if (userPacks.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize().padding(padding),
contentAlignment = Alignment.Center,
) {
Text(
"No emoji packs yet. Create one to get started.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize().padding(padding),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(userPacks, key = { it.packId }) { pack ->
PackRow(
pack = pack,
onEdit = { editingPack = pack },
onDelete = {
val updated = userPacks.filter { it.packId != pack.packId }
viewModel.saveUserEmojiPacks(updated)
},
)
}
}
}
}
}
@Composable
private fun PackRow(
pack: EmojiPack,
onEdit: () -> Unit,
onDelete: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onEdit() }
.padding(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (pack.avatarMxcUrl != null) {
AsyncImage(
model = pack.avatarMxcUrl,
contentDescription = null,
modifier = Modifier.size(40.dp).clip(CircleShape),
contentScale = ContentScale.Crop,
)
} else {
Box(
modifier = Modifier.size(40.dp).clip(CircleShape)
.then(Modifier.fillMaxWidth()),
contentAlignment = Alignment.Center,
) {
Text(
pack.displayName.take(1).uppercase(),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
}
}
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(pack.displayName, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium)
Text("${pack.emojis.size} emojis", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
IconButton(onClick = onDelete) {
Icon(Icons.Default.Delete, "Delete", tint = MaterialTheme.colorScheme.error)
}
}
}
@Composable
private fun CreatePackDialog(onDismiss: () -> Unit, onCreate: (String) -> Unit) {
var name by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Create Emoji Pack") },
text = {
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Pack name") },
singleLine = true,
)
},
confirmButton = {
TextButton(onClick = { if (name.isNotBlank()) onCreate(name.trim()) }, enabled = name.isNotBlank()) {
Text("Create")
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
},
)
}
@Composable
private fun EditPackDialog(
pack: EmojiPack,
viewModel: MainViewModel,
onDismiss: () -> Unit,
onSave: (EmojiPack) -> Unit,
) {
var emojis by remember { mutableStateOf(pack.emojis) }
var addShortcode by remember { mutableStateOf("") }
var pendingImageUri by remember { mutableStateOf<Uri?>(null) }
var isUploading by remember { mutableStateOf(false) }
val context = LocalContext.current
val scope = rememberCoroutineScope()
val imagePicker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
if (uri != null) pendingImageUri = uri
}
// When we have a pending URI and a shortcode, prompt for shortcode first
if (pendingImageUri != null) {
AlertDialog(
onDismissRequest = { pendingImageUri = null },
title = { Text("Set Shortcode") },
text = {
OutlinedTextField(
value = addShortcode,
onValueChange = { addShortcode = it },
label = { Text("Shortcode (e.g. blobcat)") },
singleLine = true,
)
},
confirmButton = {
TextButton(
onClick = {
val uri = pendingImageUri ?: return@TextButton
val code = addShortcode.trim().trimStart(':').trimEnd(':')
if (code.isBlank()) return@TextButton
isUploading = true
scope.launch(Dispatchers.IO) {
try {
val bytes = context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
val mime = context.contentResolver.getType(uri) ?: "image/png"
if (bytes != null) {
val mxcUrl = viewModel.uploadEmojiImage(mime, bytes)
if (mxcUrl != null) {
val entry = EmojiEntry(
shortcode = code,
mxcUrl = mxcUrl,
displayName = code,
resolvedUrl = mxcUrl,
)
withContext(Dispatchers.Main) {
emojis = emojis + entry
}
}
}
} finally {
withContext(Dispatchers.Main) {
isUploading = false
pendingImageUri = null
addShortcode = ""
}
}
}
},
enabled = addShortcode.isNotBlank() && !isUploading,
) {
Text(if (isUploading) "Uploading…" else "Add")
}
},
dismissButton = {
TextButton(onClick = { pendingImageUri = null; addShortcode = "" }) { Text("Cancel") }
},
)
}
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Scaffold(
topBar = {
@OptIn(ExperimentalMaterial3Api::class)
TopAppBar(
title = { Text(pack.displayName) },
navigationIcon = {
IconButton(onClick = onDismiss) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Close")
}
},
actions = {
TextButton(onClick = { onSave(pack.copy(emojis = emojis)) }) {
Text("Save")
}
},
)
},
floatingActionButton = {
ExtendedFloatingActionButton(
onClick = { imagePicker.launch("image/*") },
icon = { Icon(Icons.Default.Add, null) },
text = { Text("Add Emoji") },
)
},
) { padding ->
if (emojis.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize().padding(padding),
contentAlignment = Alignment.Center,
) {
Text("No emojis yet. Tap Add Emoji to upload one.", color = MaterialTheme.colorScheme.onSurfaceVariant)
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize().padding(padding),
contentPadding = PaddingValues(16.dp),
) {
items(emojis, key = { it.shortcode }) { entry ->
EmojiEntryRow(
entry = entry,
onDelete = { emojis = emojis.filter { it.shortcode != entry.shortcode } },
)
HorizontalDivider()
}
}
}
}
}
}
@Composable
private fun EmojiEntryRow(entry: EmojiEntry, onDelete: () -> Unit) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
AsyncImage(
model = entry.resolvedUrl,
contentDescription = entry.shortcode,
modifier = Modifier.size(40.dp).clip(RoundedCornerShape(4.dp)),
contentScale = ContentScale.Fit,
)
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(":${entry.shortcode}:", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium)
if (entry.displayName != entry.shortcode) {
Text(entry.displayName, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
IconButton(onClick = onDelete) {
Icon(Icons.Default.Delete, "Delete", tint = MaterialTheme.colorScheme.error)
}
}
}

View File

@@ -12,8 +12,9 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -30,29 +31,42 @@ import org.koin.compose.koinInject
fun MainScreen( fun MainScreen(
onLogout: () -> Unit, onLogout: () -> Unit,
onSettingsClick: () -> Unit = {}, onSettingsClick: () -> Unit = {},
onEmojiPackManagement: (String?) -> Unit = {},
viewModel: MainViewModel = koinViewModel(), viewModel: MainViewModel = koinViewModel(),
) { ) {
val spaces by viewModel.spaces.collectAsState() val spaces by viewModel.spaces.collectAsStateWithLifecycle()
val channels by viewModel.channels.collectAsState() val channels by viewModel.channels.collectAsStateWithLifecycle()
val selectedSpace by viewModel.selectedSpace.collectAsState() val selectedSpace by viewModel.selectedSpace.collectAsStateWithLifecycle()
val selectedChannel by viewModel.selectedChannel.collectAsState() val selectedChannel by viewModel.selectedChannel.collectAsStateWithLifecycle()
val showChannelList by viewModel.showChannelList.collectAsState() val showChannelList by viewModel.showChannelList.collectAsStateWithLifecycle()
val showMemberList by viewModel.showMemberList.collectAsState() val showMemberList by viewModel.showMemberList.collectAsStateWithLifecycle()
val messages by viewModel.messages.collectAsState() val messages by viewModel.messages.collectAsStateWithLifecycle()
val members by viewModel.members.collectAsState() val members by viewModel.members.collectAsStateWithLifecycle()
val channelName by viewModel.channelName.collectAsState() val channelName by viewModel.channelName.collectAsStateWithLifecycle()
val isReorderMode by viewModel.isReorderMode.collectAsState() val isReorderMode by viewModel.isReorderMode.collectAsStateWithLifecycle()
val homeUnreadStatus by viewModel.homeUnreadStatus.collectAsState() val homeUnreadStatus by viewModel.homeUnreadStatus.collectAsStateWithLifecycle()
val channelSections by viewModel.channelSections.collectAsState() val channelSections by viewModel.channelSections.collectAsStateWithLifecycle()
val unreadMarkerIndex by viewModel.unreadMarkerIndex.collectAsState() 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 currentUserId by viewModel.currentUserId.collectAsStateWithLifecycle()
val replyingTo by viewModel.replyingTo.collectAsStateWithLifecycle()
val editingMessage by viewModel.editingMessage.collectAsStateWithLifecycle()
val emojiPacks by viewModel.emojiPacks.collectAsStateWithLifecycle()
val listState = viewModel.channelListState val listState = viewModel.channelListState
val preferencesManager: PreferencesManager = koinInject() 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 // Back button: close thread first, then open channel list
BackHandler(enabled = selectedChannel != null && !showChannelList) { BackHandler(enabled = selectedThread != null || (selectedChannel != null && !showChannelList)) {
if (selectedThread != null) {
viewModel.closeThread()
} else {
viewModel.toggleChannelList() viewModel.toggleChannelList()
} }
}
Scaffold { padding -> Scaffold { padding ->
Box( Box(
@@ -100,6 +114,38 @@ fun MainScreen(
unreadMarkerIndex = unreadMarkerIndex, unreadMarkerIndex = unreadMarkerIndex,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
contentPadding = padding, 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
}
}
},
currentUserId = currentUserId,
replyingTo = replyingTo,
editingMessage = editingMessage,
onSetReplyingTo = { viewModel.setReplyingTo(it) },
onSetEditingMessage = { viewModel.setEditingMessage(it) },
onSendReply = { body, eventId -> viewModel.sendReply(body, eventId) },
onSendThreadReply = { body, eventId -> viewModel.sendThreadReply(body, eventId) },
onEditMessage = { eventId, body -> viewModel.editMessage(eventId, body) },
onEditThreadMessage = { eventId, body -> viewModel.editThreadMessage(eventId, body) },
onSendReaction = { eventId, emoji -> viewModel.sendReaction(eventId, emoji) },
onSendThreadReaction = { eventId, emoji -> viewModel.sendThreadReaction(eventId, emoji) },
emojiPacks = emojiPacks,
onOpenEmojiPackManagement = { onEmojiPackManagement(selectedChannel) },
) )
AnimatedVisibility(visible = showMemberList) { AnimatedVisibility(visible = showMemberList) {
@@ -137,6 +183,7 @@ fun MainScreen(
sections = channelSections, sections = channelSections,
selectedChannel = selectedChannel, selectedChannel = selectedChannel,
onChannelClick = { onChannelClick = {
viewModel.closeThread()
viewModel.selectChannel(it) viewModel.selectChannel(it)
viewModel.toggleChannelList() viewModel.toggleChannelList()
}, },
@@ -152,6 +199,21 @@ fun MainScreen(
onMoveChannel = { from, to -> viewModel.moveChannel(from, to) }, onMoveChannel = { from, to -> viewModel.moveChannel(from, to) },
onMoveChannelById = { id, delta -> viewModel.moveChannelById(id, delta) }, onMoveChannelById = { id, delta -> viewModel.moveChannelById(id, delta) },
onMoveChildSpace = { from, to -> viewModel.moveChildSpace(from, to) }, onMoveChildSpace = { from, to -> viewModel.moveChildSpace(from, to) },
roomThreads = roomThreads,
expandedThreadRooms = expandedThreadRooms,
selectedThread = selectedThread,
onToggleRoomThreads = { viewModel.toggleRoomThreads(it) },
onThreadClick = { roomId, threadRootEventId ->
viewModel.selectChannel(roomId)
viewModel.selectThread(roomId, threadRootEventId)
viewModel.toggleChannelList()
},
onRenameThread = { roomId, threadRootEventId, name ->
viewModel.renameThread(roomId, threadRootEventId, name)
},
onRemoveThread = { roomId, threadRootEventId ->
viewModel.removeThread(roomId, threadRootEventId)
},
) )
} }
} }

View File

@@ -4,11 +4,16 @@ import android.app.Application
import android.net.Uri import android.net.Uri
import android.provider.OpenableColumns import android.provider.OpenableColumns
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.example.fluffytrix.data.MxcUrlHelper import com.example.fluffytrix.data.MxcUrlHelper
import com.example.fluffytrix.data.local.PreferencesManager import com.example.fluffytrix.data.local.PreferencesManager
import com.example.fluffytrix.data.model.EmojiPack
import com.example.fluffytrix.data.repository.AuthRepository import com.example.fluffytrix.data.repository.AuthRepository
import com.example.fluffytrix.data.repository.EmojiPackRepository
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.async import kotlinx.coroutines.async
@@ -20,6 +25,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.EditedContent
import org.matrix.rustcomponents.sdk.EventOrTransactionId import org.matrix.rustcomponents.sdk.EventOrTransactionId
import org.matrix.rustcomponents.sdk.Membership import org.matrix.rustcomponents.sdk.Membership
import org.matrix.rustcomponents.sdk.MembershipState import org.matrix.rustcomponents.sdk.MembershipState
@@ -27,11 +33,18 @@ import org.matrix.rustcomponents.sdk.MessageType
import org.matrix.rustcomponents.sdk.MsgLikeKind import org.matrix.rustcomponents.sdk.MsgLikeKind
import org.matrix.rustcomponents.sdk.ProfileDetails import org.matrix.rustcomponents.sdk.ProfileDetails
import org.matrix.rustcomponents.sdk.SyncService 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.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.TimelineItemContent
import org.matrix.rustcomponents.sdk.TimelineListener import org.matrix.rustcomponents.sdk.TimelineListener
import org.matrix.rustcomponents.sdk.UploadParameters import org.matrix.rustcomponents.sdk.UploadParameters
import org.matrix.rustcomponents.sdk.UploadSource import org.matrix.rustcomponents.sdk.UploadSource
import org.json.JSONObject
import org.matrix.rustcomponents.sdk.messageEventContentFromHtml
import org.matrix.rustcomponents.sdk.messageEventContentFromMarkdown import org.matrix.rustcomponents.sdk.messageEventContentFromMarkdown
enum class UnreadStatus { NONE, UNREAD, MENTIONED } enum class UnreadStatus { NONE, UNREAD, MENTIONED }
@@ -51,8 +64,10 @@ data class ChannelItem(
val unreadStatus: UnreadStatus = UnreadStatus.NONE, val unreadStatus: UnreadStatus = UnreadStatus.NONE,
) )
data class InlineEmoji(val shortcode: String, val mxcUrl: String, val resolvedUrl: String)
sealed interface MessageContent { sealed interface MessageContent {
data class Text(val body: String, val urls: List<String> = emptyList()) : MessageContent data class Text(val body: String, val urls: List<String> = emptyList(), val inlineEmojis: List<InlineEmoji> = emptyList()) : MessageContent
data class Image(val body: String, val url: String, val width: Int? = null, val height: Int? = null) : MessageContent data class Image(val body: String, val url: String, val width: Int? = null, val height: Int? = null) : MessageContent
data class Gif(val body: String, val url: String, val width: Int? = null, val height: Int? = null) : MessageContent data class Gif(val body: String, val url: String, val width: Int? = null, val height: Int? = null) : MessageContent
data class Video(val body: String, val url: String? = null, val thumbnailUrl: String? = null, val width: Int? = null, val height: Int? = null) : MessageContent data class Video(val body: String, val url: String? = null, val thumbnailUrl: String? = null, val width: Int? = null, val height: Int? = null) : MessageContent
@@ -77,6 +92,17 @@ data class MessageItem(
val content: MessageContent, val content: MessageContent,
val timestamp: Long, val timestamp: Long,
val replyTo: ReplyInfo? = null, val replyTo: ReplyInfo? = null,
val threadRootEventId: String? = null,
val reactions: Map<String, List<String>> = emptyMap(), // emoji -> list of full Matrix user IDs
)
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( data class ChannelSection(
@@ -95,6 +121,7 @@ class MainViewModel(
private val application: Application, private val application: Application,
private val authRepository: AuthRepository, private val authRepository: AuthRepository,
private val preferencesManager: PreferencesManager, private val preferencesManager: PreferencesManager,
private val emojiPackRepository: EmojiPackRepository,
) : ViewModel() { ) : ViewModel() {
private val _spaces = MutableStateFlow<List<SpaceItem>>(emptyList()) private val _spaces = MutableStateFlow<List<SpaceItem>>(emptyList())
@@ -124,6 +151,24 @@ class MainViewModel(
private val _channelName = MutableStateFlow<String?>(null) private val _channelName = MutableStateFlow<String?>(null)
val channelName: StateFlow<String?> = _channelName 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() val channelListState = LazyListState()
private val _isReorderMode = MutableStateFlow(false) private val _isReorderMode = MutableStateFlow(false)
@@ -148,6 +193,18 @@ class MainViewModel(
private val _homeUnreadStatus = MutableStateFlow(UnreadStatus.NONE) private val _homeUnreadStatus = MutableStateFlow(UnreadStatus.NONE)
val homeUnreadStatus: StateFlow<UnreadStatus> = _homeUnreadStatus val homeUnreadStatus: StateFlow<UnreadStatus> = _homeUnreadStatus
private val _currentUserId = MutableStateFlow<String?>(null)
val currentUserId: StateFlow<String?> = _currentUserId
private val _replyingTo = MutableStateFlow<MessageItem?>(null)
val replyingTo: StateFlow<MessageItem?> = _replyingTo
private val _editingMessage = MutableStateFlow<MessageItem?>(null)
val editingMessage: StateFlow<MessageItem?> = _editingMessage
private val _emojiPacks = MutableStateFlow<List<EmojiPack>>(emptyList())
val emojiPacks: StateFlow<List<EmojiPack>> = _emojiPacks
private val _spaceChildrenMap = MutableStateFlow<Map<String, Set<String>>>(emptyMap()) private val _spaceChildrenMap = MutableStateFlow<Map<String, Set<String>>>(emptyMap())
private val _channelSections = MutableStateFlow<List<ChannelSection>>(emptyList()) private val _channelSections = MutableStateFlow<List<ChannelSection>>(emptyList())
@@ -156,22 +213,27 @@ class MainViewModel(
// Maps spaceId -> list of (childSpaceId, childSpaceName, Set<roomIds>) // Maps spaceId -> list of (childSpaceId, childSpaceName, Set<roomIds>)
private val _directChildSpaces = MutableStateFlow<Map<String, List<Triple<String, String, Set<String>>>>>(emptyMap()) private val _directChildSpaces = MutableStateFlow<Map<String, List<Triple<String, String, Set<String>>>>>(emptyMap())
// Per-room caches // Per-room caches — outer maps are ConcurrentHashMap to prevent structural corruption
private val messageCache = mutableMapOf<String, MutableList<MessageItem>>() // from concurrent access across Dispatchers.IO, Dispatchers.Default, and Main.
private val messageIds = mutableMapOf<String, MutableSet<String>>() private val messageCache = java.util.concurrent.ConcurrentHashMap<String, MutableList<MessageItem>>()
private val memberCache = mutableMapOf<String, List<MemberItem>>() private val messageIds = java.util.concurrent.ConcurrentHashMap<String, MutableSet<String>>()
private val channelNameCache = mutableMapOf<String, String>() private val memberCache = java.util.concurrent.ConcurrentHashMap<String, List<MemberItem>>()
private val senderAvatarCache = mutableMapOf<String, String?>() private val channelNameCache = java.util.concurrent.ConcurrentHashMap<String, String>()
private val senderNameCache = mutableMapOf<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 timelineJob: Job? = null
private var membersJob: Job? = null private var membersJob: Job? = null
private var syncService: SyncService? = null private var syncService: SyncService? = null
private var activeTimeline: org.matrix.rustcomponents.sdk.Timeline? = 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 timelineListenerHandle: org.matrix.rustcomponents.sdk.TaskHandle? = null
private var roomPollJob: Job? = null private var roomPollJob: Job? = null
private var isPaginating = false private var isPaginating = false
private var hitTimelineStart = false private val hitTimelineStartByRoom = java.util.concurrent.ConcurrentHashMap<String, Boolean>()
private val baseUrl: String by lazy { private val baseUrl: String by lazy {
try { authRepository.getClient()?.session()?.homeserverUrl?.trimEnd('/') ?: "" } try { authRepository.getClient()?.session()?.homeserverUrl?.trimEnd('/') ?: "" }
catch (_: Exception) { "" } catch (_: Exception) { "" }
@@ -180,10 +242,58 @@ class MainViewModel(
private fun avatarUrl(mxcUri: String?, size: Int = 64): String? = private fun avatarUrl(mxcUri: String?, size: Int = 64): String? =
MxcUrlHelper.mxcToThumbnailUrl(baseUrl, mxcUri, size) 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 { init {
viewModelScope.launch { viewModelScope.launch(Dispatchers.IO) {
syncService = authRepository.getOrStartSync() syncService = authRepository.getOrStartSync()
try { _currentUserId.value = authRepository.getClient()?.userId() } catch (_: Exception) {}
loadRooms() loadRooms()
loadEmojiPacks()
} }
observeSelectedChannel() observeSelectedChannel()
observeSpaceFiltering() observeSpaceFiltering()
@@ -193,12 +303,19 @@ class MainViewModel(
viewModelScope.launch { viewModelScope.launch {
preferencesManager.childSpaceOrder.collect { _childSpaceOrderMap.value = it } 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() { private fun loadRooms() {
val client = authRepository.getClient() ?: return val client = authRepository.getClient() ?: return
roomPollJob?.cancel() roomPollJob?.cancel()
roomPollJob = viewModelScope.launch { roomPollJob = viewModelScope.launch(Dispatchers.IO) {
// Poll until rooms appear, then keep polling for updates // Poll until rooms appear, then keep polling for updates
while (true) { while (true) {
try { try {
@@ -380,7 +497,6 @@ class MainViewModel(
timelineListenerHandle = null timelineListenerHandle = null
activeTimeline = null activeTimeline = null
isPaginating = false isPaginating = false
hitTimelineStart = false
membersJob?.cancel() membersJob?.cancel()
if (roomId == null) { if (roomId == null) {
@@ -403,7 +519,7 @@ class MainViewModel(
private fun loadChannelName(roomId: String) { private fun loadChannelName(roomId: String) {
val client = authRepository.getClient() ?: return val client = authRepository.getClient() ?: return
viewModelScope.launch { viewModelScope.launch(Dispatchers.IO) {
try { try {
val room = client.getRoom(roomId) val room = client.getRoom(roomId)
val name = room?.displayName() ?: roomId val name = room?.displayName() ?: roomId
@@ -415,7 +531,9 @@ class MainViewModel(
private fun loadTimeline(roomId: String): Job { private fun loadTimeline(roomId: String): Job {
val client = authRepository.getClient() ?: return Job() val client = authRepository.getClient() ?: return Job()
return viewModelScope.launch { // 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 { try {
val room = client.getRoom(roomId) ?: return@launch val room = client.getRoom(roomId) ?: return@launch
val timeline = room.timeline() val timeline = room.timeline()
@@ -449,11 +567,46 @@ class MainViewModel(
} }
is TimelineDiff.Set -> { is TimelineDiff.Set -> {
val idx = d.index.toInt() val idx = d.index.toInt()
if (idx in sdkItems.indices) sdkItems[idx] = d.value if (idx in sdkItems.indices) {
// Remove the old item from cache (e.g. TransactionId → EventId transition on send)
val old = sdkItems[idx]
sdkItems[idx] = d.value
val oldEvent = old.asEvent()
if (oldEvent != null) {
val oldId = when (val eot = oldEvent.eventOrTransactionId) {
is EventOrTransactionId.EventId -> eot.eventId
is EventOrTransactionId.TransactionId -> eot.transactionId
}
if (ids.remove(oldId)) {
val ri = cached.indexOfFirst { it.eventId == oldId }
if (ri >= 0) cached.removeAt(ri)
// Also remove from thread cache if it was a thread reply
threadMessageCache[roomId]?.values?.forEach { list ->
list.removeAll { it.eventId == oldId }
}
}
}
}
} }
is TimelineDiff.Remove -> { is TimelineDiff.Remove -> {
val idx = d.index.toInt() val idx = d.index.toInt()
if (idx in sdkItems.indices) sdkItems.removeAt(idx) if (idx in sdkItems.indices) {
val removed = sdkItems.removeAt(idx)
val removedEvent = removed.asEvent()
if (removedEvent != null) {
val removedId = when (val eot = removedEvent.eventOrTransactionId) {
is EventOrTransactionId.EventId -> eot.eventId
is EventOrTransactionId.TransactionId -> eot.transactionId
}
if (ids.remove(removedId)) {
val ri = cached.indexOfFirst { it.eventId == removedId }
if (ri >= 0) cached.removeAt(ri)
threadMessageCache[roomId]?.values?.forEach { list ->
list.removeAll { it.eventId == removedId }
}
}
}
}
} }
is TimelineDiff.Truncate -> { is TimelineDiff.Truncate -> {
val len = d.length.toInt() val len = d.length.toInt()
@@ -468,13 +621,19 @@ class MainViewModel(
} }
} }
// Rebuild cache from SDK items // Merge SDK items into persistent cache — skip events already seen.
cached.clear() // This preserves paginated history across room visits and avoids
ids.clear() // re-downloading messages when re-entering a room.
for (item in sdkItems) { for (item in sdkItems) {
val eventItem = item.asEvent() ?: continue 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) processEventItem(roomId, eventItem, cached, ids)
} }
}
if (_selectedChannel.value == roomId) { if (_selectedChannel.value == roomId) {
_messages.value = ArrayList(cached) _messages.value = ArrayList(cached)
// Clamp unread marker — only hide if beyond valid range // Clamp unread marker — only hide if beyond valid range
@@ -510,6 +669,7 @@ class MainViewModel(
val content = eventItem.content val content = eventItem.content
var replyInfo: ReplyInfo? = null var replyInfo: ReplyInfo? = null
var threadRootId: String? = null
val msgContent: MessageContent = when (content) { val msgContent: MessageContent = when (content) {
is TimelineItemContent.MsgLike -> { is TimelineItemContent.MsgLike -> {
when (val kind = content.content.kind) { when (val kind = content.content.kind) {
@@ -551,6 +711,26 @@ class MainViewModel(
} }
} catch (_: Exception) { } } catch (_: Exception) { }
val rawJson = try { eventItem.lazyProvider.debugInfo().originalJson } catch (_: Exception) { null } 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) resolveMessageType(kind.content.msgType, kind.content.body, rawJson)
?: return false ?: return false
} }
@@ -583,6 +763,12 @@ class MainViewModel(
senderNameCache[localpart] = senderName senderNameCache[localpart] = senderName
if (senderAvatar != null) senderAvatarCache[localpart] = senderAvatar if (senderAvatar != null) senderAvatarCache[localpart] = senderAvatar
val reactions = if (content is TimelineItemContent.MsgLike) {
content.content.reactions
.filter { it.senders.isNotEmpty() }
.associate { reaction -> reaction.key to reaction.senders.map { it.senderId } }
} else emptyMap()
val msg = MessageItem( val msg = MessageItem(
eventId = eventId, eventId = eventId,
senderId = localpart, senderId = localpart,
@@ -591,9 +777,29 @@ class MainViewModel(
content = msgContent, content = msgContent,
timestamp = eventItem.timestamp.toLong(), timestamp = eventItem.timestamp.toLong(),
replyTo = replyInfo, replyTo = replyInfo,
threadRootEventId = threadRootId,
reactions = reactions,
) )
ids.add(eventId) 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 // Descending order (newest at index 0) — reverseLayout shows index 0 at bottom
val insertIdx = cached.binarySearch { val insertIdx = cached.binarySearch {
msg.timestamp.compareTo(it.timestamp) msg.timestamp.compareTo(it.timestamp)
@@ -609,6 +815,7 @@ class MainViewModel(
MessageContent.Text( MessageContent.Text(
body = text, body = text,
urls = urlRegex.findAll(text).map { it.value }.toList(), urls = urlRegex.findAll(text).map { it.value }.toList(),
inlineEmojis = parseInlineEmojis(rawJson),
) )
} }
is MessageType.Notice -> { is MessageType.Notice -> {
@@ -616,10 +823,11 @@ class MainViewModel(
MessageContent.Text( MessageContent.Text(
body = text, body = text,
urls = urlRegex.findAll(text).map { it.value }.toList(), urls = urlRegex.findAll(text).map { it.value }.toList(),
inlineEmojis = parseInlineEmojis(rawJson),
) )
} }
is MessageType.Emote -> { is MessageType.Emote -> {
MessageContent.Text(body = "* ${msgType.content.body}") MessageContent.Text(body = "* ${msgType.content.body}", inlineEmojis = parseInlineEmojis(rawJson))
} }
is MessageType.Image -> { is MessageType.Image -> {
val c = msgType.content val c = msgType.content
@@ -691,7 +899,7 @@ class MainViewModel(
private fun loadMembers(roomId: String): Job { private fun loadMembers(roomId: String): Job {
val client = authRepository.getClient() ?: return Job() val client = authRepository.getClient() ?: return Job()
return viewModelScope.launch { return viewModelScope.launch(Dispatchers.IO) {
try { try {
val room = client.getRoom(roomId) ?: return@launch val room = client.getRoom(roomId) ?: return@launch
val iterator = room.members() val iterator = room.members()
@@ -747,15 +955,66 @@ class MainViewModel(
} }
} }
fun loadEmojiPacks() {
viewModelScope.launch(Dispatchers.IO) {
_emojiPacks.value = emojiPackRepository.loadAllPacks(_selectedChannel.value)
}
}
fun saveUserEmojiPacks(packs: List<EmojiPack>) {
viewModelScope.launch(Dispatchers.IO) {
emojiPackRepository.saveUserPacks(packs)
loadEmojiPacks()
}
}
suspend fun uploadEmojiImage(mimeType: String, data: ByteArray): String? {
return emojiPackRepository.uploadImage(mimeType, data)
}
private fun parseInlineEmojis(rawJson: String?): List<InlineEmoji> {
rawJson ?: return emptyList()
return try {
val json = org.json.JSONObject(rawJson)
val formattedBody = json.optJSONObject("content")?.optString("formatted_body") ?: return emptyList()
val regex = Regex("""<img[^>]+data-mx-emoticon[^>]+src="(mxc://[^"]+)"[^>]+alt="([^"]+)"[^>]*/?>""")
val packs = _emojiPacks.value
regex.findAll(formattedBody).mapNotNull { match ->
val mxcUrl = match.groupValues[1]
val alt = match.groupValues[2]
val resolvedUrl = MxcUrlHelper.mxcToDownloadUrl(baseUrl, mxcUrl) ?: mxcUrl
InlineEmoji(shortcode = alt, mxcUrl = mxcUrl, resolvedUrl = resolvedUrl)
}.toList()
} catch (_: Exception) { emptyList() }
}
fun sendMessage(body: String) { fun sendMessage(body: String) {
val timeline = activeTimeline ?: return val timeline = activeTimeline ?: return
viewModelScope.launch { viewModelScope.launch(Dispatchers.IO) {
try { try {
timeline.send(messageEventContentFromMarkdown(body)) val content = buildMessageContent(body)
timeline.send(content)
} catch (_: Exception) { } } catch (_: Exception) { }
} }
} }
private fun buildMessageContent(body: String): org.matrix.rustcomponents.sdk.RoomMessageEventContentWithoutRelation {
val packs = _emojiPacks.value
if (packs.isEmpty()) return messageEventContentFromMarkdown(body)
// Build a map of shortcode -> emoji entry across all packs
val emojiMap = packs.flatMap { it.emojis }.associateBy { ":${it.shortcode}:" }
val found = emojiMap.keys.filter { body.contains(it) }
if (found.isEmpty()) return messageEventContentFromMarkdown(body)
// Build formatted body with inline emoji img tags
var formattedBody = body
for (key in found) {
val entry = emojiMap[key] ?: continue
val imgTag = """<img data-mx-emoticon src="${entry.mxcUrl}" alt=":${entry.shortcode}:" height="32" />"""
formattedBody = formattedBody.replace(key, imgTag)
}
return messageEventContentFromHtml(body, formattedBody)
}
fun sendFiles(uris: List<Uri>, caption: String?) { fun sendFiles(uris: List<Uri>, caption: String?) {
val timeline = activeTimeline ?: return val timeline = activeTimeline ?: return
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
@@ -829,7 +1088,7 @@ class MainViewModel(
private fun preloadAllSpaceChildren() { private fun preloadAllSpaceChildren() {
val client = authRepository.getClient() ?: return val client = authRepository.getClient() ?: return
viewModelScope.launch { viewModelScope.launch(Dispatchers.IO) {
try { try {
val spaceService = client.spaceService() val spaceService = client.spaceService()
val map = mutableMapOf<String, Set<String>>() val map = mutableMapOf<String, Set<String>>()
@@ -913,7 +1172,7 @@ class MainViewModel(
return return
} }
val client = authRepository.getClient() ?: return val client = authRepository.getClient() ?: return
viewModelScope.launch { viewModelScope.launch(Dispatchers.IO) {
try { try {
val spaceService = client.spaceService() val spaceService = client.spaceService()
val allSpacedRoomIds = mutableSetOf<String>() val allSpacedRoomIds = mutableSetOf<String>()
@@ -965,7 +1224,7 @@ class MainViewModel(
return return
} }
val client = authRepository.getClient() ?: return val client = authRepository.getClient() ?: return
viewModelScope.launch { viewModelScope.launch(Dispatchers.IO) {
try { try {
val spaceService = client.spaceService() val spaceService = client.spaceService()
val allRoomIds = mutableSetOf<String>() val allRoomIds = mutableSetOf<String>()
@@ -1090,14 +1349,15 @@ class MainViewModel(
fun loadMoreMessages() { fun loadMoreMessages() {
val timeline = activeTimeline ?: return val timeline = activeTimeline ?: return
if (isPaginating || hitTimelineStart) return val roomId = _selectedChannel.value ?: return
if (isPaginating || hitTimelineStartByRoom[roomId] == true) return
isPaginating = true isPaginating = true
viewModelScope.launch { viewModelScope.launch(Dispatchers.IO) {
try { try {
val reachedStart = timeline.paginateBackwards(50u.toUShort()) val reachedStart = timeline.paginateBackwards(50u.toUShort())
if (reachedStart) { if (reachedStart) {
hitTimelineStart = true hitTimelineStartByRoom[roomId] = true
android.util.Log.d("MainVM", "Hit timeline start") android.util.Log.d("MainVM", "Hit timeline start for $roomId")
} }
} catch (e: Exception) { } catch (e: Exception) {
android.util.Log.e("MainVM", "paginateBackwards failed", e) android.util.Log.e("MainVM", "paginateBackwards failed", e)
@@ -1164,10 +1424,227 @@ 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(buildMessageContent(body))
} catch (e: Exception) {
android.util.Log.e("MainVM", "Failed to send thread message", e)
}
}
}
}
fun setReplyingTo(message: MessageItem?) { _replyingTo.value = message }
fun setEditingMessage(message: MessageItem?) { _editingMessage.value = message }
fun editMessage(eventId: String, newBody: String) {
val timeline = activeTimeline ?: return
viewModelScope.launch(Dispatchers.IO) {
try {
timeline.edit(
EventOrTransactionId.EventId(eventId),
EditedContent.RoomMessage(messageEventContentFromMarkdown(newBody)),
)
} catch (_: Exception) {}
_editingMessage.value = null
}
}
fun editThreadMessage(eventId: String, newBody: String) {
viewModelScope.launch(Dispatchers.IO) {
threadTimelineMutex.withLock {
val t = activeThreadTimeline ?: return@withLock
try {
t.edit(
EventOrTransactionId.EventId(eventId),
EditedContent.RoomMessage(messageEventContentFromMarkdown(newBody)),
)
} catch (_: Exception) {}
_editingMessage.value = null
}
}
}
fun sendReaction(eventId: String, emoji: String) {
val timeline = activeTimeline ?: return
viewModelScope.launch(Dispatchers.IO) {
try { timeline.toggleReaction(EventOrTransactionId.EventId(eventId), emoji) } catch (_: Exception) {}
}
}
fun sendThreadReaction(eventId: String, emoji: String) {
viewModelScope.launch(Dispatchers.IO) {
threadTimelineMutex.withLock {
val t = activeThreadTimeline ?: return@withLock
try { t.toggleReaction(EventOrTransactionId.EventId(eventId), emoji) } catch (_: Exception) {}
}
}
}
fun sendReply(body: String, replyToEventId: String) {
val timeline = activeTimeline ?: return
viewModelScope.launch(Dispatchers.IO) {
try { timeline.sendReply(messageEventContentFromMarkdown(body), replyToEventId) } catch (_: Exception) {}
_replyingTo.value = null
}
}
fun sendThreadReply(body: String, replyToEventId: String) {
viewModelScope.launch(Dispatchers.IO) {
threadTimelineMutex.withLock {
val t = activeThreadTimeline ?: return@withLock
try { t.sendReply(messageEventContentFromMarkdown(body), replyToEventId) } catch (_: Exception) {}
_replyingTo.value = null
}
}
}
fun logout() { fun logout() {
ProcessLifecycleOwner.get().lifecycle.removeObserver(lifecycleObserver)
viewModelScope.launch { viewModelScope.launch {
try { syncService?.stop() } catch (_: Exception) { } try { syncService?.stop() } catch (_: Exception) { }
authRepository.logout() authRepository.logout()
} }
} }
override fun onCleared() {
super.onCleared()
ProcessLifecycleOwner.get().lifecycle.removeObserver(lifecycleObserver)
timelineListenerHandle?.cancel()
timelineListenerHandle = null
activeTimeline = null
roomPollJob?.cancel()
viewModelScope.launch(Dispatchers.IO) {
threadTimelineMutex.withLock {
activeThreadTimeline?.destroy()
activeThreadTimeline = null
}
try { syncService?.stop() } catch (_: Exception) { }
}
}
} }

View File

@@ -1,8 +1,10 @@
package com.example.fluffytrix.ui.screens.main.components package com.example.fluffytrix.ui.screens.main.components
import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -36,6 +38,7 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
@@ -58,6 +61,7 @@ import androidx.compose.ui.zIndex
import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import com.example.fluffytrix.ui.screens.main.ChannelItem import com.example.fluffytrix.ui.screens.main.ChannelItem
import com.example.fluffytrix.ui.screens.main.ChannelSection import com.example.fluffytrix.ui.screens.main.ChannelSection
import com.example.fluffytrix.ui.screens.main.ThreadItem
import com.example.fluffytrix.ui.screens.main.UnreadStatus import com.example.fluffytrix.ui.screens.main.UnreadStatus
import kotlin.math.roundToInt import kotlin.math.roundToInt
@@ -76,8 +80,17 @@ fun ChannelList(
onMoveChannel: (from: Int, to: Int) -> Unit = { _, _ -> }, onMoveChannel: (from: Int, to: Int) -> Unit = { _, _ -> },
onMoveChannelById: (channelId: String, delta: Int) -> Unit = { _, _ -> }, onMoveChannelById: (channelId: String, delta: Int) -> Unit = { _, _ -> },
onMoveChildSpace: (from: Int, to: 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 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>() } val collapsedSections = remember { mutableStateMapOf<String, Boolean>() }
// Channel drag state — track by ID, no visual offset (let LazyColumn handle positioning) // 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( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -262,7 +321,10 @@ fun ChannelList(
val isSelected = channel.id == selectedChannel val isSelected = channel.id == selectedChannel
val hasUnread = channel.unreadStatus != UnreadStatus.NONE val hasUnread = channel.unreadStatus != UnreadStatus.NONE
val isDragging = draggingChannelId == channel.id val isDragging = draggingChannelId == channel.id
val threads = roomThreads[channel.id] ?: emptyList()
val isThreadExpanded = channel.id in expandedThreadRooms
Column {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -362,6 +424,65 @@ fun ChannelList(
}, },
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, 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),
) )
} }
} }
@@ -370,3 +491,6 @@ fun ChannelList(
} }
} }
} }
}
}
}

View File

@@ -3,6 +3,7 @@ package com.example.fluffytrix.ui.screens.main.components
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -16,18 +17,21 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.People import androidx.compose.material.icons.filled.People
import androidx.compose.material.icons.filled.Tag import androidx.compose.material.icons.filled.Tag
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextField import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -49,25 +53,44 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.material3.Text
import android.net.Uri import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.gestures.detectTransformGestures import androidx.compose.foundation.gestures.detectTransformGestures
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Surface
import androidx.compose.material3.TextButton
import com.mikepenz.markdown.m3.Markdown import com.mikepenz.markdown.m3.Markdown
import com.mikepenz.markdown.m3.markdownColor import com.mikepenz.markdown.m3.markdownColor
import com.mikepenz.markdown.m3.markdownTypography import com.mikepenz.markdown.m3.markdownTypography
import com.mikepenz.markdown.coil3.Coil3ImageTransformerImpl import com.mikepenz.markdown.coil3.Coil3ImageTransformerImpl
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.emoji2.emojipicker.EmojiPickerView
import androidx.emoji2.emojipicker.EmojiViewItem
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material.icons.filled.AttachFile import androidx.compose.material.icons.filled.AttachFile
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.EmojiEmotions
import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.automirrored.filled.Reply
import androidx.compose.material3.ScrollableTabRow
import androidx.compose.material3.Tab
import androidx.compose.material.icons.filled.PlayCircleFilled import androidx.compose.material.icons.filled.PlayCircleFilled
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
@@ -83,6 +106,9 @@ import androidx.media3.ui.PlayerView
import com.example.fluffytrix.data.repository.AuthRepository import com.example.fluffytrix.data.repository.AuthRepository
import org.koin.compose.koinInject import org.koin.compose.koinInject
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import com.example.fluffytrix.data.MxcUrlHelper
import com.example.fluffytrix.data.model.EmojiPack
import com.example.fluffytrix.ui.screens.main.InlineEmoji
import com.example.fluffytrix.ui.screens.main.MessageContent import com.example.fluffytrix.ui.screens.main.MessageContent
import com.example.fluffytrix.ui.screens.main.MessageItem import com.example.fluffytrix.ui.screens.main.MessageItem
import com.example.fluffytrix.ui.screens.main.ReplyInfo import com.example.fluffytrix.ui.screens.main.ReplyInfo
@@ -93,6 +119,8 @@ import java.util.Locale
private val LocalImageViewer = compositionLocalOf<(String) -> Unit> { {} } private val LocalImageViewer = compositionLocalOf<(String) -> Unit> { {} }
private val LocalVideoPlayer = compositionLocalOf<(String) -> Unit> { {} } private val LocalVideoPlayer = compositionLocalOf<(String) -> Unit> { {} }
private val LocalScrollToEvent = compositionLocalOf<(String) -> Unit> { {} } private val LocalScrollToEvent = compositionLocalOf<(String) -> Unit> { {} }
private val LocalReactionHandler = compositionLocalOf<(eventId: String, emoji: String) -> Unit> { { _, _ -> } }
private val LocalCurrentUserId = compositionLocalOf<String?> { null }
private val senderColors = arrayOf( private val senderColors = arrayOf(
Color(0xFF5865F2), Color(0xFF5865F2),
@@ -131,9 +159,30 @@ fun MessageTimeline(
unreadMarkerIndex: Int = -1, unreadMarkerIndex: Int = -1,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues(), 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,
currentUserId: String? = null,
replyingTo: MessageItem? = null,
editingMessage: MessageItem? = null,
onSetReplyingTo: (MessageItem?) -> Unit = {},
onSetEditingMessage: (MessageItem?) -> Unit = {},
onSendReply: (String, String) -> Unit = { _, _ -> },
onSendThreadReply: (String, String) -> Unit = { _, _ -> },
onEditMessage: (String, String) -> Unit = { _, _ -> },
onEditThreadMessage: (String, String) -> Unit = { _, _ -> },
onSendReaction: (String, String) -> Unit = { _, _ -> },
onSendThreadReaction: (String, String) -> Unit = { _, _ -> },
emojiPacks: List<EmojiPack> = emptyList(),
onOpenEmojiPackManagement: () -> Unit = {},
) { ) {
var fullscreenImageUrl by remember { mutableStateOf<String?>(null) } var fullscreenImageUrl by remember { mutableStateOf<String?>(null) }
var fullscreenVideoUrl by remember { mutableStateOf<String?>(null) } var fullscreenVideoUrl by remember { mutableStateOf<String?>(null) }
var contextMenuMessage by remember { mutableStateOf<MessageItem?>(null) }
if (fullscreenImageUrl != null) { if (fullscreenImageUrl != null) {
FullscreenImageViewer( FullscreenImageViewer(
@@ -149,18 +198,61 @@ fun MessageTimeline(
) )
} }
contextMenuMessage?.let { msg ->
MessageContextMenu(
message = msg,
isOwnMessage = msg.senderId == currentUserId,
isInThread = selectedThread != null,
onDismiss = { contextMenuMessage = null },
onReact = { emoji ->
contextMenuMessage = null
if (selectedThread != null) onSendThreadReaction(msg.eventId, emoji)
else onSendReaction(msg.eventId, emoji)
},
onReply = {
onSetReplyingTo(msg)
contextMenuMessage = null
},
onEdit = {
onSetEditingMessage(msg)
contextMenuMessage = null
},
onStartThread = {
onOpenThread(msg.eventId)
contextMenuMessage = null
},
emojiPacks = emojiPacks,
)
}
val reactionHandler: (String, String) -> Unit = remember(selectedThread, onSendReaction, onSendThreadReaction) {
{ eventId, emoji ->
if (selectedThread != null) onSendThreadReaction(eventId, emoji)
else onSendReaction(eventId, emoji)
}
}
CompositionLocalProvider( CompositionLocalProvider(
LocalImageViewer provides { url -> fullscreenImageUrl = url }, LocalImageViewer provides { url -> fullscreenImageUrl = url },
LocalVideoPlayer provides { url -> fullscreenVideoUrl = url }, LocalVideoPlayer provides { url -> fullscreenVideoUrl = url },
LocalReactionHandler provides reactionHandler,
LocalCurrentUserId provides currentUserId,
) { ) {
Column( Column(
modifier = modifier modifier = modifier
.fillMaxSize() .fillMaxSize()
.background(MaterialTheme.colorScheme.background) .background(MaterialTheme.colorScheme.background)
.padding(top = contentPadding.calculateTopPadding()), .padding(
top = contentPadding.calculateTopPadding(),
bottom = contentPadding.calculateBottomPadding(),
),
) { ) {
if (selectedChannel != null) { if (selectedChannel != null) {
TopBar(channelName ?: selectedChannel, onToggleMemberList) if (selectedThread != null) {
ThreadTopBar(selectedThreadName ?: "Thread in #${channelName ?: selectedChannel}", onCloseThread)
} else {
TopBar(channelName ?: selectedChannel, onToggleMemberList, onOpenEmojiPackManagement)
}
HorizontalDivider(color = MaterialTheme.colorScheme.surfaceVariant) HorizontalDivider(color = MaterialTheme.colorScheme.surfaceVariant)
} }
@@ -176,7 +268,13 @@ fun MessageTimeline(
) )
} }
} else { } 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 scope = rememberCoroutineScope()
val isAtBottom by remember { val isAtBottom by remember {
derivedStateOf { derivedStateOf {
@@ -188,15 +286,15 @@ fun MessageTimeline(
val shouldLoadMore by remember { val shouldLoadMore by remember {
derivedStateOf { derivedStateOf {
val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
lastVisible >= messages.size - 5 lastVisible >= activeMessages.size - 5
} }
} }
LaunchedEffect(shouldLoadMore, messages.size) { LaunchedEffect(shouldLoadMore, activeMessages.size) {
if (shouldLoadMore && messages.isNotEmpty()) onLoadMore() if (shouldLoadMore && activeMessages.isNotEmpty() && selectedThread == null) onLoadMore()
} }
// Auto-scroll when near bottom and new messages arrive // Auto-scroll when near bottom and new messages arrive
LaunchedEffect(messages.size) { LaunchedEffect(activeMessages.size) {
if (listState.firstVisibleItemIndex <= 2) { if (listState.firstVisibleItemIndex <= 2) {
listState.animateScrollToItem(0) listState.animateScrollToItem(0)
} }
@@ -205,7 +303,7 @@ fun MessageTimeline(
Box(modifier = Modifier.weight(1f).fillMaxWidth()) { Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
CompositionLocalProvider( CompositionLocalProvider(
LocalScrollToEvent provides { eventId -> LocalScrollToEvent provides { eventId ->
val idx = messages.indexOfFirst { it.eventId == eventId } val idx = activeMessages.indexOfFirst { it.eventId == eventId }
if (idx >= 0) { if (idx >= 0) {
scope.launch { listState.animateScrollToItem(idx) } scope.launch { listState.animateScrollToItem(idx) }
} }
@@ -217,24 +315,23 @@ fun MessageTimeline(
reverseLayout = true, reverseLayout = true,
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
) { ) {
val count = messages.size val count = activeMessages.size
items( items(
count = count, count = count,
key = { messages[it].eventId }, key = { activeMessages[it].eventId },
contentType = { contentType = {
val msg = messages[it] val msg = activeMessages[it]
val next = if (it + 1 < count) messages[it + 1] else null val next = if (it + 1 < count) activeMessages[it + 1] else null
if (next == null || next.senderId != msg.senderId) 0 else 1 if (next == null || next.senderId != msg.senderId) 0 else 1
}, },
) { index -> ) { index ->
val message = messages[index] val message = activeMessages[index]
val next = if (index + 1 < count) messages[index + 1] else null val next = if (index + 1 < count) activeMessages[index + 1] else null
val isFirstInGroup = next == null || next.senderName != message.senderName || message.replyTo != 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 // Show "NEW" divider after the last unread message
// In reverse layout, unreadMarkerIndex 0 = newest message if (index == effectiveUnreadMarker) {
// The divider goes after index unreadMarkerIndex (visually above unread block)
if (index == unreadMarkerIndex) {
Column { Column {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Row( Row(
@@ -260,17 +357,17 @@ fun MessageTimeline(
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
if (isFirstInGroup) { if (isFirstInGroup) {
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
FullMessage(message) FullMessage(message, onOpenThread = onOpenThread, threadReplyCount = threadReplyCounts[message.eventId] ?: 0, onLongPress = { contextMenuMessage = it })
} else { } else {
CompactMessage(message) CompactMessage(message, onLongPress = { contextMenuMessage = it })
} }
} }
} else { } else {
if (isFirstInGroup) { if (isFirstInGroup) {
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
FullMessage(message) FullMessage(message, onOpenThread = onOpenThread, onLongPress = { contextMenuMessage = it })
} else { } else {
CompactMessage(message) CompactMessage(message, onLongPress = { contextMenuMessage = it })
} }
} }
} }
@@ -298,14 +395,50 @@ fun MessageTimeline(
} }
HorizontalDivider(color = MaterialTheme.colorScheme.surfaceVariant) HorizontalDivider(color = MaterialTheme.colorScheme.surfaceVariant)
MessageInput(channelName ?: "message", onSendMessage, onSendFiles) // Reply preview bar
replyingTo?.let { replyMsg ->
ReplyPreviewBar(
senderName = replyMsg.senderName,
body = when (val c = replyMsg.content) {
is com.example.fluffytrix.ui.screens.main.MessageContent.Text -> c.body
else -> "Attachment"
},
onDismiss = { onSetReplyingTo(null) },
)
}
// Edit mode bar
editingMessage?.let { editMsg ->
EditModeBar(
body = when (val c = editMsg.content) {
is com.example.fluffytrix.ui.screens.main.MessageContent.Text -> c.body
else -> ""
},
onDismiss = { onSetEditingMessage(null) },
)
}
MessageInput(
channelName = if (selectedThread != null) "thread" else (channelName ?: "message"),
replyingTo = replyingTo,
editingMessage = editingMessage,
onSendMessage = activeSend,
onSendFiles = onSendFiles,
onSendReply = { body, eventId ->
if (selectedThread != null) onSendThreadReply(body, eventId)
else onSendReply(body, eventId)
},
onEditMessage = { eventId, body ->
if (selectedThread != null) onEditThreadMessage(eventId, body)
else onEditMessage(eventId, body)
},
emojiPacks = emojiPacks,
)
} }
} }
} }
} }
@Composable @Composable
private fun TopBar(name: String, onToggleMemberList: () -> Unit) { private fun TopBar(name: String, onToggleMemberList: () -> Unit, onOpenEmojiPackManagement: () -> Unit = {}) {
Row( Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp), modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -319,6 +452,9 @@ private fun TopBar(name: String, onToggleMemberList: () -> Unit) {
color = MaterialTheme.colorScheme.onBackground, color = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
IconButton(onClick = onOpenEmojiPackManagement) {
Icon(Icons.Default.EmojiEmotions, "Emoji packs", tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
IconButton(onClick = onToggleMemberList) { IconButton(onClick = onToggleMemberList) {
Icon(Icons.Default.People, "Toggle member list", tint = MaterialTheme.colorScheme.onSurfaceVariant) Icon(Icons.Default.People, "Toggle member list", tint = MaterialTheme.colorScheme.onSurfaceVariant)
} }
@@ -326,12 +462,79 @@ private fun TopBar(name: String, onToggleMemberList: () -> Unit) {
} }
@Composable @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 ReactionRow(eventId: String, reactions: Map<String, List<String>>) {
if (reactions.isEmpty()) return
val onReact = LocalReactionHandler.current
val currentUserId = LocalCurrentUserId.current
val authRepository: AuthRepository = koinInject()
val baseUrl = remember { try { authRepository.getClient()?.session()?.homeserverUrl?.trimEnd('/') ?: "" } catch (_: Exception) { "" } }
FlowRow(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.padding(top = 4.dp),
) {
reactions.forEach { (emoji, senders) ->
val isMine = currentUserId != null && senders.any { it == currentUserId }
Surface(
shape = RoundedCornerShape(12.dp),
color = if (isMine) MaterialTheme.colorScheme.primaryContainer
else MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.clickable { onReact(eventId, emoji) },
) {
Row(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
if (emoji.startsWith("mxc://")) {
val resolvedUrl = remember(emoji) { MxcUrlHelper.mxcToDownloadUrl(baseUrl, emoji) ?: emoji }
AsyncImage(
model = resolvedUrl,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
} else {
Text(emoji, fontSize = 14.sp)
}
Text(
"${senders.size}",
style = MaterialTheme.typography.labelSmall,
color = if (isMine) MaterialTheme.colorScheme.onPrimaryContainer
else MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
@Composable
private fun FullMessage(message: MessageItem, onOpenThread: (String) -> Unit = {}, threadReplyCount: Int = 0, onLongPress: (MessageItem) -> Unit = {}) {
val senderColor = remember(message.senderName) { colorForSender(message.senderName) } val senderColor = remember(message.senderName) { colorForSender(message.senderName) }
val time = remember(message.timestamp) { formatTimestamp(message.timestamp) } val time = remember(message.timestamp) { formatTimestamp(message.timestamp) }
val reply = message.replyTo val reply = message.replyTo
Column { Column(modifier = Modifier.combinedClickable(onClick = {}, onLongClick = { onLongPress(message) })) {
if (reply != null) { if (reply != null) {
ReplyConnector(reply, hasAvatar = true) ReplyConnector(reply, hasAvatar = true)
} }
@@ -366,20 +569,34 @@ private fun FullMessage(message: MessageItem) {
} }
Spacer(Modifier.height(2.dp)) Spacer(Modifier.height(2.dp))
MessageContentView(message.content) MessageContentView(message.content)
ReactionRow(message.eventId, message.reactions)
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),
)
}
} }
} }
} }
} }
@Composable @Composable
private fun CompactMessage(message: MessageItem) { private fun CompactMessage(message: MessageItem, onLongPress: (MessageItem) -> Unit = {}) {
Column { Column(modifier = Modifier.combinedClickable(onClick = {}, onLongClick = { onLongPress(message) })) {
if (message.replyTo != null) { if (message.replyTo != null) {
ReplyConnector(message.replyTo, hasAvatar = false) ReplyConnector(message.replyTo, hasAvatar = false)
} }
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 1.dp)) { Row(modifier = Modifier.fillMaxWidth().padding(vertical = 1.dp)) {
Spacer(Modifier.width(52.dp)) Spacer(Modifier.width(52.dp))
Column {
MessageContentView(message.content) MessageContentView(message.content)
ReactionRow(message.eventId, message.reactions)
}
} }
} }
} }
@@ -487,6 +704,9 @@ private fun MessageContentView(content: MessageContent) {
@Composable @Composable
private fun TextContent(content: MessageContent.Text) { private fun TextContent(content: MessageContent.Text) {
if (content.inlineEmojis.isNotEmpty()) {
InlineEmojiText(content)
} else {
Markdown( Markdown(
content = content.body, content = content.body,
colors = markdownColor( colors = markdownColor(
@@ -498,6 +718,42 @@ private fun TextContent(content: MessageContent.Text) {
imageTransformer = Coil3ImageTransformerImpl, imageTransformer = Coil3ImageTransformerImpl,
) )
} }
}
@Composable
private fun InlineEmojiText(content: MessageContent.Text) {
val body = content.body
val emojis = content.inlineEmojis
// Build segments: split body on shortcode occurrences
FlowRow(verticalArrangement = Arrangement.Center) {
var remaining = body
for (emoji in emojis) {
val idx = remaining.indexOf(emoji.shortcode)
if (idx < 0) continue
val before = remaining.substring(0, idx)
if (before.isNotEmpty()) {
Text(
before,
style = MaterialTheme.typography.bodyMedium.copy(lineHeight = 20.sp),
color = MaterialTheme.colorScheme.onBackground,
)
}
AsyncImage(
model = emoji.resolvedUrl,
contentDescription = emoji.shortcode,
modifier = Modifier.size(20.dp),
)
remaining = remaining.substring(idx + emoji.shortcode.length)
}
if (remaining.isNotEmpty()) {
Text(
remaining,
style = MaterialTheme.typography.bodyMedium.copy(lineHeight = 20.sp),
color = MaterialTheme.colorScheme.onBackground,
)
}
}
}
@Composable @Composable
private fun ImageContent(content: MessageContent.Image) { private fun ImageContent(content: MessageContent.Image) {
@@ -752,9 +1008,29 @@ private fun formatFileSize(bytes: Long): String {
} }
@Composable @Composable
private fun MessageInput(channelName: String, onSendMessage: (String) -> Unit, onSendFiles: (List<Uri>, String?) -> Unit) { private fun MessageInput(
channelName: String,
onSendMessage: (String) -> Unit,
onSendFiles: (List<Uri>, String?) -> Unit,
replyingTo: MessageItem? = null,
editingMessage: MessageItem? = null,
onSendReply: (String, String) -> Unit = { _, _ -> },
onEditMessage: (String, String) -> Unit = { _, _ -> },
emojiPacks: List<EmojiPack> = emptyList(),
) {
var text by remember { mutableStateOf("") } var text by remember { mutableStateOf("") }
var attachedUris by remember { mutableStateOf(listOf<Uri>()) } var attachedUris by remember { mutableStateOf(listOf<Uri>()) }
var showEmojiPackPicker by remember { mutableStateOf(false) }
// Pre-fill text when entering edit mode
androidx.compose.runtime.LaunchedEffect(editingMessage) {
if (editingMessage != null) {
text = when (val c = editingMessage.content) {
is com.example.fluffytrix.ui.screens.main.MessageContent.Text -> c.body
else -> ""
}
}
}
val filePickerLauncher = rememberLauncherForActivityResult( val filePickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetMultipleContents() contract = ActivityResultContracts.GetMultipleContents()
) { uris: List<Uri> -> ) { uris: List<Uri> ->
@@ -762,7 +1038,108 @@ private fun MessageInput(channelName: String, onSendMessage: (String) -> Unit, o
} }
val canSend = text.isNotBlank() || attachedUris.isNotEmpty() val canSend = text.isNotBlank() || attachedUris.isNotEmpty()
// Emoji picker dialog: custom packs first, then Unicode
if (showEmojiPackPicker) {
Dialog(
onDismissRequest = { showEmojiPackPicker = false },
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.surface,
) {
Column(modifier = Modifier.fillMaxSize().padding(4.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = { showEmojiPackPicker = false }) {
Icon(Icons.Default.Close, "Close", tint = MaterialTheme.colorScheme.onSurface)
}
Text(
"Insert Emoji",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.weight(1f),
)
}
val tabs = emojiPacks.map { it.displayName } + "Unicode"
var selectedTab by remember { mutableStateOf(0) }
ScrollableTabRow(selectedTabIndex = selectedTab) {
tabs.forEachIndexed { index, title ->
Tab(
selected = selectedTab == index,
onClick = { selectedTab = index },
text = { Text(title, maxLines = 1) },
)
}
}
if (selectedTab < emojiPacks.size) {
CustomEmojiGrid(
pack = emojiPacks[selectedTab],
onEmojiSelected = { entry ->
text = text + ":${entry.shortcode}:"
showEmojiPackPicker = false
},
modifier = Modifier.fillMaxWidth().weight(1f),
)
} else {
val currentText by rememberUpdatedState(text)
AndroidView(
factory = { ctx -> EmojiPickerView(ctx) },
update = { view ->
view.setOnEmojiPickedListener { emojiViewItem: EmojiViewItem ->
text = currentText + emojiViewItem.emoji
showEmojiPackPicker = false
}
},
modifier = Modifier.fillMaxWidth().weight(1f),
)
}
}
}
}
}
// Autocomplete strip: show matching emojis when text ends with :partialword (no space, no closing colon)
val autocompleteResults = remember(text, emojiPacks) {
if (emojiPacks.isEmpty()) return@remember emptyList()
val colonIdx = text.lastIndexOf(':')
if (colonIdx < 0) return@remember emptyList()
val partial = text.substring(colonIdx + 1)
if (partial.isEmpty() || partial.contains(' ') || partial.contains(':')) return@remember emptyList()
emojiPacks.flatMap { it.emojis }.filter { it.shortcode.contains(partial, ignoreCase = true) }.take(10)
}
Column(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.fillMaxWidth()) {
// Emoji autocomplete strip
if (autocompleteResults.isNotEmpty()) {
LazyRow(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
items(autocompleteResults) { entry ->
Column(
modifier = Modifier
.clickable {
// Replace the partial :xxx with :shortcode:
val colonIdx = text.lastIndexOf(':')
text = text.substring(0, colonIdx) + ":${entry.shortcode}:"
}
.padding(4.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
AsyncImage(
model = entry.resolvedUrl,
contentDescription = entry.shortcode,
modifier = Modifier.size(32.dp),
)
Text(entry.shortcode, style = MaterialTheme.typography.labelSmall, maxLines = 1)
}
}
}
HorizontalDivider()
}
// Attachment previews (Discord-style, above the text box) // Attachment previews (Discord-style, above the text box)
if (attachedUris.isNotEmpty()) { if (attachedUris.isNotEmpty()) {
Row( Row(
@@ -817,6 +1194,14 @@ private fun MessageInput(channelName: String, onSendMessage: (String) -> Unit, o
tint = MaterialTheme.colorScheme.onSurfaceVariant, tint = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
if (emojiPacks.isNotEmpty()) {
IconButton(onClick = { showEmojiPackPicker = true }) {
Icon(
Icons.Default.EmojiEmotions, "Insert emoji",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
TextField( TextField(
value = text, value = text,
onValueChange = { text = it }, onValueChange = { text = it },
@@ -832,22 +1217,329 @@ private fun MessageInput(channelName: String, onSendMessage: (String) -> Unit, o
) )
IconButton( IconButton(
onClick = { onClick = {
if (attachedUris.isNotEmpty()) { val trimmed = text.trim()
onSendFiles(attachedUris, text.trim().ifBlank { null }) when {
editingMessage != null && trimmed.isNotBlank() -> {
onEditMessage(editingMessage.eventId, trimmed)
text = ""
}
replyingTo != null && trimmed.isNotBlank() -> {
onSendReply(trimmed, replyingTo.eventId)
text = ""
}
attachedUris.isNotEmpty() -> {
onSendFiles(attachedUris, trimmed.ifBlank { null })
attachedUris = emptyList() attachedUris = emptyList()
text = "" text = ""
} else if (text.isNotBlank()) { }
onSendMessage(text.trim()) trimmed.isNotBlank() -> {
onSendMessage(trimmed)
text = "" text = ""
} }
}
}, },
enabled = canSend, enabled = canSend,
) { ) {
Icon( Icon(
Icons.AutoMirrored.Filled.Send, "Send", if (editingMessage != null) Icons.Default.Check else Icons.AutoMirrored.Filled.Send,
if (editingMessage != null) "Confirm edit" else "Send",
tint = if (canSend) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, tint = if (canSend) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
} }
} }
} }
private val QUICK_REACTIONS = listOf("👍", "👎", "❤️", "😂", "😮", "😢", "🎉", "🔥")
@Composable
private fun MessageContextMenu(
message: MessageItem,
isOwnMessage: Boolean,
isInThread: Boolean,
onDismiss: () -> Unit,
onReact: (String) -> Unit,
onReply: () -> Unit,
onEdit: () -> Unit,
onStartThread: () -> Unit,
emojiPacks: List<EmojiPack> = emptyList(),
) {
var showEmojiPicker by remember { mutableStateOf(false) }
val currentOnReact by rememberUpdatedState(onReact)
if (showEmojiPicker) {
Dialog(
onDismissRequest = { showEmojiPicker = false },
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.surface,
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(4.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
IconButton(onClick = { showEmojiPicker = false }) {
Icon(
Icons.Default.Close,
"Close picker",
tint = MaterialTheme.colorScheme.onSurface,
)
}
Text(
"Choose an emoji",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.weight(1f),
)
}
if (emojiPacks.isNotEmpty()) {
var selectedTab by remember { mutableStateOf(0) }
val tabs = listOf("Unicode") + emojiPacks.map { it.displayName }
ScrollableTabRow(selectedTabIndex = selectedTab) {
tabs.forEachIndexed { index, title ->
Tab(
selected = selectedTab == index,
onClick = { selectedTab = index },
text = { Text(title, maxLines = 1) },
)
}
}
when {
selectedTab == 0 -> AndroidView(
factory = { ctx -> EmojiPickerView(ctx) },
update = { view ->
view.setOnEmojiPickedListener { emojiViewItem: EmojiViewItem ->
currentOnReact(emojiViewItem.emoji)
showEmojiPicker = false
}
},
modifier = Modifier.fillMaxWidth().weight(1f),
)
else -> {
val pack = emojiPacks[selectedTab - 1]
CustomEmojiGrid(
pack = pack,
onEmojiSelected = { entry ->
currentOnReact(entry.mxcUrl)
showEmojiPicker = false
},
modifier = Modifier.fillMaxWidth().weight(1f),
)
}
}
} else {
Spacer(modifier = Modifier.height(8.dp))
AndroidView(
factory = { ctx -> EmojiPickerView(ctx) },
update = { view ->
view.setOnEmojiPickedListener { emojiViewItem: EmojiViewItem ->
currentOnReact(emojiViewItem.emoji)
showEmojiPicker = false
}
},
modifier = Modifier
.fillMaxWidth()
.weight(1f),
)
}
}
}
}
}
AlertDialog(
onDismissRequest = onDismiss,
title = null,
text = {
Column(verticalArrangement = Arrangement.spacedBy(0.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
QUICK_REACTIONS.forEach { emoji ->
Box(
modifier = Modifier
.size(36.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant)
.clickable { onReact(emoji) },
contentAlignment = Alignment.Center,
) {
Text(emoji, fontSize = 18.sp)
}
}
}
Button(
onClick = { showEmojiPicker = true },
modifier = Modifier
.fillMaxWidth()
.height(48.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
),
) {
Icon(
Icons.Default.Add,
"Add reaction",
modifier = Modifier.size(20.dp),
)
Spacer(modifier = Modifier.width(8.dp))
Text("More emojis")
}
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
TextButton(
onClick = onReply,
modifier = Modifier.fillMaxWidth(),
) {
Icon(Icons.AutoMirrored.Filled.Reply, null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("Reply", modifier = Modifier.weight(1f))
}
if (isOwnMessage) {
TextButton(
onClick = onEdit,
modifier = Modifier.fillMaxWidth(),
) {
Icon(Icons.Default.Check, null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("Edit", modifier = Modifier.weight(1f))
}
}
if (!isInThread) {
TextButton(
onClick = onStartThread,
modifier = Modifier.fillMaxWidth(),
) {
Icon(Icons.Default.Tag, null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("Start Thread", modifier = Modifier.weight(1f))
}
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
},
)
}
@Composable
private fun ReplyPreviewBar(senderName: String, body: String, onDismiss: () -> Unit) {
Surface(
color = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.width(3.dp)
.height(36.dp)
.background(MaterialTheme.colorScheme.primary, RoundedCornerShape(2.dp)),
)
Spacer(Modifier.width(8.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
"Replying to $senderName",
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
)
Text(
body,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
IconButton(onClick = onDismiss, modifier = Modifier.size(32.dp)) {
Icon(Icons.Default.Close, "Cancel reply", modifier = Modifier.size(16.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
}
@Composable
private fun EditModeBar(body: String, onDismiss: () -> Unit) {
Surface(
color = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.width(3.dp)
.height(36.dp)
.background(MaterialTheme.colorScheme.tertiary, RoundedCornerShape(2.dp)),
)
Spacer(Modifier.width(8.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
"Editing message",
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.tertiary,
)
Text(
body,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
IconButton(onClick = onDismiss, modifier = Modifier.size(32.dp)) {
Icon(Icons.Default.Close, "Cancel edit", modifier = Modifier.size(16.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
}
@Composable
private fun CustomEmojiGrid(
pack: EmojiPack,
onEmojiSelected: (com.example.fluffytrix.data.model.EmojiEntry) -> Unit,
modifier: Modifier = Modifier,
) {
LazyVerticalGrid(
columns = GridCells.Adaptive(56.dp),
modifier = modifier,
contentPadding = PaddingValues(8.dp),
) {
items(pack.emojis) { entry ->
Column(
modifier = Modifier
.clickable { onEmojiSelected(entry) }
.padding(4.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
AsyncImage(
model = entry.resolvedUrl,
contentDescription = entry.shortcode,
modifier = Modifier.size(40.dp),
)
Text(
text = entry.shortcode,
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}

View File

@@ -19,6 +19,7 @@ matrixRustSdk = "26.02.19"
coil = "3.2.0" coil = "3.2.0"
media3 = "1.6.0" media3 = "1.6.0"
markdownRenderer = "0.37.0" markdownRenderer = "0.37.0"
emojiPicker = "1.6.0"
kotlinxSerialization = "1.8.1" kotlinxSerialization = "1.8.1"
[libraries] [libraries]
@@ -43,6 +44,7 @@ activity-compose = { group = "androidx.activity", name = "activity-compose", ver
navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } 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-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-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
koin-android = { group = "io.insert-koin", name = "koin-android", version.ref = "koin" } koin-android = { group = "io.insert-koin", name = "koin-android", version.ref = "koin" }
@@ -77,6 +79,9 @@ markdown-renderer-m3 = { group = "com.mikepenz", name = "multiplatform-markdown-
markdown-renderer-code = { group = "com.mikepenz", name = "multiplatform-markdown-renderer-code-android", version.ref = "markdownRenderer" } markdown-renderer-code = { group = "com.mikepenz", name = "multiplatform-markdown-renderer-code-android", version.ref = "markdownRenderer" }
markdown-renderer-coil3 = { group = "com.mikepenz", name = "multiplatform-markdown-renderer-coil3-android", version.ref = "markdownRenderer" } markdown-renderer-coil3 = { group = "com.mikepenz", name = "multiplatform-markdown-renderer-coil3-android", version.ref = "markdownRenderer" }
# Jetpack Emoji Picker
emoji-picker = { group = "androidx.emoji2", name = "emoji2-emojipicker", version.ref = "emojiPicker" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }