feat: role mentions

Signed-off-by: Infi <infi@infi.sh>
This commit is contained in:
Infi 2025-06-28 02:29:03 +02:00
parent 0d2b037a6f
commit 1b63b04fde
14 changed files with 774 additions and 35 deletions

View File

@ -8,6 +8,8 @@ import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Brush.Companion.linearGradient
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.core.graphics.toColorInt
import chat.revolt.api.internals.colour.CSSColours
fun Brush.Companion.solidColor(colour: Color) = SolidColor(colour)
@ -202,16 +204,12 @@ object BrushCompat {
@Composable
private fun parseColourName(colour: String): Color {
return try {
val additionalWebColour = ADDITIONAL_WEB_COLOURS[colour]
if (additionalWebColour != null) {
Log.d(
"BrushCompat",
"Parsed additional web colour $colour to $additionalWebColour"
)
return additionalWebColour
val cssColour = CSSColours[colour]
if (cssColour != null) {
return cssColour
}
Color(android.graphics.Color.parseColor(colour))
Color(colour.toColorInt())
} catch (e: IllegalArgumentException) {
Log.d(
"BrushCompat",
@ -257,6 +255,246 @@ object BrushCompat {
}
else -> {
return Brush.solidColor(parseColourName(colour))
}
}
}
}
/**
* Like [BrushCompat] but does not require `@Composable` scope.
* Instead you must initialise it with the colours you want to use.
*/
class InstancedBrushCompat(
val defaultColour: Color,
val primaryColour: Color,
val onBackgroundColour: Color,
val backgroundColour: Color,
val errorColour: Color
) {
private fun parseLinearGradient(gradient: String): Brush {
val stops = mutableListOf<Pair<Float, Color>>()
val parts = mutableListOf<String>()
var startIndex = 0
var openParenthesesCount = 0
for (i in gradient.indices) {
when (gradient[i]) {
'(' -> openParenthesesCount++
')' -> openParenthesesCount--
',' -> {
if (openParenthesesCount == 0) {
val part = gradient.substring(startIndex, i).trim()
parts.add(part)
startIndex = i + 1
}
}
}
}
val lastPart = gradient.substring(startIndex).trim()
if (lastPart.isNotEmpty()) {
parts.add(lastPart)
}
parts.forEachIndexed { index, part ->
if (part.startsWith("to") || part.endsWith("deg")) {
// we don't support any other direction / blocked on compose supporting them
// TODO could probably emulate this by swapping the values around
} else {
val splitPart = part.split(" ")
val colourPart = splitPart[0]
val colour = when {
colourPart.startsWith("var(") -> {
parseVarToColour(
colourPart.substringAfter("var(").substringBeforeLast(")")
)
}
else -> parseFunctionColour(colourPart) ?: parseColourName(colourPart)
}
val stop = if (splitPart.size == 2) {
splitPart[1].removeSuffix("%").toFloat() / 100f
} else {
index.toFloat() / (parts.size - 1)
}
stops.add(stop to colour)
}
}
return linearGradient(
colorStops = stops.toTypedArray()
)
}
private fun parseRadialGradient(gradient: String): Brush {
val stops = mutableListOf<Pair<Float, Color>>()
val parts = mutableListOf<String>()
var startIndex = 0
var openParenthesesCount = 0
// Split the gradient string into individual components
for (i in gradient.indices) {
when (gradient[i]) {
'(' -> openParenthesesCount++
')' -> openParenthesesCount--
',' -> {
if (openParenthesesCount == 0) {
val part = gradient.substring(startIndex, i).trim()
parts.add(part)
startIndex = i + 1
}
}
}
}
val lastPart = gradient.substring(startIndex).trim()
if (lastPart.isNotEmpty()) {
parts.add(lastPart)
}
// Parse color stops
parts.drop(1).forEachIndexed { index, part ->
val splitPart = part.split(" ")
val colorPart = splitPart[0]
val color = when {
colorPart.startsWith("var(") -> {
parseVarToColour(
colorPart.substringAfter("var(").substringBeforeLast(")")
)
}
else -> parseFunctionColour(colorPart) ?: parseColourName(colorPart)
}
val stop = if (splitPart.size == 2) {
splitPart[1].removeSuffix("%").toFloat() / 100f
} else {
index.toFloat() / (parts.size - 2)
}
stops.add(stop to color)
}
return Brush.radialGradient(
colorStops = stops.toTypedArray()
)
}
fun parseFunctionColour(colourString: String): Color? {
val cleanedString = colourString.trim()
return try {
if (cleanedString.startsWith("rgb(")) {
parseRGBColour(cleanedString)
} else if (cleanedString.startsWith("rgba(")) {
parseRGBAColour(cleanedString)
} else {
throw IllegalArgumentException("Invalid colour format: $colourString")
}
} catch (e: Exception) {
null
}
}
private fun parseRGBColour(rgbString: String): Color {
val colourParts = rgbString.removePrefix("rgb(")
.removeSuffix(")")
.split(",")
.map { it.trim().toInt() }
val red = colourParts[0] / 255.0f
val green = colourParts[1] / 255.0f
val blue = colourParts[2] / 255.0f
return Color(red, green, blue)
}
private fun parseRGBAColour(rgbaString: String): Color {
val colourParts = rgbaString.removePrefix("rgba(")
.removeSuffix(")")
.split(",")
.map { it.trim() }
val red = colourParts[0].toInt() / 255.0f
val green = colourParts[1].toInt() / 255.0f
val blue = colourParts[2].toInt() / 255.0f
val alpha = colourParts[3].removeSuffix("%").toFloat() / 100.0f
return Color(red, green, blue, alpha)
}
private fun parseVarToColour(varName: String): Color {
return when (varName) {
"--accent" -> primaryColour
"--foreground" -> onBackgroundColour
"--background" -> backgroundColour
"--error" -> errorColour
else -> defaultColour
}
}
private fun parseVar(varName: String): Brush {
return SolidColor(parseVarToColour(varName))
}
private fun parseColourName(colour: String): Color {
return try {
val cssColour = CSSColours[colour]
if (cssColour != null) {
return cssColour
}
Color(colour.toColorInt())
} catch (e: IllegalArgumentException) {
Log.d(
"BrushCompat",
"Failed to parse colour $colour, falling back to LocalContentColor.current"
)
defaultColour
}
}
fun parseColour(colour: String): Brush {
if (colour.isEmpty()) {
return Brush.solidColor(Color.Unspecified)
}
when {
colour.startsWith("var(") -> {
Log.d(
"BrushCompat",
"Parsing variable $colour"
)
return parseVar(
colour.substringAfter("var(").substringBeforeLast(")")
)
}
colour.startsWith("linear-gradient(") || colour.startsWith("repeating-linear-gradient(") -> {
return parseLinearGradient(
colour
.substringAfter("repeating-")
.substringAfter("linear-gradient(")
.substringBeforeLast(")")
)
}
colour.startsWith("radial-gradient(") || colour.startsWith("repeating-radial-gradient(") -> {
return parseRadialGradient(
colour
.substringAfter("repeating-")
.substringAfter("radial-gradient(")
.substringBeforeLast(")")
)
}
else -> {
return Brush.solidColor(parseColourName(colour))
}

View File

@ -0,0 +1,25 @@
package chat.revolt.api.internals
enum class MessageFlag(val value: Int) {
// Message will not send push / desktop notifications
SuppressNotifications(1 shl 0),
// Message will mention all users who can see the channel
MentionsEveryone(1 shl 1),
// Message will mention all users who are online and can see the channel.
// This cannot be true if MentionsEveryone is true
MentionsOnline(1 shl 2)
}
operator fun Int.plus(other: MessageFlag): Int {
return this or other.value
}
fun Int.hasMessageFlag(flag: MessageFlag): Boolean {
return this and flag.value == flag.value
}
infix fun Int?.hasMessageFlag(flag: MessageFlag): Boolean {
return this != null && this.hasMessageFlag(flag)
}

View File

@ -1,22 +1,23 @@
package chat.revolt.api.internals
import android.graphics.Color
import android.graphics.LinearGradient
import android.graphics.Shader
import android.util.Log
import android.widget.TextView
import androidx.compose.ui.graphics.toArgb
import androidx.core.graphics.toColorInt
import chat.revolt.api.internals.colour.CSSColours
import com.google.android.material.color.MaterialColors
import com.google.android.material.elevation.SurfaceColors
object TextViewCompat {
private fun tryParseDirectColour(colour: String): Int {
val additionalWebColour = ADDITIONAL_WEB_COLOURS[colour]
if (additionalWebColour != null) {
return additionalWebColour.toArgb()
val cssColour = CSSColours[colour]
if (cssColour != null) {
return cssColour.toArgb()
}
return Color.parseColor(colour)
return colour.toColorInt()
}
private fun tryParseVariable(tv: TextView, varName: String): Int {
@ -34,7 +35,7 @@ object TextViewCompat {
"--background" -> SurfaceColors.SURFACE_0.getColor(tv.context)
"--error" -> MaterialColors.getColor(tv, com.google.android.material.R.attr.colorError)
else -> tv.currentTextColor
}
}

View File

@ -0,0 +1,161 @@
package chat.revolt.api.internals.colour
import androidx.compose.ui.graphics.Color
val CSSColours = mapOf(
// CSS Color Module Level 4 Editors Draft, 5 June 2025
// https://drafts.csswg.org/css-color/
"aliceblue" to Color(0xFFF0F8FF),
"antiquewhite" to Color(0xFFFAEBD7),
"aqua" to Color(0xFF00FFFF),
"aquamarine" to Color(0xFF7FFFD4),
"azure" to Color(0xFFF0FFFF),
"beige" to Color(0xFFF5F5DC),
"bisque" to Color(0xFFFFE4C4),
"black" to Color(0xFF000000),
"blanchedalmond" to Color(0xFFFFEBCD),
"blue" to Color(0xFF0000FF),
"blueviolet" to Color(0xFF8A2BE2),
"brown" to Color(0xFFA52A2A),
"burlywood" to Color(0xFFDEB887),
"cadetblue" to Color(0xFF5F9EA0),
"chartreuse" to Color(0xFF7FFF00),
"chocolate" to Color(0xFFD2691E),
"coral" to Color(0xFFFF7F50),
"cornflowerblue" to Color(0xFF6495ED),
"cornsilk" to Color(0xFFFFF8DC),
"crimson" to Color(0xFFDC143C),
"cyan" to Color(0xFF00FFFF),
"darkblue" to Color(0xFF00008B),
"darkcyan" to Color(0xFF008B8B),
"darkgoldenrod" to Color(0xFFB8860B),
"darkgray" to Color(0xFFA9A9A9),
"darkgreen" to Color(0xFF006400),
"darkgrey" to Color(0xFFA9A9A9),
"darkkhaki" to Color(0xFFBDB76B),
"darkmagenta" to Color(0xFF8B008B),
"darkolivegreen" to Color(0xFF556B2F),
"darkorange" to Color(0xFFFF8C00),
"darkorchid" to Color(0xFF9932CC),
"darkred" to Color(0xFF8B0000),
"darksalmon" to Color(0xFFE9967A),
"darkseagreen" to Color(0xFF8FBC8F),
"darkslateblue" to Color(0xFF483D8B),
"darkslategray" to Color(0xFF2F4F4F),
"darkslategrey" to Color(0xFF2F4F4F),
"darkturquoise" to Color(0xFF00CED1),
"darkviolet" to Color(0xFF9400D3),
"deeppink" to Color(0xFFFF1493),
"deepskyblue" to Color(0xFF00BFFF),
"dimgray" to Color(0xFF696969),
"dimgrey" to Color(0xFF696969),
"dodgerblue" to Color(0xFF1E90FF),
"firebrick" to Color(0xFFB22222),
"floralwhite" to Color(0xFFFFFAF0),
"forestgreen" to Color(0xFF228B22),
"fuchsia" to Color(0xFFFF00FF),
"gainsboro" to Color(0xFFDCDCDC),
"ghostwhite" to Color(0xFFF8F8FF),
"gold" to Color(0xFFFFD700),
"goldenrod" to Color(0xFFDAA520),
"gray" to Color(0xFF808080),
"green" to Color(0xFF008000),
"greenyellow" to Color(0xFFADFF2F),
"grey" to Color(0xFF808080),
"honeydew" to Color(0xFFF0FFF0),
"hotpink" to Color(0xFFFF69B4),
"indianred" to Color(0xFFCD5C5C),
"indigo" to Color(0xFF4B0082),
"ivory" to Color(0xFFFFFFF0),
"khaki" to Color(0xFFF0E68C),
"lavender" to Color(0xFFE6E6FA),
"lavenderblush" to Color(0xFFFFF0F5),
"lawngreen" to Color(0xFF7CFC00),
"lemonchiffon" to Color(0xFFFFFACD),
"lightblue" to Color(0xFFADD8E6),
"lightcoral" to Color(0xFFF08080),
"lightcyan" to Color(0xFFE0FFFF),
"lightgoldenrodyellow" to Color(0xFFFAFAD2),
"lightgray" to Color(0xFFD3D3D3),
"lightgreen" to Color(0xFF90EE90),
"lightgrey" to Color(0xFFD3D3D3),
"lightpink" to Color(0xFFFFB6C1),
"lightsalmon" to Color(0xFFFFA07A),
"lightseagreen" to Color(0xFF20B2AA),
"lightskyblue" to Color(0xFF87CEFA),
"lightslategray" to Color(0xFF778899),
"lightslategrey" to Color(0xFF778899),
"lightsteelblue" to Color(0xFFB0C4DE),
"lightyellow" to Color(0xFFFFFFE0),
"lime" to Color(0xFF00FF00),
"limegreen" to Color(0xFF32CD32),
"linen" to Color(0xFFFAF0E6),
"magenta" to Color(0xFFFF00FF),
"maroon" to Color(0xFF800000),
"mediumaquamarine" to Color(0xFF66CDAA),
"mediumblue" to Color(0xFF0000CD),
"mediumorchid" to Color(0xFFBA55D3),
"mediumpurple" to Color(0xFF9370DB),
"mediumseagreen" to Color(0xFF3CB371),
"mediumslateblue" to Color(0xFF7B68EE),
"mediumspringgreen" to Color(0xFF00FA9A),
"mediumturquoise" to Color(0xFF48D1CC),
"mediumvioletred" to Color(0xFFC71585),
"midnightblue" to Color(0xFF191970),
"mintcream" to Color(0xFFF5FFFA),
"mistyrose" to Color(0xFFFFE4E1),
"moccasin" to Color(0xFFFFE4B5),
"navajowhite" to Color(0xFFFFDEAD),
"navy" to Color(0xFF000080),
"oldlace" to Color(0xFFFDF5E6),
"olive" to Color(0xFF808000),
"olivedrab" to Color(0xFF6B8E23),
"orange" to Color(0xFFFFA500),
"orangered" to Color(0xFFFF4500),
"orchid" to Color(0xFFDA70D6),
"palegoldenrod" to Color(0xFFEEE8AA),
"palegreen" to Color(0xFF98FB98),
"paleturquoise" to Color(0xFFAFEEEE),
"palevioletred" to Color(0xFFDB7093),
"papayawhip" to Color(0xFFFFEFD5),
"peachpuff" to Color(0xFFFFDAB9),
"peru" to Color(0xFFCD853F),
"pink" to Color(0xFFFFC0CB),
"plum" to Color(0xFFDDA0DD),
"powderblue" to Color(0xFFB0E0E6),
"purple" to Color(0xFF800080),
"rebeccapurple" to Color(0xFF663399),
"red" to Color(0xFFFF0000),
"rosybrown" to Color(0xFFBC8F8F),
"royalblue" to Color(0xFF4169E1),
"saddlebrown" to Color(0xFF8B4513),
"salmon" to Color(0xFFFA8072),
"sandybrown" to Color(0xFFF4A460),
"seagreen" to Color(0xFF2E8B57),
"seashell" to Color(0xFFFFF5EE),
"sienna" to Color(0xFFA0522D),
"silver" to Color(0xFFC0C0C0),
"skyblue" to Color(0xFF87CEEB),
"slateblue" to Color(0xFF6A5ACD),
"slategray" to Color(0xFF708090),
"slategrey" to Color(0xFF708090),
"snow" to Color(0xFFFFFAFA),
"springgreen" to Color(0xFF00FF7F),
"steelblue" to Color(0xFF4682B4),
"tan" to Color(0xFFD2B48C),
"teal" to Color(0xFF008080),
"thistle" to Color(0xFFD8BFD8),
"tomato" to Color(0xFFFF6347),
"turquoise" to Color(0xFF40E0D0),
"violet" to Color(0xFFEE82EE),
"wheat" to Color(0xFFF5DEB3),
"white" to Color(0xFFFFFFFF),
"whitesmoke" to Color(0xFFF5F5F5),
"yellow" to Color(0xFFFFFF00),
"yellowgreen" to Color(0xFF9ACD32),
"transparent" to Color.Transparent,
"inherit" to Color.Unspecified,
"currentColor" to Color.Unspecified,
"initial" to Color.Unspecified,
"unset" to Color.Unspecified
)

View File

@ -26,6 +26,8 @@ import chat.revolt.api.realtime.frames.receivable.ServerDeleteFrame
import chat.revolt.api.realtime.frames.receivable.ServerMemberJoinFrame
import chat.revolt.api.realtime.frames.receivable.ServerMemberLeaveFrame
import chat.revolt.api.realtime.frames.receivable.ServerMemberUpdateFrame
import chat.revolt.api.realtime.frames.receivable.ServerRoleDeleteFrame
import chat.revolt.api.realtime.frames.receivable.ServerRoleUpdateFrame
import chat.revolt.api.realtime.frames.receivable.ServerUpdateFrame
import chat.revolt.api.realtime.frames.receivable.UserRelationshipFrame
import chat.revolt.api.realtime.frames.receivable.UserUpdateFrame
@ -36,6 +38,7 @@ import chat.revolt.api.realtime.frames.sendable.PingFrame
import chat.revolt.api.routes.server.fetchMember
import chat.revolt.api.schemas.Channel
import chat.revolt.api.schemas.ChannelType
import chat.revolt.api.schemas.Role
import chat.revolt.api.settings.LoadedSettings
import chat.revolt.api.settings.SyncedSettings
import chat.revolt.c2dm.ChannelRegistrator
@ -674,7 +677,6 @@ object RealtimeSocket {
}
"ServerMemberUpdate" -> {
Log.d("RealtimeSocket", "Received server member update frame. Raw: $rawFrame")
val serverMemberUpdateFrame =
RevoltJson.decodeFromString(ServerMemberUpdateFrame.serializer(), rawFrame)
Log.d(
@ -730,6 +732,77 @@ object RealtimeSocket {
)
}
"ServerRoleUpdate" -> {
val serverRoleUpdateFrame =
RevoltJson.decodeFromString(ServerRoleUpdateFrame.serializer(), rawFrame)
Log.d(
"RealtimeSocket",
"Received server role update frame for ${serverRoleUpdateFrame.id}."
)
val server = RevoltAPI.serverCache[serverRoleUpdateFrame.id]
if (server == null) {
Log.d(
"RealtimeSocket",
"Server ${serverRoleUpdateFrame.id} not found in cache. Ignoring role update."
)
return
}
val existingRole = server.roles?.get(serverRoleUpdateFrame.roleId)
if (existingRole == null) {
// New role.
Log.d(
"RealtimeSocket",
"New role ${serverRoleUpdateFrame.roleId} in server ${serverRoleUpdateFrame.id}. Adding to cache."
)
val newRole = Role().mergeWithPartial(serverRoleUpdateFrame.data)
val newServer = server.copy(
roles = server.roles?.plus(
Pair(serverRoleUpdateFrame.roleId, newRole)
) ?: mapOf(serverRoleUpdateFrame.roleId to newRole)
)
RevoltAPI.serverCache[serverRoleUpdateFrame.id] = newServer
} else {
// True role update.
Log.d(
"RealtimeSocket",
"Updating existing role ${serverRoleUpdateFrame.roleId} in server ${serverRoleUpdateFrame.id}."
)
val updatedRole = existingRole.mergeWithPartial(serverRoleUpdateFrame.data)
val newServer = server.copy(
roles = server.roles.plus(
Pair(serverRoleUpdateFrame.roleId, updatedRole)
)
)
RevoltAPI.serverCache[serverRoleUpdateFrame.id] = newServer
}
}
"ServerRoleDelete" -> {
val serverRoleDeleteFrame =
RevoltJson.decodeFromString(ServerRoleDeleteFrame.serializer(), rawFrame)
Log.d(
"RealtimeSocket",
"Received server role delete frame for ${serverRoleDeleteFrame.id} and role ${serverRoleDeleteFrame.roleId}."
)
val server = RevoltAPI.serverCache[serverRoleDeleteFrame.id]
if (server == null) {
Log.d(
"RealtimeSocket",
"Server ${serverRoleDeleteFrame.id} not found in cache. Ignoring role delete."
)
return
}
val newRoles = server.roles?.toMutableMap() ?: mutableMapOf()
newRoles.remove(serverRoleDeleteFrame.roleId)
RevoltAPI.serverCache[serverRoleDeleteFrame.id] =
server.copy(roles = newRoles)
}
"Authenticated" -> {
SyncedSettings.fetch()
LoadedSettings.hydrateWithSettings(SyncedSettings)

View File

@ -75,7 +75,17 @@ data class Role(
val colour: String? = null,
val hoist: Boolean? = null,
val rank: Double? = null
)
) {
fun mergeWithPartial(other: Role): Role {
return Role(
name = other.name ?: name,
permissions = other.permissions ?: permissions,
colour = other.colour ?: colour,
hoist = other.hoist ?: hoist,
rank = other.rank ?: rank
)
}
}
@Serializable
data class PermissionDescription(

View File

@ -1,5 +1,6 @@
package chat.revolt.composables.chat
import android.annotation.SuppressLint
import android.content.Intent
import android.icu.text.DateFormat
import android.net.Uri
@ -37,9 +38,13 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
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
@ -79,6 +84,7 @@ import chat.revolt.composables.generic.UserAvatarWidthPlaceholder
import chat.revolt.composables.markdown.LocalMarkdownTreeConfig
import chat.revolt.composables.markdown.RichMarkdown
import chat.revolt.internals.text.Gigamoji
import chat.revolt.internals.text.MessageProcessor
import chat.revolt.markdown.jbm.JBM
import chat.revolt.markdown.jbm.JBMRenderer
import chat.revolt.markdown.jbm.LocalJBMarkdownTreeState
@ -173,6 +179,7 @@ fun formatLongAsTime(time: Long): String {
}
}
@SuppressLint("UnusedBoxWithConstraintsScope")
@OptIn(ExperimentalFoundationApi::class, ExperimentalLayoutApi::class, JBM::class)
@Composable
fun Message(
@ -201,6 +208,17 @@ fun Message(
val authorIsBlocked = remember(author) { author.relationship == "Blocked" }
var mentionsSelfRole by remember(message) { mutableStateOf(false) }
LaunchedEffect(Unit) {
val serverId =
RevoltAPI.channelCache[message.channel]?.server ?: return@LaunchedEffect
var selfMember = RevoltAPI.selfId?.let { RevoltAPI.members.getMember(serverId, it) }
?: return@LaunchedEffect
var messageRoleMentions = MessageProcessor.findMentionedRoleIDs(message.content)
mentionsSelfRole = selfMember.roles?.any { it in messageRoleMentions } == true
}
Column(modifier.animateContentSize()) {
if (message.tail == false) {
Spacer(modifier = Modifier.height(10.dp))
@ -244,7 +262,7 @@ fun Message(
} else {
Column(
modifier = Modifier.then(
if (message.mentions?.contains(RevoltAPI.selfId) == true) {
if ((message.mentions?.contains(RevoltAPI.selfId) == true) || mentionsSelfRole) {
Modifier.background(
MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)
)
@ -260,12 +278,11 @@ fun Message(
InReplyTo(
channelId = chId,
messageId = reply,
withMention = replyMessage?.author?.let {
withMention = (replyMessage?.author?.let {
message.mentions?.contains(
replyMessage.author
)
}
?: false
} == true),
) {
// TODO Add jump to message
if (replyMessage == null) {
@ -511,7 +528,8 @@ fun Message(
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
message.reactions?.forEach { reaction ->
Reaction(reaction.key, reaction.value,
Reaction(
reaction.key, reaction.value,
onClick = { hasOwn ->
scope.launch {
if (hasOwn) {

View File

@ -85,6 +85,7 @@ import chat.revolt.R
import chat.revolt.activities.RevoltTweenFloat
import chat.revolt.activities.RevoltTweenInt
import chat.revolt.api.REVOLT_FILES
import chat.revolt.api.internals.BrushCompat
import chat.revolt.api.schemas.ChannelType
import chat.revolt.api.schemas.Member
import chat.revolt.composables.generic.RemoteImage
@ -128,6 +129,12 @@ sealed class AutocompleteSuggestion {
val custom: chat.revolt.api.schemas.Emoji?,
val query: String
) : AutocompleteSuggestion()
data class Role(
val role: chat.revolt.api.schemas.Role,
val id: String,
val query: String
) : AutocompleteSuggestion()
}
@OptIn(ExperimentalFoundationApi::class)
@ -218,7 +225,7 @@ fun MessageField(
lastWord.startsWith('@') -> {
if (channelId != null && serverId != null) {
autocompleteSuggestions.addAll(
Autocomplete.user(
Autocomplete.userOrRole(
channelId,
serverId,
lastWord.substring(1)
@ -281,6 +288,7 @@ fun MessageField(
is AutocompleteSuggestion.User -> item.user.id!!
is AutocompleteSuggestion.Channel -> item.channel.id!!
is AutocompleteSuggestion.Emoji -> item.shortcode
is AutocompleteSuggestion.Role -> item.id
}
}) {
when (val item = autocompleteSuggestions[it]) {
@ -317,6 +325,48 @@ fun MessageField(
)
}
is AutocompleteSuggestion.Role -> {
SuggestionChip(
onClick = {
textFieldState.edit {
val lastWordStartsAt =
textFieldState.text
.substring(0, textFieldState.selection.max)
.lastWordStartsAt()
replace(
if (lastWordStartsAt == -1) 0 else (lastWordStartsAt + 1),
textFieldState.selection.max,
"<%${item.id}> "
)
}
},
label = {
Text(
text = "@${item.role.name}",
style = item.role.colour?.let {
LocalTextStyle.current.copy(
brush = BrushCompat.parseColour(it)
)
} ?: LocalTextStyle.current
)
},
icon = {
Box(
modifier = Modifier
.clip(CircleShape)
.background(
item.role.colour?.let { BrushCompat.parseColour(it) }
?: SolidColor(MaterialTheme.colorScheme.primaryContainer)
)
.size(SuggestionChipDefaults.IconSize)
.align(Alignment.CenterHorizontally),
)
},
modifier = Modifier
.animateItem()
)
}
is AutocompleteSuggestion.Channel -> {
SuggestionChip(
onClick = {

View File

@ -21,7 +21,7 @@ object Autocomplete {
val customResults =
RevoltAPI.emojiCache.values.filter {
it.name?.contains(query, ignoreCase = true) ?: false
}.map {
}.mapNotNull {
if (it.name != null) {
AutocompleteSuggestion.Emoji(
":${it.id}:",
@ -32,16 +32,16 @@ object Autocomplete {
} else {
null
}
}.filterNotNull().distinctBy { it.custom?.id }
}.distinctBy { it.custom?.id }
return (unicodeResults + customResults)
}
fun user(
fun userOrRole(
channelId: String,
serverId: String? = null,
query: String
): List<AutocompleteSuggestion.User> {
): List<AutocompleteSuggestion> {
val channel = RevoltAPI.channelCache[channelId] ?: return emptyList()
return when (channel.channelType) {
@ -102,6 +102,7 @@ object Autocomplete {
if (serverId == null) return emptyList()
if (query.length < 2) return emptyList()
val roles = RevoltAPI.serverCache[serverId]?.roles ?: emptyMap()
val byNickname = RevoltAPI.members.filterNamesFor(serverId, query)
.map { m -> m to RevoltAPI.userCache[m.id?.user] }.filter { (_, u) ->
u != null
@ -112,7 +113,7 @@ object Autocomplete {
it.username?.contains(
query,
ignoreCase = true
) ?: false
) == true
}.mapNotNull {
it.id?.let { _ ->
RevoltAPI.members.getMember(
@ -126,15 +127,32 @@ object Autocomplete {
member!! to user
}
val all = (byNickname + byUsername).distinctBy { it.first.id }
val allUsers = (byNickname + byUsername).distinctBy { it.first.id }
val rolesByName =
roles.filter { it.value.name?.contains(query, ignoreCase = true) == true }
.map { it.value to it.key }
all.map {
(allUsers.map {
AutocompleteSuggestion.User(
it.second,
it.first,
query
)
}
} + rolesByName.map { (role, roleId) ->
AutocompleteSuggestion.Role(
role,
roleId,
query
)
})
.sortedBy {
when (it) {
is AutocompleteSuggestion.User -> it.user.username
is AutocompleteSuggestion.Role -> it.role.name
else -> ""
}
}
}
null -> emptyList()
@ -148,7 +166,7 @@ object Autocomplete {
val server = RevoltAPI.serverCache[serverId] ?: return emptyList()
val channels = server.channels?.mapNotNull { RevoltAPI.channelCache[it] } ?: emptyList()
return channels.filter { it.name?.contains(query, ignoreCase = true) ?: false }.map {
return channels.filter { it.name?.contains(query, ignoreCase = true) == true }.map {
AutocompleteSuggestion.Channel(
it,
query

View File

@ -60,4 +60,11 @@ object MessageProcessor {
return returnable
}
private val roleRegex = "<%([0-9A-HJKMNP-TV-Z]{26})>".toRegex()
fun findMentionedRoleIDs(content: String?): List<String> {
if (content.isNullOrEmpty()) return emptyList()
return roleRegex.findAll(content).map { it.groupValues[1] }.toList().distinct()
.filter { it.isNotEmpty() }
}
}

View File

@ -21,9 +21,11 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@ -46,6 +48,7 @@ import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.RoundRect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.input.pointer.pointerInput
@ -72,6 +75,8 @@ import chat.revolt.R
import chat.revolt.activities.InviteActivity
import chat.revolt.api.REVOLT_FILES
import chat.revolt.api.RevoltAPI
import chat.revolt.api.internals.BrushCompat
import chat.revolt.api.internals.InstancedBrushCompat
import chat.revolt.api.internals.isUlid
import chat.revolt.api.routes.custom.fetchEmoji
import chat.revolt.api.schemas.isInviteUri
@ -102,10 +107,12 @@ enum class JBMAnnotations(val tag: String, val clickable: Boolean) {
URL("URL", true),
UserMention("UserMention", true),
ChannelMention("ChannelMention", true),
RoleMention("RoleMention", false),
CustomEmote("CustomEmote", true),
Timestamp("Timestamp", false),
Checkbox("Checkbox", false),
UserAvatar("UserAvatar", true),
RoleChip("RoleChip", false),
JBMBackgroundRoundingStart("JBMBackgroundRoundingStart", false),
JBMBackgroundRoundingEnd("JBMBackgroundRoundingEnd", false),
}
@ -130,7 +137,8 @@ data class JBMarkdownTreeState(
val colors: JBMColors = JBMColors(
clickable = Color(0xFFFF00FF),
clickableBackground = Color(0x2000FF00)
)
),
val brushCompat: InstancedBrushCompat? = null
)
val LocalJBMarkdownTreeState =
@ -153,6 +161,13 @@ fun JBMRenderer(content: String, modifier: Modifier = Modifier) {
colors = JBMColors(
clickable = MaterialTheme.colorScheme.primary,
clickableBackground = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f)
),
brushCompat = InstancedBrushCompat(
defaultColour = LocalContentColor.current,
primaryColour = MaterialTheme.colorScheme.primary,
onBackgroundColour = MaterialTheme.colorScheme.onBackground,
backgroundColour = MaterialTheme.colorScheme.background,
errorColour = MaterialTheme.colorScheme.error,
)
)
) {
@ -244,6 +259,50 @@ private fun annotateText(
}
}
RSMElementTypes.ROLE_MENTION -> {
val contents = node.getTextInNode(sourceText).toString()
val roleId = contents.removeSurrounding("<%", ">")
if (roleId == contents || !roleId.isUlid() || state.currentServer == null) {
// Invalid role mention. Append as if it were regular text.
for (child in node.children) {
append(annotateText(state, child))
}
} else {
val server = RevoltAPI.serverCache[state.currentServer]
val role = server?.roles?.get(roleId)
val isGradient = role?.colour?.contains("gradient") == true
pushStyle(
SpanStyle(
background = state.colors.clickableBackground
)
)
pushStyle(
SpanStyle(
brush = (if (!isGradient) role?.colour?.let {
state.brushCompat?.parseColour(
it
)
} else null)
?: SolidColor(state.colors.clickable),
)
)
pushStyle(
SpanStyle(
background = state.colors.clickableBackground
)
)
append(" ")
appendInlineContent(JBMAnnotations.RoleChip.tag, roleId)
append(" ")
append(role?.name ?: "invalid-role")
append(" ")
pop()
pop()
pop()
}
}
RSMElementTypes.CUSTOM_EMOTE -> {
val contents = node.getTextInNode(sourceText).toString()
val emoteId = contents.removeSurrounding(":", ":")
@ -649,6 +708,40 @@ private fun JBMText(node: ASTNode, modifier: Modifier) {
}
}
),
JBMAnnotations.RoleChip.tag to with(LocalDensity.current) {
val placeholderBaseWidth =
(LocalTextStyle.current.fontSize * 1.5).toPx() - (avatarPadding * 2).toPx()
val widthTolerancePx =
2 // Else we get a gap of about 1-2 pixels due to rounding errors
val placeholderBaseHeight = (LocalTextStyle.current.fontSize * 1.5).toPx()
val heightTolerancePx = 2 // Dito
InlineTextContent(
placeholder = Placeholder(
width = (placeholderBaseWidth - widthTolerancePx).toSp(),
height = (placeholderBaseHeight - heightTolerancePx).toSp(),
placeholderVerticalAlign = PlaceholderVerticalAlign.Center
),
) { id ->
val role = RevoltAPI.serverCache[mdState.currentServer]?.roles?.get(id)
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.background(LocalJBMarkdownTreeState.current.colors.clickableBackground)
.padding(vertical = avatarPadding)
) {
Box(
modifier = Modifier
.clip(CircleShape)
.background(
role?.colour?.let { BrushCompat.parseColour(it) }
?: SolidColor(MaterialTheme.colorScheme.primaryContainer)
)
.size((LocalTextStyle.current.fontSize * 1.5).toDp() - (avatarPadding * 2))
)
}
}
},
JBMAnnotations.CustomEmote.tag to InlineTextContent(
placeholder = Placeholder(
width = LocalTextStyle.current.fontSize * 1.5,
@ -811,10 +904,8 @@ private fun annotateHighlights(
it.location.end
)
}
else -> null
}
}.filterNotNull()
}
return AnnotatedString(source, spanStyles = highlightStyles)
}

View File

@ -10,6 +10,9 @@ object RSMElementTypes {
@JvmField
val CHANNEL_MENTION: IElementType = MarkdownElementType("CHANNEL_MENTION")
@JvmField
val ROLE_MENTION: IElementType = MarkdownElementType("ROLE_MENTION")
@JvmField
val CUSTOM_EMOTE: IElementType = MarkdownElementType("EMOJI")

View File

@ -2,6 +2,7 @@ package chat.revolt.markdown.jbm
import chat.revolt.markdown.jbm.sequentialparsers.ChannelMentionParser
import chat.revolt.markdown.jbm.sequentialparsers.CustomEmoteParser
import chat.revolt.markdown.jbm.sequentialparsers.RoleMentionParser
import chat.revolt.markdown.jbm.sequentialparsers.UserMentionParser
import org.intellij.markdown.MarkdownTokenTypes
import org.intellij.markdown.flavours.gfm.GFMFlavourDescriptor
@ -24,6 +25,7 @@ class RSMFlavourDescriptor : GFMFlavourDescriptor() {
return listOf(
UserMentionParser(),
ChannelMentionParser(),
RoleMentionParser(),
CustomEmoteParser(),
AutolinkParser(listOf(MarkdownTokenTypes.AUTOLINK, GFMTokenTypes.GFM_AUTOLINK)),
BacktickParser(),

View File

@ -0,0 +1,42 @@
package chat.revolt.markdown.jbm.sequentialparsers
import chat.revolt.markdown.jbm.RSMElementTypes
import org.intellij.markdown.MarkdownTokenTypes
import org.intellij.markdown.parser.sequentialparsers.RangesListBuilder
import org.intellij.markdown.parser.sequentialparsers.SequentialParser
import org.intellij.markdown.parser.sequentialparsers.TokensCache
class RoleMentionParser : SequentialParser {
override fun parse(
tokens: TokensCache,
rangesToGlue: List<IntRange>
): SequentialParser.ParsingResult {
val result = SequentialParser.ParsingResultBuilder()
val delegateIndices = RangesListBuilder()
var iterator: TokensCache.Iterator = tokens.RangesListIterator(rangesToGlue)
while (iterator.type != null) {
if (iterator.type == MarkdownTokenTypes.LT && iterator.charLookup(1) == '%') {
val start = iterator.index
while (iterator.type != MarkdownTokenTypes.GT && iterator.type != null) {
iterator = iterator.advance()
}
if (iterator.type == MarkdownTokenTypes.GT) {
result.withNode(
SequentialParser.Node(
start..iterator.index + 1,
RSMElementTypes.ROLE_MENTION
)
)
}
} else if (iterator.type == MarkdownTokenTypes.LT) {
delegateIndices.put(iterator.index)
} else {
delegateIndices.put(iterator.index)
}
iterator = iterator.advance()
}
return result.withFurtherProcessing(delegateIndices.get())
}
}