custom emojis

This commit is contained in:
2026-03-02 23:14:44 +00:00
parent 2b554dc227
commit 6a87a33ea0
9 changed files with 877 additions and 30 deletions

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
@@ -116,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())
@@ -196,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())
@@ -284,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()
@@ -805,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 -> {
@@ -812,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
@@ -943,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) {
@@ -1488,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

@@ -73,11 +73,17 @@ 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.EmojiPickerView
import androidx.emoji2.emojipicker.EmojiViewItem 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
@@ -100,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
@@ -168,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) }
@@ -210,6 +221,7 @@ fun MessageTimeline(
onOpenThread(msg.eventId) onOpenThread(msg.eventId)
contextMenuMessage = null contextMenuMessage = null
}, },
emojiPacks = emojiPacks,
) )
} }
@@ -239,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)
} }
@@ -418,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,
) )
} }
} }
@@ -425,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,
@@ -439,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)
} }
@@ -470,6 +486,8 @@ private fun ReactionRow(eventId: String, reactions: Map<String, List<String>>) {
if (reactions.isEmpty()) return if (reactions.isEmpty()) return
val onReact = LocalReactionHandler.current val onReact = LocalReactionHandler.current
val currentUserId = LocalCurrentUserId.current val currentUserId = LocalCurrentUserId.current
val authRepository: AuthRepository = koinInject()
val baseUrl = remember { try { authRepository.getClient()?.session()?.homeserverUrl?.trimEnd('/') ?: "" } catch (_: Exception) { "" } }
FlowRow( FlowRow(
horizontalArrangement = Arrangement.spacedBy(4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp),
@@ -488,7 +506,16 @@ private fun ReactionRow(eventId: String, reactions: Map<String, List<String>>) {
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp), 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(emoji, fontSize = 14.sp)
}
Text( Text(
"${senders.size}", "${senders.size}",
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
@@ -677,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(
@@ -687,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
@@ -950,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) {
@@ -970,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(
@@ -1025,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 },
@@ -1085,6 +1262,7 @@ 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) } var showEmojiPicker by remember { mutableStateOf(false) }
val currentOnReact by rememberUpdatedState(onReact) val currentOnReact by rememberUpdatedState(onReact)
@@ -1122,6 +1300,42 @@ private fun MessageContextMenu(
modifier = Modifier.weight(1f), 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)) Spacer(modifier = Modifier.height(8.dp))
AndroidView( AndroidView(
factory = { ctx -> EmojiPickerView(ctx) }, factory = { ctx -> EmojiPickerView(ctx) },
@@ -1139,6 +1353,7 @@ private fun MessageContextMenu(
} }
} }
} }
}
AlertDialog( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
@@ -1294,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,
)
}
}
}
}