feat: inline custom emoji & emoji info

Signed-off-by: Infi <infi@infi.sh>
This commit is contained in:
Infi 2023-09-17 23:02:54 +02:00
parent 8b81e0a6b5
commit f9784a67a2
12 changed files with 270 additions and 10 deletions

View File

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

View File

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

View File

@ -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<Action>(

View File

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

View File

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

View File

@ -16,5 +16,6 @@ data class MarkdownContext(
val userMap: Map<String, User>,
val channelMap: Map<String, String>,
val emojiMap: Map<String, Emoji>,
val serverId: String?
val serverId: String?,
val useLargeEmojis: Boolean,
)

View File

@ -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<MarkdownContext>() {
override fun render(builder: SpannableStringBuilder, renderContext: MarkdownContext) {
@ -45,12 +51,49 @@ class ChannelMentionNode(private val channelId: String) : Node<MarkdownContext>(
}
}
class CustomEmoteNode(private val emoteId: String) : Node<MarkdownContext>() {
class CustomEmoteNode(private val emoteId: String, private val context: Context) :
Node<MarkdownContext>() {
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
)
}
}
}

View File

@ -36,14 +36,14 @@ class ChannelMentionRule<S> :
}
}
class CustomEmoteRule<S> :
class CustomEmoteRule<S>(private val context: Context) :
Rule<MarkdownContext, CustomEmoteNode, S>(Pattern.compile("^:([0-9A-Z]{26}):")) {
override fun parse(
matcher: Matcher,
parser: Parser<MarkdownContext, in CustomEmoteNode, S>,
state: S
): ParseSpec<MarkdownContext, S> {
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(),

View File

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

View File

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

View File

@ -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<Emoji?>(null) }
var parentServer by remember { mutableStateOf<Server?>(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()
}
}
}

View File

@ -187,6 +187,9 @@
<string name="channel_link_invalid">You can\'t view this channel</string>
<string name="channel_link_invalid_description">This channel may have been deleted or you may not have permission to view it.</string>
<string name="emote_info_from_server">from %1$s</string>
<string name="emote_info_from_server_unknown">from a private server</string>
<string name="channel_info_sheet_description">Channel description</string>
<string name="channel_info_sheet_description_empty">There hasn\'t been a description set for this channel yet.</string>
<string name="channel_info_sheet_options">Options</string>