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" }