From f9784a67a2a433d8970fcd6644dce2190259fbc1 Mon Sep 17 00:00:00 2001 From: Infi Date: Sun, 17 Sep 2023 23:02:54 +0200 Subject: [PATCH] feat: inline custom emoji & emoji info Signed-off-by: Infi --- .../chat/revolt/api/routes/custom/Emoji.kt | 15 ++ .../java/chat/revolt/api/schemas/Server.kt | 1 + .../chat/revolt/callbacks/ActionChannel.kt | 1 + .../revolt/components/generic/Markdown.kt | 3 +- .../revolt/internals/markdown/EmoteSpan.kt | 22 +++ .../internals/markdown/MarkdownContext.kt | 3 +- .../internals/markdown/MarkdownNodes.kt | 53 ++++++- .../internals/markdown/MarkdownRules.kt | 6 +- .../revolt/screens/chat/ChatRouterScreen.kt | 27 ++++ .../chat/views/channel/ChannelScreen.kt | 4 + .../java/chat/revolt/sheets/EmoteInfoSheet.kt | 142 ++++++++++++++++++ app/src/main/res/values/strings.xml | 3 + 12 files changed, 270 insertions(+), 10 deletions(-) create mode 100644 app/src/main/java/chat/revolt/api/routes/custom/Emoji.kt create mode 100644 app/src/main/java/chat/revolt/internals/markdown/EmoteSpan.kt create mode 100644 app/src/main/java/chat/revolt/sheets/EmoteInfoSheet.kt diff --git a/app/src/main/java/chat/revolt/api/routes/custom/Emoji.kt b/app/src/main/java/chat/revolt/api/routes/custom/Emoji.kt new file mode 100644 index 00000000..123a0eaa --- /dev/null +++ b/app/src/main/java/chat/revolt/api/routes/custom/Emoji.kt @@ -0,0 +1,15 @@ +package chat.revolt.api.routes.custom + +import chat.revolt.api.RevoltHttp +import chat.revolt.api.RevoltJson +import chat.revolt.api.schemas.Emoji +import io.ktor.client.request.get +import io.ktor.client.statement.bodyAsText + +suspend fun fetchEmoji(id: String): Emoji { + val response = RevoltHttp.get("/custom/emoji/$id").bodyAsText() + return RevoltJson.decodeFromString( + Emoji.serializer(), + response + ) +} \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/api/schemas/Server.kt b/app/src/main/java/chat/revolt/api/schemas/Server.kt index d29ddf9a..80328cca 100644 --- a/app/src/main/java/chat/revolt/api/schemas/Server.kt +++ b/app/src/main/java/chat/revolt/api/schemas/Server.kt @@ -79,6 +79,7 @@ data class Emoji( val creatorID: String? = null, val name: String? = null, val animated: Boolean? = null, + val nsfw: Boolean? = null, val type: String? = null, // this is _only_ used for websocket events! ) diff --git a/app/src/main/java/chat/revolt/callbacks/ActionChannel.kt b/app/src/main/java/chat/revolt/callbacks/ActionChannel.kt index 498e7c88..8326fcce 100644 --- a/app/src/main/java/chat/revolt/callbacks/ActionChannel.kt +++ b/app/src/main/java/chat/revolt/callbacks/ActionChannel.kt @@ -6,6 +6,7 @@ sealed class Action { data class OpenUserSheet(val userId: String, val serverId: String?) : Action() data class SwitchChannel(val channelId: String) : Action() data class LinkInfo(val url: String) : Action() + data class EmoteInfo(val emoteId: String) : Action() } val ActionChannel = Channel( diff --git a/app/src/main/java/chat/revolt/components/generic/Markdown.kt b/app/src/main/java/chat/revolt/components/generic/Markdown.kt index 0a1b8969..1a2d05a4 100644 --- a/app/src/main/java/chat/revolt/components/generic/Markdown.kt +++ b/app/src/main/java/chat/revolt/components/generic/Markdown.kt @@ -80,7 +80,8 @@ fun UIMarkdown( ch.value.name ?: ch.value.id ?: "{this does not exist 🤫}" }, emojiMap = RevoltAPI.emojiCache, - serverId = null + serverId = null, + useLargeEmojis = false ) ) diff --git a/app/src/main/java/chat/revolt/internals/markdown/EmoteSpan.kt b/app/src/main/java/chat/revolt/internals/markdown/EmoteSpan.kt new file mode 100644 index 00000000..1afeda3b --- /dev/null +++ b/app/src/main/java/chat/revolt/internals/markdown/EmoteSpan.kt @@ -0,0 +1,22 @@ +package chat.revolt.internals.markdown + +import android.graphics.drawable.Drawable +import android.text.style.ClickableSpan +import android.text.style.ImageSpan +import android.view.View +import chat.revolt.callbacks.Action +import chat.revolt.callbacks.ActionChannel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking + +class EmoteSpan(drawable: Drawable) : + ImageSpan(drawable, ALIGN_BOTTOM) { +} + +class EmoteClickableSpan(private val emoteId: String) : ClickableSpan() { + override fun onClick(widget: View) { + runBlocking(Dispatchers.IO) { + ActionChannel.send(Action.EmoteInfo(emoteId)) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/internals/markdown/MarkdownContext.kt b/app/src/main/java/chat/revolt/internals/markdown/MarkdownContext.kt index 362d9f98..6891bcb4 100644 --- a/app/src/main/java/chat/revolt/internals/markdown/MarkdownContext.kt +++ b/app/src/main/java/chat/revolt/internals/markdown/MarkdownContext.kt @@ -16,5 +16,6 @@ data class MarkdownContext( val userMap: Map, val channelMap: Map, val emojiMap: Map, - val serverId: String? + val serverId: String?, + val useLargeEmojis: Boolean, ) \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/internals/markdown/MarkdownNodes.kt b/app/src/main/java/chat/revolt/internals/markdown/MarkdownNodes.kt index 878e7e33..f54ea532 100644 --- a/app/src/main/java/chat/revolt/internals/markdown/MarkdownNodes.kt +++ b/app/src/main/java/chat/revolt/internals/markdown/MarkdownNodes.kt @@ -1,8 +1,14 @@ package chat.revolt.internals.markdown +import android.content.Context import android.text.SpannableStringBuilder import android.text.Spanned +import chat.revolt.api.REVOLT_FILES +import com.bumptech.glide.Glide import com.discord.simpleast.core.node.Node +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlin.math.min class UserMentionNode(private val userId: String) : Node() { override fun render(builder: SpannableStringBuilder, renderContext: MarkdownContext) { @@ -45,12 +51,49 @@ class ChannelMentionNode(private val channelId: String) : Node( } } -class CustomEmoteNode(private val emoteId: String) : Node() { +class CustomEmoteNode(private val emoteId: String, private val context: Context) : + Node() { override fun render(builder: SpannableStringBuilder, renderContext: MarkdownContext) { - builder.append( - renderContext.emojiMap[emoteId]?.let { ":${it.name}:" } - ?: ":${emoteId}:" - ) + val content = renderContext.emojiMap[emoteId]?.let { ":${it.name}:" } + ?: ":${emoteId}:" + val emoteUrl = "$REVOLT_FILES/emojis/$emoteId" + + val density = context.resources.displayMetrics.density.toInt() + + builder.append(content) + runBlocking(Dispatchers.IO) { + val drawable = Glide.with(context) + .asDrawable() + .load(emoteUrl) + .submit() + .get() + + val targetSize = if (renderContext.useLargeEmojis) 48 else 28 + val maxWidth = if (renderContext.useLargeEmojis) 58 else 38 + + val wantWidth = min( + (drawable.intrinsicWidth * (targetSize * density)) / drawable.intrinsicHeight, + maxWidth * density + ) + val wantHeight = targetSize * density + + builder.setSpan( + EmoteSpan( + drawable.apply { + setBounds(0, 0, wantWidth, wantHeight) + } + ), + builder.length - content.length, + builder.length, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ) + builder.setSpan( + EmoteClickableSpan(emoteId), + builder.length - content.length, + builder.length, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ) + } } } diff --git a/app/src/main/java/chat/revolt/internals/markdown/MarkdownRules.kt b/app/src/main/java/chat/revolt/internals/markdown/MarkdownRules.kt index 46ff97b4..a7f2a7f7 100644 --- a/app/src/main/java/chat/revolt/internals/markdown/MarkdownRules.kt +++ b/app/src/main/java/chat/revolt/internals/markdown/MarkdownRules.kt @@ -36,14 +36,14 @@ class ChannelMentionRule : } } -class CustomEmoteRule : +class CustomEmoteRule(private val context: Context) : Rule(Pattern.compile("^:([0-9A-Z]{26}):")) { override fun parse( matcher: Matcher, parser: Parser, state: S ): ParseSpec { - return ParseSpec.createTerminal(CustomEmoteNode(matcher.group(1)!!), state) + return ParseSpec.createTerminal(CustomEmoteNode(matcher.group(1)!!, context), state) } } @@ -200,7 +200,7 @@ fun MarkdownParser.addRevoltRules(context: Context): MarkdownParser { return addRules( UserMentionRule(), ChannelMentionRule(), - CustomEmoteRule(), + CustomEmoteRule(context), TimestampRule(context), NamedLinkRule(), LinkRule(), diff --git a/app/src/main/java/chat/revolt/screens/chat/ChatRouterScreen.kt b/app/src/main/java/chat/revolt/screens/chat/ChatRouterScreen.kt index 0c19d117..5e0db76d 100644 --- a/app/src/main/java/chat/revolt/screens/chat/ChatRouterScreen.kt +++ b/app/src/main/java/chat/revolt/screens/chat/ChatRouterScreen.kt @@ -94,6 +94,7 @@ import chat.revolt.screens.chat.views.NoCurrentChannelScreen import chat.revolt.screens.chat.views.channel.ChannelScreen import chat.revolt.sheets.AddServerSheet import chat.revolt.sheets.ChangelogSheet +import chat.revolt.sheets.EmoteInfoSheet import chat.revolt.sheets.LinkInfoSheet import chat.revolt.sheets.ServerContextSheet import chat.revolt.sheets.StatusSheet @@ -284,6 +285,9 @@ fun ChatRouterScreen( var showLinkInfoSheet by remember { mutableStateOf(false) } var linkInfoSheetUrl by remember { mutableStateOf("") } + var showEmoteInfoSheet by remember { mutableStateOf(false) } + var emoteInfoSheetTarget by remember { mutableStateOf("") } + var useTabletAwareUI by remember { mutableStateOf(false) } val drawerBackHandler = remember { @@ -389,6 +393,11 @@ fun ChatRouterScreen( linkInfoSheetUrl = action.url showLinkInfoSheet = true } + + is Action.EmoteInfo -> { + emoteInfoSheetTarget = action.emoteId + showEmoteInfoSheet = true + } } } } @@ -592,6 +601,24 @@ fun ChatRouterScreen( } } + if (showEmoteInfoSheet) { + val emoteInfoSheetState = rememberModalBottomSheetState() + + ModalBottomSheet( + sheetState = emoteInfoSheetState, + onDismissRequest = { + showEmoteInfoSheet = false + }, + ) { + EmoteInfoSheet( + id = emoteInfoSheetTarget, + onDismiss = { + showEmoteInfoSheet = false + } + ) + } + } + Column( modifier = Modifier .fillMaxWidth() diff --git a/app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreen.kt b/app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreen.kt index 58b70d9a..6c3416de 100644 --- a/app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreen.kt +++ b/app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreen.kt @@ -325,6 +325,10 @@ fun ChannelScreen( }, emojiMap = RevoltAPI.emojiCache, serverId = channel.server ?: "", + // check if message consists solely of one *or more* custom emotes + useLargeEmojis = it.content?.matches( + Regex("(:([0-9A-Z]{26}):)+") + ) == true ) ) }, diff --git a/app/src/main/java/chat/revolt/sheets/EmoteInfoSheet.kt b/app/src/main/java/chat/revolt/sheets/EmoteInfoSheet.kt new file mode 100644 index 00000000..ae6ede02 --- /dev/null +++ b/app/src/main/java/chat/revolt/sheets/EmoteInfoSheet.kt @@ -0,0 +1,142 @@ +package chat.revolt.sheets + +import android.widget.Toast +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.surfaceColorAtElevation +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import chat.revolt.R +import chat.revolt.api.REVOLT_FILES +import chat.revolt.api.RevoltAPI +import chat.revolt.api.routes.custom.fetchEmoji +import chat.revolt.api.schemas.Emoji +import chat.revolt.api.schemas.Server +import chat.revolt.components.generic.RemoteImage +import chat.revolt.components.generic.SheetClickable +import chat.revolt.internals.Platform +import kotlinx.coroutines.launch + +@Composable +fun EmoteInfoSheet(id: String, onDismiss: () -> Unit) { + val clipboardManager = LocalClipboardManager.current + val context = LocalContext.current + val coroutineScope = rememberCoroutineScope() + + var emoteInfo by remember { mutableStateOf(null) } + var parentServer by remember { mutableStateOf(null) } + + LaunchedEffect(id) { + emoteInfo = RevoltAPI.emojiCache[id] ?: fetchEmoji(id) + when (emoteInfo?.parent?.type) { + "Server" -> parentServer = RevoltAPI.serverCache[emoteInfo?.parent?.id] + } + } + + Column( + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 8.dp) + .verticalScroll(rememberScrollState()), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .clip(MaterialTheme.shapes.medium) + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceColorAtElevation(0.dp)) + ) { + RemoteImage( + url = "$REVOLT_FILES/emojis/$id", + description = emoteInfo?.name, + contentScale = ContentScale.Fit, + modifier = Modifier + .padding(16.dp) + .size(32.dp) + ) + + Column( + modifier = Modifier.padding( + top = 16.dp, + start = 0.dp, + end = 16.dp, + bottom = 16.dp + ) + ) { + Text( + text = emoteInfo?.name ?: id, + fontWeight = FontWeight.Bold, + letterSpacing = 1.15.sp + ) + + Text( + text = if (parentServer != null) { + stringResource( + id = R.string.emote_info_from_server, + parentServer?.name ?: "" + ) + } else { + stringResource(id = R.string.emote_info_from_server_unknown) + }, + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + SheetClickable( + icon = { modifier -> + Icon( + painter = painterResource(id = R.drawable.ic_content_copy_24dp), + contentDescription = null, + modifier = modifier + ) + }, + label = { style -> + Text( + text = stringResource(id = R.string.copy), + style = style + ) + }, + ) { + coroutineScope.launch { + clipboardManager.setText(AnnotatedString(":$id:")) + if (Platform.needsShowClipboardNotification()) { + Toast.makeText( + context, + context.getString(R.string.copied), + Toast.LENGTH_SHORT + ).show() + } + } + onDismiss() + } + } +} \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8875aa0f..6695c3a1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -187,6 +187,9 @@ You can\'t view this channel This channel may have been deleted or you may not have permission to view it. + from %1$s + from a private server + Channel description There hasn\'t been a description set for this channel yet. Options