diff --git a/app/build.gradle b/app/build.gradle index ef9bac75..7a9f48b7 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -49,6 +49,9 @@ android { excludes += '/META-INF/{AL2.0,LGPL2.1}' } } + lintOptions { + abortOnError false + } namespace 'chat.revolt' } @@ -56,8 +59,9 @@ dependencies { // Android/Kotlin Core 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-datetime:0.4.0" // Compose BOM implementation platform("androidx.compose:compose-bom:$compose_bom_version") diff --git a/app/src/main/java/chat/revolt/api/schemas/Messages.kt b/app/src/main/java/chat/revolt/api/schemas/Messages.kt index 8fb87b6a..7c5b44f3 100644 --- a/app/src/main/java/chat/revolt/api/schemas/Messages.kt +++ b/app/src/main/java/chat/revolt/api/schemas/Messages.kt @@ -18,7 +18,10 @@ data class Message( val edited: String? = null, val embeds: List? = null, val mentions: List? = null, + val masquerade: Masquerade? = null, + val system: SystemInfo? = null, 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? { return author?.let { RevoltAPI.userCache[it] } @@ -78,4 +81,17 @@ data class Image( @Serializable data class Special( 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 ) \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/components/chat/Message.kt b/app/src/main/java/chat/revolt/components/chat/Message.kt index a22a0590..4a76807a 100644 --- a/app/src/main/java/chat/revolt/components/chat/Message.kt +++ b/app/src/main/java/chat/revolt/components/chat/Message.kt @@ -1,9 +1,12 @@ package chat.revolt.components.chat import android.net.Uri +import android.widget.Toast import androidx.browser.customtabs.CustomTabsIntent +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.* import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme @@ -13,17 +16,21 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import chat.revolt.R import chat.revolt.api.REVOLT_FILES import chat.revolt.api.RevoltAPI import chat.revolt.api.internals.ULID import chat.revolt.api.schemas.AutumnResource import chat.revolt.components.generic.RemoteImage import chat.revolt.components.generic.UserAvatar +import chat.revolt.components.generic.UserAvatarWidthPlaceholder import chat.revolt.markdown.Renderer import chat.revolt.api.schemas.Message as MessageSchema @@ -46,42 +53,66 @@ fun formatLongAsTime(time: Long): String { return format.format(date) } +@OptIn(ExperimentalFoundationApi::class) @Composable fun Message( message: MessageSchema ) { val author = RevoltAPI.userCache[message.author] ?: return CircularProgressIndicator() val context = LocalContext.current + val clipboardManager = LocalClipboardManager.current Row( 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() ) { - UserAvatar( - username = author.username ?: "", - userId = author.id!!, - avatar = author.avatar - ) + if (message.tail == false) { + UserAvatar( + username = author.username ?: "", + userId = author.id!!, + avatar = author.avatar + ) + } else { + UserAvatarWidthPlaceholder() + } Column(modifier = Modifier.padding(start = 10.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = author.username ?: "", - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) + if (message.tail == false) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = author.username ?: "", + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) - Spacer(modifier = Modifier.width(5.dp)) + Spacer(modifier = Modifier.width(5.dp)) - Text( - text = formatLongAsTime(ULID.asTimestamp(message.id!!)), - fontSize = 12.sp, - color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.5f), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) + Text( + text = formatLongAsTime(ULID.asTimestamp(message.id!!)), + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.5f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } } message.content?.let { diff --git a/app/src/main/java/chat/revolt/components/generic/UserAvatar.kt b/app/src/main/java/chat/revolt/components/generic/UserAvatar.kt index 6b2590fd..711e6107 100644 --- a/app/src/main/java/chat/revolt/components/generic/UserAvatar.kt +++ b/app/src/main/java/chat/revolt/components/generic/UserAvatar.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable @@ -84,4 +85,12 @@ fun UserAvatar( PresenceBadge(presence) } } +} + +@Composable +fun UserAvatarWidthPlaceholder() { + Box( + modifier = Modifier + .width(40.dp) + ) } \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/screens/chat/views/ChannelScreen.kt b/app/src/main/java/chat/revolt/screens/chat/views/ChannelScreen.kt index 8929c977..9658f0d1 100644 --- a/app/src/main/java/chat/revolt/screens/chat/views/ChannelScreen.kt +++ b/app/src/main/java/chat/revolt/screens/chat/views/ChannelScreen.kt @@ -37,6 +37,7 @@ import chat.revolt.R import chat.revolt.RevoltTweenFloat import chat.revolt.RevoltTweenInt import chat.revolt.api.RevoltAPI +import chat.revolt.api.internals.ULID import chat.revolt.api.realtime.RealtimeSocket import chat.revolt.api.realtime.frames.receivable.ChannelStartTypingFrame 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 io.ktor.http.* import kotlinx.coroutines.launch +import kotlinx.datetime.Instant import java.io.File +import kotlin.time.Duration.Companion.minutes import chat.revolt.api.schemas.Message as MessageSchema class ChannelScreenViewModel : ViewModel() { @@ -186,7 +189,7 @@ class ChannelScreenViewModel : ViewModel() { messages.add(message) } } - setRenderableMessages(renderableMessages + messages) + regroupMessages(renderableMessages + messages) } } @@ -211,7 +214,7 @@ class ChannelScreenViewModel : ViewModel() { messages.add(message) } } - setRenderableMessages(renderableMessages + messages) + regroupMessages(renderableMessages + messages) } } @@ -276,6 +279,39 @@ class ChannelScreenViewModel : ViewModel() { } ?: it } } + + private fun regroupMessages(newMessages: List = renderableMessages) { + val groupedMessages = arrayListOf() + + // 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 diff --git a/app/src/main/java/chat/revolt/ui/theme/Theme.kt b/app/src/main/java/chat/revolt/ui/theme/Theme.kt index f2aa3a0e..be300940 100644 --- a/app/src/main/java/chat/revolt/ui/theme/Theme.kt +++ b/app/src/main/java/chat/revolt/ui/theme/Theme.kt @@ -1,5 +1,6 @@ package chat.revolt.ui.theme +import android.annotation.SuppressLint import android.app.Activity import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme @@ -59,6 +60,7 @@ enum class Theme { Amoled, } +@SuppressLint("NewApi") @Composable fun RevoltTheme( requestedTheme: Theme,