Compare commits
2 Commits
2169f28632
...
6a87a33ea0
| Author | SHA1 | Date | |
|---|---|---|---|
| 6a87a33ea0 | |||
| 2b554dc227 |
6
.idea/studiobot.xml
generated
Normal file
6
.idea/studiobot.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="StudioBotProjectSettings">
|
||||||
|
<option name="shareContext" value="OptedIn" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
161
AGENTS.md
161
AGENTS.md
@@ -6,7 +6,7 @@ Fluffytrix is an Android Matrix chat client with a Discord-like UI. Built with K
|
|||||||
|
|
||||||
**Package**: `com.example.fluffytrix`
|
**Package**: `com.example.fluffytrix`
|
||||||
**Build system**: Gradle with KotlinDSL, version catalog at `gradle/libs.versions.toml`
|
**Build system**: Gradle with KotlinDSL, version catalog at `gradle/libs.versions.toml`
|
||||||
**Container/DI**: Koin
|
**DI**: Koin
|
||||||
**State management**: Jetpack Compose StateFlow, ViewModel
|
**State management**: Jetpack Compose StateFlow, ViewModel
|
||||||
**UI framework**: Jetpack Compose with Material 3 (Dynamic Colors)
|
**UI framework**: Jetpack Compose with Material 3 (Dynamic Colors)
|
||||||
**Protocol**: Trixnity SDK for Matrix
|
**Protocol**: Trixnity SDK for Matrix
|
||||||
@@ -20,28 +20,22 @@ Fluffytrix is an Android Matrix chat client with a Discord-like UI. Built with K
|
|||||||
```bash
|
```bash
|
||||||
./gradlew assembleDebug # Build debug APK (minified for performance)
|
./gradlew assembleDebug # Build debug APK (minified for performance)
|
||||||
./gradlew assembleRelease # Build release APK
|
./gradlew assembleRelease # Build release APK
|
||||||
./gradlew test # Run unit tests
|
./gradlew test # Run all unit tests
|
||||||
./gradlew connectedAndroidTest # Run instrumented tests on device/emulator
|
./gradlew connectedAndroidTest # Run instrumented tests on device
|
||||||
./gradlew testDebugUnitTest --tests "com.example.fluffytrix.ExampleUnitTest" # Run single test
|
./gradlew testDebugUnitTest --tests "com.example.fluffytrix.*" # Run single test class
|
||||||
|
./gradlew app:testDebugUnitTest --tests "LoginViewModelTest" # Run specific test
|
||||||
```
|
```
|
||||||
|
|
||||||
**Notes**:
|
- Debug builds use R8 with `isDebuggable = true` for balanced speed/debuggability
|
||||||
- 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
|
- Instrumented tests require a connected device or emulator
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
**Unit tests**: Located in `app/src/test/java/`, run with `./gradlew test`
|
**Unit tests**: `app/src/test/java/`
|
||||||
**Instrumented tests**: Located in `app/src/androidTest/java/`, run with `./gradlew connectedAndroidTest`
|
**Instrumented tests**: `app/src/androidTest/java/`
|
||||||
|
**Single test**: `./gradlew testDebugUnitTest --tests "com.example.fluffytrix.ui.main.LoginViewModelTest"`
|
||||||
**Single test execution**:
|
|
||||||
```bash
|
|
||||||
./gradlew testDebugUnitTest --tests "com.example.fluffytrix.*"
|
|
||||||
./gradlew app:testDebugUnitTest --tests "ExampleUnitTest"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -49,17 +43,17 @@ Fluffytrix is an Android Matrix chat client with a Discord-like UI. Built with K
|
|||||||
|
|
||||||
### Kotlin Conventions
|
### Kotlin Conventions
|
||||||
|
|
||||||
- **Android Official Kotlin style** (`kotlin.code.style=official` in gradle.properties)
|
- **Android Official Kotlin style** (`kotlin.code.style=official`)
|
||||||
- File naming: `PascalCase.kt` (e.g., `MainViewModel.kt`)
|
- File naming: `PascalCase.kt` (e.g., `MainViewModel.kt`)
|
||||||
- Class naming: `PascalCase` (e.g., `MainViewModel`, `AuthRepository`)
|
- Class naming: `PascalCase` (e.g., `AuthRepository`, `MainViewModel`)
|
||||||
- Function/property naming: `camelCase` (e.g., `sendMessage`, `selectedChannel`)
|
- Function/property naming: `camelCase` (e.g., `sendMessage`, `selectedChannel`)
|
||||||
- Constant naming: `PascalCase` for top-level constants
|
- Constants: `PascalCase` for top-level constants
|
||||||
- **Never use underscores in variable names**
|
- **Never use underscores in variable names**
|
||||||
|
|
||||||
### Imports
|
### Imports
|
||||||
|
|
||||||
- Explicit imports only (no wildcard imports)
|
- Explicit imports only (no wildcards)
|
||||||
- Group imports: Android/X → Kotlin → Javax/Java → Third-party → Same package
|
- Group: Android/X → Kotlin → Javax/Java → Third-party → Same package
|
||||||
- Example:
|
- Example:
|
||||||
```kotlin
|
```kotlin
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
@@ -69,59 +63,37 @@ Fluffytrix is an Android Matrix chat client with a Discord-like UI. Built with K
|
|||||||
import com.example.fluffytrix.ui.theme.FluffytrixTheme
|
import com.example.fluffytrix.ui.theme.FluffytrixTheme
|
||||||
```
|
```
|
||||||
|
|
||||||
### Types
|
### Types & Error Handling
|
||||||
|
|
||||||
- Prefer `val` over `var` (immutable data)
|
- Prefer `val` over `var` (immutable data)
|
||||||
- Use `StateFlow<T>` for observable state in ViewModels
|
- Use `StateFlow<T>` for observable ViewModel state, `Flow<T>` for read-only streams
|
||||||
- Use `Flow<T>` for read-only data streams
|
- Use `suspend` for async operations, `Result<T>` for failing operations
|
||||||
- Use `suspend` functions for async operations
|
- `try-catch` with `catch (_: Exception) { }` for graceful degradation in ViewModels
|
||||||
- Use `Result<T>` for operations that can fail
|
- Use `?:` operators when appropriate, **never crash on recoverable errors**
|
||||||
|
|
||||||
### Error Handling
|
### Compose & ViewModels
|
||||||
|
|
||||||
- Prefer `try-catch` with silent failure or `?` operators where appropriate
|
- Use `@Composable` for all UI functions; use `MaterialTheme` for consistent theming
|
||||||
- In ViewModels, use `catch (_: Exception) { }` or `?:` for graceful degradation
|
- Discord-like layout: space sidebar → channel list → message area → member list
|
||||||
- Expose error state via `StateFlow<AuthState>` where users need feedback
|
- Use `Modifier.padding()`, `wrapContentWidth()`, `fillMaxWidth()` appropriately
|
||||||
- **Never crash the app on recoverable errors**
|
- Inject deps via constructor with Koin; use `viewModelScope` for coroutines
|
||||||
|
- Cache expensive operations (e.g., `messageCache`, `memberCache`)
|
||||||
|
|
||||||
### Compose UI
|
### Coroutines & Data Layer
|
||||||
|
|
||||||
- Use `@Composable` for all UI functions
|
- `Dispatchers.Default` for CPU work, `Dispatchers.IO` for I/O operations
|
||||||
- Follow Discord-like layout: space sidebar → channel list → message area → member list
|
- Cancel jobs on ViewModel cleanup: `job?.cancel()`
|
||||||
- Use `MaterialTheme` for consistent theming
|
- Use `Room` for persistent storage; `DataStore Preferences` for small key-value data
|
||||||
- Prefer `Modifier.padding()` over nested `Box` with margins
|
- Prefer Flow-based APIs; cache in ViewModels
|
||||||
- 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
|
### Naming Conventions
|
||||||
|
|
||||||
- State Flow properties: `_name` (private) / `name` (public)
|
- State Flow: `_state` (private) / `state` (public)
|
||||||
- Repository class: `AuthService`, `AuthRepository`
|
- Repositories: `AuthRepository`, `MessageRepository`
|
||||||
- ViewModel class: `MainViewModel`, `LoginViewModel`
|
- ViewModels: `MainViewModel`, `LoginViewModel`
|
||||||
- UI composable: `MainScreen`, `ChannelList`, `MessageItem`
|
- UI composables: `MainScreen`, `ChannelList`, `MessageItem`
|
||||||
- Model data class: `MessageItem`, `ChannelItem`, `SpaceItem`
|
- Models: `MessageItem`, `ChannelItem`, `SpaceItem`
|
||||||
- Use `full` property for Matrix IDs (e.g., `userId.full`, `roomId.full`)
|
- Matrix IDs: Use `RoomId`, `UserId` types; access `.full` for strings
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -130,62 +102,19 @@ Fluffytrix is an Android Matrix chat client with a Discord-like UI. Built with K
|
|||||||
**Layered Architecture**:
|
**Layered Architecture**:
|
||||||
```
|
```
|
||||||
ui/ — ViewModels, Screens, Navigation
|
ui/ — ViewModels, Screens, Navigation
|
||||||
data/ — Repositories, local storage, models
|
data/ — Repositories, storage, models
|
||||||
di/ — Koin modules
|
di/ — Koin modules
|
||||||
ui/theme/ — Material 3 Theme (colors, typography)
|
ui/theme/ — Material 3 Theme
|
||||||
```
|
```
|
||||||
|
|
||||||
**Dependency Injection**: Koin with two modules:
|
**Koin DI**:
|
||||||
- `appModule`: ViewModels (`viewModel { MainViewModel(...) }`)
|
- `appModule`: ViewModel injection
|
||||||
- `dataModule`: singleton services (`single { AuthRepository(...) }`)
|
- `dataModule`: Singleton repositories/UI Flow: NavHost in `MainActivity` with `FluffytrixTheme`, ViewModels expose StateFlow, screens observe with `collectAsState()`
|
||||||
|
|
||||||
**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)
|
## Key Dependencies
|
||||||
|
|
||||||
- **Compose BOM**: `2025.06.00`
|
- **Compose BOM**: `2025.06.00`, **Kotlin**: `2.2.10`, **AGP**: `9.0.1`
|
||||||
- **Kotlin**: `2.2.10`
|
- **Koin**: `4.1.1`, **Trixnity**: `4.22.7`, **Ktor**: `3.3.0`
|
||||||
- **AGP**: `9.0.1`
|
- **Coroutines**: `1.10.2`, **DataStore**: `1.1.7`, **Coil**: `3.2.0`
|
||||||
- **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)
|
|
||||||
|
|||||||
@@ -101,6 +101,9 @@ dependencies {
|
|||||||
implementation(libs.markdown.renderer.code)
|
implementation(libs.markdown.renderer.code)
|
||||||
implementation(libs.markdown.renderer.coil3)
|
implementation(libs.markdown.renderer.coil3)
|
||||||
|
|
||||||
|
// Jetpack Emoji Picker
|
||||||
|
implementation(libs.emoji.picker)
|
||||||
|
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
androidTestImplementation(libs.androidx.junit)
|
androidTestImplementation(libs.androidx.junit)
|
||||||
androidTestImplementation(libs.androidx.espresso.core)
|
androidTestImplementation(libs.androidx.espresso.core)
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package com.example.fluffytrix.data.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class CustomEmoji(
|
||||||
|
val url: String,
|
||||||
|
val body: String? = null,
|
||||||
|
val usage: List<String> = listOf("emoticon"),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class EmojiPackInfo(
|
||||||
|
@SerialName("display_name") val displayName: String? = null,
|
||||||
|
@SerialName("avatar_url") val avatarUrl: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class EmojiPackData(
|
||||||
|
val pack: EmojiPackInfo = EmojiPackInfo(),
|
||||||
|
val images: Map<String, CustomEmoji> = emptyMap(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class UserEmojiAccountData(
|
||||||
|
val packs: Map<String, EmojiPackData> = emptyMap(),
|
||||||
|
)
|
||||||
|
|
||||||
|
// UI-facing (resolved URLs)
|
||||||
|
data class EmojiEntry(
|
||||||
|
val shortcode: String,
|
||||||
|
val mxcUrl: String,
|
||||||
|
val displayName: String,
|
||||||
|
val resolvedUrl: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class EmojiPack(
|
||||||
|
val packId: String,
|
||||||
|
val displayName: String,
|
||||||
|
val avatarMxcUrl: String?,
|
||||||
|
val emojis: List<EmojiEntry>,
|
||||||
|
val isRoomPack: Boolean = false,
|
||||||
|
val roomId: String? = null,
|
||||||
|
)
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package com.example.fluffytrix.data.repository
|
||||||
|
|
||||||
|
import com.example.fluffytrix.data.MxcUrlHelper
|
||||||
|
import com.example.fluffytrix.data.model.CustomEmoji
|
||||||
|
import com.example.fluffytrix.data.model.EmojiEntry
|
||||||
|
import com.example.fluffytrix.data.model.EmojiPack
|
||||||
|
import com.example.fluffytrix.data.model.EmojiPackData
|
||||||
|
import com.example.fluffytrix.data.model.EmojiPackInfo
|
||||||
|
import com.example.fluffytrix.data.model.UserEmojiAccountData
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
|
class EmojiPackRepository(
|
||||||
|
private val authRepository: AuthRepository,
|
||||||
|
) {
|
||||||
|
private val baseUrl: String
|
||||||
|
get() = try {
|
||||||
|
authRepository.getClient()?.session()?.homeserverUrl?.trimEnd('/') ?: ""
|
||||||
|
} catch (_: Exception) { "" }
|
||||||
|
|
||||||
|
suspend fun loadUserPacks(): List<EmojiPack> {
|
||||||
|
val client = authRepository.getClient() ?: return emptyList()
|
||||||
|
return try {
|
||||||
|
val raw = client.accountData("im.ponies.user_emojis") ?: return emptyList()
|
||||||
|
val data = json.decodeFromString<UserEmojiAccountData>(raw)
|
||||||
|
data.packs.map { (packId, packData) ->
|
||||||
|
packData.toEmojiPack(packId, baseUrl)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e("EmojiPackRepo", "Failed to load user packs", e)
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun saveUserPacks(packs: List<EmojiPack>) {
|
||||||
|
val client = authRepository.getClient() ?: return
|
||||||
|
try {
|
||||||
|
val packsMap = packs.associate { pack ->
|
||||||
|
pack.packId to EmojiPackData(
|
||||||
|
pack = EmojiPackInfo(displayName = pack.displayName, avatarUrl = pack.avatarMxcUrl),
|
||||||
|
images = pack.emojis.associate { entry ->
|
||||||
|
entry.shortcode to CustomEmoji(url = entry.mxcUrl, body = entry.displayName.ifBlank { null })
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val data = UserEmojiAccountData(packs = packsMap)
|
||||||
|
client.setAccountData("im.ponies.user_emojis", json.encodeToString(data))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e("EmojiPackRepo", "Failed to save user packs", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun uploadImage(mimeType: String, data: ByteArray): String? {
|
||||||
|
val client = authRepository.getClient() ?: return null
|
||||||
|
return try {
|
||||||
|
client.uploadMedia(mimeType, data, null)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e("EmojiPackRepo", "Failed to upload emoji image", e)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun loadAllPacks(roomId: String? = null): List<EmojiPack> {
|
||||||
|
return loadUserPacks()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun EmojiPackData.toEmojiPack(packId: String, baseUrl: String): EmojiPack {
|
||||||
|
val emojis = images.map { (shortcode, emoji) ->
|
||||||
|
EmojiEntry(
|
||||||
|
shortcode = shortcode,
|
||||||
|
mxcUrl = emoji.url,
|
||||||
|
displayName = emoji.body ?: shortcode,
|
||||||
|
resolvedUrl = MxcUrlHelper.mxcToDownloadUrl(baseUrl, emoji.url) ?: emoji.url,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return EmojiPack(
|
||||||
|
packId = packId,
|
||||||
|
displayName = pack.displayName ?: packId,
|
||||||
|
avatarMxcUrl = pack.avatarUrl,
|
||||||
|
emojis = emojis,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package com.example.fluffytrix.di
|
|||||||
import android.app.Application
|
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.data.repository.EmojiPackRepository
|
||||||
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
|
||||||
@@ -11,7 +12,7 @@ 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 { MainViewModel(androidApplication(), get(), get(), get()) }
|
||||||
viewModel { LoginViewModel(get()) }
|
viewModel { LoginViewModel(get()) }
|
||||||
viewModel { VerificationViewModel(get()) }
|
viewModel { VerificationViewModel(get()) }
|
||||||
}
|
}
|
||||||
@@ -19,4 +20,5 @@ val appModule = module {
|
|||||||
val dataModule = module {
|
val dataModule = module {
|
||||||
single { PreferencesManager(get()) }
|
single { PreferencesManager(get()) }
|
||||||
single { AuthRepository(get(), get()) }
|
single { AuthRepository(get(), get()) }
|
||||||
|
single { EmojiPackRepository(get()) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ import androidx.navigation.compose.composable
|
|||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
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 androidx.navigation.NavType
|
||||||
|
import androidx.navigation.navArgument
|
||||||
|
import com.example.fluffytrix.ui.screens.emoji.EmojiPackManagementScreen
|
||||||
import com.example.fluffytrix.ui.screens.login.LoginScreen
|
import com.example.fluffytrix.ui.screens.login.LoginScreen
|
||||||
import com.example.fluffytrix.ui.screens.main.MainScreen
|
import com.example.fluffytrix.ui.screens.main.MainScreen
|
||||||
import com.example.fluffytrix.ui.screens.settings.SettingsScreen
|
import com.example.fluffytrix.ui.screens.settings.SettingsScreen
|
||||||
@@ -148,6 +151,19 @@ fun FluffytrixNavigation() {
|
|||||||
onSettingsClick = {
|
onSettingsClick = {
|
||||||
navController.navigate(Screen.Settings.route)
|
navController.navigate(Screen.Settings.route)
|
||||||
},
|
},
|
||||||
|
onEmojiPackManagement = { roomId ->
|
||||||
|
navController.navigate(Screen.EmojiPackManagement.route(roomId))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(
|
||||||
|
route = Screen.EmojiPackManagement.routeWithArgs,
|
||||||
|
arguments = listOf(navArgument("roomId") { type = NavType.StringType; nullable = true; defaultValue = null }),
|
||||||
|
) { backStackEntry ->
|
||||||
|
val roomId = backStackEntry.arguments?.getString("roomId")
|
||||||
|
EmojiPackManagementScreen(
|
||||||
|
roomId = roomId,
|
||||||
|
onBack = { navController.popBackStack() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(Screen.Settings.route) {
|
composable(Screen.Settings.route) {
|
||||||
|
|||||||
@@ -5,4 +5,8 @@ sealed class Screen(val route: String) {
|
|||||||
data object Verification : Screen("verification")
|
data object Verification : Screen("verification")
|
||||||
data object Main : Screen("main")
|
data object Main : Screen("main")
|
||||||
data object Settings : Screen("settings")
|
data object Settings : Screen("settings")
|
||||||
|
data object EmojiPackManagement : Screen("emoji_packs") {
|
||||||
|
fun route(roomId: String? = null) = if (roomId != null) "emoji_packs?roomId=$roomId" else "emoji_packs"
|
||||||
|
const val routeWithArgs = "emoji_packs?roomId={roomId}"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,379 @@
|
|||||||
|
package com.example.fluffytrix.ui.screens.emoji
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
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
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.Delete
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
|
import androidx.compose.ui.window.DialogProperties
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import coil3.compose.AsyncImage
|
||||||
|
import com.example.fluffytrix.data.model.EmojiEntry
|
||||||
|
import com.example.fluffytrix.data.model.EmojiPack
|
||||||
|
import com.example.fluffytrix.ui.screens.main.MainViewModel
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.koin.androidx.compose.koinViewModel
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun EmojiPackManagementScreen(
|
||||||
|
roomId: String?,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
viewModel: MainViewModel = koinViewModel(),
|
||||||
|
) {
|
||||||
|
val emojiPacks by viewModel.emojiPacks.collectAsStateWithLifecycle()
|
||||||
|
val userPacks = emojiPacks.filter { !it.isRoomPack }
|
||||||
|
var showCreateDialog by remember { mutableStateOf(false) }
|
||||||
|
var editingPack by remember { mutableStateOf<EmojiPack?>(null) }
|
||||||
|
|
||||||
|
if (showCreateDialog) {
|
||||||
|
CreatePackDialog(
|
||||||
|
onDismiss = { showCreateDialog = false },
|
||||||
|
onCreate = { name ->
|
||||||
|
val newPack = EmojiPack(
|
||||||
|
packId = name.lowercase().replace(Regex("[^a-z0-9_]"), "_"),
|
||||||
|
displayName = name,
|
||||||
|
avatarMxcUrl = null,
|
||||||
|
emojis = emptyList(),
|
||||||
|
)
|
||||||
|
val updated = userPacks + newPack
|
||||||
|
viewModel.saveUserEmojiPacks(updated)
|
||||||
|
showCreateDialog = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
editingPack?.let { pack ->
|
||||||
|
EditPackDialog(
|
||||||
|
pack = pack,
|
||||||
|
viewModel = viewModel,
|
||||||
|
onDismiss = { editingPack = null },
|
||||||
|
onSave = { updatedPack ->
|
||||||
|
val updated = userPacks.map { if (it.packId == updatedPack.packId) updatedPack else it }
|
||||||
|
viewModel.saveUserEmojiPacks(updated)
|
||||||
|
editingPack = null
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("Emoji Packs") },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
floatingActionButton = {
|
||||||
|
ExtendedFloatingActionButton(
|
||||||
|
onClick = { showCreateDialog = true },
|
||||||
|
icon = { Icon(Icons.Default.Add, null) },
|
||||||
|
text = { Text("Create Pack") },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { padding ->
|
||||||
|
if (userPacks.isEmpty()) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize().padding(padding),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"No emoji packs yet. Create one to get started.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize().padding(padding),
|
||||||
|
contentPadding = PaddingValues(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
items(userPacks, key = { it.packId }) { pack ->
|
||||||
|
PackRow(
|
||||||
|
pack = pack,
|
||||||
|
onEdit = { editingPack = pack },
|
||||||
|
onDelete = {
|
||||||
|
val updated = userPacks.filter { it.packId != pack.packId }
|
||||||
|
viewModel.saveUserEmojiPacks(updated)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PackRow(
|
||||||
|
pack: EmojiPack,
|
||||||
|
onEdit: () -> Unit,
|
||||||
|
onDelete: () -> Unit,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { onEdit() }
|
||||||
|
.padding(8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
if (pack.avatarMxcUrl != null) {
|
||||||
|
AsyncImage(
|
||||||
|
model = pack.avatarMxcUrl,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(40.dp).clip(CircleShape),
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.size(40.dp).clip(CircleShape)
|
||||||
|
.then(Modifier.fillMaxWidth()),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
pack.displayName.take(1).uppercase(),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(pack.displayName, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium)
|
||||||
|
Text("${pack.emojis.size} emojis", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
IconButton(onClick = onDelete) {
|
||||||
|
Icon(Icons.Default.Delete, "Delete", tint = MaterialTheme.colorScheme.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CreatePackDialog(onDismiss: () -> Unit, onCreate: (String) -> Unit) {
|
||||||
|
var name by remember { mutableStateOf("") }
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text("Create Emoji Pack") },
|
||||||
|
text = {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = name,
|
||||||
|
onValueChange = { name = it },
|
||||||
|
label = { Text("Pack name") },
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = { if (name.isNotBlank()) onCreate(name.trim()) }, enabled = name.isNotBlank()) {
|
||||||
|
Text("Create")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EditPackDialog(
|
||||||
|
pack: EmojiPack,
|
||||||
|
viewModel: MainViewModel,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onSave: (EmojiPack) -> Unit,
|
||||||
|
) {
|
||||||
|
var emojis by remember { mutableStateOf(pack.emojis) }
|
||||||
|
var addShortcode by remember { mutableStateOf("") }
|
||||||
|
var pendingImageUri by remember { mutableStateOf<Uri?>(null) }
|
||||||
|
var isUploading by remember { mutableStateOf(false) }
|
||||||
|
val context = LocalContext.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
val imagePicker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
||||||
|
if (uri != null) pendingImageUri = uri
|
||||||
|
}
|
||||||
|
|
||||||
|
// When we have a pending URI and a shortcode, prompt for shortcode first
|
||||||
|
if (pendingImageUri != null) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { pendingImageUri = null },
|
||||||
|
title = { Text("Set Shortcode") },
|
||||||
|
text = {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = addShortcode,
|
||||||
|
onValueChange = { addShortcode = it },
|
||||||
|
label = { Text("Shortcode (e.g. blobcat)") },
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
val uri = pendingImageUri ?: return@TextButton
|
||||||
|
val code = addShortcode.trim().trimStart(':').trimEnd(':')
|
||||||
|
if (code.isBlank()) return@TextButton
|
||||||
|
isUploading = true
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val bytes = context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
|
||||||
|
val mime = context.contentResolver.getType(uri) ?: "image/png"
|
||||||
|
if (bytes != null) {
|
||||||
|
val mxcUrl = viewModel.uploadEmojiImage(mime, bytes)
|
||||||
|
if (mxcUrl != null) {
|
||||||
|
val entry = EmojiEntry(
|
||||||
|
shortcode = code,
|
||||||
|
mxcUrl = mxcUrl,
|
||||||
|
displayName = code,
|
||||||
|
resolvedUrl = mxcUrl,
|
||||||
|
)
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
emojis = emojis + entry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
isUploading = false
|
||||||
|
pendingImageUri = null
|
||||||
|
addShortcode = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = addShortcode.isNotBlank() && !isUploading,
|
||||||
|
) {
|
||||||
|
Text(if (isUploading) "Uploading…" else "Add")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { pendingImageUri = null; addShortcode = "" }) { Text("Cancel") }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Dialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||||
|
) {
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(pack.displayName) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onDismiss) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Close")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
TextButton(onClick = { onSave(pack.copy(emojis = emojis)) }) {
|
||||||
|
Text("Save")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
floatingActionButton = {
|
||||||
|
ExtendedFloatingActionButton(
|
||||||
|
onClick = { imagePicker.launch("image/*") },
|
||||||
|
icon = { Icon(Icons.Default.Add, null) },
|
||||||
|
text = { Text("Add Emoji") },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { padding ->
|
||||||
|
if (emojis.isEmpty()) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize().padding(padding),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text("No emojis yet. Tap Add Emoji to upload one.", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize().padding(padding),
|
||||||
|
contentPadding = PaddingValues(16.dp),
|
||||||
|
) {
|
||||||
|
items(emojis, key = { it.shortcode }) { entry ->
|
||||||
|
EmojiEntryRow(
|
||||||
|
entry = entry,
|
||||||
|
onDelete = { emojis = emojis.filter { it.shortcode != entry.shortcode } },
|
||||||
|
)
|
||||||
|
HorizontalDivider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EmojiEntryRow(entry: EmojiEntry, onDelete: () -> Unit) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
AsyncImage(
|
||||||
|
model = entry.resolvedUrl,
|
||||||
|
contentDescription = entry.shortcode,
|
||||||
|
modifier = Modifier.size(40.dp).clip(RoundedCornerShape(4.dp)),
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(":${entry.shortcode}:", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium)
|
||||||
|
if (entry.displayName != entry.shortcode) {
|
||||||
|
Text(entry.displayName, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
IconButton(onClick = onDelete) {
|
||||||
|
Icon(Icons.Default.Delete, "Delete", tint = MaterialTheme.colorScheme.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ import org.koin.compose.koinInject
|
|||||||
fun MainScreen(
|
fun MainScreen(
|
||||||
onLogout: () -> Unit,
|
onLogout: () -> Unit,
|
||||||
onSettingsClick: () -> Unit = {},
|
onSettingsClick: () -> Unit = {},
|
||||||
|
onEmojiPackManagement: (String?) -> Unit = {},
|
||||||
viewModel: MainViewModel = koinViewModel(),
|
viewModel: MainViewModel = koinViewModel(),
|
||||||
) {
|
) {
|
||||||
val spaces by viewModel.spaces.collectAsStateWithLifecycle()
|
val spaces by viewModel.spaces.collectAsStateWithLifecycle()
|
||||||
@@ -53,6 +54,7 @@ fun MainScreen(
|
|||||||
val currentUserId by viewModel.currentUserId.collectAsStateWithLifecycle()
|
val currentUserId by viewModel.currentUserId.collectAsStateWithLifecycle()
|
||||||
val replyingTo by viewModel.replyingTo.collectAsStateWithLifecycle()
|
val replyingTo by viewModel.replyingTo.collectAsStateWithLifecycle()
|
||||||
val editingMessage by viewModel.editingMessage.collectAsStateWithLifecycle()
|
val editingMessage by viewModel.editingMessage.collectAsStateWithLifecycle()
|
||||||
|
val emojiPacks by viewModel.emojiPacks.collectAsStateWithLifecycle()
|
||||||
val listState = viewModel.channelListState
|
val listState = viewModel.channelListState
|
||||||
val preferencesManager: PreferencesManager = koinInject()
|
val preferencesManager: PreferencesManager = koinInject()
|
||||||
val hideSpacesWhenClosed by preferencesManager.hideSpacesWhenClosed.collectAsStateWithLifecycle(initialValue = false)
|
val hideSpacesWhenClosed by preferencesManager.hideSpacesWhenClosed.collectAsStateWithLifecycle(initialValue = false)
|
||||||
@@ -142,6 +144,8 @@ fun MainScreen(
|
|||||||
onEditThreadMessage = { eventId, body -> viewModel.editThreadMessage(eventId, body) },
|
onEditThreadMessage = { eventId, body -> viewModel.editThreadMessage(eventId, body) },
|
||||||
onSendReaction = { eventId, emoji -> viewModel.sendReaction(eventId, emoji) },
|
onSendReaction = { eventId, emoji -> viewModel.sendReaction(eventId, emoji) },
|
||||||
onSendThreadReaction = { eventId, emoji -> viewModel.sendThreadReaction(eventId, emoji) },
|
onSendThreadReaction = { eventId, emoji -> viewModel.sendThreadReaction(eventId, emoji) },
|
||||||
|
emojiPacks = emojiPacks,
|
||||||
|
onOpenEmojiPackManagement = { onEmojiPackManagement(selectedChannel) },
|
||||||
)
|
)
|
||||||
|
|
||||||
AnimatedVisibility(visible = showMemberList) {
|
AnimatedVisibility(visible = showMemberList) {
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.example.fluffytrix.data.MxcUrlHelper
|
import com.example.fluffytrix.data.MxcUrlHelper
|
||||||
import com.example.fluffytrix.data.local.PreferencesManager
|
import com.example.fluffytrix.data.local.PreferencesManager
|
||||||
|
import com.example.fluffytrix.data.model.EmojiPack
|
||||||
import com.example.fluffytrix.data.repository.AuthRepository
|
import com.example.fluffytrix.data.repository.AuthRepository
|
||||||
|
import com.example.fluffytrix.data.repository.EmojiPackRepository
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
@@ -42,6 +44,7 @@ import org.matrix.rustcomponents.sdk.TimelineListener
|
|||||||
import org.matrix.rustcomponents.sdk.UploadParameters
|
import org.matrix.rustcomponents.sdk.UploadParameters
|
||||||
import org.matrix.rustcomponents.sdk.UploadSource
|
import org.matrix.rustcomponents.sdk.UploadSource
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
|
import org.matrix.rustcomponents.sdk.messageEventContentFromHtml
|
||||||
import org.matrix.rustcomponents.sdk.messageEventContentFromMarkdown
|
import org.matrix.rustcomponents.sdk.messageEventContentFromMarkdown
|
||||||
|
|
||||||
enum class UnreadStatus { NONE, UNREAD, MENTIONED }
|
enum class UnreadStatus { NONE, UNREAD, MENTIONED }
|
||||||
@@ -61,8 +64,10 @@ data class ChannelItem(
|
|||||||
val unreadStatus: UnreadStatus = UnreadStatus.NONE,
|
val unreadStatus: UnreadStatus = UnreadStatus.NONE,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class InlineEmoji(val shortcode: String, val mxcUrl: String, val resolvedUrl: String)
|
||||||
|
|
||||||
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(), val inlineEmojis: List<InlineEmoji> = 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 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
|
||||||
@@ -88,6 +93,7 @@ data class MessageItem(
|
|||||||
val timestamp: Long,
|
val timestamp: Long,
|
||||||
val replyTo: ReplyInfo? = null,
|
val replyTo: ReplyInfo? = null,
|
||||||
val threadRootEventId: String? = null,
|
val threadRootEventId: String? = null,
|
||||||
|
val reactions: Map<String, List<String>> = emptyMap(), // emoji -> list of full Matrix user IDs
|
||||||
)
|
)
|
||||||
|
|
||||||
data class ThreadItem(
|
data class ThreadItem(
|
||||||
@@ -115,6 +121,7 @@ class MainViewModel(
|
|||||||
private val application: Application,
|
private val application: Application,
|
||||||
private val authRepository: AuthRepository,
|
private val authRepository: AuthRepository,
|
||||||
private val preferencesManager: PreferencesManager,
|
private val preferencesManager: PreferencesManager,
|
||||||
|
private val emojiPackRepository: EmojiPackRepository,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _spaces = MutableStateFlow<List<SpaceItem>>(emptyList())
|
private val _spaces = MutableStateFlow<List<SpaceItem>>(emptyList())
|
||||||
@@ -195,6 +202,9 @@ class MainViewModel(
|
|||||||
|
|
||||||
private val _editingMessage = MutableStateFlow<MessageItem?>(null)
|
private val _editingMessage = MutableStateFlow<MessageItem?>(null)
|
||||||
val editingMessage: StateFlow<MessageItem?> = _editingMessage
|
val editingMessage: StateFlow<MessageItem?> = _editingMessage
|
||||||
|
|
||||||
|
private val _emojiPacks = MutableStateFlow<List<EmojiPack>>(emptyList())
|
||||||
|
val emojiPacks: StateFlow<List<EmojiPack>> = _emojiPacks
|
||||||
private val _spaceChildrenMap = MutableStateFlow<Map<String, Set<String>>>(emptyMap())
|
private val _spaceChildrenMap = MutableStateFlow<Map<String, Set<String>>>(emptyMap())
|
||||||
|
|
||||||
private val _channelSections = MutableStateFlow<List<ChannelSection>>(emptyList())
|
private val _channelSections = MutableStateFlow<List<ChannelSection>>(emptyList())
|
||||||
@@ -283,6 +293,7 @@ class MainViewModel(
|
|||||||
syncService = authRepository.getOrStartSync()
|
syncService = authRepository.getOrStartSync()
|
||||||
try { _currentUserId.value = authRepository.getClient()?.userId() } catch (_: Exception) {}
|
try { _currentUserId.value = authRepository.getClient()?.userId() } catch (_: Exception) {}
|
||||||
loadRooms()
|
loadRooms()
|
||||||
|
loadEmojiPacks()
|
||||||
}
|
}
|
||||||
observeSelectedChannel()
|
observeSelectedChannel()
|
||||||
observeSpaceFiltering()
|
observeSpaceFiltering()
|
||||||
@@ -752,6 +763,12 @@ class MainViewModel(
|
|||||||
senderNameCache[localpart] = senderName
|
senderNameCache[localpart] = senderName
|
||||||
if (senderAvatar != null) senderAvatarCache[localpart] = senderAvatar
|
if (senderAvatar != null) senderAvatarCache[localpart] = senderAvatar
|
||||||
|
|
||||||
|
val reactions = if (content is TimelineItemContent.MsgLike) {
|
||||||
|
content.content.reactions
|
||||||
|
.filter { it.senders.isNotEmpty() }
|
||||||
|
.associate { reaction -> reaction.key to reaction.senders.map { it.senderId } }
|
||||||
|
} else emptyMap()
|
||||||
|
|
||||||
val msg = MessageItem(
|
val msg = MessageItem(
|
||||||
eventId = eventId,
|
eventId = eventId,
|
||||||
senderId = localpart,
|
senderId = localpart,
|
||||||
@@ -761,6 +778,7 @@ class MainViewModel(
|
|||||||
timestamp = eventItem.timestamp.toLong(),
|
timestamp = eventItem.timestamp.toLong(),
|
||||||
replyTo = replyInfo,
|
replyTo = replyInfo,
|
||||||
threadRootEventId = threadRootId,
|
threadRootEventId = threadRootId,
|
||||||
|
reactions = reactions,
|
||||||
)
|
)
|
||||||
|
|
||||||
ids.add(eventId)
|
ids.add(eventId)
|
||||||
@@ -797,6 +815,7 @@ class MainViewModel(
|
|||||||
MessageContent.Text(
|
MessageContent.Text(
|
||||||
body = text,
|
body = text,
|
||||||
urls = urlRegex.findAll(text).map { it.value }.toList(),
|
urls = urlRegex.findAll(text).map { it.value }.toList(),
|
||||||
|
inlineEmojis = parseInlineEmojis(rawJson),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
is MessageType.Notice -> {
|
is MessageType.Notice -> {
|
||||||
@@ -804,10 +823,11 @@ class MainViewModel(
|
|||||||
MessageContent.Text(
|
MessageContent.Text(
|
||||||
body = text,
|
body = text,
|
||||||
urls = urlRegex.findAll(text).map { it.value }.toList(),
|
urls = urlRegex.findAll(text).map { it.value }.toList(),
|
||||||
|
inlineEmojis = parseInlineEmojis(rawJson),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
is MessageType.Emote -> {
|
is MessageType.Emote -> {
|
||||||
MessageContent.Text(body = "* ${msgType.content.body}")
|
MessageContent.Text(body = "* ${msgType.content.body}", inlineEmojis = parseInlineEmojis(rawJson))
|
||||||
}
|
}
|
||||||
is MessageType.Image -> {
|
is MessageType.Image -> {
|
||||||
val c = msgType.content
|
val c = msgType.content
|
||||||
@@ -935,15 +955,66 @@ class MainViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun loadEmojiPacks() {
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
_emojiPacks.value = emojiPackRepository.loadAllPacks(_selectedChannel.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveUserEmojiPacks(packs: List<EmojiPack>) {
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
emojiPackRepository.saveUserPacks(packs)
|
||||||
|
loadEmojiPacks()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun uploadEmojiImage(mimeType: String, data: ByteArray): String? {
|
||||||
|
return emojiPackRepository.uploadImage(mimeType, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseInlineEmojis(rawJson: String?): List<InlineEmoji> {
|
||||||
|
rawJson ?: return emptyList()
|
||||||
|
return try {
|
||||||
|
val json = org.json.JSONObject(rawJson)
|
||||||
|
val formattedBody = json.optJSONObject("content")?.optString("formatted_body") ?: return emptyList()
|
||||||
|
val regex = Regex("""<img[^>]+data-mx-emoticon[^>]+src="(mxc://[^"]+)"[^>]+alt="([^"]+)"[^>]*/?>""")
|
||||||
|
val packs = _emojiPacks.value
|
||||||
|
regex.findAll(formattedBody).mapNotNull { match ->
|
||||||
|
val mxcUrl = match.groupValues[1]
|
||||||
|
val alt = match.groupValues[2]
|
||||||
|
val resolvedUrl = MxcUrlHelper.mxcToDownloadUrl(baseUrl, mxcUrl) ?: mxcUrl
|
||||||
|
InlineEmoji(shortcode = alt, mxcUrl = mxcUrl, resolvedUrl = resolvedUrl)
|
||||||
|
}.toList()
|
||||||
|
} catch (_: Exception) { emptyList() }
|
||||||
|
}
|
||||||
|
|
||||||
fun sendMessage(body: String) {
|
fun sendMessage(body: String) {
|
||||||
val timeline = activeTimeline ?: return
|
val timeline = activeTimeline ?: return
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
timeline.send(messageEventContentFromMarkdown(body))
|
val content = buildMessageContent(body)
|
||||||
|
timeline.send(content)
|
||||||
} catch (_: Exception) { }
|
} catch (_: Exception) { }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun buildMessageContent(body: String): org.matrix.rustcomponents.sdk.RoomMessageEventContentWithoutRelation {
|
||||||
|
val packs = _emojiPacks.value
|
||||||
|
if (packs.isEmpty()) return messageEventContentFromMarkdown(body)
|
||||||
|
// Build a map of shortcode -> emoji entry across all packs
|
||||||
|
val emojiMap = packs.flatMap { it.emojis }.associateBy { ":${it.shortcode}:" }
|
||||||
|
val found = emojiMap.keys.filter { body.contains(it) }
|
||||||
|
if (found.isEmpty()) return messageEventContentFromMarkdown(body)
|
||||||
|
// Build formatted body with inline emoji img tags
|
||||||
|
var formattedBody = body
|
||||||
|
for (key in found) {
|
||||||
|
val entry = emojiMap[key] ?: continue
|
||||||
|
val imgTag = """<img data-mx-emoticon src="${entry.mxcUrl}" alt=":${entry.shortcode}:" height="32" />"""
|
||||||
|
formattedBody = formattedBody.replace(key, imgTag)
|
||||||
|
}
|
||||||
|
return messageEventContentFromHtml(body, formattedBody)
|
||||||
|
}
|
||||||
|
|
||||||
fun sendFiles(uris: List<Uri>, caption: String?) {
|
fun sendFiles(uris: List<Uri>, caption: String?) {
|
||||||
val timeline = activeTimeline ?: return
|
val timeline = activeTimeline ?: return
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
@@ -1480,7 +1551,7 @@ class MainViewModel(
|
|||||||
val threadTimeline = activeThreadTimeline ?: return@withLock
|
val threadTimeline = activeThreadTimeline ?: return@withLock
|
||||||
try {
|
try {
|
||||||
// Thread-focused timeline sends automatically include the m.thread relation
|
// Thread-focused timeline sends automatically include the m.thread relation
|
||||||
threadTimeline.send(messageEventContentFromMarkdown(body))
|
threadTimeline.send(buildMessageContent(body))
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
android.util.Log.e("MainVM", "Failed to send thread message", e)
|
android.util.Log.e("MainVM", "Failed to send thread message", e)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.example.fluffytrix.ui.screens.main.components
|
|||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
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
|
||||||
@@ -22,13 +23,15 @@ import androidx.compose.foundation.shape.CircleShape
|
|||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.Send
|
import androidx.compose.material.icons.automirrored.filled.Send
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material.icons.filled.People
|
import androidx.compose.material.icons.filled.People
|
||||||
import androidx.compose.material.icons.filled.Tag
|
import androidx.compose.material.icons.filled.Tag
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TextField
|
import androidx.compose.material3.TextField
|
||||||
import androidx.compose.material3.TextFieldDefaults
|
import androidx.compose.material3.TextFieldDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -50,6 +53,7 @@ import androidx.compose.ui.text.font.FontWeight
|
|||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
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 androidx.compose.material3.Text
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
@@ -67,17 +71,26 @@ import com.mikepenz.markdown.m3.markdownColor
|
|||||||
import com.mikepenz.markdown.m3.markdownTypography
|
import com.mikepenz.markdown.m3.markdownTypography
|
||||||
import com.mikepenz.markdown.coil3.Coil3ImageTransformerImpl
|
import com.mikepenz.markdown.coil3.Coil3ImageTransformerImpl
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.emoji2.emojipicker.EmojiPickerView
|
||||||
|
import androidx.emoji2.emojipicker.EmojiViewItem
|
||||||
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
import androidx.compose.material.icons.filled.AttachFile
|
import androidx.compose.material.icons.filled.AttachFile
|
||||||
import androidx.compose.material.icons.filled.Check
|
import androidx.compose.material.icons.filled.Check
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.EmojiEmotions
|
||||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||||
import androidx.compose.material.icons.automirrored.filled.Reply
|
import androidx.compose.material.icons.automirrored.filled.Reply
|
||||||
|
import androidx.compose.material3.ScrollableTabRow
|
||||||
|
import androidx.compose.material3.Tab
|
||||||
import androidx.compose.material.icons.filled.PlayCircleFilled
|
import androidx.compose.material.icons.filled.PlayCircleFilled
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.compositionLocalOf
|
import androidx.compose.runtime.compositionLocalOf
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
@@ -93,6 +106,9 @@ import androidx.media3.ui.PlayerView
|
|||||||
import com.example.fluffytrix.data.repository.AuthRepository
|
import com.example.fluffytrix.data.repository.AuthRepository
|
||||||
import org.koin.compose.koinInject
|
import org.koin.compose.koinInject
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
|
import com.example.fluffytrix.data.MxcUrlHelper
|
||||||
|
import com.example.fluffytrix.data.model.EmojiPack
|
||||||
|
import com.example.fluffytrix.ui.screens.main.InlineEmoji
|
||||||
import com.example.fluffytrix.ui.screens.main.MessageContent
|
import com.example.fluffytrix.ui.screens.main.MessageContent
|
||||||
import com.example.fluffytrix.ui.screens.main.MessageItem
|
import com.example.fluffytrix.ui.screens.main.MessageItem
|
||||||
import com.example.fluffytrix.ui.screens.main.ReplyInfo
|
import com.example.fluffytrix.ui.screens.main.ReplyInfo
|
||||||
@@ -103,6 +119,8 @@ import java.util.Locale
|
|||||||
private val LocalImageViewer = compositionLocalOf<(String) -> Unit> { {} }
|
private val LocalImageViewer = compositionLocalOf<(String) -> Unit> { {} }
|
||||||
private val LocalVideoPlayer = compositionLocalOf<(String) -> Unit> { {} }
|
private val LocalVideoPlayer = compositionLocalOf<(String) -> Unit> { {} }
|
||||||
private val LocalScrollToEvent = compositionLocalOf<(String) -> Unit> { {} }
|
private val LocalScrollToEvent = compositionLocalOf<(String) -> Unit> { {} }
|
||||||
|
private val LocalReactionHandler = compositionLocalOf<(eventId: String, emoji: String) -> Unit> { { _, _ -> } }
|
||||||
|
private val LocalCurrentUserId = compositionLocalOf<String?> { null }
|
||||||
|
|
||||||
private val senderColors = arrayOf(
|
private val senderColors = arrayOf(
|
||||||
Color(0xFF5865F2),
|
Color(0xFF5865F2),
|
||||||
@@ -159,6 +177,8 @@ fun MessageTimeline(
|
|||||||
onEditThreadMessage: (String, String) -> Unit = { _, _ -> },
|
onEditThreadMessage: (String, String) -> Unit = { _, _ -> },
|
||||||
onSendReaction: (String, String) -> Unit = { _, _ -> },
|
onSendReaction: (String, String) -> Unit = { _, _ -> },
|
||||||
onSendThreadReaction: (String, String) -> Unit = { _, _ -> },
|
onSendThreadReaction: (String, String) -> Unit = { _, _ -> },
|
||||||
|
emojiPacks: List<EmojiPack> = emptyList(),
|
||||||
|
onOpenEmojiPackManagement: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
var fullscreenImageUrl by remember { mutableStateOf<String?>(null) }
|
var fullscreenImageUrl by remember { mutableStateOf<String?>(null) }
|
||||||
var fullscreenVideoUrl by remember { mutableStateOf<String?>(null) }
|
var fullscreenVideoUrl by remember { mutableStateOf<String?>(null) }
|
||||||
@@ -201,12 +221,22 @@ fun MessageTimeline(
|
|||||||
onOpenThread(msg.eventId)
|
onOpenThread(msg.eventId)
|
||||||
contextMenuMessage = null
|
contextMenuMessage = null
|
||||||
},
|
},
|
||||||
|
emojiPacks = emojiPacks,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val reactionHandler: (String, String) -> Unit = remember(selectedThread, onSendReaction, onSendThreadReaction) {
|
||||||
|
{ eventId, emoji ->
|
||||||
|
if (selectedThread != null) onSendThreadReaction(eventId, emoji)
|
||||||
|
else onSendReaction(eventId, emoji)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
LocalImageViewer provides { url -> fullscreenImageUrl = url },
|
LocalImageViewer provides { url -> fullscreenImageUrl = url },
|
||||||
LocalVideoPlayer provides { url -> fullscreenVideoUrl = url },
|
LocalVideoPlayer provides { url -> fullscreenVideoUrl = url },
|
||||||
|
LocalReactionHandler provides reactionHandler,
|
||||||
|
LocalCurrentUserId provides currentUserId,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
@@ -221,7 +251,7 @@ fun MessageTimeline(
|
|||||||
if (selectedThread != null) {
|
if (selectedThread != null) {
|
||||||
ThreadTopBar(selectedThreadName ?: "Thread in #${channelName ?: selectedChannel}", onCloseThread)
|
ThreadTopBar(selectedThreadName ?: "Thread in #${channelName ?: selectedChannel}", onCloseThread)
|
||||||
} else {
|
} else {
|
||||||
TopBar(channelName ?: selectedChannel, onToggleMemberList)
|
TopBar(channelName ?: selectedChannel, onToggleMemberList, onOpenEmojiPackManagement)
|
||||||
}
|
}
|
||||||
HorizontalDivider(color = MaterialTheme.colorScheme.surfaceVariant)
|
HorizontalDivider(color = MaterialTheme.colorScheme.surfaceVariant)
|
||||||
}
|
}
|
||||||
@@ -400,6 +430,7 @@ fun MessageTimeline(
|
|||||||
if (selectedThread != null) onEditThreadMessage(eventId, body)
|
if (selectedThread != null) onEditThreadMessage(eventId, body)
|
||||||
else onEditMessage(eventId, body)
|
else onEditMessage(eventId, body)
|
||||||
},
|
},
|
||||||
|
emojiPacks = emojiPacks,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -407,7 +438,7 @@ fun MessageTimeline(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun TopBar(name: String, onToggleMemberList: () -> Unit) {
|
private fun TopBar(name: String, onToggleMemberList: () -> Unit, onOpenEmojiPackManagement: () -> Unit = {}) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
@@ -421,6 +452,9 @@ private fun TopBar(name: String, onToggleMemberList: () -> Unit) {
|
|||||||
color = MaterialTheme.colorScheme.onBackground,
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
|
IconButton(onClick = onOpenEmojiPackManagement) {
|
||||||
|
Icon(Icons.Default.EmojiEmotions, "Emoji packs", tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
IconButton(onClick = onToggleMemberList) {
|
IconButton(onClick = onToggleMemberList) {
|
||||||
Icon(Icons.Default.People, "Toggle member list", tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
Icon(Icons.Default.People, "Toggle member list", tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
}
|
}
|
||||||
@@ -447,6 +481,53 @@ private fun ThreadTopBar(title: String, onClose: () -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ReactionRow(eventId: String, reactions: Map<String, List<String>>) {
|
||||||
|
if (reactions.isEmpty()) return
|
||||||
|
val onReact = LocalReactionHandler.current
|
||||||
|
val currentUserId = LocalCurrentUserId.current
|
||||||
|
val authRepository: AuthRepository = koinInject()
|
||||||
|
val baseUrl = remember { try { authRepository.getClient()?.session()?.homeserverUrl?.trimEnd('/') ?: "" } catch (_: Exception) { "" } }
|
||||||
|
FlowRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
modifier = Modifier.padding(top = 4.dp),
|
||||||
|
) {
|
||||||
|
reactions.forEach { (emoji, senders) ->
|
||||||
|
val isMine = currentUserId != null && senders.any { it == currentUserId }
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
color = if (isMine) MaterialTheme.colorScheme.primaryContainer
|
||||||
|
else MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
modifier = Modifier.clickable { onReact(eventId, emoji) },
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
if (emoji.startsWith("mxc://")) {
|
||||||
|
val resolvedUrl = remember(emoji) { MxcUrlHelper.mxcToDownloadUrl(baseUrl, emoji) ?: emoji }
|
||||||
|
AsyncImage(
|
||||||
|
model = resolvedUrl,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text(emoji, fontSize = 14.sp)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
"${senders.size}",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = if (isMine) MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun FullMessage(message: MessageItem, onOpenThread: (String) -> Unit = {}, threadReplyCount: Int = 0, onLongPress: (MessageItem) -> Unit = {}) {
|
private fun FullMessage(message: MessageItem, onOpenThread: (String) -> Unit = {}, threadReplyCount: Int = 0, onLongPress: (MessageItem) -> Unit = {}) {
|
||||||
val senderColor = remember(message.senderName) { colorForSender(message.senderName) }
|
val senderColor = remember(message.senderName) { colorForSender(message.senderName) }
|
||||||
@@ -488,6 +569,7 @@ private fun FullMessage(message: MessageItem, onOpenThread: (String) -> Unit = {
|
|||||||
}
|
}
|
||||||
Spacer(Modifier.height(2.dp))
|
Spacer(Modifier.height(2.dp))
|
||||||
MessageContentView(message.content)
|
MessageContentView(message.content)
|
||||||
|
ReactionRow(message.eventId, message.reactions)
|
||||||
if (threadReplyCount > 0) {
|
if (threadReplyCount > 0) {
|
||||||
Text(
|
Text(
|
||||||
text = "$threadReplyCount ${if (threadReplyCount == 1) "reply" else "replies"}",
|
text = "$threadReplyCount ${if (threadReplyCount == 1) "reply" else "replies"}",
|
||||||
@@ -511,7 +593,10 @@ private fun CompactMessage(message: MessageItem, onLongPress: (MessageItem) -> U
|
|||||||
}
|
}
|
||||||
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 1.dp)) {
|
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 1.dp)) {
|
||||||
Spacer(Modifier.width(52.dp))
|
Spacer(Modifier.width(52.dp))
|
||||||
|
Column {
|
||||||
MessageContentView(message.content)
|
MessageContentView(message.content)
|
||||||
|
ReactionRow(message.eventId, message.reactions)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -619,6 +704,9 @@ private fun MessageContentView(content: MessageContent) {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun TextContent(content: MessageContent.Text) {
|
private fun TextContent(content: MessageContent.Text) {
|
||||||
|
if (content.inlineEmojis.isNotEmpty()) {
|
||||||
|
InlineEmojiText(content)
|
||||||
|
} else {
|
||||||
Markdown(
|
Markdown(
|
||||||
content = content.body,
|
content = content.body,
|
||||||
colors = markdownColor(
|
colors = markdownColor(
|
||||||
@@ -630,6 +718,42 @@ private fun TextContent(content: MessageContent.Text) {
|
|||||||
imageTransformer = Coil3ImageTransformerImpl,
|
imageTransformer = Coil3ImageTransformerImpl,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun InlineEmojiText(content: MessageContent.Text) {
|
||||||
|
val body = content.body
|
||||||
|
val emojis = content.inlineEmojis
|
||||||
|
// Build segments: split body on shortcode occurrences
|
||||||
|
FlowRow(verticalArrangement = Arrangement.Center) {
|
||||||
|
var remaining = body
|
||||||
|
for (emoji in emojis) {
|
||||||
|
val idx = remaining.indexOf(emoji.shortcode)
|
||||||
|
if (idx < 0) continue
|
||||||
|
val before = remaining.substring(0, idx)
|
||||||
|
if (before.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
before,
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(lineHeight = 20.sp),
|
||||||
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
AsyncImage(
|
||||||
|
model = emoji.resolvedUrl,
|
||||||
|
contentDescription = emoji.shortcode,
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
)
|
||||||
|
remaining = remaining.substring(idx + emoji.shortcode.length)
|
||||||
|
}
|
||||||
|
if (remaining.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
remaining,
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(lineHeight = 20.sp),
|
||||||
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ImageContent(content: MessageContent.Image) {
|
private fun ImageContent(content: MessageContent.Image) {
|
||||||
@@ -892,9 +1016,11 @@ private fun MessageInput(
|
|||||||
editingMessage: MessageItem? = null,
|
editingMessage: MessageItem? = null,
|
||||||
onSendReply: (String, String) -> Unit = { _, _ -> },
|
onSendReply: (String, String) -> Unit = { _, _ -> },
|
||||||
onEditMessage: (String, String) -> Unit = { _, _ -> },
|
onEditMessage: (String, String) -> Unit = { _, _ -> },
|
||||||
|
emojiPacks: List<EmojiPack> = emptyList(),
|
||||||
) {
|
) {
|
||||||
var text by remember { mutableStateOf("") }
|
var text by remember { mutableStateOf("") }
|
||||||
var attachedUris by remember { mutableStateOf(listOf<Uri>()) }
|
var attachedUris by remember { mutableStateOf(listOf<Uri>()) }
|
||||||
|
var showEmojiPackPicker by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
// Pre-fill text when entering edit mode
|
// Pre-fill text when entering edit mode
|
||||||
androidx.compose.runtime.LaunchedEffect(editingMessage) {
|
androidx.compose.runtime.LaunchedEffect(editingMessage) {
|
||||||
@@ -912,7 +1038,108 @@ private fun MessageInput(
|
|||||||
}
|
}
|
||||||
val canSend = text.isNotBlank() || attachedUris.isNotEmpty()
|
val canSend = text.isNotBlank() || attachedUris.isNotEmpty()
|
||||||
|
|
||||||
|
// Emoji picker dialog: custom packs first, then Unicode
|
||||||
|
if (showEmojiPackPicker) {
|
||||||
|
Dialog(
|
||||||
|
onDismissRequest = { showEmojiPackPicker = false },
|
||||||
|
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
color = MaterialTheme.colorScheme.surface,
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.fillMaxSize().padding(4.dp)) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
IconButton(onClick = { showEmojiPackPicker = false }) {
|
||||||
|
Icon(Icons.Default.Close, "Close", tint = MaterialTheme.colorScheme.onSurface)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
"Insert Emoji",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val tabs = emojiPacks.map { it.displayName } + "Unicode"
|
||||||
|
var selectedTab by remember { mutableStateOf(0) }
|
||||||
|
ScrollableTabRow(selectedTabIndex = selectedTab) {
|
||||||
|
tabs.forEachIndexed { index, title ->
|
||||||
|
Tab(
|
||||||
|
selected = selectedTab == index,
|
||||||
|
onClick = { selectedTab = index },
|
||||||
|
text = { Text(title, maxLines = 1) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (selectedTab < emojiPacks.size) {
|
||||||
|
CustomEmojiGrid(
|
||||||
|
pack = emojiPacks[selectedTab],
|
||||||
|
onEmojiSelected = { entry ->
|
||||||
|
text = text + ":${entry.shortcode}:"
|
||||||
|
showEmojiPackPicker = false
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth().weight(1f),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
val currentText by rememberUpdatedState(text)
|
||||||
|
AndroidView(
|
||||||
|
factory = { ctx -> EmojiPickerView(ctx) },
|
||||||
|
update = { view ->
|
||||||
|
view.setOnEmojiPickedListener { emojiViewItem: EmojiViewItem ->
|
||||||
|
text = currentText + emojiViewItem.emoji
|
||||||
|
showEmojiPackPicker = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth().weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Autocomplete strip: show matching emojis when text ends with :partialword (no space, no closing colon)
|
||||||
|
val autocompleteResults = remember(text, emojiPacks) {
|
||||||
|
if (emojiPacks.isEmpty()) return@remember emptyList()
|
||||||
|
val colonIdx = text.lastIndexOf(':')
|
||||||
|
if (colonIdx < 0) return@remember emptyList()
|
||||||
|
val partial = text.substring(colonIdx + 1)
|
||||||
|
if (partial.isEmpty() || partial.contains(' ') || partial.contains(':')) return@remember emptyList()
|
||||||
|
emojiPacks.flatMap { it.emojis }.filter { it.shortcode.contains(partial, ignoreCase = true) }.take(10)
|
||||||
|
}
|
||||||
|
|
||||||
Column(modifier = Modifier.fillMaxWidth()) {
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
// Emoji autocomplete strip
|
||||||
|
if (autocompleteResults.isNotEmpty()) {
|
||||||
|
LazyRow(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
items(autocompleteResults) { entry ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.clickable {
|
||||||
|
// Replace the partial :xxx with :shortcode:
|
||||||
|
val colonIdx = text.lastIndexOf(':')
|
||||||
|
text = text.substring(0, colonIdx) + ":${entry.shortcode}:"
|
||||||
|
}
|
||||||
|
.padding(4.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
AsyncImage(
|
||||||
|
model = entry.resolvedUrl,
|
||||||
|
contentDescription = entry.shortcode,
|
||||||
|
modifier = Modifier.size(32.dp),
|
||||||
|
)
|
||||||
|
Text(entry.shortcode, style = MaterialTheme.typography.labelSmall, maxLines = 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HorizontalDivider()
|
||||||
|
}
|
||||||
// Attachment previews (Discord-style, above the text box)
|
// Attachment previews (Discord-style, above the text box)
|
||||||
if (attachedUris.isNotEmpty()) {
|
if (attachedUris.isNotEmpty()) {
|
||||||
Row(
|
Row(
|
||||||
@@ -967,6 +1194,14 @@ private fun MessageInput(
|
|||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (emojiPacks.isNotEmpty()) {
|
||||||
|
IconButton(onClick = { showEmojiPackPicker = true }) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.EmojiEmotions, "Insert emoji",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
TextField(
|
TextField(
|
||||||
value = text,
|
value = text,
|
||||||
onValueChange = { text = it },
|
onValueChange = { text = it },
|
||||||
@@ -1027,30 +1262,139 @@ private fun MessageContextMenu(
|
|||||||
onReply: () -> Unit,
|
onReply: () -> Unit,
|
||||||
onEdit: () -> Unit,
|
onEdit: () -> Unit,
|
||||||
onStartThread: () -> Unit,
|
onStartThread: () -> Unit,
|
||||||
|
emojiPacks: List<EmojiPack> = emptyList(),
|
||||||
) {
|
) {
|
||||||
|
var showEmojiPicker by remember { mutableStateOf(false) }
|
||||||
|
val currentOnReact by rememberUpdatedState(onReact)
|
||||||
|
|
||||||
|
if (showEmojiPicker) {
|
||||||
|
Dialog(
|
||||||
|
onDismissRequest = { showEmojiPicker = false },
|
||||||
|
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
color = MaterialTheme.colorScheme.surface,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(4.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
IconButton(onClick = { showEmojiPicker = false }) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Close,
|
||||||
|
"Close picker",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
"Choose an emoji",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (emojiPacks.isNotEmpty()) {
|
||||||
|
var selectedTab by remember { mutableStateOf(0) }
|
||||||
|
val tabs = listOf("Unicode") + emojiPacks.map { it.displayName }
|
||||||
|
ScrollableTabRow(selectedTabIndex = selectedTab) {
|
||||||
|
tabs.forEachIndexed { index, title ->
|
||||||
|
Tab(
|
||||||
|
selected = selectedTab == index,
|
||||||
|
onClick = { selectedTab = index },
|
||||||
|
text = { Text(title, maxLines = 1) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
when {
|
||||||
|
selectedTab == 0 -> AndroidView(
|
||||||
|
factory = { ctx -> EmojiPickerView(ctx) },
|
||||||
|
update = { view ->
|
||||||
|
view.setOnEmojiPickedListener { emojiViewItem: EmojiViewItem ->
|
||||||
|
currentOnReact(emojiViewItem.emoji)
|
||||||
|
showEmojiPicker = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth().weight(1f),
|
||||||
|
)
|
||||||
|
else -> {
|
||||||
|
val pack = emojiPacks[selectedTab - 1]
|
||||||
|
CustomEmojiGrid(
|
||||||
|
pack = pack,
|
||||||
|
onEmojiSelected = { entry ->
|
||||||
|
currentOnReact(entry.mxcUrl)
|
||||||
|
showEmojiPicker = false
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth().weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
AndroidView(
|
||||||
|
factory = { ctx -> EmojiPickerView(ctx) },
|
||||||
|
update = { view ->
|
||||||
|
view.setOnEmojiPickedListener { emojiViewItem: EmojiViewItem ->
|
||||||
|
currentOnReact(emojiViewItem.emoji)
|
||||||
|
showEmojiPicker = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
title = null,
|
title = null,
|
||||||
text = {
|
text = {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(0.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(0.dp)) {
|
||||||
// Quick emoji reactions
|
Row(
|
||||||
LazyRow(
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
contentPadding = PaddingValues(vertical = 8.dp),
|
|
||||||
) {
|
) {
|
||||||
items(QUICK_REACTIONS) { emoji ->
|
QUICK_REACTIONS.forEach { emoji ->
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(40.dp)
|
.size(36.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||||
.clickable { onReact(emoji) },
|
.clickable { onReact(emoji) },
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Text(emoji, fontSize = 20.sp)
|
Text(emoji, fontSize = 18.sp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Button(
|
||||||
|
onClick = { showEmojiPicker = true },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(48.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Add,
|
||||||
|
"Add reaction",
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text("More emojis")
|
||||||
|
}
|
||||||
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
|
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = onReply,
|
onClick = onReply,
|
||||||
@@ -1165,3 +1509,37 @@ private fun EditModeBar(body: String, onDismiss: () -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CustomEmojiGrid(
|
||||||
|
pack: EmojiPack,
|
||||||
|
onEmojiSelected: (com.example.fluffytrix.data.model.EmojiEntry) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
LazyVerticalGrid(
|
||||||
|
columns = GridCells.Adaptive(56.dp),
|
||||||
|
modifier = modifier,
|
||||||
|
contentPadding = PaddingValues(8.dp),
|
||||||
|
) {
|
||||||
|
items(pack.emojis) { entry ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.clickable { onEmojiSelected(entry) }
|
||||||
|
.padding(4.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
AsyncImage(
|
||||||
|
model = entry.resolvedUrl,
|
||||||
|
contentDescription = entry.shortcode,
|
||||||
|
modifier = Modifier.size(40.dp),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = entry.shortcode,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ matrixRustSdk = "26.02.19"
|
|||||||
coil = "3.2.0"
|
coil = "3.2.0"
|
||||||
media3 = "1.6.0"
|
media3 = "1.6.0"
|
||||||
markdownRenderer = "0.37.0"
|
markdownRenderer = "0.37.0"
|
||||||
|
emojiPicker = "1.6.0"
|
||||||
kotlinxSerialization = "1.8.1"
|
kotlinxSerialization = "1.8.1"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
@@ -78,6 +79,9 @@ markdown-renderer-m3 = { group = "com.mikepenz", name = "multiplatform-markdown-
|
|||||||
markdown-renderer-code = { group = "com.mikepenz", name = "multiplatform-markdown-renderer-code-android", version.ref = "markdownRenderer" }
|
markdown-renderer-code = { group = "com.mikepenz", name = "multiplatform-markdown-renderer-code-android", version.ref = "markdownRenderer" }
|
||||||
markdown-renderer-coil3 = { group = "com.mikepenz", name = "multiplatform-markdown-renderer-coil3-android", version.ref = "markdownRenderer" }
|
markdown-renderer-coil3 = { group = "com.mikepenz", name = "multiplatform-markdown-renderer-coil3-android", version.ref = "markdownRenderer" }
|
||||||
|
|
||||||
|
# Jetpack Emoji Picker
|
||||||
|
emoji-picker = { group = "androidx.emoji2", name = "emoji2-emojipicker", version.ref = "emojiPicker" }
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||||
|
|||||||
Reference in New Issue
Block a user