diff --git a/app/src/main/java/com/example/fluffytrix/data/model/EmojiPack.kt b/app/src/main/java/com/example/fluffytrix/data/model/EmojiPack.kt new file mode 100644 index 0000000..e37a81b --- /dev/null +++ b/app/src/main/java/com/example/fluffytrix/data/model/EmojiPack.kt @@ -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 = 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 = emptyMap(), +) + +@Serializable +data class UserEmojiAccountData( + val packs: Map = 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, + val isRoomPack: Boolean = false, + val roomId: String? = null, +) diff --git a/app/src/main/java/com/example/fluffytrix/data/repository/EmojiPackRepository.kt b/app/src/main/java/com/example/fluffytrix/data/repository/EmojiPackRepository.kt new file mode 100644 index 0000000..e2655c3 --- /dev/null +++ b/app/src/main/java/com/example/fluffytrix/data/repository/EmojiPackRepository.kt @@ -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 { + val client = authRepository.getClient() ?: return emptyList() + return try { + val raw = client.accountData("im.ponies.user_emojis") ?: return emptyList() + val data = json.decodeFromString(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) { + 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 { + 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, + ) +} diff --git a/app/src/main/java/com/example/fluffytrix/di/AppModule.kt b/app/src/main/java/com/example/fluffytrix/di/AppModule.kt index edab268..7514473 100644 --- a/app/src/main/java/com/example/fluffytrix/di/AppModule.kt +++ b/app/src/main/java/com/example/fluffytrix/di/AppModule.kt @@ -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()) } } diff --git a/app/src/main/java/com/example/fluffytrix/ui/navigation/FluffytrixNavigation.kt b/app/src/main/java/com/example/fluffytrix/ui/navigation/FluffytrixNavigation.kt index c1cb6ee..9de3d02 100644 --- a/app/src/main/java/com/example/fluffytrix/ui/navigation/FluffytrixNavigation.kt +++ b/app/src/main/java/com/example/fluffytrix/ui/navigation/FluffytrixNavigation.kt @@ -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) { diff --git a/app/src/main/java/com/example/fluffytrix/ui/navigation/Screen.kt b/app/src/main/java/com/example/fluffytrix/ui/navigation/Screen.kt index ea76483..5505a62 100644 --- a/app/src/main/java/com/example/fluffytrix/ui/navigation/Screen.kt +++ b/app/src/main/java/com/example/fluffytrix/ui/navigation/Screen.kt @@ -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}" + } } diff --git a/app/src/main/java/com/example/fluffytrix/ui/screens/emoji/EmojiPackManagementScreen.kt b/app/src/main/java/com/example/fluffytrix/ui/screens/emoji/EmojiPackManagementScreen.kt new file mode 100644 index 0000000..ec534a5 --- /dev/null +++ b/app/src/main/java/com/example/fluffytrix/ui/screens/emoji/EmojiPackManagementScreen.kt @@ -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(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(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) + } + } +} diff --git a/app/src/main/java/com/example/fluffytrix/ui/screens/main/MainScreen.kt b/app/src/main/java/com/example/fluffytrix/ui/screens/main/MainScreen.kt index 34ccf55..eed5801 100644 --- a/app/src/main/java/com/example/fluffytrix/ui/screens/main/MainScreen.kt +++ b/app/src/main/java/com/example/fluffytrix/ui/screens/main/MainScreen.kt @@ -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) { diff --git a/app/src/main/java/com/example/fluffytrix/ui/screens/main/MainViewModel.kt b/app/src/main/java/com/example/fluffytrix/ui/screens/main/MainViewModel.kt index 1db6bfd..251e438 100644 --- a/app/src/main/java/com/example/fluffytrix/ui/screens/main/MainViewModel.kt +++ b/app/src/main/java/com/example/fluffytrix/ui/screens/main/MainViewModel.kt @@ -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 = emptyList()) : MessageContent + data class Text(val body: String, val urls: List = emptyList(), val inlineEmojis: List = 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>(emptyList()) @@ -196,6 +202,9 @@ class MainViewModel( private val _editingMessage = MutableStateFlow(null) val editingMessage: StateFlow = _editingMessage + + private val _emojiPacks = MutableStateFlow>(emptyList()) + val emojiPacks: StateFlow> = _emojiPacks private val _spaceChildrenMap = MutableStateFlow>>(emptyMap()) private val _channelSections = MutableStateFlow>(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) { + 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 { + rawJson ?: return emptyList() + return try { + val json = org.json.JSONObject(rawJson) + val formattedBody = json.optJSONObject("content")?.optString("formatted_body") ?: return emptyList() + val regex = Regex("""]+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 = """:${entry.shortcode}:""" + formattedBody = formattedBody.replace(key, imgTag) + } + return messageEventContentFromHtml(body, formattedBody) + } + fun sendFiles(uris: List, 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) } diff --git a/app/src/main/java/com/example/fluffytrix/ui/screens/main/components/MessageTimeline.kt b/app/src/main/java/com/example/fluffytrix/ui/screens/main/components/MessageTimeline.kt index 1d50a9e..3658e9c 100644 --- a/app/src/main/java/com/example/fluffytrix/ui/screens/main/components/MessageTimeline.kt +++ b/app/src/main/java/com/example/fluffytrix/ui/screens/main/components/MessageTimeline.kt @@ -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 = emptyList(), + onOpenEmojiPackManagement: () -> Unit = {}, ) { var fullscreenImageUrl by remember { mutableStateOf(null) } var fullscreenVideoUrl by remember { mutableStateOf(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>) { 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>) { verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp), ) { - Text(emoji, fontSize = 14.sp) + 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,16 +704,55 @@ private fun MessageContentView(content: MessageContent) { @Composable private fun TextContent(content: MessageContent.Text) { - Markdown( - content = content.body, - colors = markdownColor( - text = MaterialTheme.colorScheme.onBackground, - ), - typography = markdownTypography( - text = MaterialTheme.typography.bodyMedium.copy(lineHeight = 20.sp), - ), - imageTransformer = Coil3ImageTransformerImpl, - ) + if (content.inlineEmojis.isNotEmpty()) { + InlineEmojiText(content) + } else { + Markdown( + content = content.body, + colors = markdownColor( + text = MaterialTheme.colorScheme.onBackground, + ), + typography = markdownTypography( + text = MaterialTheme.typography.bodyMedium.copy(lineHeight = 20.sp), + ), + 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 = emptyList(), ) { var text by remember { mutableStateOf("") } var attachedUris by remember { mutableStateOf(listOf()) } + 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 = emptyList(), ) { var showEmojiPicker by remember { mutableStateOf(false) } val currentOnReact by rememberUpdatedState(onReact) @@ -1122,19 +1300,56 @@ private fun MessageContextMenu( modifier = Modifier.weight(1f), ) } - Spacer(modifier = Modifier.height(8.dp)) - AndroidView( - factory = { ctx -> EmojiPickerView(ctx) }, - update = { view -> - view.setOnEmojiPickedListener { emojiViewItem: EmojiViewItem -> - currentOnReact(emojiViewItem.emoji) - showEmojiPicker = false + 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) }, + ) } - }, - modifier = Modifier - .fillMaxWidth() - .weight(1f), - ) + } + 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), + ) + } } } } @@ -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, + ) + } + } + } +}