parent
0d2b037a6f
commit
1b63b04fde
|
|
@ -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))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue