feat: inline custom emoji & emoji info
Signed-off-by: Infi <infi@infi.sh>
This commit is contained in:
parent
8b81e0a6b5
commit
f9784a67a2
|
|
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -79,6 +79,7 @@ data class Emoji(
|
||||||
val creatorID: String? = null,
|
val creatorID: String? = null,
|
||||||
val name: String? = null,
|
val name: String? = null,
|
||||||
val animated: Boolean? = null,
|
val animated: Boolean? = null,
|
||||||
|
val nsfw: Boolean? = null,
|
||||||
val type: String? = null, // this is _only_ used for websocket events!
|
val type: String? = null, // this is _only_ used for websocket events!
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ sealed class Action {
|
||||||
data class OpenUserSheet(val userId: String, val serverId: String?) : Action()
|
data class OpenUserSheet(val userId: String, val serverId: String?) : Action()
|
||||||
data class SwitchChannel(val channelId: String) : Action()
|
data class SwitchChannel(val channelId: String) : Action()
|
||||||
data class LinkInfo(val url: String) : Action()
|
data class LinkInfo(val url: String) : Action()
|
||||||
|
data class EmoteInfo(val emoteId: String) : Action()
|
||||||
}
|
}
|
||||||
|
|
||||||
val ActionChannel = Channel<Action>(
|
val ActionChannel = Channel<Action>(
|
||||||
|
|
|
||||||
|
|
@ -80,7 +80,8 @@ fun UIMarkdown(
|
||||||
ch.value.name ?: ch.value.id ?: "{this does not exist 🤫}"
|
ch.value.name ?: ch.value.id ?: "{this does not exist 🤫}"
|
||||||
},
|
},
|
||||||
emojiMap = RevoltAPI.emojiCache,
|
emojiMap = RevoltAPI.emojiCache,
|
||||||
serverId = null
|
serverId = null,
|
||||||
|
useLargeEmojis = false
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -16,5 +16,6 @@ data class MarkdownContext(
|
||||||
val userMap: Map<String, User>,
|
val userMap: Map<String, User>,
|
||||||
val channelMap: Map<String, String>,
|
val channelMap: Map<String, String>,
|
||||||
val emojiMap: Map<String, Emoji>,
|
val emojiMap: Map<String, Emoji>,
|
||||||
val serverId: String?
|
val serverId: String?,
|
||||||
|
val useLargeEmojis: Boolean,
|
||||||
)
|
)
|
||||||
|
|
@ -1,8 +1,14 @@
|
||||||
package chat.revolt.internals.markdown
|
package chat.revolt.internals.markdown
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import android.text.SpannableStringBuilder
|
import android.text.SpannableStringBuilder
|
||||||
import android.text.Spanned
|
import android.text.Spanned
|
||||||
|
import chat.revolt.api.REVOLT_FILES
|
||||||
|
import com.bumptech.glide.Glide
|
||||||
import com.discord.simpleast.core.node.Node
|
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>() {
|
class UserMentionNode(private val userId: String) : Node<MarkdownContext>() {
|
||||||
override fun render(builder: SpannableStringBuilder, renderContext: 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) {
|
override fun render(builder: SpannableStringBuilder, renderContext: MarkdownContext) {
|
||||||
builder.append(
|
val content = renderContext.emojiMap[emoteId]?.let { ":${it.name}:" }
|
||||||
renderContext.emojiMap[emoteId]?.let { ":${it.name}:" }
|
?: ":${emoteId}:"
|
||||||
?: ":${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
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}):")) {
|
Rule<MarkdownContext, CustomEmoteNode, S>(Pattern.compile("^:([0-9A-Z]{26}):")) {
|
||||||
override fun parse(
|
override fun parse(
|
||||||
matcher: Matcher,
|
matcher: Matcher,
|
||||||
parser: Parser<MarkdownContext, in CustomEmoteNode, S>,
|
parser: Parser<MarkdownContext, in CustomEmoteNode, S>,
|
||||||
state: S
|
state: S
|
||||||
): ParseSpec<MarkdownContext, 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(
|
return addRules(
|
||||||
UserMentionRule(),
|
UserMentionRule(),
|
||||||
ChannelMentionRule(),
|
ChannelMentionRule(),
|
||||||
CustomEmoteRule(),
|
CustomEmoteRule(context),
|
||||||
TimestampRule(context),
|
TimestampRule(context),
|
||||||
NamedLinkRule(),
|
NamedLinkRule(),
|
||||||
LinkRule(),
|
LinkRule(),
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,7 @@ import chat.revolt.screens.chat.views.NoCurrentChannelScreen
|
||||||
import chat.revolt.screens.chat.views.channel.ChannelScreen
|
import chat.revolt.screens.chat.views.channel.ChannelScreen
|
||||||
import chat.revolt.sheets.AddServerSheet
|
import chat.revolt.sheets.AddServerSheet
|
||||||
import chat.revolt.sheets.ChangelogSheet
|
import chat.revolt.sheets.ChangelogSheet
|
||||||
|
import chat.revolt.sheets.EmoteInfoSheet
|
||||||
import chat.revolt.sheets.LinkInfoSheet
|
import chat.revolt.sheets.LinkInfoSheet
|
||||||
import chat.revolt.sheets.ServerContextSheet
|
import chat.revolt.sheets.ServerContextSheet
|
||||||
import chat.revolt.sheets.StatusSheet
|
import chat.revolt.sheets.StatusSheet
|
||||||
|
|
@ -284,6 +285,9 @@ fun ChatRouterScreen(
|
||||||
var showLinkInfoSheet by remember { mutableStateOf(false) }
|
var showLinkInfoSheet by remember { mutableStateOf(false) }
|
||||||
var linkInfoSheetUrl by remember { mutableStateOf("") }
|
var linkInfoSheetUrl by remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
var showEmoteInfoSheet by remember { mutableStateOf(false) }
|
||||||
|
var emoteInfoSheetTarget by remember { mutableStateOf("") }
|
||||||
|
|
||||||
var useTabletAwareUI by remember { mutableStateOf(false) }
|
var useTabletAwareUI by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val drawerBackHandler = remember {
|
val drawerBackHandler = remember {
|
||||||
|
|
@ -389,6 +393,11 @@ fun ChatRouterScreen(
|
||||||
linkInfoSheetUrl = action.url
|
linkInfoSheetUrl = action.url
|
||||||
showLinkInfoSheet = true
|
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(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
|
|
||||||
|
|
@ -325,6 +325,10 @@ fun ChannelScreen(
|
||||||
},
|
},
|
||||||
emojiMap = RevoltAPI.emojiCache,
|
emojiMap = RevoltAPI.emojiCache,
|
||||||
serverId = channel.server ?: "",
|
serverId = channel.server ?: "",
|
||||||
|
// check if message consists solely of one *or more* custom emotes
|
||||||
|
useLargeEmojis = it.content?.matches(
|
||||||
|
Regex("(:([0-9A-Z]{26}):)+")
|
||||||
|
) == true
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -187,6 +187,9 @@
|
||||||
<string name="channel_link_invalid">You can\'t view this channel</string>
|
<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="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">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_description_empty">There hasn\'t been a description set for this channel yet.</string>
|
||||||
<string name="channel_info_sheet_options">Options</string>
|
<string name="channel_info_sheet_options">Options</string>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue