This commit is contained in:
2026-02-22 01:26:41 +00:00
parent 42486ac5df
commit 3ca324d34f
13 changed files with 469 additions and 49 deletions

View File

@@ -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:*)"
]
}
}

1
.idea/misc.xml generated
View File

@@ -1,4 +1,3 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">

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

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

191
AGENTS.md Normal file
View File

@@ -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<T>` for observable state in ViewModels
- Use `Flow<T>` for read-only data streams
- Use `suspend` functions for async operations
- Use `Result<T>` 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<AuthState>` 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<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
- 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)

View File

@@ -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)

View File

@@ -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()

View File

@@ -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 {

View File

@@ -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,
)

View File

@@ -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<String> = 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<List<ChannelItem>>(emptyList())
private val _spaceChildren = MutableStateFlow<Set<RoomId>?>(null)
private val _roomUnreadStatus = MutableStateFlow<Map<RoomId, UnreadStatus>>(emptyMap())
private val _spaceChildrenMap = MutableStateFlow<Map<RoomId, Set<RoomId>>>(emptyMap())
// Per-room caches
private val messageCache = mutableMapOf<RoomId, MutableList<MessageItem>>()
private val messageIds = mutableMapOf<RoomId, MutableSet<String>>()
@@ -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<RoomId, UnreadStatus>()
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<RoomId>()
val allSpaceIds = allSpaces.map { it.roomId }.toSet()
val spaceChildMap = mutableMapOf<RoomId, Set<RoomId>>()
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<ChannelItem>
val children = args[1] as Set<RoomId>?
val spaceId = args[2] as RoomId?
val orderMap = args[3] as Map<String, List<String>>
val unreadMap = args[4] as Map<RoomId, UnreadStatus>
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.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 -> 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.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() {

View File

@@ -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,

View File

@@ -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<MessageItem>,
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,

View File

@@ -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,6 +90,7 @@ fun SpaceList(
items(spaces, key = { it.id.full }) { space ->
val isSelected = space.id == selectedSpace
Box(modifier = Modifier.size(48.dp)) {
Box(
modifier = Modifier
.size(48.dp)
@@ -117,6 +119,19 @@ fun SpaceList(
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,
),
)
}
}
}
}
}

View File

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