feat: xml messageview (for future optimisations)

Signed-off-by: Infi <infi@infi.sh>
This commit is contained in:
Infi 2024-01-17 17:54:46 +01:00
parent b25ea4dffc
commit 98bbc09cd2
11 changed files with 567 additions and 17 deletions

View File

@ -15,7 +15,7 @@ fun Brush.Companion.solidColor(colour: Color) = SolidColor(colour)
// not exhaustive, but covers most of the ones I've seen in the wild
// for the sake of all of us, please just use hex codes
// reference: https://developer.mozilla.org/en-US/docs/Web/CSS/color_value
private val ADDITIONAL_WEB_COLOURS = mapOf(
val ADDITIONAL_WEB_COLOURS = mapOf(
"orange" to Color(0xFFFFA500),
"rebeccapurple" to Color(0xFF663399),
"transparent" to Color.Transparent,
@ -24,7 +24,7 @@ private val ADDITIONAL_WEB_COLOURS = mapOf(
"unset" to Color.Unspecified
)
object WebCompat {
object BrushCompat {
@Composable
private fun parseLinearGradient(gradient: String): Brush {
val stops = mutableListOf<Pair<Float, Color>>()
@ -85,8 +85,7 @@ object WebCompat {
)
}
@Composable
private fun parseFunctionColour(colourString: String): Color? {
fun parseFunctionColour(colourString: String): Color? {
val cleanedString = colourString.trim()
return try {
@ -151,7 +150,7 @@ object WebCompat {
val additionalWebColour = ADDITIONAL_WEB_COLOURS[colour]
if (additionalWebColour != null) {
Log.d(
"WebCompat",
"BrushCompat",
"Parsed additional web colour $colour to $additionalWebColour"
)
return additionalWebColour
@ -160,7 +159,7 @@ object WebCompat {
Color(android.graphics.Color.parseColor(colour))
} catch (e: IllegalArgumentException) {
Log.d(
"WebCompat",
"BrushCompat",
"Failed to parse colour $colour, falling back to LocalContentColor.current"
)
LocalContentColor.current
@ -172,7 +171,7 @@ object WebCompat {
when {
colour.startsWith("var(") -> {
Log.d(
"WebCompat",
"BrushCompat",
"Parsing variable $colour"
)
return parseVar(

View File

@ -0,0 +1,138 @@
package chat.revolt.api.internals
import android.graphics.Color
import android.graphics.LinearGradient
import android.graphics.Shader
import android.util.Log
import android.widget.TextView
import androidx.compose.ui.graphics.toArgb
import com.google.android.material.color.MaterialColors
import com.google.android.material.elevation.SurfaceColors
object TextViewCompat {
private fun tryParseDirectColour(colour: String): Int {
val additionalWebColour = ADDITIONAL_WEB_COLOURS[colour]
if (additionalWebColour != null) {
return additionalWebColour.toArgb()
}
return Color.parseColor(colour)
}
private fun tryParseVariable(tv: TextView, varName: String): Int {
return when (varName) {
"--accent" -> MaterialColors.getColor(
tv,
com.google.android.material.R.attr.colorPrimary
)
"--foreground" -> MaterialColors.getColor(
tv,
com.google.android.material.R.attr.colorOnBackground
)
"--background" -> SurfaceColors.SURFACE_0.getColor(tv.context)
"--error" -> MaterialColors.getColor(tv, com.google.android.material.R.attr.colorError)
else -> tv.currentTextColor
}
}
private fun tryParseSetLinearGradient(tv: TextView, gradient: String): Pair<Shader, Int> {
val stops = mutableListOf<Pair<Float, Int>>()
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(") -> {
tryParseVariable(
tv,
colourPart.substringAfter("var(").substringBeforeLast(")")
)
}
else -> BrushCompat.parseFunctionColour(colourPart)?.toArgb()
?: tryParseDirectColour(colourPart)
}
val stop = if (splitPart.size == 2) {
splitPart[1].removeSuffix("%").toFloat() / 100f
} else {
index.toFloat() / (parts.size - 1)
}
stops.add(stop to colour)
}
}
val width = tv.paint.measureText(tv.text.toString())
return LinearGradient(
0f,
0f,
width,
0f,
stops.map { it.second }.toIntArray(),
stops.map { it.first }.toFloatArray(),
Shader.TileMode.CLAMP
) to stops.first().second
}
fun setColourFromRoleColour(tv: TextView, colour: String) {
when {
colour.startsWith("var(") -> {
val varName = colour.substringAfter("var(").substringBeforeLast(")")
val parsedColour = tryParseVariable(tv, varName)
tv.setTextColor(parsedColour)
}
colour.startsWith("linear-gradient(") || colour.startsWith("repeating-linear-gradient(") -> {
val gradient = colour.substringAfter("(").substringBeforeLast(")")
val shader = tryParseSetLinearGradient(tv, gradient)
tv.paint.shader = shader.first
}
else -> {
try {
val directColour = tryParseDirectColour(colour)
tv.setTextColor(directColour)
} catch (e: IllegalArgumentException) {
Log.d(
"TextViewCompat",
"Failed to parse colour $colour, not setting colour"
)
}
}
}
}
}

View File

@ -24,7 +24,7 @@ import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import chat.revolt.api.internals.WebCompat
import chat.revolt.api.internals.BrushCompat
import chat.revolt.api.internals.solidColor
import chat.revolt.api.routes.microservices.january.asJanuaryProxyUrl
import chat.revolt.api.schemas.Embed
@ -51,7 +51,7 @@ fun RegularEmbed(
.width(4.dp)
.fillMaxHeight()
.background(
embed.colour?.let { WebCompat.parseColour(it) }
embed.colour?.let { BrushCompat.parseColour(it) }
?: Brush.solidColor(MaterialTheme.colorScheme.primary)
)
)

View File

@ -10,7 +10,7 @@ import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.text.style.TextOverflow
import chat.revolt.api.REVOLT_FILES
import chat.revolt.api.internals.Roles
import chat.revolt.api.internals.WebCompat
import chat.revolt.api.internals.BrushCompat
import chat.revolt.api.internals.solidColor
import chat.revolt.api.schemas.Member
import chat.revolt.api.schemas.User
@ -35,7 +35,7 @@ fun MemberListItem(
}
}
val colour = highestColourRole?.colour?.let { WebCompat.parseColour(it) }
val colour = highestColourRole?.colour?.let { BrushCompat.parseColour(it) }
?: Brush.solidColor(LocalContentColor.current)
ListItem(

View File

@ -68,7 +68,7 @@ import chat.revolt.api.RevoltAPI
import chat.revolt.api.internals.Roles
import chat.revolt.api.internals.SpecialUsers
import chat.revolt.api.internals.ULID
import chat.revolt.api.internals.WebCompat
import chat.revolt.api.internals.BrushCompat
import chat.revolt.api.internals.solidColor
import chat.revolt.api.routes.channel.react
import chat.revolt.api.routes.channel.unreact
@ -86,7 +86,7 @@ import chat.revolt.api.schemas.Message as MessageSchema
@Composable
fun authorColour(message: MessageSchema): Brush {
return if (message.masquerade?.colour != null) {
WebCompat.parseColour(message.masquerade.colour)
BrushCompat.parseColour(message.masquerade.colour)
} else {
val defaultColour = Brush.solidColor(LocalContentColor.current)
@ -96,7 +96,7 @@ fun authorColour(message: MessageSchema): Brush {
Roles.resolveHighestRole(serverId, it, withColour = true)
} ?: return defaultColour
highestRole.colour?.let { WebCompat.parseColour(it) }
highestRole.colour?.let { BrushCompat.parseColour(it) }
?: defaultColour
}
}

View File

@ -126,6 +126,15 @@ fun LabsHomeScreen(navController: NavController) {
}
)
Divider()
ListItem(
headlineContent = {
Text("XML Message Column")
},
modifier = Modifier.clickable {
navController.navigate("mockups/xmlmessage")
}
)
Divider()
}
}
}

View File

@ -14,6 +14,7 @@ import androidx.navigation.compose.rememberNavController
import chat.revolt.api.settings.FeatureFlags
import chat.revolt.api.settings.LabsAccessControlVariates
import chat.revolt.screens.labs.ui.mockups.CallScreenMockup
import chat.revolt.screens.labs.ui.mockups.XMLMessageMockup
annotation class LabsFeature
@ -65,6 +66,10 @@ fun LabsRootScreen(topNav: NavController) {
composable("mockups/call") {
CallScreenMockup()
}
composable("mockups/xmlmessage") {
XMLMessageMockup()
}
}
}
}

View File

@ -0,0 +1,122 @@
package chat.revolt.screens.labs.ui.mockups
import android.widget.Toast
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import chat.revolt.api.RevoltAPI
import chat.revolt.api.schemas.Message
import chat.revolt.components.chat.Message
import chat.revolt.internals.markdown.MarkdownContext
import chat.revolt.internals.markdown.MarkdownParser
import chat.revolt.internals.markdown.MarkdownState
import chat.revolt.internals.markdown.addRevoltRules
import chat.revolt.internals.markdown.createCodeRule
import chat.revolt.internals.markdown.createInlineCodeRule
import chat.revolt.views.MessageView
import com.discord.simpleast.core.simple.SimpleMarkdownRules
import com.discord.simpleast.core.simple.SimpleRenderer
@Composable
fun XMLMessageMockup() {
var message by remember { mutableStateOf<Message?>(null) }
val context = LocalContext.current
val codeBlockColor = MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp)
fun reroll() {
message = RevoltAPI.messageCache.values.random()
}
LaunchedEffect(Unit) {
reroll()
}
Column(
modifier = Modifier
.fillMaxSize()
.safeDrawingPadding()
) {
message?.let {
AndroidView(
factory = {
MessageView(it, onLongPress = {
Toast.makeText(context, "Long pressed!", Toast.LENGTH_SHORT).show()
})
},
update = {
it.fromMessage(message!!)
},
modifier = Modifier
.fillMaxWidth()
)
Message(
message = message!!.copy(tail = false),
truncate = false,
onMessageContextMenu = {
Toast.makeText(context, "Context menu!", Toast.LENGTH_SHORT).show()
},
parse = {
val parser = MarkdownParser()
.addRules(
SimpleMarkdownRules.createEscapeRule()
)
.addRevoltRules(context)
.addRules(
createCodeRule(context, codeBlockColor.toArgb()),
createInlineCodeRule(
context,
codeBlockColor.toArgb()
)
)
.addRules(
SimpleMarkdownRules.createSimpleMarkdownRules(
includeEscapeRule = false
)
)
SimpleRenderer.render(
source = it.content ?: "",
parser = parser,
initialState = MarkdownState(0),
renderContext = MarkdownContext(
memberMap = mapOf(),
userMap = RevoltAPI.userCache.toMap(),
channelMap = RevoltAPI.channelCache.mapValues { ch ->
ch.value.name ?: ch.value.id
?: "#DeletedChannel"
},
emojiMap = RevoltAPI.emojiCache,
serverId = message!!.channel?.let { x -> RevoltAPI.channelCache[x] }?.server
?: "",
// check if message consists solely of one *or more* custom emotes
useLargeEmojis = it.content?.matches(
Regex("(:([0-9A-Z]{26}):)+")
) == true
)
)
},
)
TextButton(onClick = { reroll() }) {
Text("Different message")
}
}
}
}

View File

@ -30,7 +30,7 @@ import androidx.compose.ui.unit.sp
import chat.revolt.R
import chat.revolt.api.RevoltAPI
import chat.revolt.api.internals.ULID
import chat.revolt.api.internals.WebCompat
import chat.revolt.api.internals.BrushCompat
import chat.revolt.api.internals.solidColor
import chat.revolt.api.routes.user.fetchUserProfile
import chat.revolt.api.schemas.Profile
@ -120,7 +120,7 @@ fun UserInfoSheet(
role?.let {
RoleListEntry(
label = role.name ?: "null",
brush = role.colour?.let { WebCompat.parseColour(it) }
brush = role.colour?.let { BrushCompat.parseColour(it) }
?: Brush.solidColor(LocalContentColor.current)
)
}
@ -136,7 +136,7 @@ fun UserInfoSheet(
role?.let {
RoleListEntry(
label = role.name ?: "null",
brush = role.colour?.let { WebCompat.parseColour(it) }
brush = role.colour?.let { BrushCompat.parseColour(it) }
?: Brush.solidColor(LocalContentColor.current)
)
}

View File

@ -0,0 +1,212 @@
package chat.revolt.views
import android.content.Context
import android.icu.text.DateFormat
import android.text.format.DateUtils
import androidx.constraintlayout.widget.ConstraintLayout
import chat.revolt.R
import chat.revolt.api.REVOLT_FILES
import chat.revolt.api.RevoltAPI
import chat.revolt.api.internals.Roles
import chat.revolt.api.internals.TextViewCompat
import chat.revolt.api.internals.ULID
import chat.revolt.api.schemas.Message
import chat.revolt.api.schemas.User
import chat.revolt.databinding.ViewMessageBinding
import chat.revolt.internals.markdown.MarkdownContext
import chat.revolt.internals.markdown.MarkdownParser
import chat.revolt.internals.markdown.MarkdownState
import chat.revolt.internals.markdown.addRevoltRules
import chat.revolt.internals.markdown.createCodeRule
import chat.revolt.internals.markdown.createInlineCodeRule
import com.bumptech.glide.GenericTransitionOptions
import com.bumptech.glide.Glide
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.request.transition.DrawableCrossFadeFactory
import com.discord.simpleast.core.simple.SimpleMarkdownRules
import com.discord.simpleast.core.simple.SimpleRenderer
import com.google.android.material.color.MaterialColors
import com.google.android.material.elevation.SurfaceColors
class MessageView(
ctx: Context
) : ConstraintLayout(ctx) {
private var binding: ViewMessageBinding
private val parser = MarkdownParser()
.addRules(
SimpleMarkdownRules.createEscapeRule()
)
.addRevoltRules(context)
.addRules(
createCodeRule(context, SurfaceColors.SURFACE_2.getColor(ctx)),
createInlineCodeRule(
context,
SurfaceColors.SURFACE_2.getColor(ctx),
)
)
.addRules(
SimpleMarkdownRules.createSimpleMarkdownRules(
includeEscapeRule = false
)
)
private var messageServer: String? = null
constructor(
ctx: Context,
onLongPress: (() -> Unit)? = null
) : this(ctx) {
binding.root.setOnLongClickListener {
onLongPress?.invoke()
onLongPress != null
}
}
init {
inflate(ctx, R.layout.view_message, this)
binding = ViewMessageBinding.bind(this)
}
fun setAuthor(author: String) {
binding.author.text = author
}
fun setContent(content: String) {
binding.messageContent.text = SimpleRenderer.render(
source = content,
parser = parser,
initialState = MarkdownState(0),
renderContext = MarkdownContext(
memberMap = messageServer?.let { RevoltAPI.members.markdownMemberMapFor(it) }
?: mapOf(),
userMap = RevoltAPI.userCache.toMap(),
channelMap = RevoltAPI.channelCache.mapValues { ch ->
ch.value.name ?: ch.value.id
?: "#DeletedChannel"
},
emojiMap = RevoltAPI.emojiCache,
serverId = messageServer,
// check if message consists solely of one *or more* custom emotes
useLargeEmojis = content.matches(
Regex("(:([0-9A-Z]{26}):)+")
)
)
)
}
fun setTimestamp(timestamp: String) {
binding.timestamp.text = timestamp
}
fun setAvatarUrl(avatar: String?) {
if (avatar == null) {
Glide.with(this).clear(binding.avatar)
}
val factory = DrawableCrossFadeFactory.Builder().setCrossFadeEnabled(true).build()
Glide.with(this).load(avatar).diskCacheStrategy(DiskCacheStrategy.ALL)
.transition(GenericTransitionOptions.with(factory))
.circleCrop()
.into(binding.avatar)
}
private fun formatLongAsTime(time: Long): String {
val date = java.util.Date(time)
val withinLastWeek = System.currentTimeMillis() - time < 604800000
return if (withinLastWeek) {
val relativeDate = DateUtils.getRelativeTimeSpanString(
time,
System.currentTimeMillis(),
DateUtils.DAY_IN_MILLIS,
DateUtils.FORMAT_ABBREV_ALL
)
val relativeTime = DateFormat.getTimeInstance(DateFormat.SHORT).format(date)
"$relativeDate $relativeTime"
} else {
val absoluteDate = DateFormat.getDateInstance(DateFormat.SHORT).format(date)
val absoluteTime = DateFormat.getTimeInstance(DateFormat.SHORT).format(date)
"$absoluteDate $absoluteTime"
}
}
private fun authorName(message: Message): String {
if (message.masquerade?.name != null) {
return message.masquerade.name
}
messageServer
?: return RevoltAPI.userCache[message.author]?.let { User.resolveDefaultName(it) }
?: context.getString(R.string.unknown)
val member = messageServer?.let { sid ->
message.author?.let {
RevoltAPI.members.getMember(
sid,
it
)
}
}
?: return context.getString(R.string.unknown)
return member.nickname
?: RevoltAPI.userCache[message.author]?.let { User.resolveDefaultName(it) }
?: context.getString(R.string.unknown)
}
private fun resetAuthorColour() {
binding.author.setTextColor(
MaterialColors.getColor(
binding.author,
com.google.android.material.R.attr.colorOnBackground
)
)
binding.author.paint.shader = null
}
private fun setAuthorColour(message: Message) {
resetAuthorColour()
if (message.masquerade?.colour != null) {
TextViewCompat.setColourFromRoleColour(binding.author, message.masquerade.colour)
} else {
val serverId = RevoltAPI.channelCache[message.channel]?.server ?: return
val highestRole = message.author?.let {
Roles.resolveHighestRole(serverId, it, withColour = true)
} ?: return
highestRole.colour?.let {
TextViewCompat.setColourFromRoleColour(binding.author, it)
}
}
}
fun fromMessage(message: Message) {
messageServer = RevoltAPI.channelCache[message.channel]?.server
message.content?.let { setContent(it) }
message.id?.let { setTimestamp(formatLongAsTime(ULID.asTimestamp(it))) }
// dont have this
val resolvedAuthor = RevoltAPI.userCache[message.author]
// dont inline this
setAvatarUrl(resolvedAuthor?.avatar?.let { "$REVOLT_FILES/avatars/${it.id}?max_side=256" }
?: "")
setAuthor(authorName(message))
setAuthorColour(message)
}
fun reset() {
resetAuthorColour()
setContent("")
setTimestamp("")
setAuthor("")
setAvatarUrl(null)
}
}

View File

@ -0,0 +1,65 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:longClickable="true"
android:id="@+id/message_container"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<com.google.android.material.imageview.ShapeableImageView
android:layout_width="42dp"
android:layout_height="42dp"
android:id="@+id/avatar"
android:layout_marginTop="4dp"
android:layout_marginStart="8dp"
tools:src="@drawable/ic_launcher_background"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<com.google.android.material.textview.MaterialTextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:maxLines="1"
android:ellipsize="end"
android:textSize="16sp"
android:textFontWeight="700"
android:fontFamily="@font/inter"
android:id="@+id/author"
android:layout_marginStart="10dp"
tools:text="Jennifer"
app:layout_constraintStart_toEndOf="@id/avatar"
app:layout_constraintTop_toTopOf="parent"
app:layout_constrainedWidth="true"
tools:targetApi="p" />
<com.google.android.material.textview.MaterialTextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:maxLines="1"
android:textSize="12sp"
android:fontFamily="@font/inter"
android:id="@+id/timestamp"
android:layout_marginStart="6dp"
android:alpha="0.6"
tools:text="10:00 AM"
app:layout_constraintStart_toEndOf="@id/author"
app:layout_constraintTop_toTopOf="@id/author"
app:layout_constraintBottom_toBottomOf="@id/author"
tools:targetApi="p" />
<com.google.android.material.textview.MaterialTextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:textSize="16sp"
android:fontFamily="@font/inter"
android:id="@+id/message_content"
android:layout_marginEnd="8dp"
tools:text="Hello fdsfdsfoijsdijofsjoi oijjofsdjois fdsfds fdsfsddoijsdf"
app:layout_constrainedWidth="true"
app:layout_constraintStart_toStartOf="@id/author"
app:layout_constraintTop_toBottomOf="@id/author"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>