From 1b63b04fde7c16c699beb0c33225c921cee2f152 Mon Sep 17 00:00:00 2001 From: Infi Date: Sat, 28 Jun 2025 02:29:03 +0200 Subject: [PATCH] feat: role mentions Signed-off-by: Infi --- .../chat/revolt/api/internals/BrushCompat.kt | 254 +++++++++++++++++- .../chat/revolt/api/internals/MessageFlag.kt | 25 ++ .../revolt/api/internals/TextViewCompat.kt | 13 +- .../revolt/api/internals/colour/CSSColours.kt | 161 +++++++++++ .../revolt/api/realtime/RealtimeSocket.kt | 75 +++++- .../java/chat/revolt/api/schemas/Server.kt | 12 +- .../chat/revolt/composables/chat/Message.kt | 28 +- .../revolt/composables/chat/MessageField.kt | 52 +++- .../chat/revolt/internals/Autocomplete.kt | 36 ++- .../revolt/internals/text/MessageProcessor.kt | 7 + .../chat/revolt/markdown/jbm/JBMRenderer.kt | 99 ++++++- .../revolt/markdown/jbm/RSMElementTypes.kt | 3 + .../markdown/jbm/RSMFlavourDescriptor.kt | 2 + .../sequentialparsers/RoleMentionParser.kt | 42 +++ 14 files changed, 774 insertions(+), 35 deletions(-) create mode 100644 app/src/main/java/chat/revolt/api/internals/MessageFlag.kt create mode 100644 app/src/main/java/chat/revolt/api/internals/colour/CSSColours.kt create mode 100644 app/src/main/java/chat/revolt/markdown/jbm/sequentialparsers/RoleMentionParser.kt diff --git a/app/src/main/java/chat/revolt/api/internals/BrushCompat.kt b/app/src/main/java/chat/revolt/api/internals/BrushCompat.kt index 0921054f..81944a4b 100644 --- a/app/src/main/java/chat/revolt/api/internals/BrushCompat.kt +++ b/app/src/main/java/chat/revolt/api/internals/BrushCompat.kt @@ -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>() + + val parts = mutableListOf() + 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>() + + val parts = mutableListOf() + 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)) } diff --git a/app/src/main/java/chat/revolt/api/internals/MessageFlag.kt b/app/src/main/java/chat/revolt/api/internals/MessageFlag.kt new file mode 100644 index 00000000..299b8e31 --- /dev/null +++ b/app/src/main/java/chat/revolt/api/internals/MessageFlag.kt @@ -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) +} \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/api/internals/TextViewCompat.kt b/app/src/main/java/chat/revolt/api/internals/TextViewCompat.kt index a611a5d3..988f196a 100644 --- a/app/src/main/java/chat/revolt/api/internals/TextViewCompat.kt +++ b/app/src/main/java/chat/revolt/api/internals/TextViewCompat.kt @@ -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 } } diff --git a/app/src/main/java/chat/revolt/api/internals/colour/CSSColours.kt b/app/src/main/java/chat/revolt/api/internals/colour/CSSColours.kt new file mode 100644 index 00000000..67d3748c --- /dev/null +++ b/app/src/main/java/chat/revolt/api/internals/colour/CSSColours.kt @@ -0,0 +1,161 @@ +package chat.revolt.api.internals.colour + +import androidx.compose.ui.graphics.Color + +val CSSColours = mapOf( + // CSS Color Module Level 4 Editor’s 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 +) \ No newline at end of file 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 c31dd0c6..34ab648c 100644 --- a/app/src/main/java/chat/revolt/api/realtime/RealtimeSocket.kt +++ b/app/src/main/java/chat/revolt/api/realtime/RealtimeSocket.kt @@ -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) diff --git a/app/src/main/java/chat/revolt/api/schemas/Server.kt b/app/src/main/java/chat/revolt/api/schemas/Server.kt index 8bd38a38..4b637e9c 100644 --- a/app/src/main/java/chat/revolt/api/schemas/Server.kt +++ b/app/src/main/java/chat/revolt/api/schemas/Server.kt @@ -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( diff --git a/app/src/main/java/chat/revolt/composables/chat/Message.kt b/app/src/main/java/chat/revolt/composables/chat/Message.kt index 67f3ecc4..cb572a3f 100644 --- a/app/src/main/java/chat/revolt/composables/chat/Message.kt +++ b/app/src/main/java/chat/revolt/composables/chat/Message.kt @@ -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) { diff --git a/app/src/main/java/chat/revolt/composables/chat/MessageField.kt b/app/src/main/java/chat/revolt/composables/chat/MessageField.kt index 557970e9..605a32c3 100644 --- a/app/src/main/java/chat/revolt/composables/chat/MessageField.kt +++ b/app/src/main/java/chat/revolt/composables/chat/MessageField.kt @@ -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 = { diff --git a/app/src/main/java/chat/revolt/internals/Autocomplete.kt b/app/src/main/java/chat/revolt/internals/Autocomplete.kt index 0205ae29..7844e0b8 100644 --- a/app/src/main/java/chat/revolt/internals/Autocomplete.kt +++ b/app/src/main/java/chat/revolt/internals/Autocomplete.kt @@ -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 { + ): List { 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 diff --git a/app/src/main/java/chat/revolt/internals/text/MessageProcessor.kt b/app/src/main/java/chat/revolt/internals/text/MessageProcessor.kt index 51b2bf47..37fc89eb 100644 --- a/app/src/main/java/chat/revolt/internals/text/MessageProcessor.kt +++ b/app/src/main/java/chat/revolt/internals/text/MessageProcessor.kt @@ -60,4 +60,11 @@ object MessageProcessor { return returnable } + + private val roleRegex = "<%([0-9A-HJKMNP-TV-Z]{26})>".toRegex() + fun findMentionedRoleIDs(content: String?): List { + if (content.isNullOrEmpty()) return emptyList() + return roleRegex.findAll(content).map { it.groupValues[1] }.toList().distinct() + .filter { it.isNotEmpty() } + } } \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/markdown/jbm/JBMRenderer.kt b/app/src/main/java/chat/revolt/markdown/jbm/JBMRenderer.kt index eab610e8..ca8abc97 100644 --- a/app/src/main/java/chat/revolt/markdown/jbm/JBMRenderer.kt +++ b/app/src/main/java/chat/revolt/markdown/jbm/JBMRenderer.kt @@ -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) } diff --git a/app/src/main/java/chat/revolt/markdown/jbm/RSMElementTypes.kt b/app/src/main/java/chat/revolt/markdown/jbm/RSMElementTypes.kt index 2ca4be1a..c3683ff1 100644 --- a/app/src/main/java/chat/revolt/markdown/jbm/RSMElementTypes.kt +++ b/app/src/main/java/chat/revolt/markdown/jbm/RSMElementTypes.kt @@ -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") diff --git a/app/src/main/java/chat/revolt/markdown/jbm/RSMFlavourDescriptor.kt b/app/src/main/java/chat/revolt/markdown/jbm/RSMFlavourDescriptor.kt index 0a7d6ec4..0defc7e2 100644 --- a/app/src/main/java/chat/revolt/markdown/jbm/RSMFlavourDescriptor.kt +++ b/app/src/main/java/chat/revolt/markdown/jbm/RSMFlavourDescriptor.kt @@ -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(), diff --git a/app/src/main/java/chat/revolt/markdown/jbm/sequentialparsers/RoleMentionParser.kt b/app/src/main/java/chat/revolt/markdown/jbm/sequentialparsers/RoleMentionParser.kt new file mode 100644 index 00000000..98cecafd --- /dev/null +++ b/app/src/main/java/chat/revolt/markdown/jbm/sequentialparsers/RoleMentionParser.kt @@ -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 + ): 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()) + } +} \ No newline at end of file