dajks
This commit is contained in:
@@ -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
1
.idea/misc.xml
generated
@@ -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
6
.idea/vcs.xml
generated
Normal 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
191
AGENTS.md
Normal 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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user