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 // 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 // for the sake of all of us, please just use hex codes
// reference: https://developer.mozilla.org/en-US/docs/Web/CSS/color_value // 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), "orange" to Color(0xFFFFA500),
"rebeccapurple" to Color(0xFF663399), "rebeccapurple" to Color(0xFF663399),
"transparent" to Color.Transparent, "transparent" to Color.Transparent,
@ -24,7 +24,7 @@ private val ADDITIONAL_WEB_COLOURS = mapOf(
"unset" to Color.Unspecified "unset" to Color.Unspecified
) )
object WebCompat { object BrushCompat {
@Composable @Composable
private fun parseLinearGradient(gradient: String): Brush { private fun parseLinearGradient(gradient: String): Brush {
val stops = mutableListOf<Pair<Float, Color>>() val stops = mutableListOf<Pair<Float, Color>>()
@ -85,8 +85,7 @@ object WebCompat {
) )
} }
@Composable fun parseFunctionColour(colourString: String): Color? {
private fun parseFunctionColour(colourString: String): Color? {
val cleanedString = colourString.trim() val cleanedString = colourString.trim()
return try { return try {
@ -151,7 +150,7 @@ object WebCompat {
val additionalWebColour = ADDITIONAL_WEB_COLOURS[colour] val additionalWebColour = ADDITIONAL_WEB_COLOURS[colour]
if (additionalWebColour != null) { if (additionalWebColour != null) {
Log.d( Log.d(
"WebCompat", "BrushCompat",
"Parsed additional web colour $colour to $additionalWebColour" "Parsed additional web colour $colour to $additionalWebColour"
) )
return additionalWebColour return additionalWebColour
@ -160,7 +159,7 @@ object WebCompat {
Color(android.graphics.Color.parseColor(colour)) Color(android.graphics.Color.parseColor(colour))
} catch (e: IllegalArgumentException) { } catch (e: IllegalArgumentException) {
Log.d( Log.d(
"WebCompat", "BrushCompat",
"Failed to parse colour $colour, falling back to LocalContentColor.current" "Failed to parse colour $colour, falling back to LocalContentColor.current"
) )
LocalContentColor.current LocalContentColor.current
@ -172,7 +171,7 @@ object WebCompat {
when { when {
colour.startsWith("var(") -> { colour.startsWith("var(") -> {
Log.d( Log.d(
"WebCompat", "BrushCompat",
"Parsing variable $colour" "Parsing variable $colour"
) )
return parseVar( 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.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp 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.internals.solidColor
import chat.revolt.api.routes.microservices.january.asJanuaryProxyUrl import chat.revolt.api.routes.microservices.january.asJanuaryProxyUrl
import chat.revolt.api.schemas.Embed import chat.revolt.api.schemas.Embed
@ -51,7 +51,7 @@ fun RegularEmbed(
.width(4.dp) .width(4.dp)
.fillMaxHeight() .fillMaxHeight()
.background( .background(
embed.colour?.let { WebCompat.parseColour(it) } embed.colour?.let { BrushCompat.parseColour(it) }
?: Brush.solidColor(MaterialTheme.colorScheme.primary) ?: 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 androidx.compose.ui.text.style.TextOverflow
import chat.revolt.api.REVOLT_FILES import chat.revolt.api.REVOLT_FILES
import chat.revolt.api.internals.Roles 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.internals.solidColor
import chat.revolt.api.schemas.Member import chat.revolt.api.schemas.Member
import chat.revolt.api.schemas.User 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) ?: Brush.solidColor(LocalContentColor.current)
ListItem( ListItem(

View File

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

View File

@ -126,6 +126,15 @@ fun LabsHomeScreen(navController: NavController) {
} }
) )
Divider() 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.FeatureFlags
import chat.revolt.api.settings.LabsAccessControlVariates import chat.revolt.api.settings.LabsAccessControlVariates
import chat.revolt.screens.labs.ui.mockups.CallScreenMockup import chat.revolt.screens.labs.ui.mockups.CallScreenMockup
import chat.revolt.screens.labs.ui.mockups.XMLMessageMockup
annotation class LabsFeature annotation class LabsFeature
@ -65,6 +66,10 @@ fun LabsRootScreen(topNav: NavController) {
composable("mockups/call") { composable("mockups/call") {
CallScreenMockup() 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.R
import chat.revolt.api.RevoltAPI import chat.revolt.api.RevoltAPI
import chat.revolt.api.internals.ULID 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.internals.solidColor
import chat.revolt.api.routes.user.fetchUserProfile import chat.revolt.api.routes.user.fetchUserProfile
import chat.revolt.api.schemas.Profile import chat.revolt.api.schemas.Profile
@ -120,7 +120,7 @@ fun UserInfoSheet(
role?.let { role?.let {
RoleListEntry( RoleListEntry(
label = role.name ?: "null", label = role.name ?: "null",
brush = role.colour?.let { WebCompat.parseColour(it) } brush = role.colour?.let { BrushCompat.parseColour(it) }
?: Brush.solidColor(LocalContentColor.current) ?: Brush.solidColor(LocalContentColor.current)
) )
} }
@ -136,7 +136,7 @@ fun UserInfoSheet(
role?.let { role?.let {
RoleListEntry( RoleListEntry(
label = role.name ?: "null", label = role.name ?: "null",
brush = role.colour?.let { WebCompat.parseColour(it) } brush = role.colour?.let { BrushCompat.parseColour(it) }
?: Brush.solidColor(LocalContentColor.current) ?: 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>