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:central.sonatype.com)",
"WebFetch(domain:gitlab.com)", "WebFetch(domain:gitlab.com)",
"Bash(JAVA_HOME=/nix/store/3xf2cjni3xqn10xnsa0cyvjmnd8sqg7b-openjdk-17.0.18+8 jar tf:*)", "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"> <project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" /> <component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK"> <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) // Coil (image loading)
implementation(libs.coil.compose) implementation(libs.coil.compose)
implementation(libs.coil.network.okhttp) implementation(libs.coil.network.okhttp)
implementation(libs.coil.gif)
implementation(libs.coil.video)
// Media3 (video playback) // Media3 (video playback)
implementation(libs.media3.exoplayer) implementation(libs.media3.exoplayer)

View File

@@ -6,6 +6,8 @@ import coil3.SingletonImageLoader
import coil3.disk.DiskCache import coil3.disk.DiskCache
import coil3.disk.directory import coil3.disk.directory
import coil3.memory.MemoryCache import coil3.memory.MemoryCache
import coil3.gif.AnimatedImageDecoder
import coil3.video.VideoFrameDecoder
import coil3.network.okhttp.OkHttpNetworkFetcherFactory import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import com.example.fluffytrix.data.repository.AuthRepository import com.example.fluffytrix.data.repository.AuthRepository
import com.example.fluffytrix.di.appModule import com.example.fluffytrix.di.appModule
@@ -45,6 +47,8 @@ class FluffytrixApplication : Application(), SingletonImageLoader.Factory {
return ImageLoader.Builder(context) return ImageLoader.Builder(context)
.components { .components {
add(OkHttpNetworkFetcherFactory(callFactory = { okHttpClient })) add(OkHttpNetworkFetcherFactory(callFactory = { okHttpClient }))
add(AnimatedImageDecoder.Factory())
add(VideoFrameDecoder.Factory())
} }
.memoryCache { .memoryCache {
MemoryCache.Builder() MemoryCache.Builder()

View File

@@ -1,17 +1,19 @@
package com.example.fluffytrix.di package com.example.fluffytrix.di
import android.app.Application
import com.example.fluffytrix.data.local.PreferencesManager import com.example.fluffytrix.data.local.PreferencesManager
import com.example.fluffytrix.data.repository.AuthRepository import com.example.fluffytrix.data.repository.AuthRepository
import com.example.fluffytrix.ui.screens.login.LoginViewModel import com.example.fluffytrix.ui.screens.login.LoginViewModel
import com.example.fluffytrix.ui.screens.main.MainViewModel import com.example.fluffytrix.ui.screens.main.MainViewModel
import com.example.fluffytrix.ui.screens.verification.VerificationViewModel import com.example.fluffytrix.ui.screens.verification.VerificationViewModel
import org.koin.android.ext.koin.androidApplication
import org.koin.core.module.dsl.viewModel import org.koin.core.module.dsl.viewModel
import org.koin.dsl.module import org.koin.dsl.module
val appModule = module { val appModule = module {
viewModel { MainViewModel(androidApplication(), get(), get()) }
viewModel { LoginViewModel(get()) } viewModel { LoginViewModel(get()) }
viewModel { VerificationViewModel(get()) } viewModel { VerificationViewModel(get()) }
viewModel { MainViewModel(get(), get()) }
} }
val dataModule = module { val dataModule = module {

View File

@@ -55,6 +55,7 @@ fun MainScreen(
messages = messages, messages = messages,
onToggleMemberList = { viewModel.toggleMemberList() }, onToggleMemberList = { viewModel.toggleMemberList() },
onSendMessage = { viewModel.sendMessage(it) }, onSendMessage = { viewModel.sendMessage(it) },
onSendFile = { viewModel.sendFile(it) },
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
contentPadding = padding, contentPadding = padding,
) )

View File

@@ -1,5 +1,8 @@
package com.example.fluffytrix.ui.screens.main 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.compose.runtime.Immutable
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
@@ -21,7 +24,12 @@ import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import net.folivo.trixnity.client.room 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.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.Room
import net.folivo.trixnity.client.store.isEncrypted import net.folivo.trixnity.client.store.isEncrypted
import net.folivo.trixnity.client.user 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.room.RoomMessageEventContent
import net.folivo.trixnity.core.model.events.m.space.ChildEventContent import net.folivo.trixnity.core.model.events.m.space.ChildEventContent
enum class UnreadStatus { NONE, UNREAD, MENTIONED }
data class SpaceItem( data class SpaceItem(
val id: RoomId, val id: RoomId,
val name: String, val name: String,
val avatarUrl: String?, val avatarUrl: String?,
val unreadStatus: UnreadStatus = UnreadStatus.NONE,
) )
data class ChannelItem( data class ChannelItem(
@@ -43,16 +54,19 @@ data class ChannelItem(
val name: String, val name: String,
val isEncrypted: Boolean, val isEncrypted: Boolean,
val avatarUrl: String? = null, val avatarUrl: String? = null,
val unreadStatus: UnreadStatus = UnreadStatus.NONE,
) )
sealed interface MessageContent { sealed interface MessageContent {
data class Text(val body: String, val urls: List<String> = emptyList()) : MessageContent data class Text(val body: String, val urls: List<String> = emptyList()) : MessageContent
data class Image(val body: String, val url: String, val width: Int? = null, val height: Int? = null) : MessageContent data class Image(val body: String, val url: String, val width: Int? = null, val height: Int? = null) : MessageContent
data class Gif(val body: String, val url: String, val width: Int? = null, val height: Int? = null) : MessageContent
data class Video(val body: String, val url: String? = null, val thumbnailUrl: String? = null, val width: Int? = null, val height: Int? = null) : MessageContent data class Video(val body: String, val url: String? = null, val thumbnailUrl: String? = null, val width: Int? = null, val height: Int? = null) : MessageContent
data class File(val body: String, val fileName: String? = null, val size: Long? = 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 urlRegex = Regex("""https?://[^\s<>"{}|\\^`\[\]]+""")
private val gifUrlRegex = Regex("""https?://(tenor\.com|giphy\.com|gfycat\.com|media\d*\.giphy\.com)/""", RegexOption.IGNORE_CASE)
@Immutable @Immutable
data class MessageItem( data class MessageItem(
@@ -71,6 +85,7 @@ data class MemberItem(
) )
class MainViewModel( class MainViewModel(
private val application: Application,
private val authRepository: AuthRepository, private val authRepository: AuthRepository,
private val preferencesManager: PreferencesManager, private val preferencesManager: PreferencesManager,
) : ViewModel() { ) : ViewModel() {
@@ -110,6 +125,9 @@ class MainViewModel(
private val _allChannelRooms = MutableStateFlow<List<ChannelItem>>(emptyList()) private val _allChannelRooms = MutableStateFlow<List<ChannelItem>>(emptyList())
private val _spaceChildren = MutableStateFlow<Set<RoomId>?>(null) 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 // Per-room caches
private val messageCache = mutableMapOf<RoomId, MutableList<MessageItem>>() private val messageCache = mutableMapOf<RoomId, MutableList<MessageItem>>()
private val messageIds = mutableMapOf<RoomId, MutableSet<String>>() private val messageIds = mutableMapOf<RoomId, MutableSet<String>>()
@@ -178,31 +196,49 @@ class MainViewModel(
cachedRoomData = allResolved cachedRoomData = allResolved
val joinedRooms = allResolved.values.filter { it.membership == Membership.JOIN } 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 val allSpaces = joinedRooms
.filter { it.createEventContent?.type is RoomType.Space } .filter { it.createEventContent?.type is RoomType.Space }
// Collect child space IDs so we only show top-level spaces // Collect child space IDs so we only show top-level spaces
val childSpaceIds = mutableSetOf<RoomId>() val childSpaceIds = mutableSetOf<RoomId>()
val allSpaceIds = allSpaces.map { it.roomId }.toSet() val allSpaceIds = allSpaces.map { it.roomId }.toSet()
val spaceChildMap = mutableMapOf<RoomId, Set<RoomId>>()
coroutineScope { coroutineScope {
allSpaces.map { space -> allSpaces.map { space ->
async { async {
try { try {
val children = client.room.getAllState(space.roomId, ChildEventContent::class) val children = client.room.getAllState(space.roomId, ChildEventContent::class)
.firstOrNull()?.keys?.map { RoomId(it) } ?: emptyList() .firstOrNull()?.keys?.map { RoomId(it) } ?: emptyList()
children.filter { it in allSpaceIds } space.roomId to children
} catch (_: Exception) { emptyList() } } 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 _spaces.value = allSpaces
.filter { it.roomId !in childSpaceIds } .filter { it.roomId !in childSpaceIds }
.map { room -> .map { room ->
val childRooms = spaceChildMap[room.roomId] ?: emptySet()
val spaceUnread = childRooms.mapNotNull { syncUnread[it] }
.maxByOrNull { it.ordinal } ?: UnreadStatus.NONE
SpaceItem( SpaceItem(
id = room.roomId, id = room.roomId,
name = room.name?.explicitName ?: room.roomId.full, name = room.name?.explicitName ?: room.roomId.full,
avatarUrl = mxcToThumbnailUrl(baseUrl, room.avatarUrl, 96), avatarUrl = mxcToThumbnailUrl(baseUrl, room.avatarUrl, 96),
unreadStatus = spaceUnread,
) )
} }
@@ -223,14 +259,23 @@ class MainViewModel(
private fun observeSpaceFiltering() { private fun observeSpaceFiltering() {
viewModelScope.launch { 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 val filtered = if (children == null) allChannels
else allChannels.filter { it.id in children } 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] } val savedOrder = spaceId?.let { orderMap[it.full] }
if (savedOrder != null) { if (savedOrder != null) {
val indexMap = savedOrder.withIndex().associate { (i, id) -> id to i } val indexMap = savedOrder.withIndex().associate { (i, id) -> id to i }
filtered.sortedBy { indexMap[it.id.full] ?: Int.MAX_VALUE } withUnread.sortedBy { indexMap[it.id.full] ?: Int.MAX_VALUE }
} else filtered } else withUnread
}.collect { _channels.value = it } }.collect { _channels.value = it }
} }
} }
@@ -361,19 +406,29 @@ class MainViewModel(
private fun resolveContent(content: net.folivo.trixnity.core.model.events.RoomEventContent, baseUrl: String): MessageContent? { private fun resolveContent(content: net.folivo.trixnity.core.model.events.RoomEventContent, baseUrl: String): MessageContent? {
return when (content) { return when (content) {
is RoomMessageEventContent.FileBased.Image -> MessageContent.Image( is RoomMessageEventContent.FileBased.Image -> {
body = content.body, val isGif = content.info?.mimeType == "image/gif"
url = mxcToDownloadUrl(baseUrl, content.url) ?: return null, val url = mxcToDownloadUrl(baseUrl, content.url) ?: return null
width = content.info?.width?.toInt(), if (isGif) MessageContent.Gif(
height = content.info?.height?.toInt(), body = content.body, url = url,
) width = content.info?.width?.toInt(), height = content.info?.height?.toInt(),
is RoomMessageEventContent.FileBased.Video -> MessageContent.Video( ) else MessageContent.Image(
body = content.body, body = content.body, url = url,
url = mxcToDownloadUrl(baseUrl, content.url), width = content.info?.width?.toInt(), height = content.info?.height?.toInt(),
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( is RoomMessageEventContent.FileBased.Audio -> MessageContent.File(
body = content.body, body = content.body,
fileName = content.fileName ?: 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) { fun selectSpace(spaceId: RoomId) {
if (_selectedSpace.value == spaceId) { if (_selectedSpace.value == spaceId) {
_showChannelList.value = !_showChannelList.value _showChannelList.value = !_showChannelList.value
@@ -502,6 +604,23 @@ class MainViewModel(
fun selectChannel(channelId: RoomId) { fun selectChannel(channelId: RoomId) {
_selectedChannel.value = channelId _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() { fun toggleChannelList() {

View File

@@ -5,6 +5,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -50,6 +51,7 @@ import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex import androidx.compose.ui.zIndex
import com.example.fluffytrix.ui.screens.main.ChannelItem import com.example.fluffytrix.ui.screens.main.ChannelItem
import com.example.fluffytrix.ui.screens.main.UnreadStatus
import net.folivo.trixnity.core.model.RoomId import net.folivo.trixnity.core.model.RoomId
import kotlin.math.roundToInt import kotlin.math.roundToInt
@@ -200,6 +202,20 @@ fun ChannelList(
) )
Spacer(modifier = Modifier.width(4.dp)) 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( Icon(
imageVector = if (channel.isEncrypted) Icons.Default.Lock else Icons.Default.Tag, imageVector = if (channel.isEncrypted) Icons.Default.Lock else Icons.Default.Tag,
contentDescription = null, 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
@@ -43,6 +44,8 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import android.net.Uri import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTransformGestures import androidx.compose.foundation.gestures.detectTransformGestures
import com.mikepenz.markdown.m3.Markdown import com.mikepenz.markdown.m3.Markdown
@@ -116,6 +119,7 @@ fun MessageTimeline(
messages: List<MessageItem>, messages: List<MessageItem>,
onToggleMemberList: () -> Unit, onToggleMemberList: () -> Unit,
onSendMessage: (String) -> Unit, onSendMessage: (String) -> Unit,
onSendFile: (Uri) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues(), contentPadding: PaddingValues = PaddingValues(),
) { ) {
@@ -229,7 +233,7 @@ fun MessageTimeline(
} }
HorizontalDivider(color = MaterialTheme.colorScheme.surfaceVariant) 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) { when (content) {
is MessageContent.Text -> TextContent(content) is MessageContent.Text -> TextContent(content)
is MessageContent.Image -> ImageContent(content) is MessageContent.Image -> ImageContent(content)
is MessageContent.Gif -> GifContent(content)
is MessageContent.Video -> VideoContent(content) is MessageContent.Video -> VideoContent(content)
is MessageContent.File -> FileContent(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 @Composable
private fun VideoContent(content: MessageContent.Video) { private fun VideoContent(content: MessageContent.Video) {
val onPlayVideo = LocalVideoPlayer.current val onPlayVideo = LocalVideoPlayer.current
@@ -536,19 +586,30 @@ private fun formatFileSize(bytes: Long): String {
} }
@Composable @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("") } var text by remember { mutableStateOf("") }
val filePickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent()
) { uri: Uri? ->
if (uri != null) onSendFile(uri)
}
Row( Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
IconButton(onClick = { filePickerLauncher.launch("*/*") }) {
Icon(
Icons.Default.AttachFile, "Attach file",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
TextField( TextField(
value = text, value = text,
onValueChange = { text = it }, onValueChange = { text = it },
placeholder = { Text("Message #$channelName", color = MaterialTheme.colorScheme.onSurfaceVariant) }, placeholder = { Text("Message #$channelName", color = MaterialTheme.colorScheme.onSurfaceVariant) },
modifier = Modifier.weight(1f).clip(RoundedCornerShape(8.dp)), modifier = Modifier.weight(1f).clip(RoundedCornerShape(8.dp)).heightIn(max = 160.dp),
singleLine = true, maxLines = 8,
colors = TextFieldDefaults.colors( colors = TextFieldDefaults.colors(
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
focusedContainerColor = 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 androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import com.example.fluffytrix.ui.screens.main.SpaceItem import com.example.fluffytrix.ui.screens.main.SpaceItem
import com.example.fluffytrix.ui.screens.main.UnreadStatus
import net.folivo.trixnity.core.model.RoomId import net.folivo.trixnity.core.model.RoomId
@Composable @Composable
@@ -89,32 +90,46 @@ fun SpaceList(
items(spaces, key = { it.id.full }) { space -> items(spaces, key = { it.id.full }) { space ->
val isSelected = space.id == selectedSpace val isSelected = space.id == selectedSpace
Box( Box(modifier = Modifier.size(48.dp)) {
modifier = Modifier Box(
.size(48.dp) modifier = Modifier
.clip(if (isSelected) RoundedCornerShape(16.dp) else CircleShape) .size(48.dp)
.background( .clip(if (isSelected) RoundedCornerShape(16.dp) else CircleShape)
if (isSelected) MaterialTheme.colorScheme.primary .background(
else MaterialTheme.colorScheme.surface 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 },
) )
.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 { } else {
SpaceInitial(space.name, isSelected) 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,
),
)
} }
} }
} }

View File

@@ -67,6 +67,8 @@ ktor-client-okhttp = { group = "io.ktor", name = "ktor-client-okhttp", version.r
# Coil (image loading) # Coil (image loading)
coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" } 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-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)
media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" } media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" }