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 name: String? = null,
|
||||
val animated: Boolean? = null,
|
||||
val nsfw: Boolean? = null,
|
||||
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 SwitchChannel(val channelId: String) : Action()
|
||||
data class LinkInfo(val url: String) : Action()
|
||||
data class EmoteInfo(val emoteId: String) : Action()
|
||||
}
|
||||
|
||||
val ActionChannel = Channel<Action>(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 channelMap: Map<String, String>,
|
||||
val emojiMap: Map<String, Emoji>,
|
||||
val serverId: String?
|
||||
val serverId: String?,
|
||||
val useLargeEmojis: Boolean,
|
||||
)
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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_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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue