feat: reaction rendering
Signed-off-by: Infi <infi@infi.sh>
This commit is contained in:
parent
555385646e
commit
8b1dc84237
|
|
@ -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)]
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue