feat: message tail algorithm

This commit is contained in:
Infi 2023-01-31 00:31:05 +01:00
parent 3f2ba36622
commit d56149fe3f
6 changed files with 122 additions and 24 deletions

View File

@ -49,6 +49,9 @@ android {
excludes += '/META-INF/{AL2.0,LGPL2.1}' excludes += '/META-INF/{AL2.0,LGPL2.1}'
} }
} }
lintOptions {
abortOnError false
}
namespace 'chat.revolt' namespace 'chat.revolt'
} }
@ -56,8 +59,9 @@ dependencies {
// Android/Kotlin Core // Android/Kotlin Core
implementation 'androidx.core:core-ktx:1.9.0' implementation 'androidx.core:core-ktx:1.9.0'
// JSON Serialization // Kotlinx - various first-party extensions for Kotlin
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.4.1" implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.4.1"
implementation "org.jetbrains.kotlinx:kotlinx-datetime:0.4.0"
// Compose BOM // Compose BOM
implementation platform("androidx.compose:compose-bom:$compose_bom_version") implementation platform("androidx.compose:compose-bom:$compose_bom_version")

View File

@ -18,7 +18,10 @@ data class Message(
val edited: String? = null, val edited: String? = null,
val embeds: List<Embed>? = null, val embeds: List<Embed>? = null,
val mentions: List<String>? = null, val mentions: List<String>? = null,
val masquerade: Masquerade? = null,
val system: SystemInfo? = null,
val type: String? = null, // this is _only_ used for websocket events! val type: String? = null, // this is _only_ used for websocket events!
val tail: Boolean? = null, // this is used to determine if the message is the last in a message group
) { ) {
fun getAuthor(): User? { fun getAuthor(): User? {
return author?.let { RevoltAPI.userCache[it] } return author?.let { RevoltAPI.userCache[it] }
@ -79,3 +82,16 @@ data class Image(
data class Special( data class Special(
val type: String? = null val type: String? = null
) )
@Serializable
data class Masquerade(
val name: String? = null,
val avatar: String? = null,
val colour: String? = null
)
@Serializable
data class SystemInfo(
val type: String? = null,
val id: String? = null
)

View File

@ -1,9 +1,12 @@
package chat.revolt.components.chat package chat.revolt.components.chat
import android.net.Uri import android.net.Uri
import android.widget.Toast
import androidx.browser.customtabs.CustomTabsIntent import androidx.browser.customtabs.CustomTabsIntent
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@ -13,17 +16,21 @@ 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
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import chat.revolt.R
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.ULID import chat.revolt.api.internals.ULID
import chat.revolt.api.schemas.AutumnResource import chat.revolt.api.schemas.AutumnResource
import chat.revolt.components.generic.RemoteImage import chat.revolt.components.generic.RemoteImage
import chat.revolt.components.generic.UserAvatar import chat.revolt.components.generic.UserAvatar
import chat.revolt.components.generic.UserAvatarWidthPlaceholder
import chat.revolt.markdown.Renderer import chat.revolt.markdown.Renderer
import chat.revolt.api.schemas.Message as MessageSchema import chat.revolt.api.schemas.Message as MessageSchema
@ -46,42 +53,66 @@ fun formatLongAsTime(time: Long): String {
return format.format(date) return format.format(date)
} }
@OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun Message( fun Message(
message: MessageSchema message: MessageSchema
) { ) {
val author = RevoltAPI.userCache[message.author] ?: return CircularProgressIndicator() val author = RevoltAPI.userCache[message.author] ?: return CircularProgressIndicator()
val context = LocalContext.current val context = LocalContext.current
val clipboardManager = LocalClipboardManager.current
Row( Row(
modifier = Modifier modifier = Modifier
.padding(8.dp) .combinedClickable(
onClick = {},
onLongClick = {
if (message.content != null && message.content.isNotEmpty()) {
clipboardManager.setText(AnnotatedString(message.content))
Toast
.makeText(
context,
context.getString(R.string.copied),
Toast.LENGTH_SHORT
)
.show()
}
}
)
.padding(4.dp)
.fillMaxWidth() .fillMaxWidth()
) { ) {
UserAvatar( if (message.tail == false) {
username = author.username ?: "", UserAvatar(
userId = author.id!!, username = author.username ?: "",
avatar = author.avatar userId = author.id!!,
) avatar = author.avatar
)
} else {
UserAvatarWidthPlaceholder()
}
Column(modifier = Modifier.padding(start = 10.dp)) { Column(modifier = Modifier.padding(start = 10.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) { if (message.tail == false) {
Text( Row(verticalAlignment = Alignment.CenterVertically) {
text = author.username ?: "", Text(
fontWeight = FontWeight.Bold, text = author.username ?: "",
maxLines = 1, fontWeight = FontWeight.Bold,
overflow = TextOverflow.Ellipsis maxLines = 1,
) overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.width(5.dp)) Spacer(modifier = Modifier.width(5.dp))
Text( Text(
text = formatLongAsTime(ULID.asTimestamp(message.id!!)), text = formatLongAsTime(ULID.asTimestamp(message.id!!)),
fontSize = 12.sp, fontSize = 12.sp,
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.5f), color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.5f),
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
}
} }
message.content?.let { message.content?.let {

View File

@ -4,6 +4,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -85,3 +86,11 @@ fun UserAvatar(
} }
} }
} }
@Composable
fun UserAvatarWidthPlaceholder() {
Box(
modifier = Modifier
.width(40.dp)
)
}

View File

@ -37,6 +37,7 @@ import chat.revolt.R
import chat.revolt.RevoltTweenFloat import chat.revolt.RevoltTweenFloat
import chat.revolt.RevoltTweenInt import chat.revolt.RevoltTweenInt
import chat.revolt.api.RevoltAPI import chat.revolt.api.RevoltAPI
import chat.revolt.api.internals.ULID
import chat.revolt.api.realtime.RealtimeSocket import chat.revolt.api.realtime.RealtimeSocket
import chat.revolt.api.realtime.frames.receivable.ChannelStartTypingFrame import chat.revolt.api.realtime.frames.receivable.ChannelStartTypingFrame
import chat.revolt.api.realtime.frames.receivable.ChannelStopTypingFrame import chat.revolt.api.realtime.frames.receivable.ChannelStopTypingFrame
@ -56,7 +57,9 @@ import chat.revolt.components.screens.chat.AttachmentManager
import chat.revolt.components.screens.chat.ChannelIcon import chat.revolt.components.screens.chat.ChannelIcon
import io.ktor.http.* import io.ktor.http.*
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.datetime.Instant
import java.io.File import java.io.File
import kotlin.time.Duration.Companion.minutes
import chat.revolt.api.schemas.Message as MessageSchema import chat.revolt.api.schemas.Message as MessageSchema
class ChannelScreenViewModel : ViewModel() { class ChannelScreenViewModel : ViewModel() {
@ -186,7 +189,7 @@ class ChannelScreenViewModel : ViewModel() {
messages.add(message) messages.add(message)
} }
} }
setRenderableMessages(renderableMessages + messages) regroupMessages(renderableMessages + messages)
} }
} }
@ -211,7 +214,7 @@ class ChannelScreenViewModel : ViewModel() {
messages.add(message) messages.add(message)
} }
} }
setRenderableMessages(renderableMessages + messages) regroupMessages(renderableMessages + messages)
} }
} }
@ -276,6 +279,39 @@ class ChannelScreenViewModel : ViewModel() {
} ?: it } ?: it
} }
} }
private fun regroupMessages(newMessages: List<MessageSchema> = renderableMessages) {
val groupedMessages = arrayListOf<MessageSchema>()
// Verbatim implementation of https://wiki.rvlt.gg/index.php/Text_Channel_(UI)#Message_Grouping_Algorithm
// The exception is the date variable being pushed into cache, we don't need that here.
// Keep in mind: Recomposing UI is incredibly cheap in Jetpack Compose.
newMessages.forEach { message ->
var tail = true
val next = newMessages.getOrNull(newMessages.indexOf(message) + 1)
if (next != null) {
val dateA = Instant.fromEpochMilliseconds(ULID.asTimestamp(message.id!!))
val dateB = Instant.fromEpochMilliseconds(ULID.asTimestamp(next.id!!))
if (
message.author != next.author ||
dateB - dateA >= 7.minutes ||
message.masquerade != next.masquerade ||
message.system != null || next.system != null ||
message.replies != null || next.replies != null
) {
tail = false
}
} else {
tail = false
}
groupedMessages.add(message.copy(tail = tail))
}
setRenderableMessages(groupedMessages)
}
} }
@Composable @Composable

View File

@ -1,5 +1,6 @@
package chat.revolt.ui.theme package chat.revolt.ui.theme
import android.annotation.SuppressLint
import android.app.Activity import android.app.Activity
import android.os.Build import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
@ -59,6 +60,7 @@ enum class Theme {
Amoled, Amoled,
} }
@SuppressLint("NewApi")
@Composable @Composable
fun RevoltTheme( fun RevoltTheme(
requestedTheme: Theme, requestedTheme: Theme,