Compare commits

...

2 Commits

Author SHA1 Message Date
6a87a33ea0 custom emojis 2026-03-02 23:14:44 +00:00
2b554dc227 better emoji reactions 2026-03-02 22:21:23 +00:00
13 changed files with 1069 additions and 143 deletions

6
.idea/studiobot.xml generated Normal file
View 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>

163
AGENTS.md
View File

@@ -5,8 +5,8 @@
Fluffytrix is an Android Matrix chat client with a Discord-like UI. Built with Kotlin, targeting Android 14+ (minSdk 34, targetSdk 36, compileSdk 36). 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` **Package**: `com.example.fluffytrix`
**Build system**: Gradle with Kotlin DSL, 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)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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(
@@ -629,6 +717,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
@@ -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,
)
}
}
}
}

View File

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