dajks
This commit is contained in:
@@ -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
1
.idea/misc.xml
generated
@@ -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
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)
|
// 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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
) 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,
|
is RoomMessageEventContent.FileBased.Video -> {
|
||||||
url = mxcToDownloadUrl(baseUrl, content.url),
|
val isGif = content.info?.mimeType == "image/gif" || gifUrlRegex.containsMatchIn(content.body)
|
||||||
thumbnailUrl = mxcToThumbnailUrl(baseUrl, content.info?.thumbnailUrl, 300),
|
val url = mxcToDownloadUrl(baseUrl, content.url)
|
||||||
width = content.info?.width?.toInt(),
|
if (isGif && url != null) MessageContent.Gif(
|
||||||
height = content.info?.height?.toInt(),
|
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() {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,6 +90,7 @@ 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(modifier = Modifier.size(48.dp)) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(48.dp)
|
.size(48.dp)
|
||||||
@@ -117,6 +119,19 @@ fun SpaceList(
|
|||||||
SpaceInitial(space.name, isSelected)
|
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 (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" }
|
||||||
|
|||||||
Reference in New Issue
Block a user