diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 3d34b07..895b48d 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -9,7 +9,9 @@ "WebFetch(domain:central.sonatype.com)", "WebFetch(domain:gitlab.com)", "Bash(JAVA_HOME=/nix/store/3xf2cjni3xqn10xnsa0cyvjmnd8sqg7b-openjdk-17.0.18+8 jar tf:*)", - "Bash(unzip:*)" + "Bash(unzip:*)", + "WebFetch(domain:raw.githubusercontent.com)", + "Bash(export TERM=dumb:*)" ] } } diff --git a/.idea/misc.xml b/.idea/misc.xml index 74dd639..b2c751a 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,4 +1,3 @@ - diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..fe2c52e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,191 @@ +# Fluffytrix Agent Guidelines + +## Project Overview + +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 +**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 + +--- + +## Build Commands + +```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 +``` + +**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 +- 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" +``` + +--- + +## Code Style Guidelines + +### Kotlin Conventions + +- **Android Official Kotlin style** (`kotlin.code.style=official` in gradle.properties) +- File naming: `PascalCase.kt` (e.g., `MainViewModel.kt`) +- Class naming: `PascalCase` (e.g., `MainViewModel`, `AuthRepository`) +- Function/property naming: `camelCase` (e.g., `sendMessage`, `selectedChannel`) +- Constant naming: `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 +- Example: + ```kotlin + import android.os.Bundle + import androidx.compose.material3.Text + import kotlinx.coroutines.flow.StateFlow + import net.folivo.trixnity.client.MatrixClient + import com.example.fluffytrix.ui.theme.FluffytrixTheme + ``` + +### Types + +- 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 + +### Error Handling + +- 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** + +### Compose UI + +- 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`) + +### 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`) + +--- + +## Architecture Patterns + +**Layered Architecture**: +``` +ui/ — ViewModels, Screens, Navigation +data/ — Repositories, local storage, models +di/ — Koin modules +ui/theme/ — Material 3 Theme (colors, typography) +``` + +**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()` + +--- + +## Key Dependencies (from libs.versions.toml) + +- **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) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e3315a4..c9dc6d4 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -103,6 +103,8 @@ dependencies { // Coil (image loading) implementation(libs.coil.compose) implementation(libs.coil.network.okhttp) + implementation(libs.coil.gif) + implementation(libs.coil.video) // Media3 (video playback) implementation(libs.media3.exoplayer) diff --git a/app/src/main/java/com/example/fluffytrix/FluffytrixApplication.kt b/app/src/main/java/com/example/fluffytrix/FluffytrixApplication.kt index 9c2b6c0..fa1ddae 100644 --- a/app/src/main/java/com/example/fluffytrix/FluffytrixApplication.kt +++ b/app/src/main/java/com/example/fluffytrix/FluffytrixApplication.kt @@ -6,6 +6,8 @@ import coil3.SingletonImageLoader import coil3.disk.DiskCache import coil3.disk.directory import coil3.memory.MemoryCache +import coil3.gif.AnimatedImageDecoder +import coil3.video.VideoFrameDecoder import coil3.network.okhttp.OkHttpNetworkFetcherFactory import com.example.fluffytrix.data.repository.AuthRepository import com.example.fluffytrix.di.appModule @@ -45,6 +47,8 @@ class FluffytrixApplication : Application(), SingletonImageLoader.Factory { return ImageLoader.Builder(context) .components { add(OkHttpNetworkFetcherFactory(callFactory = { okHttpClient })) + add(AnimatedImageDecoder.Factory()) + add(VideoFrameDecoder.Factory()) } .memoryCache { MemoryCache.Builder() diff --git a/app/src/main/java/com/example/fluffytrix/di/AppModule.kt b/app/src/main/java/com/example/fluffytrix/di/AppModule.kt index 70c585c..edab268 100644 --- a/app/src/main/java/com/example/fluffytrix/di/AppModule.kt +++ b/app/src/main/java/com/example/fluffytrix/di/AppModule.kt @@ -1,17 +1,19 @@ package com.example.fluffytrix.di +import android.app.Application import com.example.fluffytrix.data.local.PreferencesManager import com.example.fluffytrix.data.repository.AuthRepository import com.example.fluffytrix.ui.screens.login.LoginViewModel import com.example.fluffytrix.ui.screens.main.MainViewModel import com.example.fluffytrix.ui.screens.verification.VerificationViewModel +import org.koin.android.ext.koin.androidApplication import org.koin.core.module.dsl.viewModel import org.koin.dsl.module val appModule = module { + viewModel { MainViewModel(androidApplication(), get(), get()) } viewModel { LoginViewModel(get()) } viewModel { VerificationViewModel(get()) } - viewModel { MainViewModel(get(), get()) } } val dataModule = module { diff --git a/app/src/main/java/com/example/fluffytrix/ui/screens/main/MainScreen.kt b/app/src/main/java/com/example/fluffytrix/ui/screens/main/MainScreen.kt index 6c67d07..92b2a2a 100644 --- a/app/src/main/java/com/example/fluffytrix/ui/screens/main/MainScreen.kt +++ b/app/src/main/java/com/example/fluffytrix/ui/screens/main/MainScreen.kt @@ -55,6 +55,7 @@ fun MainScreen( messages = messages, onToggleMemberList = { viewModel.toggleMemberList() }, onSendMessage = { viewModel.sendMessage(it) }, + onSendFile = { viewModel.sendFile(it) }, modifier = Modifier.weight(1f), contentPadding = padding, ) 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 37233d2..d49efd3 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 @@ -1,5 +1,8 @@ package com.example.fluffytrix.ui.screens.main +import android.app.Application +import android.net.Uri +import android.provider.OpenableColumns import androidx.compose.runtime.Immutable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -21,7 +24,12 @@ import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import net.folivo.trixnity.client.room +import net.folivo.trixnity.client.room.message.file +import net.folivo.trixnity.client.room.message.image import net.folivo.trixnity.client.room.message.text +import net.folivo.trixnity.client.room.message.video +import io.ktor.http.ContentType +import kotlinx.coroutines.flow.flowOf import net.folivo.trixnity.client.store.Room import net.folivo.trixnity.client.store.isEncrypted import net.folivo.trixnity.client.user @@ -32,10 +40,13 @@ import net.folivo.trixnity.core.model.events.m.room.Membership import net.folivo.trixnity.core.model.events.m.room.RoomMessageEventContent import net.folivo.trixnity.core.model.events.m.space.ChildEventContent +enum class UnreadStatus { NONE, UNREAD, MENTIONED } + data class SpaceItem( val id: RoomId, val name: String, val avatarUrl: String?, + val unreadStatus: UnreadStatus = UnreadStatus.NONE, ) data class ChannelItem( @@ -43,16 +54,19 @@ data class ChannelItem( val name: String, val isEncrypted: Boolean, val avatarUrl: String? = null, + val unreadStatus: UnreadStatus = UnreadStatus.NONE, ) sealed interface MessageContent { data class Text(val body: String, val urls: List = emptyList()) : 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 Video(val body: String, val url: String? = null, val thumbnailUrl: String? = null, val width: Int? = null, val height: Int? = null) : MessageContent data class File(val body: String, val fileName: String? = null, val size: Long? = null) : MessageContent } private val urlRegex = Regex("""https?://[^\s<>"{}|\\^`\[\]]+""") +private val gifUrlRegex = Regex("""https?://(tenor\.com|giphy\.com|gfycat\.com|media\d*\.giphy\.com)/""", RegexOption.IGNORE_CASE) @Immutable data class MessageItem( @@ -71,6 +85,7 @@ data class MemberItem( ) class MainViewModel( + private val application: Application, private val authRepository: AuthRepository, private val preferencesManager: PreferencesManager, ) : ViewModel() { @@ -110,6 +125,9 @@ class MainViewModel( private val _allChannelRooms = MutableStateFlow>(emptyList()) private val _spaceChildren = MutableStateFlow?>(null) + private val _roomUnreadStatus = MutableStateFlow>(emptyMap()) + private val _spaceChildrenMap = MutableStateFlow>>(emptyMap()) + // Per-room caches private val messageCache = mutableMapOf>() private val messageIds = mutableMapOf>() @@ -178,31 +196,49 @@ class MainViewModel( cachedRoomData = allResolved val joinedRooms = allResolved.values.filter { it.membership == Membership.JOIN } + // Derive per-room unread status from server-provided counts + val syncUnread = mutableMapOf() + for (room in joinedRooms) { + if (room.roomId == _selectedChannel.value) continue + val count = room.unreadMessageCount ?: 0 + if (count > 0) syncUnread[room.roomId] = UnreadStatus.UNREAD + } + _roomUnreadStatus.value = syncUnread + val allSpaces = joinedRooms .filter { it.createEventContent?.type is RoomType.Space } // Collect child space IDs so we only show top-level spaces val childSpaceIds = mutableSetOf() val allSpaceIds = allSpaces.map { it.roomId }.toSet() + val spaceChildMap = mutableMapOf>() coroutineScope { allSpaces.map { space -> async { try { val children = client.room.getAllState(space.roomId, ChildEventContent::class) .firstOrNull()?.keys?.map { RoomId(it) } ?: emptyList() - children.filter { it in allSpaceIds } - } catch (_: Exception) { emptyList() } + space.roomId to children + } catch (_: Exception) { space.roomId to emptyList() } } - }.awaitAll().forEach { childSpaceIds.addAll(it) } + }.awaitAll().forEach { (spaceId, children) -> + spaceChildMap[spaceId] = children.toSet() + childSpaceIds.addAll(children.filter { it in allSpaceIds }) + } } + _spaceChildrenMap.value = spaceChildMap _spaces.value = allSpaces .filter { it.roomId !in childSpaceIds } .map { room -> + val childRooms = spaceChildMap[room.roomId] ?: emptySet() + val spaceUnread = childRooms.mapNotNull { syncUnread[it] } + .maxByOrNull { it.ordinal } ?: UnreadStatus.NONE SpaceItem( id = room.roomId, name = room.name?.explicitName ?: room.roomId.full, avatarUrl = mxcToThumbnailUrl(baseUrl, room.avatarUrl, 96), + unreadStatus = spaceUnread, ) } @@ -223,14 +259,23 @@ class MainViewModel( private fun observeSpaceFiltering() { viewModelScope.launch { - combine(_allChannelRooms, _spaceChildren, _selectedSpace, _channelOrderMap) { allChannels, children, spaceId, orderMap -> + combine(_allChannelRooms, _spaceChildren, _selectedSpace, _channelOrderMap, _roomUnreadStatus) { args -> + val allChannels = args[0] as List + val children = args[1] as Set? + val spaceId = args[2] as RoomId? + val orderMap = args[3] as Map> + val unreadMap = args[4] as Map val filtered = if (children == null) allChannels else allChannels.filter { it.id in children } + val withUnread = filtered.map { ch -> + val status = unreadMap[ch.id] ?: UnreadStatus.NONE + if (status != ch.unreadStatus) ch.copy(unreadStatus = status) else ch + } val savedOrder = spaceId?.let { orderMap[it.full] } if (savedOrder != null) { val indexMap = savedOrder.withIndex().associate { (i, id) -> id to i } - filtered.sortedBy { indexMap[it.id.full] ?: Int.MAX_VALUE } - } else filtered + withUnread.sortedBy { indexMap[it.id.full] ?: Int.MAX_VALUE } + } else withUnread }.collect { _channels.value = it } } } @@ -361,19 +406,29 @@ class MainViewModel( private fun resolveContent(content: net.folivo.trixnity.core.model.events.RoomEventContent, baseUrl: String): MessageContent? { return when (content) { - is RoomMessageEventContent.FileBased.Image -> MessageContent.Image( - body = content.body, - url = mxcToDownloadUrl(baseUrl, content.url) ?: return null, - width = content.info?.width?.toInt(), - height = content.info?.height?.toInt(), - ) - is RoomMessageEventContent.FileBased.Video -> MessageContent.Video( - body = content.body, - url = mxcToDownloadUrl(baseUrl, content.url), - thumbnailUrl = mxcToThumbnailUrl(baseUrl, content.info?.thumbnailUrl, 300), - width = content.info?.width?.toInt(), - height = content.info?.height?.toInt(), - ) + is RoomMessageEventContent.FileBased.Image -> { + val isGif = content.info?.mimeType == "image/gif" + val url = mxcToDownloadUrl(baseUrl, content.url) ?: return null + if (isGif) MessageContent.Gif( + body = content.body, url = url, + width = content.info?.width?.toInt(), height = content.info?.height?.toInt(), + ) else MessageContent.Image( + body = content.body, url = url, + width = content.info?.width?.toInt(), height = content.info?.height?.toInt(), + ) + } + is RoomMessageEventContent.FileBased.Video -> { + val isGif = content.info?.mimeType == "image/gif" || gifUrlRegex.containsMatchIn(content.body) + val url = mxcToDownloadUrl(baseUrl, content.url) + if (isGif && url != null) MessageContent.Gif( + body = content.body, url = url, + width = content.info?.width?.toInt(), height = content.info?.height?.toInt(), + ) else MessageContent.Video( + body = content.body, url = url, + thumbnailUrl = mxcToThumbnailUrl(baseUrl, content.info?.thumbnailUrl, 300) ?: url, + width = content.info?.width?.toInt(), height = content.info?.height?.toInt(), + ) + } is RoomMessageEventContent.FileBased.Audio -> MessageContent.File( body = content.body, fileName = content.fileName ?: content.body, @@ -490,6 +545,53 @@ class MainViewModel( } } + fun sendFile(uri: Uri) { + val roomId = _selectedChannel.value ?: return + val client = authRepository.getClient() ?: return + viewModelScope.launch(Dispatchers.IO) { + try { + val contentResolver = application.contentResolver + val mimeType = contentResolver.getType(uri) ?: "application/octet-stream" + val fileName = contentResolver.query(uri, null, null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) { + val idx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (idx >= 0) cursor.getString(idx) else null + } else null + } ?: "file" + val bytes = contentResolver.openInputStream(uri)?.use { it.readBytes() } ?: return@launch + val byteArrayFlow = flowOf(bytes) + val contentType = ContentType.parse(mimeType) + val size = bytes.size.toLong() + + client.room.sendMessage(roomId) { + when { + mimeType.startsWith("image/") -> image( + body = fileName, + image = byteArrayFlow, + type = contentType, + size = size, + fileName = fileName, + ) + mimeType.startsWith("video/") -> video( + body = fileName, + video = byteArrayFlow, + type = contentType, + size = size, + fileName = fileName, + ) + else -> file( + body = fileName, + file = byteArrayFlow, + type = contentType, + size = size, + fileName = fileName, + ) + } + } + } catch (_: Exception) { } + } + } + fun selectSpace(spaceId: RoomId) { if (_selectedSpace.value == spaceId) { _showChannelList.value = !_showChannelList.value @@ -502,6 +604,23 @@ class MainViewModel( fun selectChannel(channelId: RoomId) { _selectedChannel.value = channelId + // Clear unread status for the opened room + if (_roomUnreadStatus.value.containsKey(channelId)) { + _roomUnreadStatus.value = _roomUnreadStatus.value - channelId + updateSpaceUnreadStatus() + } + } + + private fun updateSpaceUnreadStatus() { + val unreadMap = _roomUnreadStatus.value + val childrenMap = _spaceChildrenMap.value + val baseUrl = authRepository.getBaseUrl() ?: return + _spaces.value = _spaces.value.map { space -> + val childRooms = childrenMap[space.id] ?: emptySet() + val spaceUnread = childRooms.mapNotNull { unreadMap[it] } + .maxByOrNull { it.ordinal } ?: UnreadStatus.NONE + if (spaceUnread != space.unreadStatus) space.copy(unreadStatus = spaceUnread) else space + } } fun toggleChannelList() { diff --git a/app/src/main/java/com/example/fluffytrix/ui/screens/main/components/ChannelList.kt b/app/src/main/java/com/example/fluffytrix/ui/screens/main/components/ChannelList.kt index f950a73..94545de 100644 --- a/app/src/main/java/com/example/fluffytrix/ui/screens/main/components/ChannelList.kt +++ b/app/src/main/java/com/example/fluffytrix/ui/screens/main/components/ChannelList.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress 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 @@ -50,6 +51,7 @@ import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex import com.example.fluffytrix.ui.screens.main.ChannelItem +import com.example.fluffytrix.ui.screens.main.UnreadStatus import net.folivo.trixnity.core.model.RoomId import kotlin.math.roundToInt @@ -200,6 +202,20 @@ fun ChannelList( ) Spacer(modifier = Modifier.width(4.dp)) } + if (channel.unreadStatus != UnreadStatus.NONE) { + Box( + modifier = Modifier + .size(8.dp) + .background( + if (channel.unreadStatus == UnreadStatus.MENTIONED) androidx.compose.ui.graphics.Color.Red + else androidx.compose.ui.graphics.Color.Gray, + androidx.compose.foundation.shape.CircleShape, + ), + ) + Spacer(modifier = Modifier.width(4.dp)) + } else { + Spacer(modifier = Modifier.width(12.dp)) + } Icon( imageVector = if (channel.isEncrypted) Icons.Default.Lock else Icons.Default.Tag, contentDescription = null, 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 99d8657..99d7882 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 @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -43,6 +44,8 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectTransformGestures import com.mikepenz.markdown.m3.Markdown @@ -116,6 +119,7 @@ fun MessageTimeline( messages: List, onToggleMemberList: () -> Unit, onSendMessage: (String) -> Unit, + onSendFile: (Uri) -> Unit, modifier: Modifier = Modifier, contentPadding: PaddingValues = PaddingValues(), ) { @@ -229,7 +233,7 @@ fun MessageTimeline( } HorizontalDivider(color = MaterialTheme.colorScheme.surfaceVariant) - MessageInput(channelName ?: "message", onSendMessage) + MessageInput(channelName ?: "message", onSendMessage, onSendFile) } } } @@ -309,6 +313,7 @@ private fun MessageContentView(content: MessageContent) { when (content) { is MessageContent.Text -> TextContent(content) is MessageContent.Image -> ImageContent(content) + is MessageContent.Gif -> GifContent(content) is MessageContent.Video -> VideoContent(content) is MessageContent.File -> FileContent(content) } @@ -346,6 +351,51 @@ private fun ImageContent(content: MessageContent.Image) { ) } +@Composable +@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) +private fun GifContent(content: MessageContent.Gif) { + val context = LocalContext.current + val authRepository: AuthRepository = koinInject() + val aspectRatio = if (content.width != null && content.height != null && content.height > 0) + content.width.toFloat() / content.height.toFloat() else 16f / 9f + + val exoPlayer = remember(content.url) { + val token = authRepository.getAccessToken() + val dataSourceFactory = DefaultHttpDataSource.Factory().apply { + if (token != null) { + setDefaultRequestProperties(mapOf("Authorization" to "Bearer $token")) + } + } + val mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory) + .createMediaSource(MediaItem.fromUri(Uri.parse(content.url))) + ExoPlayer.Builder(context).build().apply { + setMediaSource(mediaSource) + prepare() + playWhenReady = true + repeatMode = ExoPlayer.REPEAT_MODE_ALL + volume = 0f + } + } + + DisposableEffect(content.url) { + onDispose { exoPlayer.release() } + } + + AndroidView( + factory = { ctx -> + PlayerView(ctx).apply { + player = exoPlayer + useController = false + setShowBuffering(PlayerView.SHOW_BUFFERING_NEVER) + } + }, + modifier = Modifier + .width((200.dp * aspectRatio).coerceAtMost(400.dp)) + .height(200.dp) + .clip(RoundedCornerShape(8.dp)), + ) +} + @Composable private fun VideoContent(content: MessageContent.Video) { val onPlayVideo = LocalVideoPlayer.current @@ -536,19 +586,30 @@ private fun formatFileSize(bytes: Long): String { } @Composable -private fun MessageInput(channelName: String, onSendMessage: (String) -> Unit) { +private fun MessageInput(channelName: String, onSendMessage: (String) -> Unit, onSendFile: (Uri) -> Unit) { var text by remember { mutableStateOf("") } + val filePickerLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.GetContent() + ) { uri: Uri? -> + if (uri != null) onSendFile(uri) + } Row( modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, + verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { + IconButton(onClick = { filePickerLauncher.launch("*/*") }) { + Icon( + Icons.Default.AttachFile, "Attach file", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } TextField( value = text, onValueChange = { text = it }, placeholder = { Text("Message #$channelName", color = MaterialTheme.colorScheme.onSurfaceVariant) }, - modifier = Modifier.weight(1f).clip(RoundedCornerShape(8.dp)), - singleLine = true, + modifier = Modifier.weight(1f).clip(RoundedCornerShape(8.dp)).heightIn(max = 160.dp), + maxLines = 8, colors = TextFieldDefaults.colors( unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, diff --git a/app/src/main/java/com/example/fluffytrix/ui/screens/main/components/SpaceList.kt b/app/src/main/java/com/example/fluffytrix/ui/screens/main/components/SpaceList.kt index 9aa33ec..40784bf 100644 --- a/app/src/main/java/com/example/fluffytrix/ui/screens/main/components/SpaceList.kt +++ b/app/src/main/java/com/example/fluffytrix/ui/screens/main/components/SpaceList.kt @@ -34,6 +34,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil3.compose.AsyncImage import com.example.fluffytrix.ui.screens.main.SpaceItem +import com.example.fluffytrix.ui.screens.main.UnreadStatus import net.folivo.trixnity.core.model.RoomId @Composable @@ -89,32 +90,46 @@ fun SpaceList( items(spaces, key = { it.id.full }) { space -> val isSelected = space.id == selectedSpace - Box( - modifier = Modifier - .size(48.dp) - .clip(if (isSelected) RoundedCornerShape(16.dp) else CircleShape) - .background( - if (isSelected) MaterialTheme.colorScheme.primary - else MaterialTheme.colorScheme.surface - ) - .clickable { onSpaceClick(space.id) }, - contentAlignment = Alignment.Center, - ) { - if (space.avatarUrl != null) { - var imageError by remember { mutableStateOf(false) } - if (!imageError) { - AsyncImage( - model = space.avatarUrl, - contentDescription = space.name, - modifier = Modifier.size(48.dp).clip(if (isSelected) RoundedCornerShape(16.dp) else CircleShape), - contentScale = ContentScale.Crop, - onError = { imageError = true }, + Box(modifier = Modifier.size(48.dp)) { + Box( + modifier = Modifier + .size(48.dp) + .clip(if (isSelected) RoundedCornerShape(16.dp) else CircleShape) + .background( + if (isSelected) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.surface ) + .clickable { onSpaceClick(space.id) }, + contentAlignment = Alignment.Center, + ) { + if (space.avatarUrl != null) { + var imageError by remember { mutableStateOf(false) } + if (!imageError) { + AsyncImage( + model = space.avatarUrl, + contentDescription = space.name, + modifier = Modifier.size(48.dp).clip(if (isSelected) RoundedCornerShape(16.dp) else CircleShape), + contentScale = ContentScale.Crop, + onError = { imageError = true }, + ) + } else { + SpaceInitial(space.name, isSelected) + } } else { SpaceInitial(space.name, isSelected) } - } else { - SpaceInitial(space.name, isSelected) + } + if (space.unreadStatus != UnreadStatus.NONE) { + Box( + modifier = Modifier + .size(8.dp) + .align(Alignment.TopEnd) + .background( + if (space.unreadStatus == UnreadStatus.MENTIONED) androidx.compose.ui.graphics.Color.Red + else androidx.compose.ui.graphics.Color.Gray, + CircleShape, + ), + ) } } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index da7e643..a9611ff 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -67,6 +67,8 @@ ktor-client-okhttp = { group = "io.ktor", name = "ktor-client-okhttp", version.r # Coil (image loading) coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" } coil-network-okhttp = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", version.ref = "coil" } +coil-gif = { group = "io.coil-kt.coil3", name = "coil-gif", version.ref = "coil" } +coil-video = { group = "io.coil-kt.coil3", name = "coil-video", version.ref = "coil" } # Media3 (ExoPlayer) media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" }