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.Brush.Companion.linearGradient
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor 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) fun Brush.Companion.solidColor(colour: Color) = SolidColor(colour)
@ -202,16 +204,12 @@ object BrushCompat {
@Composable @Composable
private fun parseColourName(colour: String): Color { private fun parseColourName(colour: String): Color {
return try { return try {
val additionalWebColour = ADDITIONAL_WEB_COLOURS[colour] val cssColour = CSSColours[colour]
if (additionalWebColour != null) { if (cssColour != null) {
Log.d( return cssColour
"BrushCompat",
"Parsed additional web colour $colour to $additionalWebColour"
)
return additionalWebColour
} }
Color(android.graphics.Color.parseColor(colour)) Color(colour.toColorInt())
} catch (e: IllegalArgumentException) { } catch (e: IllegalArgumentException) {
Log.d( Log.d(
"BrushCompat", "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 -> { else -> {
return Brush.solidColor(parseColourName(colour)) 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 package chat.revolt.api.internals
import android.graphics.Color
import android.graphics.LinearGradient import android.graphics.LinearGradient
import android.graphics.Shader import android.graphics.Shader
import android.util.Log import android.util.Log
import android.widget.TextView import android.widget.TextView
import androidx.compose.ui.graphics.toArgb 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.color.MaterialColors
import com.google.android.material.elevation.SurfaceColors import com.google.android.material.elevation.SurfaceColors
object TextViewCompat { object TextViewCompat {
private fun tryParseDirectColour(colour: String): Int { private fun tryParseDirectColour(colour: String): Int {
val additionalWebColour = ADDITIONAL_WEB_COLOURS[colour] val cssColour = CSSColours[colour]
if (additionalWebColour != null) { if (cssColour != null) {
return additionalWebColour.toArgb() return cssColour.toArgb()
} }
return Color.parseColor(colour) return colour.toColorInt()
} }
private fun tryParseVariable(tv: TextView, varName: String): Int { private fun tryParseVariable(tv: TextView, varName: String): Int {
@ -34,7 +35,7 @@ object TextViewCompat {
"--background" -> SurfaceColors.SURFACE_0.getColor(tv.context) "--background" -> SurfaceColors.SURFACE_0.getColor(tv.context)
"--error" -> MaterialColors.getColor(tv, com.google.android.material.R.attr.colorError) "--error" -> MaterialColors.getColor(tv, com.google.android.material.R.attr.colorError)
else -> tv.currentTextColor 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.ServerMemberJoinFrame
import chat.revolt.api.realtime.frames.receivable.ServerMemberLeaveFrame import chat.revolt.api.realtime.frames.receivable.ServerMemberLeaveFrame
import chat.revolt.api.realtime.frames.receivable.ServerMemberUpdateFrame 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.ServerUpdateFrame
import chat.revolt.api.realtime.frames.receivable.UserRelationshipFrame import chat.revolt.api.realtime.frames.receivable.UserRelationshipFrame
import chat.revolt.api.realtime.frames.receivable.UserUpdateFrame 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.routes.server.fetchMember
import chat.revolt.api.schemas.Channel import chat.revolt.api.schemas.Channel
import chat.revolt.api.schemas.ChannelType import chat.revolt.api.schemas.ChannelType
import chat.revolt.api.schemas.Role
import chat.revolt.api.settings.LoadedSettings import chat.revolt.api.settings.LoadedSettings
import chat.revolt.api.settings.SyncedSettings import chat.revolt.api.settings.SyncedSettings
import chat.revolt.c2dm.ChannelRegistrator import chat.revolt.c2dm.ChannelRegistrator
@ -674,7 +677,6 @@ object RealtimeSocket {
} }
"ServerMemberUpdate" -> { "ServerMemberUpdate" -> {
Log.d("RealtimeSocket", "Received server member update frame. Raw: $rawFrame")
val serverMemberUpdateFrame = val serverMemberUpdateFrame =
RevoltJson.decodeFromString(ServerMemberUpdateFrame.serializer(), rawFrame) RevoltJson.decodeFromString(ServerMemberUpdateFrame.serializer(), rawFrame)
Log.d( 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" -> { "Authenticated" -> {
SyncedSettings.fetch() SyncedSettings.fetch()
LoadedSettings.hydrateWithSettings(SyncedSettings) LoadedSettings.hydrateWithSettings(SyncedSettings)

View File

@ -75,7 +75,17 @@ data class Role(
val colour: String? = null, val colour: String? = null,
val hoist: Boolean? = null, val hoist: Boolean? = null,
val rank: Double? = 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 @Serializable
data class PermissionDescription( data class PermissionDescription(

View File

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

View File

@ -85,6 +85,7 @@ import chat.revolt.R
import chat.revolt.activities.RevoltTweenFloat import chat.revolt.activities.RevoltTweenFloat
import chat.revolt.activities.RevoltTweenInt import chat.revolt.activities.RevoltTweenInt
import chat.revolt.api.REVOLT_FILES import chat.revolt.api.REVOLT_FILES
import chat.revolt.api.internals.BrushCompat
import chat.revolt.api.schemas.ChannelType import chat.revolt.api.schemas.ChannelType
import chat.revolt.api.schemas.Member import chat.revolt.api.schemas.Member
import chat.revolt.composables.generic.RemoteImage import chat.revolt.composables.generic.RemoteImage
@ -128,6 +129,12 @@ sealed class AutocompleteSuggestion {
val custom: chat.revolt.api.schemas.Emoji?, val custom: chat.revolt.api.schemas.Emoji?,
val query: String val query: String
) : AutocompleteSuggestion() ) : AutocompleteSuggestion()
data class Role(
val role: chat.revolt.api.schemas.Role,
val id: String,
val query: String
) : AutocompleteSuggestion()
} }
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@ -218,7 +225,7 @@ fun MessageField(
lastWord.startsWith('@') -> { lastWord.startsWith('@') -> {
if (channelId != null && serverId != null) { if (channelId != null && serverId != null) {
autocompleteSuggestions.addAll( autocompleteSuggestions.addAll(
Autocomplete.user( Autocomplete.userOrRole(
channelId, channelId,
serverId, serverId,
lastWord.substring(1) lastWord.substring(1)
@ -281,6 +288,7 @@ fun MessageField(
is AutocompleteSuggestion.User -> item.user.id!! is AutocompleteSuggestion.User -> item.user.id!!
is AutocompleteSuggestion.Channel -> item.channel.id!! is AutocompleteSuggestion.Channel -> item.channel.id!!
is AutocompleteSuggestion.Emoji -> item.shortcode is AutocompleteSuggestion.Emoji -> item.shortcode
is AutocompleteSuggestion.Role -> item.id
} }
}) { }) {
when (val item = autocompleteSuggestions[it]) { 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 -> { is AutocompleteSuggestion.Channel -> {
SuggestionChip( SuggestionChip(
onClick = { onClick = {

View File

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

View File

@ -60,4 +60,11 @@ object MessageProcessor {
return returnable 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.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.InlineTextContent import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.appendInlineContent import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text 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.geometry.RoundRect
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
@ -72,6 +75,8 @@ import chat.revolt.R
import chat.revolt.activities.InviteActivity import chat.revolt.activities.InviteActivity
import chat.revolt.api.REVOLT_FILES import chat.revolt.api.REVOLT_FILES
import chat.revolt.api.RevoltAPI 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.internals.isUlid
import chat.revolt.api.routes.custom.fetchEmoji import chat.revolt.api.routes.custom.fetchEmoji
import chat.revolt.api.schemas.isInviteUri import chat.revolt.api.schemas.isInviteUri
@ -102,10 +107,12 @@ enum class JBMAnnotations(val tag: String, val clickable: Boolean) {
URL("URL", true), URL("URL", true),
UserMention("UserMention", true), UserMention("UserMention", true),
ChannelMention("ChannelMention", true), ChannelMention("ChannelMention", true),
RoleMention("RoleMention", false),
CustomEmote("CustomEmote", true), CustomEmote("CustomEmote", true),
Timestamp("Timestamp", false), Timestamp("Timestamp", false),
Checkbox("Checkbox", false), Checkbox("Checkbox", false),
UserAvatar("UserAvatar", true), UserAvatar("UserAvatar", true),
RoleChip("RoleChip", false),
JBMBackgroundRoundingStart("JBMBackgroundRoundingStart", false), JBMBackgroundRoundingStart("JBMBackgroundRoundingStart", false),
JBMBackgroundRoundingEnd("JBMBackgroundRoundingEnd", false), JBMBackgroundRoundingEnd("JBMBackgroundRoundingEnd", false),
} }
@ -130,7 +137,8 @@ data class JBMarkdownTreeState(
val colors: JBMColors = JBMColors( val colors: JBMColors = JBMColors(
clickable = Color(0xFFFF00FF), clickable = Color(0xFFFF00FF),
clickableBackground = Color(0x2000FF00) clickableBackground = Color(0x2000FF00)
) ),
val brushCompat: InstancedBrushCompat? = null
) )
val LocalJBMarkdownTreeState = val LocalJBMarkdownTreeState =
@ -153,6 +161,13 @@ fun JBMRenderer(content: String, modifier: Modifier = Modifier) {
colors = JBMColors( colors = JBMColors(
clickable = MaterialTheme.colorScheme.primary, clickable = MaterialTheme.colorScheme.primary,
clickableBackground = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f) 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 -> { RSMElementTypes.CUSTOM_EMOTE -> {
val contents = node.getTextInNode(sourceText).toString() val contents = node.getTextInNode(sourceText).toString()
val emoteId = contents.removeSurrounding(":", ":") 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( JBMAnnotations.CustomEmote.tag to InlineTextContent(
placeholder = Placeholder( placeholder = Placeholder(
width = LocalTextStyle.current.fontSize * 1.5, width = LocalTextStyle.current.fontSize * 1.5,
@ -811,10 +904,8 @@ private fun annotateHighlights(
it.location.end it.location.end
) )
} }
else -> null
} }
}.filterNotNull() }
return AnnotatedString(source, spanStyles = highlightStyles) return AnnotatedString(source, spanStyles = highlightStyles)
} }

View File

@ -10,6 +10,9 @@ object RSMElementTypes {
@JvmField @JvmField
val CHANNEL_MENTION: IElementType = MarkdownElementType("CHANNEL_MENTION") val CHANNEL_MENTION: IElementType = MarkdownElementType("CHANNEL_MENTION")
@JvmField
val ROLE_MENTION: IElementType = MarkdownElementType("ROLE_MENTION")
@JvmField @JvmField
val CUSTOM_EMOTE: IElementType = MarkdownElementType("EMOJI") 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.ChannelMentionParser
import chat.revolt.markdown.jbm.sequentialparsers.CustomEmoteParser import chat.revolt.markdown.jbm.sequentialparsers.CustomEmoteParser
import chat.revolt.markdown.jbm.sequentialparsers.RoleMentionParser
import chat.revolt.markdown.jbm.sequentialparsers.UserMentionParser import chat.revolt.markdown.jbm.sequentialparsers.UserMentionParser
import org.intellij.markdown.MarkdownTokenTypes import org.intellij.markdown.MarkdownTokenTypes
import org.intellij.markdown.flavours.gfm.GFMFlavourDescriptor import org.intellij.markdown.flavours.gfm.GFMFlavourDescriptor
@ -24,6 +25,7 @@ class RSMFlavourDescriptor : GFMFlavourDescriptor() {
return listOf( return listOf(
UserMentionParser(), UserMentionParser(),
ChannelMentionParser(), ChannelMentionParser(),
RoleMentionParser(),
CustomEmoteParser(), CustomEmoteParser(),
AutolinkParser(listOf(MarkdownTokenTypes.AUTOLINK, GFMTokenTypes.GFM_AUTOLINK)), AutolinkParser(listOf(MarkdownTokenTypes.AUTOLINK, GFMTokenTypes.GFM_AUTOLINK)),
BacktickParser(), 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())
}
}