From 8b1dc8423792fe6c2933cffe1d1e53c44cd0e74d Mon Sep 17 00:00:00 2001 From: Infi Date: Thu, 7 Dec 2023 21:16:08 +0100 Subject: [PATCH] feat: reaction rendering Signed-off-by: Infi --- .../java/chat/revolt/api/internals/ULID.kt | 52 +++--- .../revolt/api/realtime/RealtimeSocket.kt | 64 +++++++ .../chat/revolt/api/routes/channel/Message.kt | 13 ++ .../chat/revolt/components/chat/Message.kt | 45 ++++- .../chat/revolt/components/chat/Reaction.kt | 157 ++++++++++++++++++ .../views/channel/ChannelScreenViewModel.kt | 42 +++++ 6 files changed, 348 insertions(+), 25 deletions(-) create mode 100644 app/src/main/java/chat/revolt/api/routes/channel/Message.kt create mode 100644 app/src/main/java/chat/revolt/components/chat/Reaction.kt diff --git a/app/src/main/java/chat/revolt/api/internals/ULID.kt b/app/src/main/java/chat/revolt/api/internals/ULID.kt index d4442959..af6bd303 100644 --- a/app/src/main/java/chat/revolt/api/internals/ULID.kt +++ b/app/src/main/java/chat/revolt/api/internals/ULID.kt @@ -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)] diff --git a/app/src/main/java/chat/revolt/api/realtime/RealtimeSocket.kt b/app/src/main/java/chat/revolt/api/realtime/RealtimeSocket.kt index 1602ca48..5cf4b8b5 100644 --- a/app/src/main/java/chat/revolt/api/realtime/RealtimeSocket.kt +++ b/app/src/main/java/chat/revolt/api/realtime/RealtimeSocket.kt @@ -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) diff --git a/app/src/main/java/chat/revolt/api/routes/channel/Message.kt b/app/src/main/java/chat/revolt/api/routes/channel/Message.kt new file mode 100644 index 00000000..b6628537 --- /dev/null +++ b/app/src/main/java/chat/revolt/api/routes/channel/Message.kt @@ -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") +} \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/components/chat/Message.kt b/app/src/main/java/chat/revolt/components/chat/Message.kt index f3315164..470a5573 100644 --- a/app/src/main/java/chat/revolt/components/chat/Message.kt +++ b/app/src/main/java/chat/revolt/components/chat/Message.kt @@ -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)) } } } diff --git a/app/src/main/java/chat/revolt/components/chat/Reaction.kt b/app/src/main/java/chat/revolt/components/chat/Reaction.kt new file mode 100644 index 00000000..76d46bb6 --- /dev/null +++ b/app/src/main/java/chat/revolt/components/chat/Reaction.kt @@ -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, + 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) +} \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreenViewModel.kt b/app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreenViewModel.kt index 839849fe..327e6308 100644 --- a/app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreenViewModel.kt +++ b/app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreenViewModel.kt @@ -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