feat: reaction rendering

Signed-off-by: Infi <infi@infi.sh>
This commit is contained in:
Infi 2023-12-07 21:16:08 +01:00
parent 555385646e
commit 8b1dc84237
6 changed files with 348 additions and 25 deletions

View File

@ -3,6 +3,10 @@ package chat.revolt.api.internals
import kotlin.experimental.and
import kotlin.random.Random
fun String.isUlid(): Boolean {
return "[0-9A-HJKMNP-TV-Z]{26}".toRegex().matches(this)
}
object ULID {
private const val entropy = 10
private const val len = 26
@ -47,64 +51,64 @@ object ULID {
chars[11] =
b32chars[
(
entropy[0].toInt() shl 2 or (entropy[1].toShort() and 0xff).toInt()
.ushr(6) and 0x1f
)
entropy[0].toInt() shl 2 or (entropy[1].toShort() and 0xff).toInt()
.ushr(6) and 0x1f
)
]
chars[12] = b32chars[((entropy[1].toShort() and 0xff).toInt().ushr(1) and 0x1f)]
chars[13] =
b32chars[
(
entropy[1].toInt() shl 4 or (entropy[2].toShort() and 0xff).toInt()
.ushr(4) and 0x1f
)
entropy[1].toInt() shl 4 or (entropy[2].toShort() and 0xff).toInt()
.ushr(4) and 0x1f
)
]
chars[14] =
b32chars[
(
entropy[2].toInt() shl 5 or (entropy[3].toShort() and 0xff).toInt()
.ushr(7) and 0x1f
)
entropy[2].toInt() shl 5 or (entropy[3].toShort() and 0xff).toInt()
.ushr(7) and 0x1f
)
]
chars[15] = b32chars[((entropy[3].toShort() and 0xff).toInt().ushr(2) and 0x1f)]
chars[16] =
b32chars[
(
entropy[3].toInt() shl 3 or (entropy[4].toShort() and 0xff).toInt()
.ushr(5) and 0x1f
)
entropy[3].toInt() shl 3 or (entropy[4].toShort() and 0xff).toInt()
.ushr(5) and 0x1f
)
]
chars[17] = b32chars[(entropy[4].toInt() and 0x1f)]
chars[18] = b32chars[(entropy[5].toShort() and 0xff).toInt().ushr(3)]
chars[19] =
b32chars[
(
entropy[5].toInt() shl 2 or (entropy[6].toShort() and 0xff).toInt()
.ushr(6) and 0x1f
)
entropy[5].toInt() shl 2 or (entropy[6].toShort() and 0xff).toInt()
.ushr(6) and 0x1f
)
]
chars[20] = b32chars[((entropy[6].toShort() and 0xff).toInt().ushr(1) and 0x1f)]
chars[21] =
b32chars[
(
entropy[6].toInt() shl 4 or (entropy[7].toShort() and 0xff).toInt()
.ushr(4) and 0x1f
)
entropy[6].toInt() shl 4 or (entropy[7].toShort() and 0xff).toInt()
.ushr(4) and 0x1f
)
]
chars[22] =
b32chars[
(
entropy[7].toInt() shl 5 or (entropy[8].toShort() and 0xff).toInt()
.ushr(7) and 0x1f
)
entropy[7].toInt() shl 5 or (entropy[8].toShort() and 0xff).toInt()
.ushr(7) and 0x1f
)
]
chars[23] = b32chars[((entropy[8].toShort() and 0xff).toInt().ushr(2) and 0x1f)]
chars[24] =
b32chars[
(
entropy[8].toInt() shl 3 or (entropy[9].toShort() and 0xff).toInt()
.ushr(5) and 0x1f
)
entropy[8].toInt() shl 3 or (entropy[9].toShort() and 0xff).toInt()
.ushr(5) and 0x1f
)
]
chars[25] = b32chars[(entropy[9].toInt() and 0x1f)]

View File

@ -14,6 +14,7 @@ import chat.revolt.api.realtime.frames.receivable.ChannelStopTypingFrame
import chat.revolt.api.realtime.frames.receivable.ChannelUpdateFrame
import chat.revolt.api.realtime.frames.receivable.MessageAppendFrame
import chat.revolt.api.realtime.frames.receivable.MessageFrame
import chat.revolt.api.realtime.frames.receivable.MessageReactFrame
import chat.revolt.api.realtime.frames.receivable.MessageUpdateFrame
import chat.revolt.api.realtime.frames.receivable.PongFrame
import chat.revolt.api.realtime.frames.receivable.ReadyFrame
@ -255,6 +256,69 @@ object RealtimeSocket {
RevoltAPI.wsFrameChannel.send(messageUpdateFrame)
}
"MessageReact" -> {
val messageReactFrame =
RevoltJson.decodeFromString(MessageReactFrame.serializer(), rawFrame)
Log.d(
"RealtimeSocket",
"Received message react frame for ${messageReactFrame.id}."
)
val oldMessage = RevoltAPI.messageCache[messageReactFrame.id]
if (oldMessage == null) {
Log.d(
"RealtimeSocket",
"Message ${messageReactFrame.id} not found in cache. Will not update."
)
return
}
val reactions = oldMessage.reactions?.toMutableMap() ?: mutableMapOf()
val forEmoji =
reactions[messageReactFrame.emoji_id]?.toMutableList() ?: mutableListOf()
forEmoji.add(messageReactFrame.user_id)
reactions[messageReactFrame.emoji_id] = forEmoji
RevoltAPI.messageCache[messageReactFrame.id] =
oldMessage.copy(reactions = reactions)
RevoltAPI.wsFrameChannel.send(messageReactFrame)
}
"MessageUnreact" -> {
val messageUnreactFrame =
RevoltJson.decodeFromString(MessageReactFrame.serializer(), rawFrame)
Log.d(
"RealtimeSocket",
"Received message unreact frame for ${messageUnreactFrame.id}."
)
val oldMessage = RevoltAPI.messageCache[messageUnreactFrame.id]
if (oldMessage == null) {
Log.d(
"RealtimeSocket",
"Message ${messageUnreactFrame.id} not found in cache. Will not update."
)
return
}
val reactions = oldMessage.reactions?.toMutableMap() ?: mutableMapOf()
val forEmoji =
reactions[messageUnreactFrame.emoji_id]?.toMutableList() ?: mutableListOf()
forEmoji.remove(messageUnreactFrame.user_id)
if (forEmoji.isEmpty()) {
reactions.remove(messageUnreactFrame.emoji_id)
} else {
reactions[messageUnreactFrame.emoji_id] = forEmoji
}
RevoltAPI.messageCache[messageUnreactFrame.id] =
oldMessage.copy(reactions = reactions)
RevoltAPI.wsFrameChannel.send(messageUnreactFrame)
}
"UserUpdate" -> {
val userUpdateFrame =
RevoltJson.decodeFromString(UserUpdateFrame.serializer(), rawFrame)

View File

@ -0,0 +1,13 @@
package chat.revolt.api.routes.channel
import chat.revolt.api.RevoltHttp
import io.ktor.client.request.delete
import io.ktor.client.request.put
suspend fun react(channelId: String, messageId: String, emoji: String) {
RevoltHttp.put("/channels/$channelId/messages/$messageId/reactions/$emoji")
}
suspend fun unreact(channelId: String, messageId: String, emoji: String) {
RevoltHttp.delete("/channels/$channelId/messages/$messageId/reactions/$emoji")
}

View File

@ -20,8 +20,11 @@ import androidx.browser.customtabs.CustomTabsIntent
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
@ -39,6 +42,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.key
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
@ -62,12 +66,15 @@ import chat.revolt.api.internals.SpecialUsers
import chat.revolt.api.internals.ULID
import chat.revolt.api.internals.WebCompat
import chat.revolt.api.internals.solidColor
import chat.revolt.api.routes.channel.react
import chat.revolt.api.routes.channel.unreact
import chat.revolt.api.routes.microservices.january.asJanuaryProxyUrl
import chat.revolt.api.schemas.AutumnResource
import chat.revolt.api.schemas.User
import chat.revolt.components.generic.UserAvatar
import chat.revolt.components.generic.UserAvatarWidthPlaceholder
import chat.revolt.internals.markdown.LongClickableSpan
import kotlinx.coroutines.launch
import chat.revolt.api.schemas.Message as MessageSchema
@Composable
@ -155,7 +162,7 @@ fun formatLongAsTime(time: Long): String {
}
}
@OptIn(ExperimentalFoundationApi::class)
@OptIn(ExperimentalFoundationApi::class, ExperimentalLayoutApi::class)
@Composable
fun Message(
message: MessageSchema,
@ -171,6 +178,8 @@ fun Message(
val context = LocalContext.current
val contentColor = LocalContentColor.current
val scope = rememberCoroutineScope()
val attachmentView = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult(),
onResult = {
@ -450,6 +459,40 @@ fun Message(
})
Spacer(modifier = Modifier.height(8.dp))
}
}
if ((message.reactions?.size ?: 0) > 0) {
Spacer(modifier = Modifier.height(8.dp))
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
message.reactions?.forEach { reaction ->
Reaction(reaction.key, reaction.value,
onClick = { hasOwn ->
scope.launch {
if (hasOwn) {
unreact(
message.channel!!,
message.id!!,
reaction.key
)
} else {
react(
message.channel!!,
message.id!!,
reaction.key
)
}
}
}
) {
}
}
}
Spacer(modifier = Modifier.height(8.dp))
}
}
}

View File

@ -0,0 +1,157 @@
package chat.revolt.components.chat
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.PlatformTextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.revolt.api.REVOLT_FILES
import chat.revolt.api.RevoltAPI
import chat.revolt.api.internals.isUlid
import chat.revolt.components.generic.RemoteImage
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun Reaction(
emoji: String,
members: List<String>,
onClick: (Boolean) -> Unit,
onLongClick: () -> Unit
) {
val hasOwn = members.contains(RevoltAPI.selfId)
val background by animateColorAsState(
targetValue = if (hasOwn) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp)
},
label = "Reaction background"
)
val foreground by animateColorAsState(
targetValue = if (hasOwn) {
MaterialTheme.colorScheme.onPrimary
} else {
MaterialTheme.colorScheme.onSurface
},
label = "Reaction foreground"
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.clip(MaterialTheme.shapes.small)
.background(background)
.combinedClickable(
onClick = { onClick(hasOwn) },
onLongClick = onLongClick,
)
.padding(8.dp)
) {
CompositionLocalProvider(LocalContentColor provides foreground) {
if (emoji.isUlid()) {
RemoteImage(
url = "$REVOLT_FILES/emojis/${emoji}/emoji.gif?max_side=64",
description = null,
modifier = Modifier.size(16.dp)
)
} else {
Box(modifier = Modifier.size(16.dp), contentAlignment = Alignment.Center) {
Text(
text = emoji,
style = MaterialTheme.typography.bodyLarge.copy(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
platformStyle = PlatformTextStyle(
includeFontPadding = false
)
),
modifier = Modifier
.size(16.dp)
)
}
}
Spacer(Modifier.width(8.dp))
members.size.let { number ->
number.toString()
.mapIndexed { index, c ->
ReactionDigit(
digitChar = c,
fullNumber = number,
place = index
)
}
.forEach {
AnimatedContent(
targetState = it,
transitionSpec = {
if (targetState > initialState) {
slideInVertically { -it } togetherWith slideOutVertically { it }
} else {
slideInVertically { it } togetherWith slideOutVertically { -it }
}
},
label = "Reaction count",
) { target ->
Text(
text = target.digitChar.toString(),
style = MaterialTheme.typography.bodyLarge.copy(
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
fontFeatureSettings = "tnum"
)
)
}
}
}
}
}
}
data class ReactionDigit(val digitChar: Char, val fullNumber: Int, val place: Int) {
override fun equals(other: Any?): Boolean {
return when (other) {
is ReactionDigit -> digitChar == other.digitChar
else -> super.equals(other)
}
}
override fun hashCode(): Int {
var result = digitChar.hashCode()
result = 31 * result + fullNumber
result = 31 * result + place
return result
}
}
operator fun ReactionDigit.compareTo(other: ReactionDigit): Int {
return fullNumber.compareTo(other.fullNumber)
}

View File

@ -26,6 +26,8 @@ import chat.revolt.api.realtime.frames.receivable.ChannelStopTypingFrame
import chat.revolt.api.realtime.frames.receivable.MessageAppendFrame
import chat.revolt.api.realtime.frames.receivable.MessageDeleteFrame
import chat.revolt.api.realtime.frames.receivable.MessageFrame
import chat.revolt.api.realtime.frames.receivable.MessageReactFrame
import chat.revolt.api.realtime.frames.receivable.MessageUnreactFrame
import chat.revolt.api.realtime.frames.receivable.MessageUpdateFrame
import chat.revolt.api.routes.channel.SendMessageReply
import chat.revolt.api.routes.channel.ackChannel
@ -353,6 +355,46 @@ class ChannelScreenViewModel : ViewModel() {
)
}
is MessageReactFrame -> {
if (it.channel_id != activeChannel?.id) return@onEach
val hasMessage = renderableMessages.any { currentMsg ->
currentMsg.id == it.id
}
if (!hasMessage) return@onEach
regroupMessages(
renderableMessages.map { currentMsg ->
if (currentMsg.id == it.id) {
RevoltAPI.messageCache[it.id] ?: currentMsg
} else {
currentMsg
}
}
)
}
is MessageUnreactFrame -> {
if (it.channel_id != activeChannel?.id) return@onEach
val hasMessage = renderableMessages.any { currentMsg ->
currentMsg.id == it.id
}
if (!hasMessage) return@onEach
regroupMessages(
renderableMessages.map { currentMsg ->
if (currentMsg.id == it.id) {
RevoltAPI.messageCache[it.id] ?: currentMsg
} else {
currentMsg
}
}
)
}
is MessageDeleteFrame -> {
if (it.channel != activeChannel?.id) return@onEach