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 com.example.fluffytrix.data.local.PreferencesManager
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.main.MainViewModel
import com.example.fluffytrix.ui.screens.verification.VerificationViewModel
@@ -11,7 +12,7 @@ import org.koin.core.module.dsl.viewModel
import org.koin.dsl.module
val appModule = module {
viewModel { MainViewModel(androidApplication(), get(), get()) }
viewModel { MainViewModel(androidApplication(), get(), get(), get()) }
viewModel { LoginViewModel(get()) }
viewModel { VerificationViewModel(get()) }
}
@@ -19,4 +20,5 @@ val appModule = module {
val dataModule = module {
single { PreferencesManager(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 com.example.fluffytrix.data.local.PreferencesManager
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.main.MainScreen
import com.example.fluffytrix.ui.screens.settings.SettingsScreen
@@ -148,6 +151,19 @@ fun FluffytrixNavigation() {
onSettingsClick = {
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) {

View File

@@ -5,4 +5,8 @@ sealed class Screen(val route: String) {
data object Verification : Screen("verification")
data object Main : Screen("main")
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(
onLogout: () -> Unit,
onSettingsClick: () -> Unit = {},
onEmojiPackManagement: (String?) -> Unit = {},
viewModel: MainViewModel = koinViewModel(),
) {
val spaces by viewModel.spaces.collectAsStateWithLifecycle()
@@ -53,6 +54,7 @@ fun MainScreen(
val currentUserId by viewModel.currentUserId.collectAsStateWithLifecycle()
val replyingTo by viewModel.replyingTo.collectAsStateWithLifecycle()
val editingMessage by viewModel.editingMessage.collectAsStateWithLifecycle()
val emojiPacks by viewModel.emojiPacks.collectAsStateWithLifecycle()
val listState = viewModel.channelListState
val preferencesManager: PreferencesManager = koinInject()
val hideSpacesWhenClosed by preferencesManager.hideSpacesWhenClosed.collectAsStateWithLifecycle(initialValue = false)
@@ -142,6 +144,8 @@ fun MainScreen(
onEditThreadMessage = { eventId, body -> viewModel.editThreadMessage(eventId, body) },
onSendReaction = { eventId, emoji -> viewModel.sendReaction(eventId, emoji) },
onSendThreadReaction = { eventId, emoji -> viewModel.sendThreadReaction(eventId, emoji) },
emojiPacks = emojiPacks,
onOpenEmojiPackManagement = { onEmojiPackManagement(selectedChannel) },
)
AnimatedVisibility(visible = showMemberList) {

View File

@@ -11,7 +11,9 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.fluffytrix.data.MxcUrlHelper
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.EmojiPackRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
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.UploadSource
import org.json.JSONObject
import org.matrix.rustcomponents.sdk.messageEventContentFromHtml
import org.matrix.rustcomponents.sdk.messageEventContentFromMarkdown
enum class UnreadStatus { NONE, UNREAD, MENTIONED }
@@ -61,8 +64,10 @@ data class ChannelItem(
val unreadStatus: UnreadStatus = UnreadStatus.NONE,
)
data class InlineEmoji(val shortcode: String, val mxcUrl: String, val resolvedUrl: String)
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 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
@@ -116,6 +121,7 @@ class MainViewModel(
private val application: Application,
private val authRepository: AuthRepository,
private val preferencesManager: PreferencesManager,
private val emojiPackRepository: EmojiPackRepository,
) : ViewModel() {
private val _spaces = MutableStateFlow<List<SpaceItem>>(emptyList())
@@ -196,6 +202,9 @@ class MainViewModel(
private val _editingMessage = MutableStateFlow<MessageItem?>(null)
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 _channelSections = MutableStateFlow<List<ChannelSection>>(emptyList())
@@ -284,6 +293,7 @@ class MainViewModel(
syncService = authRepository.getOrStartSync()
try { _currentUserId.value = authRepository.getClient()?.userId() } catch (_: Exception) {}
loadRooms()
loadEmojiPacks()
}
observeSelectedChannel()
observeSpaceFiltering()
@@ -805,6 +815,7 @@ class MainViewModel(
MessageContent.Text(
body = text,
urls = urlRegex.findAll(text).map { it.value }.toList(),
inlineEmojis = parseInlineEmojis(rawJson),
)
}
is MessageType.Notice -> {
@@ -812,10 +823,11 @@ class MainViewModel(
MessageContent.Text(
body = text,
urls = urlRegex.findAll(text).map { it.value }.toList(),
inlineEmojis = parseInlineEmojis(rawJson),
)
}
is MessageType.Emote -> {
MessageContent.Text(body = "* ${msgType.content.body}")
MessageContent.Text(body = "* ${msgType.content.body}", inlineEmojis = parseInlineEmojis(rawJson))
}
is MessageType.Image -> {
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) {
val timeline = activeTimeline ?: return
viewModelScope.launch(Dispatchers.IO) {
try {
timeline.send(messageEventContentFromMarkdown(body))
val content = buildMessageContent(body)
timeline.send(content)
} 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?) {
val timeline = activeTimeline ?: return
viewModelScope.launch(Dispatchers.IO) {
@@ -1488,7 +1551,7 @@ class MainViewModel(
val threadTimeline = activeThreadTimeline ?: return@withLock
try {
// Thread-focused timeline sends automatically include the m.thread relation
threadTimeline.send(messageEventContentFromMarkdown(body))
threadTimeline.send(buildMessageContent(body))
} catch (e: Exception) {
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.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.Check
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.automirrored.filled.Reply
import androidx.compose.material3.ScrollableTabRow
import androidx.compose.material3.Tab
import androidx.compose.material.icons.filled.PlayCircleFilled
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
@@ -100,6 +106,9 @@ import androidx.media3.ui.PlayerView
import com.example.fluffytrix.data.repository.AuthRepository
import org.koin.compose.koinInject
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.MessageItem
import com.example.fluffytrix.ui.screens.main.ReplyInfo
@@ -168,6 +177,8 @@ fun MessageTimeline(
onEditThreadMessage: (String, String) -> Unit = { _, _ -> },
onSendReaction: (String, String) -> Unit = { _, _ -> },
onSendThreadReaction: (String, String) -> Unit = { _, _ -> },
emojiPacks: List<EmojiPack> = emptyList(),
onOpenEmojiPackManagement: () -> Unit = {},
) {
var fullscreenImageUrl by remember { mutableStateOf<String?>(null) }
var fullscreenVideoUrl by remember { mutableStateOf<String?>(null) }
@@ -210,6 +221,7 @@ fun MessageTimeline(
onOpenThread(msg.eventId)
contextMenuMessage = null
},
emojiPacks = emojiPacks,
)
}
@@ -239,7 +251,7 @@ fun MessageTimeline(
if (selectedThread != null) {
ThreadTopBar(selectedThreadName ?: "Thread in #${channelName ?: selectedChannel}", onCloseThread)
} else {
TopBar(channelName ?: selectedChannel, onToggleMemberList)
TopBar(channelName ?: selectedChannel, onToggleMemberList, onOpenEmojiPackManagement)
}
HorizontalDivider(color = MaterialTheme.colorScheme.surfaceVariant)
}
@@ -418,6 +430,7 @@ fun MessageTimeline(
if (selectedThread != null) onEditThreadMessage(eventId, body)
else onEditMessage(eventId, body)
},
emojiPacks = emojiPacks,
)
}
}
@@ -425,7 +438,7 @@ fun MessageTimeline(
}
@Composable
private fun TopBar(name: String, onToggleMemberList: () -> Unit) {
private fun TopBar(name: String, onToggleMemberList: () -> Unit, onOpenEmojiPackManagement: () -> Unit = {}) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
@@ -439,6 +452,9 @@ private fun TopBar(name: String, onToggleMemberList: () -> Unit) {
color = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.weight(1f),
)
IconButton(onClick = onOpenEmojiPackManagement) {
Icon(Icons.Default.EmojiEmotions, "Emoji packs", tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
IconButton(onClick = onToggleMemberList) {
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
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),
@@ -488,7 +506,16 @@ private fun ReactionRow(eventId: String, reactions: Map<String, List<String>>) {
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,
@@ -677,6 +704,9 @@ private fun MessageContentView(content: MessageContent) {
@Composable
private fun TextContent(content: MessageContent.Text) {
if (content.inlineEmojis.isNotEmpty()) {
InlineEmojiText(content)
} else {
Markdown(
content = content.body,
colors = markdownColor(
@@ -687,6 +717,42 @@ private fun TextContent(content: MessageContent.Text) {
),
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
@@ -950,9 +1016,11 @@ private fun MessageInput(
editingMessage: MessageItem? = null,
onSendReply: (String, String) -> Unit = { _, _ -> },
onEditMessage: (String, String) -> Unit = { _, _ -> },
emojiPacks: List<EmojiPack> = emptyList(),
) {
var text by remember { mutableStateOf("") }
var attachedUris by remember { mutableStateOf(listOf<Uri>()) }
var showEmojiPackPicker by remember { mutableStateOf(false) }
// Pre-fill text when entering edit mode
androidx.compose.runtime.LaunchedEffect(editingMessage) {
@@ -970,7 +1038,108 @@ private fun MessageInput(
}
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()) {
// 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)
if (attachedUris.isNotEmpty()) {
Row(
@@ -1025,6 +1194,14 @@ private fun MessageInput(
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (emojiPacks.isNotEmpty()) {
IconButton(onClick = { showEmojiPackPicker = true }) {
Icon(
Icons.Default.EmojiEmotions, "Insert emoji",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
TextField(
value = text,
onValueChange = { text = it },
@@ -1085,6 +1262,7 @@ private fun MessageContextMenu(
onReply: () -> Unit,
onEdit: () -> Unit,
onStartThread: () -> Unit,
emojiPacks: List<EmojiPack> = emptyList(),
) {
var showEmojiPicker by remember { mutableStateOf(false) }
val currentOnReact by rememberUpdatedState(onReact)
@@ -1122,6 +1300,42 @@ private fun MessageContextMenu(
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) },
@@ -1139,6 +1353,7 @@ private fun MessageContextMenu(
}
}
}
}
AlertDialog(
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,
)
}
}
}
}