diff --git a/.idea/studiobot.xml b/.idea/studiobot.xml
new file mode 100644
index 0000000..539e3b8
--- /dev/null
+++ b/.idea/studiobot.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/AGENTS.md b/AGENTS.md
index fe2c52e..b81c5bc 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -5,13 +5,13 @@
Fluffytrix is an Android Matrix chat client with a Discord-like UI. Built with Kotlin, targeting Android 14+ (minSdk 34, targetSdk 36, compileSdk 36).
**Package**: `com.example.fluffytrix`
-**Build system**: Gradle with Kotlin DSL, version catalog at `gradle/libs.versions.toml`
-**Container/DI**: Koin
+**Build system**: Gradle with KotlinDSL, version catalog at `gradle/libs.versions.toml`
+**DI**: Koin
**State management**: Jetpack Compose StateFlow, ViewModel
**UI framework**: Jetpack Compose with Material 3 (Dynamic Colors)
**Protocol**: Trixnity SDK for Matrix
**Storage**: Room Database, DataStore Preferences
-**Async**: Kotlin Coroutines
+**Async**: Kotlin Coroutines
---
@@ -20,28 +20,22 @@ Fluffytrix is an Android Matrix chat client with a Discord-like UI. Built with K
```bash
./gradlew assembleDebug # Build debug APK (minified for performance)
./gradlew assembleRelease # Build release APK
-./gradlew test # Run unit tests
-./gradlew connectedAndroidTest # Run instrumented tests on device/emulator
-./gradlew testDebugUnitTest --tests "com.example.fluffytrix.ExampleUnitTest" # Run single test
+./gradlew test # Run all unit tests
+./gradlew connectedAndroidTest # Run instrumented tests on device
+./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` but strip material-icons-extended and optimize Compose for performance
-- Use `--tests` with Gradle test tasks to run a single test class
+- Debug builds use R8 with `isDebuggable = true` for balanced speed/debuggability
- Instrumented tests require a connected device or emulator
---
## Testing
-**Unit tests**: Located in `app/src/test/java/`, run with `./gradlew test`
-**Instrumented tests**: Located in `app/src/androidTest/java/`, run with `./gradlew connectedAndroidTest`
-
-**Single test execution**:
-```bash
-./gradlew testDebugUnitTest --tests "com.example.fluffytrix.*"
-./gradlew app:testDebugUnitTest --tests "ExampleUnitTest"
-```
+**Unit tests**: `app/src/test/java/`
+**Instrumented tests**: `app/src/androidTest/java/`
+**Single test**: `./gradlew testDebugUnitTest --tests "com.example.fluffytrix.ui.main.LoginViewModelTest"`
---
@@ -49,17 +43,17 @@ Fluffytrix is an Android Matrix chat client with a Discord-like UI. Built with K
### 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`)
-- Class naming: `PascalCase` (e.g., `MainViewModel`, `AuthRepository`)
+- Class naming: `PascalCase` (e.g., `AuthRepository`, `MainViewModel`)
- 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**
### Imports
-- Explicit imports only (no wildcard imports)
-- Group imports: Android/X → Kotlin → Javax/Java → Third-party → Same package
+- Explicit imports only (no wildcards)
+- Group: Android/X → Kotlin → Javax/Java → Third-party → Same package
- Example:
```kotlin
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
```
-### Types
+### Types & Error Handling
- Prefer `val` over `var` (immutable data)
-- Use `StateFlow` for observable state in ViewModels
-- Use `Flow` for read-only data streams
-- Use `suspend` functions for async operations
-- Use `Result` for operations that can fail
+- Use `StateFlow` for observable ViewModel state, `Flow` for read-only streams
+- Use `suspend` for async operations, `Result` for failing operations
+- `try-catch` with `catch (_: Exception) { }` for graceful degradation in ViewModels
+- Use `?:` operators when appropriate, **never crash on recoverable errors**
-### Error Handling
+### Compose & ViewModels
-- Prefer `try-catch` with silent failure or `?` operators where appropriate
-- In ViewModels, use `catch (_: Exception) { }` or `?:` for graceful degradation
-- Expose error state via `StateFlow` where users need feedback
-- **Never crash the app on recoverable errors**
+- Use `@Composable` for all UI functions; use `MaterialTheme` for consistent theming
+- Discord-like layout: space sidebar → channel list → message area → member list
+- Use `Modifier.padding()`, `wrapContentWidth()`, `fillMaxWidth()` appropriately
+- 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
-- Follow Discord-like layout: space sidebar → channel list → message area → member list
-- Use `MaterialTheme` for consistent theming
-- Prefer `Modifier.padding()` over nested `Box` with margins
-- 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>`)
-- 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`)
+- `Dispatchers.Default` for CPU work, `Dispatchers.IO` for I/O operations
+- Cancel jobs on ViewModel cleanup: `job?.cancel()`
+- Use `Room` for persistent storage; `DataStore Preferences` for small key-value data
+- Prefer Flow-based APIs; cache in ViewModels
### Naming Conventions
-- State Flow properties: `_name` (private) / `name` (public)
-- Repository class: `AuthService`, `AuthRepository`
-- ViewModel class: `MainViewModel`, `LoginViewModel`
-- UI composable: `MainScreen`, `ChannelList`, `MessageItem`
-- Model data class: `MessageItem`, `ChannelItem`, `SpaceItem`
-- Use `full` property for Matrix IDs (e.g., `userId.full`, `roomId.full`)
+- State Flow: `_state` (private) / `state` (public)
+- Repositories: `AuthRepository`, `MessageRepository`
+- ViewModels: `MainViewModel`, `LoginViewModel`
+- UI composables: `MainScreen`, `ChannelList`, `MessageItem`
+- Models: `MessageItem`, `ChannelItem`, `SpaceItem`
+- 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**:
```
ui/ — ViewModels, Screens, Navigation
-data/ — Repositories, local storage, models
+data/ — Repositories, storage, models
di/ — Koin modules
-ui/theme/ — Material 3 Theme (colors, typography)
+ui/theme/ — Material 3 Theme
```
-**Dependency Injection**: Koin with two modules:
-- `appModule`: ViewModels (`viewModel { MainViewModel(...) }`)
-- `dataModule`: singleton services (`single { AuthRepository(...) }`)
-
-**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()`
+**Koin DI**:
+- `appModule`: ViewModel injection
+- `dataModule`: Singleton repositories/UI Flow: NavHost in `MainActivity` with `FluffytrixTheme`, ViewModels expose StateFlow, screens observe with `collectAsState()`
---
-## Key Dependencies (from libs.versions.toml)
+## Key Dependencies
-- **Compose BOM**: `2025.06.00`
-- **Kotlin**: `2.2.10`
-- **AGP**: `9.0.1`
-- **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)
+- **Compose BOM**: `2025.06.00`, **Kotlin**: `2.2.10`, **AGP**: `9.0.1`
+- **Koin**: `4.1.1`, **Trixnity**: `4.22.7`, **Ktor**: `3.3.0`
+- **Coroutines**: `1.10.2`, **DataStore**: `1.1.7`, **Coil**: `3.2.0`
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 07db7b6..9092868 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -101,6 +101,9 @@ dependencies {
implementation(libs.markdown.renderer.code)
implementation(libs.markdown.renderer.coil3)
+ // Jetpack Emoji Picker
+ implementation(libs.emoji.picker)
+
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
diff --git a/app/src/main/java/com/example/fluffytrix/ui/screens/main/MainViewModel.kt b/app/src/main/java/com/example/fluffytrix/ui/screens/main/MainViewModel.kt
index 352cbe0..1db6bfd 100644
--- a/app/src/main/java/com/example/fluffytrix/ui/screens/main/MainViewModel.kt
+++ b/app/src/main/java/com/example/fluffytrix/ui/screens/main/MainViewModel.kt
@@ -88,6 +88,7 @@ data class MessageItem(
val timestamp: Long,
val replyTo: ReplyInfo? = null,
val threadRootEventId: String? = null,
+ val reactions: Map> = emptyMap(), // emoji -> list of full Matrix user IDs
)
data class ThreadItem(
@@ -752,6 +753,12 @@ class MainViewModel(
senderNameCache[localpart] = senderName
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(
eventId = eventId,
senderId = localpart,
@@ -761,6 +768,7 @@ class MainViewModel(
timestamp = eventItem.timestamp.toLong(),
replyTo = replyInfo,
threadRootEventId = threadRootId,
+ reactions = reactions,
)
ids.add(eventId)
diff --git a/app/src/main/java/com/example/fluffytrix/ui/screens/main/components/MessageTimeline.kt b/app/src/main/java/com/example/fluffytrix/ui/screens/main/components/MessageTimeline.kt
index dde91ff..1d50a9e 100644
--- a/app/src/main/java/com/example/fluffytrix/ui/screens/main/components/MessageTimeline.kt
+++ b/app/src/main/java/com/example/fluffytrix/ui/screens/main/components/MessageTimeline.kt
@@ -3,6 +3,7 @@ package com.example.fluffytrix.ui.screens.main.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
@@ -22,13 +23,15 @@ 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.Send
+import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.People
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.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
@@ -50,6 +53,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
+import androidx.compose.material3.Text
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
@@ -67,6 +71,8 @@ import com.mikepenz.markdown.m3.markdownColor
import com.mikepenz.markdown.m3.markdownTypography
import com.mikepenz.markdown.coil3.Coil3ImageTransformerImpl
import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.emoji2.emojipicker.EmojiPickerView
+import androidx.emoji2.emojipicker.EmojiViewItem
import androidx.compose.material.icons.filled.AttachFile
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
@@ -78,6 +84,7 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.rememberUpdatedState
import kotlinx.coroutines.launch
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
@@ -103,6 +110,8 @@ import java.util.Locale
private val LocalImageViewer = compositionLocalOf<(String) -> Unit> { {} }
private val LocalVideoPlayer = compositionLocalOf<(String) -> Unit> { {} }
private val LocalScrollToEvent = compositionLocalOf<(String) -> Unit> { {} }
+private val LocalReactionHandler = compositionLocalOf<(eventId: String, emoji: String) -> Unit> { { _, _ -> } }
+private val LocalCurrentUserId = compositionLocalOf { null }
private val senderColors = arrayOf(
Color(0xFF5865F2),
@@ -204,9 +213,18 @@ fun MessageTimeline(
)
}
+ val reactionHandler: (String, String) -> Unit = remember(selectedThread, onSendReaction, onSendThreadReaction) {
+ { eventId, emoji ->
+ if (selectedThread != null) onSendThreadReaction(eventId, emoji)
+ else onSendReaction(eventId, emoji)
+ }
+ }
+
CompositionLocalProvider(
LocalImageViewer provides { url -> fullscreenImageUrl = url },
LocalVideoPlayer provides { url -> fullscreenVideoUrl = url },
+ LocalReactionHandler provides reactionHandler,
+ LocalCurrentUserId provides currentUserId,
) {
Column(
modifier = modifier
@@ -447,6 +465,42 @@ private fun ThreadTopBar(title: String, onClose: () -> Unit) {
}
}
+@Composable
+private fun ReactionRow(eventId: String, reactions: Map>) {
+ if (reactions.isEmpty()) return
+ val onReact = LocalReactionHandler.current
+ val currentUserId = LocalCurrentUserId.current
+ 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),
+ ) {
+ 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) }
@@ -488,6 +542,7 @@ private fun FullMessage(message: MessageItem, onOpenThread: (String) -> Unit = {
}
Spacer(Modifier.height(2.dp))
MessageContentView(message.content)
+ ReactionRow(message.eventId, message.reactions)
if (threadReplyCount > 0) {
Text(
text = "$threadReplyCount ${if (threadReplyCount == 1) "reply" else "replies"}",
@@ -511,7 +566,10 @@ private fun CompactMessage(message: MessageItem, onLongPress: (MessageItem) -> U
}
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 1.dp)) {
Spacer(Modifier.width(52.dp))
- MessageContentView(message.content)
+ Column {
+ MessageContentView(message.content)
+ ReactionRow(message.eventId, message.reactions)
+ }
}
}
}
@@ -1028,29 +1086,100 @@ private fun MessageContextMenu(
onEdit: () -> Unit,
onStartThread: () -> Unit,
) {
+ 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),
+ )
+ }
+ 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)) {
- // Quick emoji reactions
- LazyRow(
+ Row(
+ modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(4.dp),
- contentPadding = PaddingValues(vertical = 8.dp),
) {
- items(QUICK_REACTIONS) { emoji ->
+ QUICK_REACTIONS.forEach { emoji ->
Box(
modifier = Modifier
- .size(40.dp)
+ .size(36.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant)
.clickable { onReact(emoji) },
contentAlignment = Alignment.Center,
) {
- Text(emoji, fontSize = 20.sp)
+ 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,
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index ca0d67c..db38b24 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -19,6 +19,7 @@ matrixRustSdk = "26.02.19"
coil = "3.2.0"
media3 = "1.6.0"
markdownRenderer = "0.37.0"
+emojiPicker = "1.6.0"
kotlinxSerialization = "1.8.1"
[libraries]
@@ -78,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-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]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }