refactor(ChannelScreen): introduce channel screen 2

Signed-off-by: Infi <infi@infi.sh>
This commit is contained in:
Infi 2024-05-28 02:21:22 +02:00
parent 1d7bc309a3
commit 7550d01c8b
27 changed files with 2243 additions and 1655 deletions

View File

@ -58,8 +58,8 @@ android {
applicationId "chat.revolt"
minSdk 24
targetSdk 34
versionCode Integer.parseInt("000_007_000".replaceAll("_", ""), 10)
versionName "0.7.0"
versionCode Integer.parseInt("000_008_000".replaceAll("_", ""), 10)
versionName "0.8.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {

View File

@ -69,7 +69,7 @@ import chat.revolt.components.screens.chat.AttachmentManager
import chat.revolt.components.screens.chat.drawer.server.DrawerChannel
import chat.revolt.components.screens.chat.drawer.server.DrawerChannelIconType
import chat.revolt.persistence.KVStorage
import chat.revolt.screens.chat.views.channel.BottomPane
import chat.revolt.screens.chat.views.channel.ChannelScreenActivePane
import chat.revolt.ui.theme.RevoltTheme
import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.lifecycle.HiltViewModel
@ -174,7 +174,7 @@ class ShareTargetScreenViewModel @Inject constructor(
var attachments = mutableStateListOf<FileArgs>()
var attachmentsUploading by mutableStateOf(false)
var attachmentProgress by mutableFloatStateOf(0f)
var activeBottomPane by mutableStateOf<BottomPane>(BottomPane.None)
var activeBottomPane by mutableStateOf<ChannelScreenActivePane>(ChannelScreenActivePane.None)
suspend fun isLoggedIn(): Boolean {
return kvStorage.get("sessionToken") != null
@ -411,10 +411,10 @@ fun ShareTargetScreen(
onAddAttachment = {},
onCommitAttachment = {},
onPickEmoji = {
if (viewModel.activeBottomPane is BottomPane.EmojiPicker) {
viewModel.activeBottomPane = BottomPane.None
if (viewModel.activeBottomPane is ChannelScreenActivePane.EmojiPicker) {
viewModel.activeBottomPane = ChannelScreenActivePane.None
} else {
viewModel.activeBottomPane = BottomPane.EmojiPicker
viewModel.activeBottomPane = ChannelScreenActivePane.EmojiPicker
}
},
onSendMessage = {
@ -436,9 +436,9 @@ fun ShareTargetScreen(
channelName = RevoltAPI.channelCache[selectedChannel]?.name ?: "",
)
AnimatedVisibility(viewModel.activeBottomPane is BottomPane.EmojiPicker) {
BackHandler(enabled = viewModel.activeBottomPane == BottomPane.EmojiPicker) {
viewModel.activeBottomPane = BottomPane.None
AnimatedVisibility(viewModel.activeBottomPane is ChannelScreenActivePane.EmojiPicker) {
BackHandler(enabled = viewModel.activeBottomPane == ChannelScreenActivePane.EmojiPicker) {
viewModel.activeBottomPane = ChannelScreenActivePane.None
}
Column(

View File

@ -11,6 +11,7 @@ import chat.revolt.api.schemas.MessagesInChannel
import chat.revolt.api.schemas.User
import io.ktor.client.request.delete
import io.ktor.client.request.get
import io.ktor.client.request.header
import io.ktor.client.request.parameter
import io.ktor.client.request.patch
import io.ktor.client.request.post
@ -94,25 +95,25 @@ data class CreateInviteResponse(
val channel: String,
)
suspend
fun sendMessage(
suspend fun sendMessage(
channelId: String,
content: String,
nonce: String? = ULID.makeNext(),
nonce: String = ULID.makeNext(),
replies: List<SendMessageReply>? = null,
attachments: List<String>? = null
attachments: List<String>? = null,
idempotencyKey: String = ULID.makeNext()
): String {
val response = RevoltHttp.post("/channels/$channelId/messages") {
contentType(ContentType.Application.Json)
setBody(
SendMessageBody(
content = content,
nonce = nonce ?: ULID.makeNext(),
nonce = nonce,
replies = replies ?: emptyList(),
attachments = attachments
)
)
header("Idempotency-Key", idempotencyKey)
}
.bodyAsText()

View File

@ -1,5 +1,6 @@
package chat.revolt.callbacks
import chat.revolt.screens.chat.ChatRouterDestination
import kotlinx.coroutines.channels.Channel
sealed class Action {
@ -9,7 +10,9 @@ sealed class Action {
data class EmoteInfo(val emoteId: String) : Action()
data class MessageReactionInfo(val messageId: String, val emoji: String) : Action()
data class TopNavigate(val route: String) : Action()
data class ChatNavigate(val route: String) : Action()
data class ChatNavigate(val destination: ChatRouterDestination) : Action()
data class ReportUser(val userId: String) : Action()
data class ReportMessage(val messageId: String) : Action()
data class OpenVoiceChannelOverlay(val channelId: String) : Action()
}

View File

@ -0,0 +1,59 @@
package chat.revolt.components.chat
import android.icu.text.DateFormat
import android.text.format.DateUtils
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlinx.datetime.Instant
@Composable
fun DateDivider(instant: Instant, modifier: Modifier = Modifier) {
val context = LocalContext.current
val formattedDate = remember(instant) {
DateUtils.formatDateTime(
context,
instant.toEpochMilliseconds(),
DateFormat.FULL
)
}
Column {
Spacer(modifier = Modifier.height(16.dp))
Row(
modifier = modifier
.padding(8.dp)
.alpha(0.8F),
verticalAlignment = Alignment.CenterVertically,
) {
HorizontalDivider(
modifier = Modifier.width(44.dp),
thickness = Dp.Hairline
)
Text(
text = formattedDate,
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(horizontal = 8.dp)
)
HorizontalDivider(
modifier = Modifier.weight(1f),
thickness = Dp.Hairline
)
}
}
}

View File

@ -358,11 +358,9 @@ fun Message(
CompositionLocalProvider(
LocalMarkdownTreeConfig provides LocalMarkdownTreeConfig.current.copy(
currentServer = RevoltAPI.channelCache[message.channel]?.server
),
LocalTextStyle provides LocalTextStyle.current.copy(
lineHeight = 25.sp,
)
) {
Spacer(modifier = Modifier.height(2.dp))
RichMarkdown(input = message.content)
}
}

View File

@ -36,7 +36,6 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
@ -345,25 +344,20 @@ fun NativeMessageField(
) {
Spacer(modifier = Modifier.width(8.dp))
if (canAttach) {
// Note: There is an assumption that editing a message implies canAttach = false and editMode = true
AnimatedVisibility(canAttach) {
Icon(
when {
editMode -> Icons.Default.Close
else -> Icons.Default.Add
},
Icons.Default.Add,
tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f),
contentDescription = stringResource(id = R.string.add_attachment_alt),
modifier = Modifier
.clip(CircleShape)
.size(32.dp)
.clickable {
when {
editMode -> cancelEdit()
else -> {
// hide keyboard because it's annoying
clearFocus()
onAddAttachment()
}
if (!editMode) {
// hide keyboard because it's annoying
clearFocus()
onAddAttachment()
}
}
.padding(4.dp)

View File

@ -53,15 +53,15 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.revolt.R
@ -79,7 +79,11 @@ import chat.revolt.internals.UnicodeEmojiSection
import kotlinx.coroutines.launch
@Composable
fun EmojiPicker(onEmojiSelected: (String) -> Unit) {
fun EmojiPicker(
onSearchFocus: (Boolean) -> Unit = {},
bottomInset: Dp = 0.dp,
onEmojiSelected: (String) -> Unit,
) {
val view = LocalView.current
val focusManager = LocalFocusManager.current
@ -214,6 +218,9 @@ fun EmojiPicker(onEmojiSelected: (String) -> Unit) {
.fillMaxWidth(.9f)
.alpha(searchFieldOpacity)
.align(Alignment.CenterStart)
.onFocusChanged {
onSearchFocus(it.isFocused)
}
) { innerTextField ->
Box(
modifier = Modifier
@ -559,6 +566,15 @@ fun EmojiPicker(onEmojiSelected: (String) -> Unit) {
onServerEmoteInfo = onServerEmoteInfo
)
}
item(
key = "bottomInset",
span = {
GridItemSpan(spanCount)
}
) {
Spacer(Modifier.height(bottomInset))
}
}
}
}

View File

@ -64,8 +64,6 @@ object MarkdownTextRegularExpressions {
val Channel = Regex("<#([0-9A-Z]{26})>")
val CustomEmote = Regex(":([0-9A-Z]{26}):")
val Timestamp = Regex("<t:([0-9]+?)(:[tTDfFR])?>")
val UrlFallback =
Regex("<?https?://(www\\.)?[-a-zA-Z0-9@:%._+~#=]{2,256}\\.[a-z]{2,4}\\b([-a-zA-Z0-9@:%_+.~#?&/=]*)>?")
}
/**
@ -82,7 +80,6 @@ fun annotateText(node: AstNode): AnnotatedString {
val channels = MarkdownTextRegularExpressions.Channel.findAll(text)
val customEmotes = MarkdownTextRegularExpressions.CustomEmote.findAll(text)
val timestamps = MarkdownTextRegularExpressions.Timestamp.findAll(text)
val urls = MarkdownTextRegularExpressions.UrlFallback.findAll(text)
var lastIndex = 0
for (mention in mentions) {
@ -170,26 +167,6 @@ fun annotateText(node: AstNode): AnnotatedString {
lastIndex = timestamp.range.last + 1
}
// Yes, cmark should handle this, but for gTLDs like .chat it doesn't.
// As a service with a .chat TLD, this is a problem. Duct tape fix, their fault.
for (url in urls) {
append(text.substring(lastIndex, url.range.first))
pushStringAnnotation(
tag = Annotations.URL.tag,
annotation = url.value
)
pushStyle(
LocalTextStyle.current.toSpanStyle()
.copy(
color = MaterialTheme.colorScheme.primary
)
)
append(url.value)
pop()
pop()
lastIndex = url.range.last + 1
}
append(text.substring(lastIndex, text.length))
}

View File

@ -102,6 +102,7 @@ fun InbuiltMediaPicker(
onClose: () -> Unit,
onMediaSelected: (Media) -> Unit,
pendingMedia: List<String>,
modifier: Modifier = Modifier,
disabled: Boolean = false
) {
val context = LocalContext.current
@ -265,9 +266,9 @@ fun InbuiltMediaPicker(
}
Column(
modifier = Modifier
modifier = modifier
.fillMaxWidth()
.fillMaxHeight(0.5f)
.fillMaxHeight()
.background(MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp)),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center

View File

@ -1,44 +1,156 @@
package chat.revolt.components.screens.chat
import android.text.format.Formatter
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.ProgressIndicatorDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
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.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import chat.revolt.R
import chat.revolt.api.routes.microservices.autumn.FileArgs
import chat.revolt.components.generic.RemoteImage
import kotlinx.coroutines.launch
import java.io.File
@Composable
fun FilePreviewSheet(
args: FileArgs,
canRemove: Boolean,
onRemove: () -> Unit,
onDismiss: () -> Unit
) {
val context = LocalContext.current
Column(
Modifier.padding(start = 16.dp, end = 16.dp, bottom = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
if (args.contentType.startsWith("image/") || args.contentType.startsWith("video/")) {
RemoteImage(
url = args.file.toURI().toURL().toString(),
contentScale = ContentScale.Fit,
description = null,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f)
)
}
Text(
args.filename,
fontWeight = FontWeight.Medium,
textAlign = TextAlign.Center
)
Text(
Formatter.formatFileSize(context, args.file.length()),
color = LocalContentColor.current.copy(alpha = 0.6f),
textAlign = TextAlign.Center
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = {
onDismiss()
}, modifier = Modifier.weight(1f)) {
Icon(Icons.Default.Close, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text(stringResource(R.string.attachment_preview_close))
}
if (canRemove) {
TextButton(onClick = {
onRemove()
}, modifier = Modifier.weight(1f)) {
Icon(
painterResource(R.drawable.ic_paperclip_minus_24dp),
contentDescription = null
)
Spacer(modifier = Modifier.width(8.dp))
Text(stringResource(R.string.attachment_preview_remove))
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AttachmentManager(
attachments: List<FileArgs>,
uploading: Boolean,
uploadProgress: Float = 0f,
onRemove: (FileArgs) -> Unit,
canRemove: Boolean = true
canRemove: Boolean = true,
canPreview: Boolean = true
) {
var showPreviewSheet by remember { mutableStateOf(false) }
var previewingAttachment by remember { mutableStateOf<FileArgs?>(null) }
val scope = rememberCoroutineScope()
if (showPreviewSheet) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(onDismissRequest = {
showPreviewSheet = false
}, sheetState = sheetState) {
previewingAttachment?.let {
FilePreviewSheet(
args = it,
canRemove = canRemove,
onRemove = {
onRemove(it)
scope.launch {
sheetState.hide()
showPreviewSheet = false
}
},
onDismiss = {
scope.launch {
sheetState.hide()
showPreviewSheet = false
}
}
)
}
}
}
val animatedProgress by animateFloatAsState(
targetValue = uploadProgress,
animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec,
@ -61,7 +173,10 @@ fun AttachmentManager(
.padding(4.dp)
.clip(MaterialTheme.shapes.small)
.clickable {
onRemove(attachment)
if (canPreview) {
previewingAttachment = attachment
showPreviewSheet = true
}
}
.background(
color = MaterialTheme.colorScheme.background,
@ -70,13 +185,6 @@ fun AttachmentManager(
.padding(8.dp)
) {
Text(attachment.filename, maxLines = 1)
if (canRemove) {
Spacer(modifier = Modifier.width(4.dp))
Icon(
Icons.Default.Close,
contentDescription = stringResource(R.string.remove_attachment_alt)
)
}
}
Spacer(modifier = Modifier.width(8.dp))
}

View File

@ -31,8 +31,8 @@ import chat.revolt.R
import chat.revolt.api.RevoltAPI
import chat.revolt.api.internals.ULID
import chat.revolt.api.routes.channel.SendMessageReply
import chat.revolt.api.routes.microservices.january.asJanuaryProxyUrl
import chat.revolt.api.schemas.Message
import chat.revolt.components.chat.authorAvatarUrl
import chat.revolt.components.chat.authorColour
import chat.revolt.components.chat.authorName
import chat.revolt.components.generic.UserAvatar
@ -73,11 +73,12 @@ fun ManageableReply(reply: SendMessageReply, onToggleMention: () -> Unit, onRemo
Spacer(modifier = Modifier.width(8.dp))
UserAvatar(
username = authorName(message = replyMessage),
userId = replyAuthor.id ?: ULID.makeSpecial(0),
avatar = replyAuthor.avatar,
rawUrl = replyMessage.masquerade?.avatar?.let { asJanuaryProxyUrl(it) },
rawUrl = authorAvatarUrl(message = replyMessage),
size = 16.dp
)
@ -86,9 +87,6 @@ fun ManageableReply(reply: SendMessageReply, onToggleMention: () -> Unit, onRemo
Text(
text = authorName(message = replyMessage),
modifier = Modifier
.clickable {
onToggleMention()
}
.padding(4.dp),
style = LocalTextStyle.current.copy(
brush = authorColour(message = replyMessage),
@ -103,9 +101,6 @@ fun ManageableReply(reply: SendMessageReply, onToggleMention: () -> Unit, onRemo
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.clickable {
onToggleMention()
}
.padding(4.dp)
.weight(1f)
)

View File

@ -12,12 +12,10 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@ -25,23 +23,27 @@ import androidx.compose.ui.unit.sp
import chat.revolt.R
import chat.revolt.activities.RevoltTweenFloat
import chat.revolt.activities.RevoltTweenInt
import chat.revolt.api.REVOLT_FILES
import chat.revolt.api.RevoltAPI
import chat.revolt.api.schemas.User
import chat.revolt.components.generic.UserAvatar
@Composable
fun StackedUserAvatars(users: List<String>, amount: Int = 3) {
fun StackedUserAvatars(users: List<String>, amount: Int = 3, serverId: String?) {
Box(
modifier = Modifier
.size(16.dp + (8.dp * minOf(users.size, amount)), 16.dp)
) {
users.take(amount).forEachIndexed { index, userId ->
val user = RevoltAPI.userCache[userId]
val maybeMember = serverId?.let { RevoltAPI.members.getMember(serverId, userId) }
UserAvatar(
avatar = user?.avatar,
userId = userId,
username = user?.let { User.resolveDefaultName(it) }
?: stringResource(id = R.string.unknown),
rawUrl = maybeMember?.avatar?.let { "$REVOLT_FILES/avatars/${it.id}?max_side=256" },
size = 16.dp,
modifier = Modifier
.offset(
@ -53,7 +55,7 @@ fun StackedUserAvatars(users: List<String>, amount: Int = 3) {
}
@Composable
fun TypingIndicator(users: List<String>) {
fun TypingIndicator(users: List<String>, serverId: String?) {
fun typingMessageResource(): Int {
return when (users.size) {
0 -> R.string.typing_blank
@ -77,24 +79,21 @@ fun TypingIndicator(users: List<String>) {
Row(
Modifier
.fillMaxWidth()
.clip(
RoundedCornerShape(
topStart = 16.dp,
topEnd = 16.dp
)
)
.background(MaterialTheme.colorScheme.background.copy(alpha = 0.9f))
.padding(top = 4.dp, start = 16.dp, end = 16.dp)
.padding(vertical = 8.dp, horizontal = 16.dp)
) {
StackedUserAvatars(users = users)
StackedUserAvatars(users = users, serverId = serverId)
Text(
text = stringResource(
id = typingMessageResource(),
users.joinToString {
RevoltAPI.userCache[it]?.let { u ->
User.resolveDefaultName(u)
} ?: it
users.joinToString { userId ->
RevoltAPI.userCache[userId]?.let { u ->
val maybeMember =
serverId?.let { RevoltAPI.members.getMember(serverId, userId) }
maybeMember?.nickname ?: User.resolveDefaultName(u)
} ?: userId
}
),
fontSize = 12.sp,

View File

@ -71,6 +71,7 @@ import chat.revolt.api.schemas.has
import chat.revolt.components.generic.presenceFromStatus
import chat.revolt.components.screens.chat.drawer.server.DrawerChannel
import chat.revolt.components.screens.chat.drawer.server.DrawerChannelIconType
import chat.revolt.screens.chat.ChatRouterDestination
import chat.revolt.sheets.ChannelContextSheet
import com.bumptech.glide.Glide
import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions
@ -83,11 +84,9 @@ const val BANNER_HEIGHT_EXPANDED = 128
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable
fun RowScope.ChannelList(
serverId: String,
currentDestination: String?,
currentChannel: String?,
onChannelClick: (String) -> Unit,
onSpecialClick: (String) -> Unit,
serverId: String?,
currentDestination: ChatRouterDestination,
onDestinationChange: (ChatRouterDestination) -> Unit,
onServerSheetOpenFor: (String) -> Unit
) {
val lazyListState = rememberLazyListState()
@ -163,7 +162,7 @@ fun RowScope.ChannelList(
.fillMaxSize(),
state = lazyListState
) {
if (serverId == "home") {
if (serverId == null) {
stickyHeader(
key = "header"
) {
@ -192,10 +191,10 @@ fun RowScope.ChannelList(
DrawerChannel(
name = stringResource(R.string.home),
iconType = DrawerChannelIconType.Painter(painterResource(R.drawable.ic_home_24dp)),
selected = currentDestination == "home",
selected = currentDestination == ChatRouterDestination.Home,
hasUnread = false,
onClick = {
onSpecialClick("home")
onDestinationChange(ChatRouterDestination.Home)
},
large = true
)
@ -207,10 +206,10 @@ fun RowScope.ChannelList(
DrawerChannel(
name = stringResource(R.string.friends),
iconType = DrawerChannelIconType.Painter(painterResource(R.drawable.ic_human_greeting_variant_24dp)),
selected = currentDestination == "friends",
selected = currentDestination == ChatRouterDestination.Friends,
hasUnread = false,
onClick = {
onSpecialClick("friends")
onDestinationChange(ChatRouterDestination.Friends)
},
large = true
)
@ -225,11 +224,13 @@ fun RowScope.ChannelList(
DrawerChannel(
name = stringResource(R.string.channel_notes),
iconType = DrawerChannelIconType.Channel(ChannelType.SavedMessages),
selected = currentDestination == "channel/$notesChannelId",
selected = currentDestination == ChatRouterDestination.Channel(
notesChannelId ?: ""
),
hasUnread = false,
onClick = {
if (notesChannelId != null) {
onChannelClick(notesChannelId)
onDestinationChange(ChatRouterDestination.Channel(notesChannelId))
return@DrawerChannel
}
@ -239,7 +240,11 @@ fun RowScope.ChannelList(
if (RevoltAPI.channelCache[notesChannel.id] == null)
RevoltAPI.channelCache[notesChannel.id] = notesChannel
}
onChannelClick(notesChannel.id ?: return@launch)
onDestinationChange(
ChatRouterDestination.Channel(
notesChannel.id ?: return@launch
)
)
}
},
large = true
@ -284,7 +289,9 @@ fun RowScope.ChannelList(
iconType = DrawerChannelIconType.Channel(
channel.channelType ?: ChannelType.TextChannel
),
selected = currentDestination == "channel/${channel.id}",
selected = currentDestination == ChatRouterDestination.Channel(
channel.id ?: ""
),
hasUnread = channel.lastMessageID?.let { lastMessageID ->
RevoltAPI.unreads.hasUnread(
channel.id!!,
@ -299,7 +306,11 @@ fun RowScope.ChannelList(
online = partner?.online ?: false
),
onClick = {
onChannelClick(channel.id ?: return@DrawerChannel)
onDestinationChange(
ChatRouterDestination.Channel(
channel.id ?: return@DrawerChannel
)
)
},
onLongClick = {
channelContextSheetTarget = channel.id ?: return@DrawerChannel
@ -476,7 +487,7 @@ fun RowScope.ChannelList(
)
IconButton(onClick = {
onServerSheetOpenFor(serverId)
onServerSheetOpenFor(serverId ?: return@IconButton)
}) {
Icon(
imageVector = Icons.Default.MoreVert,
@ -497,7 +508,7 @@ fun RowScope.ChannelList(
if (categorisedChannels.isNullOrEmpty()) {
item {
Column(
Modifier.weight(1f),
Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
@ -550,7 +561,9 @@ fun RowScope.ChannelList(
iconType = DrawerChannelIconType.Channel(
channel.channelType ?: ChannelType.TextChannel
),
selected = currentDestination == "channel/${channel.id}",
selected = currentDestination == ChatRouterDestination.Channel(
channel.id ?: ""
),
hasUnread = channel.lastMessageID?.let { lastMessageID ->
RevoltAPI.unreads.hasUnread(
channel.id!!,
@ -569,7 +582,11 @@ fun RowScope.ChannelList(
online = partner?.online ?: false
),
onClick = {
onChannelClick(channel.id ?: return@DrawerChannel)
onDestinationChange(
ChatRouterDestination.Channel(
channel.id ?: return@DrawerChannel
)
)
},
onLongClick = {
channelContextSheetTarget =

View File

@ -280,7 +280,7 @@ fun UserButtons(
},
onClick = {
scope.launch {
ActionChannel.send(Action.ChatNavigate("report/user/${user.id}"))
ActionChannel.send(Action.ReportUser(user.id))
if (Platform.needsShowClipboardNotification()) {
Toast.makeText(

View File

@ -4,15 +4,15 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableLongState
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import chat.revolt.api.RevoltAPI
import chat.revolt.api.internals.Roles
@Composable
fun rememberChannelPermissions(channelId: String): MutableLongState {
val permissions = remember { mutableLongStateOf(0L) }
fun rememberChannelPermissions(channelId: String, key1: Any = Unit): MutableLongState {
val permissions = rememberSaveable { mutableLongStateOf(0L) }
LaunchedEffect(channelId) {
LaunchedEffect(channelId, key1) {
if (RevoltAPI.selfId == null) return@LaunchedEffect
if (RevoltAPI.userCache[RevoltAPI.selfId] == null) return@LaunchedEffect
if (RevoltAPI.channelCache[channelId] == null) return@LaunchedEffect

View File

@ -7,9 +7,9 @@ import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.firstOrNull
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.flow.firstOrNull
val Context.revoltKVStorage: DataStore<Preferences> by preferencesDataStore(name = "revolt_kv")
@ -39,6 +39,16 @@ class KVStorage @Inject constructor(
return dataStore.data.firstOrNull()?.get(stringPreferencesKey(key))?.toBoolean()
}
suspend fun set(key: String, value: Int) {
dataStore.edit { preferences ->
preferences[stringPreferencesKey(key)] = value.toString()
}
}
suspend fun getInt(key: String): Int? {
return dataStore.data.firstOrNull()?.get(stringPreferencesKey(key))?.toInt()
}
suspend fun remove(key: String) {
dataStore.edit { preferences ->
preferences.remove(stringPreferencesKey(key))

View File

@ -62,18 +62,12 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.navigation.NavController
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.dialog
import androidx.navigation.compose.rememberNavController
import chat.revolt.R
import chat.revolt.api.RevoltAPI
import chat.revolt.api.internals.ChannelUtils
import chat.revolt.api.internals.DirectMessages
import chat.revolt.api.realtime.DisconnectionState
import chat.revolt.api.realtime.RealtimeSocket
import chat.revolt.api.routes.server.fetchMembers
import chat.revolt.api.schemas.ChannelType
import chat.revolt.api.schemas.User
import chat.revolt.api.settings.SyncedSettings
@ -96,7 +90,7 @@ import chat.revolt.screens.chat.dialogs.safety.ReportUserDialog
import chat.revolt.screens.chat.views.FriendsScreen
import chat.revolt.screens.chat.views.HomeScreen
import chat.revolt.screens.chat.views.NoCurrentChannelScreen
import chat.revolt.screens.chat.views.channel.ChannelScreen
import chat.revolt.screens.chat.views.channel.ChannelScreen2
import chat.revolt.sheets.AddServerSheet
import chat.revolt.sheets.ChangelogSheet
import chat.revolt.sheets.EmoteInfoSheet
@ -116,15 +110,49 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
import javax.inject.Inject
sealed class ChatRouterDestination {
data object Home : ChatRouterDestination()
data object Friends : ChatRouterDestination()
data class Channel(val channelId: String) : ChatRouterDestination()
data class NoCurrentChannel(val serverId: String?) : ChatRouterDestination()
fun asSerialisedString(): String {
return when (this) {
is Home -> "home"
is Friends -> "friends"
is Channel -> "channel/$channelId"
is NoCurrentChannel -> "no_current_channel/$serverId"
}
}
companion object {
val default = Home
val defaultForDMList = Home
fun fromString(destination: String): ChatRouterDestination {
return when {
destination == "home" -> Home
destination == "friends" -> Friends
destination.startsWith("no_current_channel/") -> NoCurrentChannel(
destination.removePrefix(
"no_current_channel/"
)
)
destination.startsWith("channel/") -> Channel(destination.removePrefix("channel/"))
else -> default
}
}
}
}
@HiltViewModel
@SuppressLint("StaticFieldLeak")
class ChatRouterViewModel @Inject constructor(
private val kvStorage: KVStorage,
@ApplicationContext val context: Context
) : ViewModel() {
var currentServer by mutableStateOf("home")
var currentChannel by mutableStateOf<String?>(null)
var currentRoute by mutableStateOf("home")
var currentDestination by mutableStateOf<ChatRouterDestination>(ChatRouterDestination.default)
var sidebarSparkDisplayed by mutableStateOf(true)
var latestChangelogRead by mutableStateOf(true)
var latestChangelog by mutableStateOf("")
@ -133,9 +161,8 @@ class ChatRouterViewModel @Inject constructor(
init {
viewModelScope.launch {
currentServer = kvStorage.get("currentServer") ?: "home"
currentChannel = kvStorage.get("currentChannel")
currentRoute = kvStorage.get("currentRoute") ?: "home"
val current = kvStorage.get("currentDestination")
setSaveDestination(ChatRouterDestination.fromString(current ?: ""))
sidebarSparkDisplayed = if (kvStorage.getBoolean("sidebarSpark") == null) {
false
@ -151,27 +178,18 @@ class ChatRouterViewModel @Inject constructor(
}
}
private suspend fun setSaveCurrentServer(serverId: String) {
currentServer = serverId
kvStorage.set("currentServer", serverId)
if (serverId != "home") fetchMembers(serverId, includeOffline = false, pure = false)
}
private fun setSaveCurrentRoute(route: String) {
currentRoute = route
fun setSaveDestination(destination: ChatRouterDestination) {
currentDestination = destination
viewModelScope.launch {
kvStorage.set("currentRoute", route)
}
}
kvStorage.set("currentDestination", destination.asSerialisedString())
private fun setSaveCurrentChannel(channelId: String) {
currentChannel = channelId
viewModelScope.launch {
kvStorage.set("currentChannel", channelId)
if (destination is ChatRouterDestination.Channel) {
val server = RevoltAPI.channelCache[destination.channelId]?.server
if (server != null) {
kvStorage.set("lastChannel/$server", destination.channelId)
}
}
}
}
@ -180,64 +198,17 @@ class ChatRouterViewModel @Inject constructor(
sidebarSparkDisplayed = true
}
fun navigateToServer(serverId: String, navController: NavController) {
if (serverId == "home") {
navController.navigate("home") {
navController.graph.startDestinationRoute?.let { route ->
popUpTo(route)
}
}
viewModelScope.launch {
setSaveCurrentServer("home")
setSaveCurrentRoute("home")
}
return
}
val channelId = RevoltAPI.serverCache[serverId]?.channels?.firstOrNull()
fun navigateToServer(serverId: String) {
viewModelScope.launch {
setSaveCurrentServer(serverId)
}
val savedLastChannel = kvStorage.get("lastChannel/$serverId")
val channelId =
savedLastChannel ?: RevoltAPI.serverCache[serverId]?.channels?.firstOrNull()
if (channelId != null) {
navigateToChannel(channelId, navController)
} else {
navController.navigate("no_current_channel") {
navController.graph.startDestinationRoute?.let { route ->
popUpTo(route)
}
if (channelId != null) {
setSaveDestination(ChatRouterDestination.Channel(channelId))
} else {
setSaveDestination(ChatRouterDestination.NoCurrentChannel(serverId))
}
viewModelScope.launch {
setSaveCurrentRoute("no_current_channel")
}
}
}
fun navigateToChannel(channelId: String, navController: NavController, pure: Boolean = false) {
if (!pure) setSaveCurrentChannel(channelId)
navController.navigate("channel/$channelId") {
navController.graph.startDestinationRoute?.let { route ->
popUpTo(route)
}
}
viewModelScope.launch {
setSaveCurrentRoute("channel/$channelId")
}
}
fun navigateToSpecial(destination: String, navController: NavController) {
navController.navigate(destination) {
navController.graph.startDestinationRoute?.let { route ->
popUpTo(route)
}
}
viewModelScope.launch {
setSaveCurrentRoute(destination)
}
}
}
@ -255,8 +226,6 @@ fun ChatRouterScreen(
val context = LocalContext.current
val view = LocalView.current
val navController = rememberNavController()
val showSidebarSpark = remember { mutableStateOf(false) }
val sidebarSparkComposition by rememberLottieComposition(
LottieCompositionSpec.RawRes(R.raw.open_settings_tutorial)
@ -294,6 +263,15 @@ fun ChatRouterScreen(
var voiceChannelOverlay by remember { mutableStateOf(false) }
var voiceChannelOverlayChannelId by remember { mutableStateOf("") }
var showReportUser by remember { mutableStateOf(false) }
var reportUserTarget by remember { mutableStateOf("") }
var showReportMessage by remember { mutableStateOf(false) }
var reportMessageTarget by remember { mutableStateOf("") }
var showReportServer by remember { mutableStateOf(false) }
var reportServerTarget by remember { mutableStateOf("") }
val toggleDrawerLambda = remember {
{
scope.launch {
@ -306,6 +284,20 @@ fun ChatRouterScreen(
}
}
val currentServer = remember(viewModel.currentDestination) {
when (viewModel.currentDestination) {
is ChatRouterDestination.Channel -> {
RevoltAPI.channelCache[(viewModel.currentDestination as ChatRouterDestination.Channel).channelId]?.server
}
is ChatRouterDestination.NoCurrentChannel -> {
(viewModel.currentDestination as ChatRouterDestination.NoCurrentChannel).serverId
}
else -> null
}
}
LaunchedEffect(drawerState) {
snapshotFlow { drawerState.currentValue }
.distinctUntilChanged()
@ -318,16 +310,6 @@ fun ChatRouterScreen(
}
}
LaunchedEffect(viewModel.currentChannel) {
snapshotFlow { viewModel.currentChannel }
.distinctUntilChanged()
.collect { channelId ->
if (channelId != null) {
viewModel.navigateToChannel(channelId, navController, pure = true)
}
}
}
LaunchedEffect(viewModel.sidebarSparkDisplayed) {
snapshotFlow { viewModel.sidebarSparkDisplayed }
.distinctUntilChanged()
@ -383,12 +365,7 @@ fun ChatRouterScreen(
return@let
}
if (resolvedChannel.server != null) {
viewModel.navigateToServer(resolvedChannel.server, navController)
} else {
viewModel.navigateToServer("home", navController)
}
viewModel.navigateToChannel(action.channelId, navController)
viewModel.setSaveDestination(ChatRouterDestination.Channel(action.channelId))
}
is Action.LinkInfo -> {
@ -412,7 +389,17 @@ fun ChatRouterScreen(
}
is Action.ChatNavigate -> {
navController.navigate(action.route)
viewModel.setSaveDestination(action.destination)
}
is Action.ReportUser -> {
reportUserTarget = action.userId
showReportUser = true
}
is Action.ReportMessage -> {
reportMessageTarget = action.messageId
showReportMessage = true
}
is Action.OpenVoiceChannelOverlay -> {
@ -501,8 +488,7 @@ fun ChatRouterScreen(
TextButton(onClick = {
showPlatformModDMHint = false
DirectMessages.getPlatformModerationDM()?.id?.let {
viewModel.navigateToServer("home", navController)
viewModel.navigateToChannel(it, navController)
viewModel.setSaveDestination(ChatRouterDestination.Channel(it))
}
}) {
Text(stringResource(id = R.string.notice_platform_mod_dm_acknowledge))
@ -563,7 +549,8 @@ fun ChatRouterScreen(
showServerContextSheet = false
},
onReportServer = {
navController.navigate("report/server/${serverContextSheetTarget}")
reportServerTarget = currentServer ?: ""
showReportServer = true
}
)
}
@ -589,6 +576,27 @@ fun ChatRouterScreen(
}
}
if (showReportUser) {
ReportUserDialog(
onDismiss = { showReportUser = false },
userId = reportUserTarget
)
}
if (showReportMessage) {
ReportMessageDialog(
onDismiss = { showReportMessage = false },
messageId = reportMessageTarget
)
}
if (showReportServer) {
ReportServerDialog(
onDismiss = { showReportServer = false },
serverId = reportServerTarget
)
}
if (showChannelUnavailableAlert) {
AlertDialog(
onDismissRequest = {
@ -717,7 +725,7 @@ fun ChatRouterScreen(
Sidebar(
viewModel = viewModel,
topNav = topNav,
navController = navController,
currentServer = currentServer,
onShowStatusSheet = {
showStatusSheet = true
},
@ -735,16 +743,11 @@ fun ChatRouterScreen(
)
}
ChannelNavigator(
navController = navController,
dest = viewModel.currentDestination,
topNav = topNav,
useDrawer = false,
toggleDrawer = {
toggleDrawerLambda()
},
onShowUserContextSheet = { target, server ->
userContextSheetTarget = target
userContextSheetServer = server
showUserContextSheet = true
}
)
}
@ -759,7 +762,7 @@ fun ChatRouterScreen(
Sidebar(
viewModel = viewModel,
topNav = topNav,
navController = navController,
currentServer = currentServer,
onShowStatusSheet = {
showStatusSheet = true
},
@ -781,18 +784,13 @@ fun ChatRouterScreen(
content = {
Row(Modifier.fillMaxSize()) {
ChannelNavigator(
navController = navController,
dest = viewModel.currentDestination,
topNav = topNav,
useDrawer = true,
toggleDrawer = {
toggleDrawerLambda()
},
drawerState = drawerState,
onShowUserContextSheet = { target, server ->
userContextSheetTarget = target
userContextSheetServer = server
showUserContextSheet = true
}
drawerState = drawerState
)
}
}
@ -804,8 +802,8 @@ fun ChatRouterScreen(
@Composable
fun Sidebar(
viewModel: ChatRouterViewModel,
currentServer: String?,
topNav: NavController,
navController: NavHostController,
drawerState: DrawerState? = null,
onShowStatusSheet: () -> Unit,
onShowServerContextSheet: (String) -> Unit,
@ -839,7 +837,7 @@ fun Sidebar(
size = 48.dp,
presenceSize = 16.dp,
onClick = {
viewModel.navigateToServer("home", navController)
viewModel.setSaveDestination(ChatRouterDestination.defaultForDMList)
},
onLongClick = onShowStatusSheet,
modifier = Modifier
@ -854,14 +852,7 @@ fun Sidebar(
size = 48.dp,
onClick = {
it.id?.let { id ->
viewModel.navigateToServer(
"home",
navController
)
viewModel.navigateToChannel(
id,
navController
)
viewModel.setSaveDestination(ChatRouterDestination.Channel(id))
}
},
icon = it.icon,
@ -898,13 +889,10 @@ fun Sidebar(
presenceSize = 16.dp,
onClick = {
it.id?.let { id ->
viewModel.navigateToServer(
"home",
navController
)
viewModel.navigateToChannel(
id,
navController
viewModel.setSaveDestination(
ChatRouterDestination.Channel(
id
)
)
}
},
@ -952,10 +940,7 @@ fun Sidebar(
onShowServerContextSheet(server.id)
}
) {
viewModel.navigateToServer(
server.id,
navController
)
viewModel.navigateToServer(server.id)
}
}
@ -993,20 +978,17 @@ fun Sidebar(
}
Crossfade(
targetState = viewModel.currentServer,
targetState = currentServer,
label = "Channel List"
) {
ChannelList(
serverId = it,
currentDestination = viewModel.currentRoute,
currentChannel = viewModel.currentChannel,
onChannelClick = { channelId ->
viewModel.navigateToChannel(channelId, navController)
scope.launch { drawerState?.close() }
},
onSpecialClick = { destination ->
viewModel.navigateToSpecial(destination, navController)
scope.launch { drawerState?.close() }
currentDestination = viewModel.currentDestination,
onDestinationChange = { destination ->
viewModel.setSaveDestination(destination)
scope.launch {
drawerState?.close()
}
},
onServerSheetOpenFor = { target ->
onShowServerContextSheet(target)
@ -1019,21 +1001,21 @@ fun Sidebar(
@Composable
fun ChannelNavigator(
navController: NavHostController,
dest: ChatRouterDestination,
topNav: NavController,
useDrawer: Boolean,
toggleDrawer: () -> Unit,
drawerState: DrawerState? = null,
onShowUserContextSheet: (String, String?) -> Unit
drawerState: DrawerState? = null
) {
val scope = rememberCoroutineScope()
BackHandler(enabled = useDrawer) {
toggleDrawer()
}
Column(Modifier.fillMaxSize()) {
NavHost(navController = navController, startDestination = "home") {
composable("home") {
BackHandler(enabled = useDrawer) {
toggleDrawer()
}
when (dest) {
is ChatRouterDestination.Home -> {
HomeScreen(
navController = topNav,
useDrawer = useDrawer,
@ -1041,10 +1023,7 @@ fun ChannelNavigator(
)
}
composable("friends") {
BackHandler(enabled = useDrawer) {
toggleDrawer()
}
is ChatRouterDestination.Friends -> {
FriendsScreen(
topNav = topNav,
useDrawer = useDrawer,
@ -1052,70 +1031,25 @@ fun ChannelNavigator(
)
}
composable("channel/{channelId}") { backStackEntry ->
BackHandler(enabled = useDrawer) {
toggleDrawer()
}
val channelId = backStackEntry.arguments?.getString("channelId")
if (channelId != null) {
ChannelScreen(
navController = navController,
channelId = channelId,
onToggleDrawer = {
scope.launch {
if (drawerState?.isOpen == true) {
drawerState.close()
} else {
drawerState?.open()
}
is ChatRouterDestination.Channel -> {
ChannelScreen2(
channelId = dest.channelId,
onToggleDrawer = {
scope.launch {
if (drawerState?.isOpen == true) {
drawerState.close()
} else {
drawerState?.open()
}
},
onUserSheetOpenFor = { target, server ->
onShowUserContextSheet(target, server)
},
useDrawer = useDrawer
)
}
}
},
useDrawer = useDrawer
)
}
composable("no_current_channel") {
BackHandler(enabled = useDrawer) {
toggleDrawer()
}
is ChatRouterDestination.NoCurrentChannel -> {
NoCurrentChannelScreen(useDrawer = useDrawer, onDrawerClicked = toggleDrawer)
}
dialog("report/message/{messageId}") { backStackEntry ->
val messageId = backStackEntry.arguments?.getString("messageId")
if (messageId != null) {
ReportMessageDialog(
navController = navController,
messageId = messageId
)
}
}
dialog("report/server/{serverId}") { backStackEntry ->
val serverId = backStackEntry.arguments?.getString("serverId")
if (serverId != null) {
ReportServerDialog(
navController = navController,
serverId = serverId
)
}
}
dialog("report/user/{userId}") { backStackEntry ->
val userId = backStackEntry.arguments?.getString("userId")
if (userId != null) {
ReportUserDialog(
navController = navController,
userId = userId
)
}
}
}
}
}

View File

@ -40,7 +40,6 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import chat.revolt.R
import chat.revolt.api.RevoltAPI
import chat.revolt.api.routes.safety.putMessageReport
@ -59,10 +58,10 @@ enum class MessageReportFlowState {
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ReportMessageDialog(navController: NavController, messageId: String) {
fun ReportMessageDialog(onDismiss: () -> Unit, messageId: String) {
val message = RevoltAPI.messageCache[messageId]
if (message == null) {
navController.popBackStack()
onDismiss()
return
}
@ -206,7 +205,7 @@ fun ReportMessageDialog(navController: NavController, messageId: String) {
dismissButton = {
TextButton(
onClick = {
navController.popBackStack()
onDismiss()
},
modifier = Modifier.testTag("report_cancel")
) {
@ -270,7 +269,7 @@ fun ReportMessageDialog(navController: NavController, messageId: String) {
AlertDialog(
onDismissRequest = {
navController.popBackStack()
onDismiss()
},
icon = {
Icon(
@ -311,7 +310,7 @@ fun ReportMessageDialog(navController: NavController, messageId: String) {
dismissButton = {
TextButton(
onClick = {
navController.popBackStack()
onDismiss()
},
modifier = Modifier.testTag("report_block_no")
) {
@ -324,7 +323,7 @@ fun ReportMessageDialog(navController: NavController, messageId: String) {
scope.launch {
blockUser(message.author ?: return@launch)
}
navController.popBackStack()
onDismiss()
},
modifier = Modifier.testTag("report_block_yes")
) {
@ -337,7 +336,7 @@ fun ReportMessageDialog(navController: NavController, messageId: String) {
MessageReportFlowState.Error -> {
AlertDialog(
onDismissRequest = {
navController.popBackStack()
onDismiss()
},
icon = {
Icon(
@ -364,7 +363,7 @@ fun ReportMessageDialog(navController: NavController, messageId: String) {
dismissButton = {
TextButton(
onClick = {
navController.popBackStack()
onDismiss()
},
modifier = Modifier.testTag("report_error_ok")
) {

View File

@ -31,7 +31,6 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import chat.revolt.R
import chat.revolt.api.RevoltAPI
import chat.revolt.api.routes.safety.putServerReport
@ -49,10 +48,10 @@ enum class ServerReportFlowState {
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ReportServerDialog(navController: NavController, serverId: String) {
fun ReportServerDialog(onDismiss: () -> Unit, serverId: String) {
val server = RevoltAPI.serverCache[serverId]
if (server == null) {
navController.popBackStack()
onDismiss()
return
}
@ -171,7 +170,7 @@ fun ReportServerDialog(navController: NavController, serverId: String) {
dismissButton = {
TextButton(
onClick = {
navController.popBackStack()
onDismiss()
},
modifier = Modifier.testTag("report_cancel")
) {
@ -233,7 +232,7 @@ fun ReportServerDialog(navController: NavController, serverId: String) {
ServerReportFlowState.Done -> {
AlertDialog(
onDismissRequest = {
navController.popBackStack()
onDismiss()
},
icon = {
Icon(
@ -265,7 +264,7 @@ fun ReportServerDialog(navController: NavController, serverId: String) {
confirmButton = {
TextButton(
onClick = {
navController.popBackStack()
onDismiss()
},
modifier = Modifier.testTag("report_close")
) {
@ -278,7 +277,7 @@ fun ReportServerDialog(navController: NavController, serverId: String) {
ServerReportFlowState.Error -> {
AlertDialog(
onDismissRequest = {
navController.popBackStack()
onDismiss()
},
icon = {
Icon(
@ -305,7 +304,7 @@ fun ReportServerDialog(navController: NavController, serverId: String) {
dismissButton = {
TextButton(
onClick = {
navController.popBackStack()
onDismiss()
},
modifier = Modifier.testTag("report_error_ok")
) {

View File

@ -32,7 +32,6 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import chat.revolt.R
import chat.revolt.api.RevoltAPI
import chat.revolt.api.routes.safety.putUserReport
@ -51,10 +50,10 @@ enum class UserReportFlowState {
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ReportUserDialog(navController: NavController, userId: String) {
fun ReportUserDialog(onDismiss: () -> Unit, userId: String) {
val user = RevoltAPI.userCache[userId]
if (user == null) {
navController.popBackStack()
onDismiss()
return
}
@ -167,7 +166,7 @@ fun ReportUserDialog(navController: NavController, userId: String) {
dismissButton = {
TextButton(
onClick = {
navController.popBackStack()
onDismiss()
},
modifier = Modifier.testTag("report_cancel")
) {
@ -231,7 +230,7 @@ fun ReportUserDialog(navController: NavController, userId: String) {
AlertDialog(
onDismissRequest = {
navController.popBackStack()
onDismiss()
},
icon = {
Icon(
@ -272,7 +271,7 @@ fun ReportUserDialog(navController: NavController, userId: String) {
dismissButton = {
TextButton(
onClick = {
navController.popBackStack()
onDismiss()
},
modifier = Modifier.testTag("report_block_no")
) {
@ -285,7 +284,7 @@ fun ReportUserDialog(navController: NavController, userId: String) {
scope.launch {
blockUser(userId)
}
navController.popBackStack()
onDismiss()
},
modifier = Modifier.testTag("report_block_yes")
) {
@ -298,7 +297,7 @@ fun ReportUserDialog(navController: NavController, userId: String) {
UserReportFlowState.Error -> {
AlertDialog(
onDismissRequest = {
navController.popBackStack()
onDismiss()
},
icon = {
Icon(
@ -325,7 +324,7 @@ fun ReportUserDialog(navController: NavController, userId: String) {
dismissButton = {
TextButton(
onClick = {
navController.popBackStack()
onDismiss()
},
modifier = Modifier.testTag("report_error_ok")
) {

View File

@ -1,653 +0,0 @@
package chat.revolt.screens.chat.views.channel
import android.content.ContentValues
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import android.widget.Toast
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SmallFloatingActionButton
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import chat.revolt.R
import chat.revolt.activities.RevoltTweenDp
import chat.revolt.activities.RevoltTweenFloat
import chat.revolt.activities.RevoltTweenInt
import chat.revolt.api.RevoltAPI
import chat.revolt.api.internals.ChannelUtils
import chat.revolt.api.routes.channel.react
import chat.revolt.api.routes.microservices.autumn.FileArgs
import chat.revolt.api.schemas.Channel
import chat.revolt.api.schemas.ChannelType
import chat.revolt.api.settings.FeatureFlags
import chat.revolt.callbacks.Action
import chat.revolt.callbacks.ActionChannel
import chat.revolt.components.chat.Message
import chat.revolt.components.chat.NativeMessageField
import chat.revolt.components.chat.SystemMessage
import chat.revolt.components.emoji.EmojiPicker
import chat.revolt.components.media.InbuiltMediaPicker
import chat.revolt.components.screens.chat.AttachmentManager
import chat.revolt.components.screens.chat.ChannelHeader
import chat.revolt.components.screens.chat.ReplyManager
import chat.revolt.components.screens.chat.TypingIndicator
import chat.revolt.sheets.ChannelInfoSheet
import chat.revolt.sheets.MessageContextSheet
import chat.revolt.sheets.ReactSheet
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
import java.io.File
import java.io.FileNotFoundException
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChannelScreen(
navController: NavController,
channelId: String,
onToggleDrawer: () -> Unit,
onUserSheetOpenFor: (String, String?) -> Unit,
useDrawer: Boolean,
viewModel: ChannelScreenViewModel = viewModel()
) {
val channel = viewModel.activeChannel
val context = LocalContext.current
val lazyListState = rememberLazyListState()
val coroutineScope = rememberCoroutineScope()
var channelInfoSheetShown by remember { mutableStateOf(false) }
var messageContextSheetShown by remember { mutableStateOf(false) }
var messageContextSheetTarget by remember { mutableStateOf("") }
var reactSheetShown by remember { mutableStateOf(false) }
var reactSheetTarget by remember { mutableStateOf("") }
val focusManager = LocalFocusManager.current
fun processFileUri(uri: Uri, pickerIdentifier: String? = null) {
DocumentFile.fromSingleUri(context, uri)?.let { file ->
val mFile = File(context.cacheDir, file.name ?: "attachment")
mFile.outputStream().use { output ->
@Suppress("Recycle")
context.contentResolver.openInputStream(uri)?.copyTo(output)
}
// If the file is already pending and was picked from the inbuilt picker, remove it.
// This is so you can "toggle" the file in the picker.
// If the file was picked via DocumentsUI we don't want toggling functionality as
// if you specifically opened it from DocumentsUI you probably want to send it anyway.
if (
pickerIdentifier != null &&
viewModel.pendingAttachments.any { it.pickerIdentifier == pickerIdentifier }
) {
viewModel.pendingAttachments.removeIf { it.pickerIdentifier == pickerIdentifier }
return
}
viewModel.pendingAttachments.add(
FileArgs(
file = mFile,
contentType = file.type ?: "application/octet-stream",
filename = file.name ?: "attachment",
pickerIdentifier = pickerIdentifier
)
)
}
}
val pickFileLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenMultipleDocuments()
) { uriList ->
uriList.let { list ->
list.forEach { uri ->
processFileUri(uri)
}
}
}
val capturedPhotoUri = rememberSaveable { mutableStateOf<Uri?>(null) }
val pickCameraLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.TakePicture()
) { uriUpdated ->
if (uriUpdated) {
capturedPhotoUri.value?.let { uri ->
processFileUri(uri)
}
}
}
val scrollDownFABPadding by animateDpAsState(
if (viewModel.typingUsers.isNotEmpty()) 25.dp else 0.dp,
animationSpec = RevoltTweenDp,
label = "ScrollDownFABPadding"
)
LaunchedEffect(channelId) {
viewModel.activeChannelId = channelId
viewModel.fetchChannel(channelId)
coroutineScope.launch {
viewModel.listenForWsFrames()
}
coroutineScope.launch {
viewModel.listenForUiCallbacks()
}
}
if (channelInfoSheetShown) {
val channelInfoSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(
sheetState = channelInfoSheetState,
onDismissRequest = {
channelInfoSheetShown = false
}
) {
ChannelInfoSheet(
channelId = channelId,
onHideSheet = {
channelInfoSheetState.hide()
channelInfoSheetShown = false
}
)
}
}
if (messageContextSheetShown) {
val messageContextSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(
sheetState = messageContextSheetState,
onDismissRequest = {
messageContextSheetShown = false
}
) {
MessageContextSheet(
messageId = messageContextSheetTarget,
onHideSheet = {
messageContextSheetState.hide()
messageContextSheetShown = false
},
onReportMessage = {
navController.navigate("report/message/$messageContextSheetTarget")
}
)
}
}
if (reactSheetShown) {
val reactSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(
sheetState = reactSheetState,
onDismissRequest = {
reactSheetShown = false
}
) {
ReactSheet(reactSheetTarget) {
if (it == null) return@ReactSheet
coroutineScope.launch {
react(channelId, reactSheetTarget, it)
reactSheetState.hide()
reactSheetShown = false
}
}
}
}
Column(
modifier = Modifier
.imePadding()
.windowInsetsPadding(WindowInsets.navigationBars)
) {
ChannelHeader(
channel = channel ?: Channel(
id = channelId,
name = stringResource(R.string.loading),
channelType = ChannelType.TextChannel
),
onChannelClick = {
channelInfoSheetShown = true
},
onToggleDrawer = onToggleDrawer,
useDrawer = useDrawer
)
if (FeatureFlags.mediaConversationsGranted && channel?.channelType == ChannelType.VoiceChannel) {
Button(onClick = {
coroutineScope.launch {
ActionChannel.send(
Action.OpenVoiceChannelOverlay(
channelId
)
)
}
}) {
Text("Open voice channel overlay")
}
}
val isScrolledToBottom = remember(lazyListState) {
derivedStateOf {
lazyListState.firstVisibleItemIndex <= 6
}
}
val isScrolledToTop = remember {
derivedStateOf {
val layoutInfo = lazyListState.layoutInfo
val totalItemsNumber = layoutInfo.totalItemsCount
val lastVisibleItemIndex =
(layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0) + 1
val buffer = 6
lastVisibleItemIndex > (totalItemsNumber - buffer)
}
}
val isMessageTooLong by remember(viewModel.pendingMessageContent) {
derivedStateOf {
viewModel.pendingMessageContent.length > MAX_MESSAGE_LENGTH
}
}
LaunchedEffect(isScrolledToTop) {
snapshotFlow { isScrolledToTop.value }
.distinctUntilChanged()
.collect {
if (it) {
coroutineScope.launch {
if (viewModel.hasNoMoreMessages) return@launch
viewModel.fetchOlderMessages()
}
}
}
}
LaunchedEffect(viewModel.activeChannel, RevoltAPI.channelCache, RevoltAPI.serverCache) {
viewModel.doInitialChecks()
}
Box(
modifier = Modifier.weight(1f),
contentAlignment = Alignment.BottomEnd
) {
Crossfade(targetState = viewModel.showAgeGate, label = "Age gate shown/hidden") {
if (it) {
ChannelScreenAgeGate(
onAccept = {
viewModel.showAgeGate = false
},
onDeny = {
onToggleDrawer()
}
)
} else {
LazyColumn(state = lazyListState, reverseLayout = true) {
item {
Spacer(modifier = Modifier.height(25.dp))
}
items(
items = viewModel.renderableMessages,
key = { it.id!! }
) { message ->
when {
message.system != null -> SystemMessage(message)
else -> Message(
message,
onMessageContextMenu = {
messageContextSheetShown = true
messageContextSheetTarget = message.id ?: ""
},
onAvatarClick = {
message.author?.let { author ->
onUserSheetOpenFor(author, channel?.server)
}
},
onNameClick = {
val author = message.author?.let { RevoltAPI.userCache[it] }
?: return@Message
viewModel.putAtCursorPosition("@${author.username}#${author.discriminator}")
},
canReply = true,
onReply = {
if (viewModel.pendingReplies.size >= 5) {
Toast.makeText(
context,
context.getString(R.string.too_many_replies, 5),
Toast.LENGTH_SHORT
).show()
return@Message
}
viewModel.replyToMessage(message)
},
onAddReaction = {
message.id?.let {
reactSheetShown = true
reactSheetTarget = it
}
},
)
}
}
item {
if (viewModel.hasNoMoreMessages) {
Text(
text = stringResource(R.string.start_of_conversation),
modifier = Modifier
.padding(
start = 8.dp,
end = 8.dp,
top = 64.dp,
bottom = 32.dp
)
.fillMaxWidth(),
style = MaterialTheme.typography.labelLarge,
textAlign = TextAlign.Center
)
} else {
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
modifier = Modifier
.padding(16.dp)
)
}
}
}
}
androidx.compose.animation.AnimatedVisibility(
!isScrolledToBottom.value,
enter = slideInVertically(
animationSpec = RevoltTweenInt,
initialOffsetY = { it }
) + fadeIn(animationSpec = RevoltTweenFloat),
exit = slideOutVertically(
animationSpec = RevoltTweenInt,
targetOffsetY = { it }
) + fadeOut(animationSpec = RevoltTweenFloat),
modifier = Modifier
.align(Alignment.BottomCenter)
) {
SmallFloatingActionButton(
modifier = Modifier
.padding(bottom = scrollDownFABPadding)
.align(Alignment.BottomCenter)
.padding(16.dp),
onClick = {
coroutineScope.launch {
lazyListState.animateScrollToItem(0)
}
},
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
containerColor = MaterialTheme.colorScheme.surfaceVariant
) {
Icon(
painter = painterResource(R.drawable.ic_arrow_down_24dp),
contentDescription = stringResource(R.string.scroll_to_bottom)
)
}
}
}
}
if (!viewModel.showAgeGate) {
TypingIndicator(
users = viewModel.typingUsers
)
}
}
Column(
modifier = Modifier
.background(MaterialTheme.colorScheme.background.copy(alpha = 0.9f))
.padding(
start = 16.dp,
end = 16.dp,
bottom = 8.dp,
top = 8.dp
)
.clip(MaterialTheme.shapes.medium)
) {
AnimatedVisibility(visible = viewModel.pendingReplies.isNotEmpty() && !viewModel.denyMessageField) {
ReplyManager(
replies = viewModel.pendingReplies,
onRemove = { viewModel.pendingReplies.remove(it) },
onToggleMention = viewModel::toggleReplyMentionFor
)
}
AnimatedVisibility(visible = viewModel.pendingAttachments.isNotEmpty() && !viewModel.denyMessageField) {
AttachmentManager(
attachments = viewModel.pendingAttachments,
uploading = viewModel.isSendingMessage,
uploadProgress = viewModel.pendingUploadProgress,
onRemove = { viewModel.pendingAttachments.remove(it) }
)
}
Crossfade(
viewModel.denyMessageField,
label = "denyMessageField switch"
) { denyMessageField ->
if (denyMessageField) {
Text(
text = stringResource(id = viewModel.denyMessageFieldReasonResource),
modifier = Modifier
.padding(8.dp)
.fillMaxWidth(),
style = MaterialTheme.typography.labelLarge,
textAlign = TextAlign.Center
)
} else {
NativeMessageField(
value = viewModel.pendingMessageContent,
onValueChange = viewModel::updatePendingMessageContent,
onSendMessage = viewModel::sendPendingMessage,
onAddAttachment = {
val isTiramisu = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
when {
isTiramisu -> {
focusManager.clearFocus()
if (viewModel.currentBottomPane == BottomPane.InbuiltMediaPicker) {
viewModel.currentBottomPane = BottomPane.None
} else {
viewModel.currentBottomPane = BottomPane.InbuiltMediaPicker
}
}
!isTiramisu -> {
pickFileLauncher.launch(arrayOf("*/*"))
}
}
},
onCommitAttachment = { uri ->
processFileUri(uri)
},
onPickEmoji = {
focusManager.clearFocus()
if (viewModel.currentBottomPane == BottomPane.EmojiPicker) {
viewModel.currentBottomPane = BottomPane.None
} else {
viewModel.currentBottomPane = BottomPane.EmojiPicker
}
},
channelType = channel?.channelType ?: ChannelType.TextChannel,
channelName = channel?.name
?: channel?.let { ChannelUtils.resolveDMName(it) }
?: stringResource(
R.string.unknown
),
forceSendButton = viewModel.pendingAttachments.isNotEmpty(),
disabled = (viewModel.pendingAttachments.isNotEmpty() && viewModel.isSendingMessage) || viewModel.showAgeGate,
channelId = channelId,
serverId = channel?.server,
editMode = viewModel.editingMessage != null,
cancelEdit = viewModel::cancelEditingMessage,
failedValidation = isMessageTooLong,
onFocusChange = { nowFocused ->
if (nowFocused && viewModel.currentBottomPane != BottomPane.None) {
viewModel.currentBottomPane = BottomPane.None
}
},
onSelectionChange = {
viewModel.textSelection = it
}
)
}
}
AnimatedVisibility(
visible = viewModel.currentBottomPane == BottomPane.InbuiltMediaPicker
) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
InbuiltMediaPicker(
onOpenDocumentsUi = {
pickFileLauncher.launch(arrayOf("*/*"))
viewModel.currentBottomPane = BottomPane.None
},
onOpenCamera = {
// Create a new content URI to store the captured image.
val contentResolver = context.contentResolver
val contentValues = ContentValues().apply {
put(
MediaStore.MediaColumns.DISPLAY_NAME,
"RVL_${System.currentTimeMillis()}.jpg"
)
put(MediaStore.MediaColumns.MIME_TYPE, "image/jpeg")
put(
MediaStore.MediaColumns.RELATIVE_PATH,
Environment.DIRECTORY_PICTURES
)
}
capturedPhotoUri.value = contentResolver.insert(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
contentValues
)
try {
pickCameraLauncher.launch(capturedPhotoUri.value)
} catch (e: Exception) {
Toast.makeText(
context,
context.getString(
R.string.file_picker_chip_camera_none_installed
),
Toast.LENGTH_SHORT
).show()
}
viewModel.currentBottomPane = BottomPane.None
},
onClose = {
viewModel.currentBottomPane = BottomPane.None
},
onMediaSelected = { media ->
try {
processFileUri(
media.uri,
pickerIdentifier = media.uri.lastPathSegment
)
} catch (e: Exception) {
if (e is FileNotFoundException) {
Toast.makeText(
context,
context.getString(
R.string.file_picker_cannot_attach_file_invalid
),
Toast.LENGTH_SHORT
).show()
}
}
},
pendingMedia = viewModel.pendingAttachments
.filterNot { it.pickerIdentifier == null }
.map { it.pickerIdentifier!! },
disabled = viewModel.isSendingMessage
)
}
}
AnimatedVisibility(visible = viewModel.currentBottomPane == BottomPane.EmojiPicker) {
BackHandler(enabled = viewModel.currentBottomPane == BottomPane.EmojiPicker) {
viewModel.currentBottomPane = BottomPane.None
}
Column(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(0.5f)
.background(MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp))
.padding(4.dp)
) {
EmojiPicker(onEmojiSelected = viewModel::putAtCursorPosition)
}
}
}
}
}

View File

@ -0,0 +1,947 @@
package chat.revolt.screens.chat.views.channel
import android.content.ContentValues
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import android.util.DisplayMetrics
import android.util.Log
import android.widget.Toast
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateIntAsState
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.imeAnimationTarget
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredHeight
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material3.AssistChip
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SmallFloatingActionButton
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.LineHeightStyle
import androidx.compose.ui.text.style.TextAlign
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.documentfile.provider.DocumentFile
import androidx.hilt.navigation.compose.hiltViewModel
import chat.revolt.R
import chat.revolt.RevoltApplication
import chat.revolt.activities.RevoltTweenDp
import chat.revolt.activities.RevoltTweenFloat
import chat.revolt.activities.RevoltTweenInt
import chat.revolt.api.RevoltAPI
import chat.revolt.api.internals.ChannelUtils
import chat.revolt.api.internals.PermissionBit
import chat.revolt.api.internals.has
import chat.revolt.api.routes.channel.react
import chat.revolt.api.routes.microservices.autumn.FileArgs
import chat.revolt.api.schemas.ChannelType
import chat.revolt.api.schemas.Message
import chat.revolt.callbacks.Action
import chat.revolt.callbacks.ActionChannel
import chat.revolt.components.chat.DateDivider
import chat.revolt.components.chat.Message
import chat.revolt.components.chat.NativeMessageField
import chat.revolt.components.chat.SystemMessage
import chat.revolt.components.emoji.EmojiPicker
import chat.revolt.components.generic.UserAvatarWidthPlaceholder
import chat.revolt.components.media.InbuiltMediaPicker
import chat.revolt.components.screens.chat.AttachmentManager
import chat.revolt.components.screens.chat.ChannelIcon
import chat.revolt.components.screens.chat.ReplyManager
import chat.revolt.components.screens.chat.TypingIndicator
import chat.revolt.internals.extensions.rememberChannelPermissions
import chat.revolt.internals.extensions.zero
import chat.revolt.sheets.ChannelInfoSheet
import chat.revolt.sheets.MessageContextSheet
import chat.revolt.sheets.ReactSheet
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
import kotlinx.datetime.Instant
import java.io.File
import java.io.FileNotFoundException
import kotlin.math.max
sealed class ChannelScreenItem {
data class RegularMessage(val message: Message) : ChannelScreenItem()
data class ProspectiveMessage(val message: Message) : ChannelScreenItem()
data class FailedMessage(val message: Message) : ChannelScreenItem()
data class SystemMessage(val message: Message) : ChannelScreenItem()
data class DateDivider(val instant: Instant) : ChannelScreenItem()
data class LoadTrigger(val after: String?, val before: String?) :
ChannelScreenItem()
data object Loading : ChannelScreenItem()
}
sealed class ChannelScreenActivePane {
data object None : ChannelScreenActivePane()
data object EmojiPicker : ChannelScreenActivePane()
data object AttachmentPicker : ChannelScreenActivePane()
}
private fun pxAsDp(px: Int): Dp {
return (
px / (
RevoltApplication.instance.resources
.displayMetrics.densityDpi.toFloat() / DisplayMetrics.DENSITY_DEFAULT
)
).dp
}
@OptIn(
ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class,
ExperimentalAnimationApi::class
)
@Composable
fun ChannelScreen2(
channelId: String,
onToggleDrawer: () -> Unit,
useDrawer: Boolean,
viewModel: ChannelScreen2ViewModel = hiltViewModel()
) {
// Setup
val scope = rememberCoroutineScope()
val context = LocalContext.current
LaunchedEffect(Unit) {
viewModel.startListening()
}
// Load/switch channel
val channelPermissions by rememberChannelPermissions(channelId, viewModel.ensuredSelfMember)
LaunchedEffect(channelId) {
viewModel.switchChannel(channelId)
}
// Keyboard height
val imeTarget = WindowInsets.imeAnimationTarget.getBottom(LocalDensity.current)
val navigationBarsInset = WindowInsets.navigationBars.getBottom(LocalDensity.current)
val imeCurrentInset = WindowInsets.ime.getBottom(LocalDensity.current)
var imeInTransition by remember { mutableStateOf(false) }
var emojiSearchFocused by remember { mutableStateOf(false) }
val fallbackKeyboardHeight by animateIntAsState(
targetValue = if (viewModel.activePane == ChannelScreenActivePane.None && !imeInTransition) navigationBarsInset else viewModel.keyboardHeight,
label = "keyboardHeight"
)
LaunchedEffect(imeTarget) {
if (imeTarget > 0) {
viewModel.updateSaveKeyboardHeight(imeTarget)
} else {
imeInTransition = false
}
}
// Attachment handling
val processFileUri: (Uri, String?) -> Unit = remember {
{ uri, pickerIdentifier ->
DocumentFile.fromSingleUri(context, uri)?.let { file ->
val mFile = File(context.cacheDir, file.name ?: "attachment")
mFile.outputStream().use { output ->
@Suppress("Recycle")
context.contentResolver.openInputStream(uri)?.copyTo(output)
}
// If the file is already pending and was picked from the inbuilt picker, remove it.
// This is so you can "toggle" the file in the picker.
// If the file was picked via DocumentsUI we don't want toggling functionality as
// if you specifically opened it from DocumentsUI you probably want to send it anyway.
if (
pickerIdentifier != null &&
viewModel.draftAttachments.any { it.pickerIdentifier == pickerIdentifier }
) {
viewModel.draftAttachments.removeIf { it.pickerIdentifier == pickerIdentifier }
return@let
}
viewModel.draftAttachments.add(
FileArgs(
file = mFile,
contentType = file.type ?: "application/octet-stream",
filename = file.name ?: "attachment",
pickerIdentifier = pickerIdentifier
)
)
}
}
}
val pickFileLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenMultipleDocuments()
) { uriList ->
uriList.let { list ->
list.forEach { uri ->
processFileUri(uri, null)
}
}
}
val capturedPhotoUri = rememberSaveable { mutableStateOf<Uri?>(null) }
val pickCameraLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.TakePicture()
) { uriUpdated ->
if (uriUpdated) {
capturedPhotoUri.value?.let { uri ->
processFileUri(uri, null)
}
}
}
// UI elements
val lazyListState = rememberLazyListState()
val isScrolledToBottom = remember(lazyListState) {
derivedStateOf {
lazyListState.firstVisibleItemIndex <= 6
}
}
val isNearTop = remember(lazyListState) {
derivedStateOf {
val layoutInfo = lazyListState.layoutInfo
val totalItemsNumber = layoutInfo.totalItemsCount
val lastVisibleItemIndex =
(layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0) + 1
val buffer = 6
lastVisibleItemIndex > (totalItemsNumber - buffer)
}
}
val scrollDownFABPadding by animateDpAsState(
if (viewModel.typingUsers.isNotEmpty()) 25.dp else 0.dp,
animationSpec = RevoltTweenDp,
label = "ScrollDownFABPadding"
)
// Load more messages when we reach the top of the list
// TODO: Temp - use LoadTrigger instead
LaunchedEffect(isNearTop) {
snapshotFlow { isNearTop.value }
.distinctUntilChanged()
.collect { isNearTop ->
if (isNearTop) {
Log.d("ChannelScreen2", "Loading more messages")
viewModel.loadMessages(before = viewModel.items.lastOrNull {
it is ChannelScreenItem.RegularMessage || it is ChannelScreenItem.SystemMessage
}?.let {
when (it) {
is ChannelScreenItem.RegularMessage -> it.message.id
is ChannelScreenItem.SystemMessage -> it.message.id
else -> null
}
}, amount = 50)
}
}
}
// Sheets
var channelInfoSheetShown by remember { mutableStateOf(false) }
if (channelInfoSheetShown) {
val channelInfoSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(
sheetState = channelInfoSheetState,
onDismissRequest = {
channelInfoSheetShown = false
}
) {
ChannelInfoSheet(
channelId = channelId,
onHideSheet = {
channelInfoSheetState.hide()
channelInfoSheetShown = false
}
)
}
}
var messageContextSheetShown by remember { mutableStateOf(false) }
var messageContextSheetTarget by remember { mutableStateOf("") }
if (messageContextSheetShown) {
val messageContextSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(
sheetState = messageContextSheetState,
onDismissRequest = {
messageContextSheetShown = false
}
) {
MessageContextSheet(
messageId = messageContextSheetTarget,
onHideSheet = {
messageContextSheetState.hide()
messageContextSheetShown = false
},
onReportMessage = {
scope.launch {
ActionChannel.send(Action.ReportMessage(messageContextSheetTarget))
}
}
)
}
}
var reactSheetShown by remember { mutableStateOf(false) }
var reactSheetTarget by remember { mutableStateOf("") }
if (reactSheetShown) {
val reactSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(
sheetState = reactSheetState,
onDismissRequest = {
reactSheetShown = false
}
) {
ReactSheet(reactSheetTarget) {
if (it == null) return@ReactSheet
scope.launch {
react(channelId, reactSheetTarget, it)
reactSheetState.hide()
reactSheetShown = false
}
}
}
}
// Begin UI composition
Scaffold(
contentWindowInsets = WindowInsets(
left = 0,
right = 0,
top = 0,
bottom = 0
),
topBar = {
TopAppBar(
modifier = Modifier.clickable {
channelInfoSheetShown = true
},
title = {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
viewModel.channel?.let {
ChannelIcon(
channelType = it.channelType ?: ChannelType.TextChannel,
modifier = Modifier
.size(24.dp)
.alpha(0.8f)
)
CompositionLocalProvider(
LocalTextStyle provides LocalTextStyle.current.copy(
fontSize = 20.sp,
lineHeightStyle = LineHeightStyle(
alignment = LineHeightStyle.Alignment.Bottom,
trim = LineHeightStyle.Trim.LastLineBottom
)
)
) {
when (it.channelType) {
ChannelType.TextChannel, ChannelType.VoiceChannel, ChannelType.Group -> Text(
it.name ?: stringResource(R.string.unknown),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
ChannelType.SavedMessages -> Text(
stringResource(R.string.channel_notes),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
ChannelType.DirectMessage -> Text(
ChannelUtils.resolveDMName(it)
?: stringResource(R.string.unknown),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
else -> Text(
stringResource(R.string.unknown),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
Icon(
imageVector = Icons.AutoMirrored.Default.KeyboardArrowRight,
contentDescription = null,
modifier = Modifier
.size(16.dp)
.alpha(0.5f)
)
}
}
},
windowInsets = WindowInsets.zero,
navigationIcon = {
if (useDrawer) {
IconButton(onClick = onToggleDrawer) {
Icon(
imageVector = Icons.Default.Menu,
contentDescription = stringResource(id = R.string.menu)
)
}
}
}
)
}
) { pv ->
Column(
modifier = Modifier
.padding(pv)
) {
Box(
modifier = Modifier.weight(1f),
contentAlignment = Alignment.BottomCenter
) {
LazyColumn(
state = lazyListState,
reverseLayout = true,
contentPadding = PaddingValues(top = 16.dp, bottom = 32.dp)
) {
// If we don't have a guaranteed first item, the message list will not scroll
// to the bottom when new messages are added. Evil hack to make our other evil
// hack (clear/addAll) work. Too bad!
item(key = "guaranteed_first") {
Box {}
}
items(
viewModel.items.size,
key = { index ->
when (val item = viewModel.items[index]) {
is ChannelScreenItem.RegularMessage -> item.message.id!!
is ChannelScreenItem.ProspectiveMessage -> item.message.id!!
is ChannelScreenItem.FailedMessage -> item.message.id!!
is ChannelScreenItem.SystemMessage -> item.message.id!!
is ChannelScreenItem.DateDivider -> item.instant.toEpochMilliseconds()
is ChannelScreenItem.LoadTrigger -> index
is ChannelScreenItem.Loading -> index
}
},
contentType = { index ->
when (viewModel.items.getOrNull(index)) {
null -> null
is ChannelScreenItem.RegularMessage -> "RegularMessage"
is ChannelScreenItem.ProspectiveMessage -> "ProspectiveMessage"
is ChannelScreenItem.FailedMessage -> "FailedMessage"
is ChannelScreenItem.SystemMessage -> "SystemMessage"
is ChannelScreenItem.DateDivider -> "DateDivider"
is ChannelScreenItem.LoadTrigger -> "LoadTrigger"
is ChannelScreenItem.Loading -> "Loading"
}
}
) { index ->
when (val item = viewModel.items[index]) {
is ChannelScreenItem.RegularMessage -> {
Message(
message = item.message,
onMessageContextMenu = {
item.message.id?.let { messageId ->
messageContextSheetTarget = messageId
messageContextSheetShown = true
}
},
onAvatarClick = {
item.message.author?.let { author ->
scope.launch {
ActionChannel.send(
Action.OpenUserSheet(
author,
viewModel.channel?.server
)
)
}
}
},
onNameClick = {
val author =
item.message.author?.let { RevoltAPI.userCache[it] }
?: return@Message
viewModel.putAtCursorPosition("@${author.username}#${author.discriminator}")
},
canReply = true,
onReply = {
item.message.id?.let { messageId ->
scope.launch { viewModel.addReplyTo(messageId) }
}
},
onAddReaction = {
item.message.id?.let { messageId ->
reactSheetTarget = messageId
reactSheetShown = true
}
}
)
}
is ChannelScreenItem.ProspectiveMessage -> {
Box(Modifier.alpha(0.5f)) {
Message(
message = item.message,
onMessageContextMenu = {
// TODO Context menu that allows you to cancel send
},
onAvatarClick = {},
onNameClick = {},
canReply = false,
onReply = {},
onAddReaction = {}
)
}
}
is ChannelScreenItem.FailedMessage -> {
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.error) {
Column {
Message(
message = item.message,
onMessageContextMenu = {},
onAvatarClick = {},
onNameClick = {},
canReply = false,
onReply = {},
onAddReaction = {}
)
Row {
UserAvatarWidthPlaceholder()
Text(
stringResource(R.string.message_failed_to_send),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error.copy(alpha = 0.8f),
modifier = Modifier.padding(
top = 4.dp,
bottom = 4.dp,
start = 20.dp
)
)
}
}
}
}
is ChannelScreenItem.SystemMessage -> {
SystemMessage(message = item.message)
}
is ChannelScreenItem.DateDivider -> {
DateDivider(instant = item.instant)
}
is ChannelScreenItem.LoadTrigger -> {
LaunchedEffect(Unit) {
Log.d(
"ChannelScreen2",
"LoadTrigger: After ${item.after} Before ${item.before}"
)
}
}
is ChannelScreenItem.Loading -> {
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
modifier = Modifier
.padding(16.dp)
)
}
}
}
}
}
TypingIndicator(
users = viewModel.typingUsers,
serverId = viewModel.channel?.server
)
androidx.compose.animation.AnimatedVisibility(
!isScrolledToBottom.value,
enter = slideInVertically(
animationSpec = RevoltTweenInt,
initialOffsetY = { it }
) + fadeIn(animationSpec = RevoltTweenFloat),
exit = slideOutVertically(
animationSpec = RevoltTweenInt,
targetOffsetY = { it }
) + fadeOut(animationSpec = RevoltTweenFloat)
) {
SmallFloatingActionButton(
modifier = Modifier
.padding(bottom = scrollDownFABPadding)
.align(Alignment.BottomCenter)
.padding(16.dp),
onClick = {
scope.launch {
lazyListState.animateScrollToItem(0)
}
},
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
containerColor = MaterialTheme.colorScheme.surfaceVariant
) {
Icon(
painter = painterResource(R.drawable.ic_arrow_down_24dp),
contentDescription = stringResource(R.string.scroll_to_bottom)
)
}
}
}
Column(
modifier = Modifier
.background(MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp))
.fillMaxWidth(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
AnimatedContent(
targetState = viewModel.denyMessageField,
label = "denyMessageField"
) { deny ->
if (!deny) {
Column {
AnimatedVisibility(
visible = viewModel.draftReplyTo.isNotEmpty() && !viewModel.denyMessageField
) {
ReplyManager(
replies = viewModel.draftReplyTo,
onToggleMention = {
scope.launch { viewModel.toggleMentionOnReply(it.id) }
},
onRemove = {
viewModel.draftReplyTo.remove(it)
}
)
}
AnimatedVisibility(
visible = viewModel.draftAttachments.isNotEmpty() && !viewModel.denyMessageField
) {
AttachmentManager(
attachments = viewModel.draftAttachments,
uploading = viewModel.attachmentUploadProgress > 0,
uploadProgress = viewModel.attachmentUploadProgress,
canRemove = true,
canPreview = true,
onRemove = {
viewModel.draftAttachments.remove(it)
}
)
}
AnimatedVisibility(visible = viewModel.editingMessage != null) {
Row(Modifier.padding(start = 24.dp, top = 8.dp)) {
AssistChip(
onClick = {
viewModel.editingMessage = null
viewModel.putDraftContent("")
},
label = {
Text(stringResource(R.string.message_field_editing_message))
},
leadingIcon = {
Icon(
imageVector = Icons.Default.Edit,
contentDescription = null
)
},
trailingIcon = {
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringResource(R.string.message_field_editing_message_cancel_alt),
tint = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.alpha(0.8f)
)
}
)
}
}
NativeMessageField(
value = viewModel.draftContent,
onValueChange = viewModel::putDraftContent,
onAddAttachment = {
if (viewModel.activePane == ChannelScreenActivePane.AttachmentPicker) {
viewModel.activePane = ChannelScreenActivePane.None
} else {
viewModel.activePane =
ChannelScreenActivePane.AttachmentPicker
}
},
onCommitAttachment = {
processFileUri(it, null)
},
onPickEmoji = {
if (viewModel.activePane == ChannelScreenActivePane.EmojiPicker) {
viewModel.activePane = ChannelScreenActivePane.None
} else {
viewModel.activePane = ChannelScreenActivePane.EmojiPicker
}
},
onSendMessage = viewModel::sendPendingMessage,
channelType = viewModel.channel?.channelType
?: ChannelType.TextChannel,
channelName = viewModel.channel?.name
?: stringResource(R.string.unknown),
onFocusChange = { isFocused ->
if (isFocused && viewModel.activePane != ChannelScreenActivePane.None) {
viewModel.activePane = ChannelScreenActivePane.None
imeInTransition = true
}
},
forceSendButton = viewModel.draftAttachments.isNotEmpty(),
canAttach = (channelPermissions has PermissionBit.UploadFiles) && viewModel.editingMessage == null,
)
}
} else {
Box(
modifier = Modifier
.padding(horizontal = 32.dp, vertical = 16.dp)
) {
Text(
stringResource(viewModel.denyMessageFieldReasonResource),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center
)
}
}
}
if (viewModel.activePane == ChannelScreenActivePane.None && !imeInTransition) {
Spacer(
Modifier
.imePadding()
.navigationBarsPadding()
.background(MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp))
)
} else {
Box(
Modifier
.heightIn(min = pxAsDp(fallbackKeyboardHeight))
) {
Box(
Modifier.then(
if (emojiSearchFocused) {
Modifier.requiredHeight(
pxAsDp(
max(
imeCurrentInset * 2,
fallbackKeyboardHeight
)
)
)
} else {
Modifier.requiredHeight(pxAsDp(fallbackKeyboardHeight))
}
)
) {
when (viewModel.activePane) {
ChannelScreenActivePane.EmojiPicker -> {
BackHandler(enabled = viewModel.activePane == ChannelScreenActivePane.EmojiPicker) {
viewModel.activePane = ChannelScreenActivePane.None
}
Column(
modifier = Modifier
.fillMaxWidth()
.background(
MaterialTheme.colorScheme.surfaceColorAtElevation(
1.dp
)
)
.padding(4.dp)
.navigationBarsPadding()
) {
EmojiPicker(
onEmojiSelected = viewModel::putAtCursorPosition,
bottomInset = pxAsDp(
max(
imeCurrentInset - navigationBarsInset,
0
)
),
onSearchFocus = {
emojiSearchFocused = it
}
)
}
}
ChannelScreenActivePane.AttachmentPicker -> {
BackHandler(enabled = viewModel.activePane == ChannelScreenActivePane.AttachmentPicker) {
viewModel.activePane = ChannelScreenActivePane.None
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
InbuiltMediaPicker(
onOpenDocumentsUi = {
pickFileLauncher.launch(arrayOf("*/*"))
viewModel.activePane = ChannelScreenActivePane.None
},
onOpenCamera = {
// Create a new content URI to store the captured image.
val contentResolver = context.contentResolver
val contentValues = ContentValues().apply {
put(
MediaStore.MediaColumns.DISPLAY_NAME,
"RVL_${System.currentTimeMillis()}.jpg"
)
put(
MediaStore.MediaColumns.MIME_TYPE,
"image/jpeg"
)
put(
MediaStore.MediaColumns.RELATIVE_PATH,
Environment.DIRECTORY_PICTURES
)
}
capturedPhotoUri.value = contentResolver.insert(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
contentValues
)
try {
pickCameraLauncher.launch(capturedPhotoUri.value)
} catch (e: Exception) {
Toast.makeText(
context,
context.getString(
R.string.file_picker_chip_camera_none_installed
),
Toast.LENGTH_SHORT
).show()
}
viewModel.activePane = ChannelScreenActivePane.None
},
onClose = {
viewModel.activePane = ChannelScreenActivePane.None
},
onMediaSelected = { media ->
try {
processFileUri(
media.uri,
media.uri.lastPathSegment
)
} catch (e: Exception) {
if (e is FileNotFoundException) {
Toast.makeText(
context,
context.getString(
R.string.file_picker_cannot_attach_file_invalid
),
Toast.LENGTH_SHORT
).show()
}
}
},
pendingMedia = viewModel.draftAttachments
.filterNot { it.pickerIdentifier == null }
.map { it.pickerIdentifier!! },
modifier = Modifier
.imePadding()
.navigationBarsPadding()
)
}
}
else -> {
// Do nothing
}
}
}
Box(Modifier.imePadding())
}
}
}
}
}
}

View File

@ -0,0 +1,801 @@
package chat.revolt.screens.chat.views.channel
import android.util.Log
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.toMutableStateList
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import chat.revolt.R
import chat.revolt.api.RevoltAPI
import chat.revolt.api.RevoltJson
import chat.revolt.api.internals.ChannelUtils
import chat.revolt.api.internals.PermissionBit
import chat.revolt.api.internals.Roles
import chat.revolt.api.internals.SpecialUsers
import chat.revolt.api.internals.ULID
import chat.revolt.api.internals.has
import chat.revolt.api.realtime.RealtimeSocket
import chat.revolt.api.realtime.RealtimeSocketFrames
import chat.revolt.api.realtime.frames.receivable.ChannelDeleteFrame
import chat.revolt.api.realtime.frames.receivable.ChannelStartTypingFrame
import chat.revolt.api.realtime.frames.receivable.ChannelStopTypingFrame
import chat.revolt.api.realtime.frames.receivable.MessageAppendFrame
import chat.revolt.api.realtime.frames.receivable.MessageDeleteFrame
import chat.revolt.api.realtime.frames.receivable.MessageFrame
import chat.revolt.api.realtime.frames.receivable.MessageReactFrame
import chat.revolt.api.realtime.frames.receivable.MessageUnreactFrame
import chat.revolt.api.realtime.frames.receivable.MessageUpdateFrame
import chat.revolt.api.routes.channel.SendMessageReply
import chat.revolt.api.routes.channel.ackChannel
import chat.revolt.api.routes.channel.editMessage
import chat.revolt.api.routes.channel.fetchMessagesFromChannel
import chat.revolt.api.routes.channel.sendMessage
import chat.revolt.api.routes.microservices.autumn.FileArgs
import chat.revolt.api.routes.microservices.autumn.MAX_ATTACHMENTS_PER_MESSAGE
import chat.revolt.api.routes.microservices.autumn.uploadToAutumn
import chat.revolt.api.routes.server.fetchMember
import chat.revolt.api.routes.user.addUserIfUnknown
import chat.revolt.api.routes.user.fetchUser
import chat.revolt.api.schemas.Channel
import chat.revolt.api.schemas.Message
import chat.revolt.callbacks.Action
import chat.revolt.callbacks.ActionChannel
import chat.revolt.callbacks.UiCallback
import chat.revolt.callbacks.UiCallbacks
import chat.revolt.persistence.KVStorage
import chat.revolt.screens.chat.ChatRouterDestination
import dagger.hilt.android.lifecycle.HiltViewModel
import io.ktor.http.ContentType
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import kotlinx.datetime.toJavaInstant
import java.time.ZoneId
import javax.inject.Inject
@HiltViewModel
class ChannelScreen2ViewModel @Inject constructor(
private val kvStorage: KVStorage,
) : ViewModel() {
var items = mutableStateListOf<ChannelScreenItem>()
var typingUsers = mutableStateListOf<String>()
var channel by mutableStateOf<Channel?>(null)
var activePane by mutableStateOf<ChannelScreenActivePane>(ChannelScreenActivePane.None)
var keyboardHeight by mutableIntStateOf(0)
var draftContent by mutableStateOf("")
var draftAttachments = mutableStateListOf<FileArgs>()
var draftReplyTo = mutableStateListOf<SendMessageReply>()
var attachmentUploadProgress by mutableStateOf(0f)
var endOfChannel by mutableStateOf(false)
var didInitialChannelFetch by mutableStateOf(false)
var ensuredSelfMember by mutableStateOf(false)
var denyMessageField by mutableStateOf(false)
var denyMessageFieldReasonResource by mutableIntStateOf(R.string.typing_blank)
var editingMessage by mutableStateOf<String?>(null)
init {
viewModelScope.launch {
keyboardHeight = kvStorage.getInt("keyboardHeight") ?: 900 // reasonable default for now
}
}
fun switchChannel(id: String) {
// Reset state
this.channel = RevoltAPI.channelCache[id]
this.items = mutableStateListOf(ChannelScreenItem.Loading)
this.activePane = ChannelScreenActivePane.None
this.typingUsers = mutableStateListOf()
this.endOfChannel = false
this.didInitialChannelFetch = false
this.ensuredSelfMember = false
this.denyMessageField = false
this.denyMessageFieldReasonResource = R.string.typing_blank
this.editingMessage = null
viewModelScope.launch {
draftContent = kvStorage.get("draftContent/$id") ?: ""
}
this.draftAttachments = mutableStateListOf()
this.draftReplyTo = mutableStateListOf()
this.attachmentUploadProgress = 0f
viewModelScope.launch {
ensureSelfHasMember()
shouldDenyMessageField()
}
this.loadMessages(50)
}
private suspend fun ensureSelfHasMember() {
channel?.server?.let { serverId ->
RevoltAPI.selfId?.let { selfId ->
if (!RevoltAPI.members.hasMember(serverId, selfId)) {
fetchMember(serverId, selfId)
}
ensuredSelfMember = true
}
}
}
private suspend fun shouldDenyMessageField() {
if (channel == null) return
val selfUser = RevoltAPI.userCache[RevoltAPI.selfId] ?: return
val selfMember = if (channel!!.server == null) {
null
} else {
channel?.server?.let { serverId ->
RevoltAPI.members.getMember(serverId, selfUser.id!!) ?: fetchMember(
serverId,
selfUser.id
)
}
}
val permission = Roles.permissionFor(channel!!, selfUser, selfMember)
val canSend = permission has PermissionBit.SendMessage
val partnerId = ChannelUtils.resolveDMPartner(channel!!)
denyMessageField = when {
partnerId == SpecialUsers.PLATFORM_MODERATION_USER -> true
!canSend -> true
else -> false
}
denyMessageFieldReasonResource = when {
partnerId == SpecialUsers.PLATFORM_MODERATION_USER -> R.string.message_field_denied_platform_moderation
!canSend -> R.string.message_field_denied_no_permission
else -> R.string.message_field_denied_generic
}
}
fun putAtCursorPosition(text: String) {
putDraftContent(draftContent + text)
}
private var lastSentBeginTyping: Instant? = null
private fun startTyping() {
if (editingMessage != null) return
if (lastSentBeginTyping != null) {
val diff = Clock.System.now() - lastSentBeginTyping!!
if (diff.inWholeSeconds < 1) return
}
viewModelScope.launch {
withContext(RevoltAPI.realtimeContext) {
channel?.id?.let {
RealtimeSocket.beginTyping(it)
}
}
}
lastSentBeginTyping = Clock.System.now()
}
private var stopTypingJob: Job? = null
private fun queueStopTyping() {
stopTypingJob = viewModelScope.launch {
delay(5000)
stopTyping()
}
}
private fun stopTyping() {
if (editingMessage != null) return
viewModelScope.launch {
withContext(RevoltAPI.realtimeContext) {
channel?.id?.let {
RealtimeSocket.endTyping(it)
}
}
}
}
fun putDraftContent(content: String) {
viewModelScope.launch {
kvStorage.set("draftContent/${channel?.id}", content)
}
if (editingMessage == null) {
if (content.isNotBlank()) {
startTyping()
stopTypingJob?.cancel()
queueStopTyping()
} else {
stopTyping()
}
}
draftContent = content
}
suspend fun addReplyTo(messageId: String) {
if (draftReplyTo.any { it.id == messageId }) return
val shouldMention = kvStorage.getBoolean("mentionOnReply") ?: false
draftReplyTo.add(SendMessageReply(messageId, shouldMention))
}
suspend fun toggleMentionOnReply(messageId: String) {
val shouldMention = draftReplyTo.find { it.id == messageId }?.mention ?: false
val newItems = draftReplyTo.map {
if (it.id == messageId) {
it.copy(mention = !shouldMention)
} else {
it
}
}
draftReplyTo.clear()
draftReplyTo.addAll(newItems)
kvStorage.set("mentionOnReply", !shouldMention)
}
fun updateSaveKeyboardHeight(height: Int) {
viewModelScope.launch {
kvStorage.set("keyboardHeight", height)
}
keyboardHeight = height
}
private suspend fun applyMessageEdit() {
try {
editMessage(
channelId = channel?.id ?: return,
messageId = editingMessage ?: return,
newContent = draftContent,
)
putDraftContent("")
} catch (e: Exception) {
Log.e("ChannelScreen2ViewModel", "Failed to edit message", e)
}
}
fun sendPendingMessage() {
if (editingMessage != null) {
viewModelScope.launch {
applyMessageEdit()
editingMessage = null
}
return
}
// Immediately, make copies of the draft content and replyTo list, as
// 1. they will be cleared
// 2. if the user changes the content while the message is being sent we want to persist
// the original content
val content = draftContent
val replyTo = draftReplyTo.toList()
// First we upload (the next 5) attachments...
viewModelScope.launch {
val attachmentIds = arrayListOf<String>()
val takenAttachments =
this@ChannelScreen2ViewModel.draftAttachments.take(MAX_ATTACHMENTS_PER_MESSAGE)
val totalTaken = takenAttachments.size
takenAttachments.forEachIndexed { index, it ->
try {
val id = uploadToAutumn(
it.file,
it.filename,
"attachments",
ContentType.parse(it.contentType),
onProgress = { current, total ->
attachmentUploadProgress =
((current.toFloat() / total.toFloat()) / totalTaken.toFloat()) + (index.toFloat() / totalTaken.toFloat())
}
)
attachmentIds.add(id)
} catch (e: Exception) {
Log.e("ChannelScreen2ViewModel", "Failed to upload attachment", e)
attachmentUploadProgress = 0f
// TODO show error message
return@launch
}
}
val nonce = ULID.makeNext()
val prospectiveMessage = Message(
id = nonce,
channel = channel?.id,
author = RevoltAPI.selfId,
content = draftContent,
nonce = nonce,
attachments = listOf(),
replies = listOf(),
tail = items.firstOrNull()?.let {
if (it is ChannelScreenItem.RegularMessage) {
it.message.author == RevoltAPI.selfId
} else if (it is ChannelScreenItem.ProspectiveMessage) {
it.message.author == RevoltAPI.selfId
} else {
false
}
} ?: false
)
updateItems(listOf(ChannelScreenItem.ProspectiveMessage(prospectiveMessage)) + items)
kvStorage.remove("draftContent/${channel?.id}")
putDraftContent("")
draftReplyTo.clear()
attachmentUploadProgress = 0f
this@ChannelScreen2ViewModel.draftAttachments.removeAll(takenAttachments)
try {
sendMessage(
channelId = channel?.id ?: return@launch,
content = content,
nonce = nonce,
replies = replyTo,
attachments = attachmentIds,
idempotencyKey = ULID.makeNext()
)
} catch (e: Exception) {
Log.e("ChannelScreen2ViewModel", "Failed to send message", e)
updateItems(listOf(ChannelScreenItem.FailedMessage(prospectiveMessage)) + items.filter { it !is ChannelScreenItem.ProspectiveMessage })
}
}
}
fun loadMessages(
amount: Int,
before: String? = null,
after: String? = null,
around: String? = null,
ignoreExisting: Boolean = false
) {
channel?.id?.let { channelId ->
viewModelScope.launch {
try {
val messages = arrayListOf<Message>()
fetchMessagesFromChannel(channelId, amount, true, before, after, around).let {
if (it.messages.isNullOrEmpty() || it.messages.size < 50) {
endOfChannel = true
}
it.users?.forEach { user ->
if (!RevoltAPI.userCache.containsKey(user.id)) {
RevoltAPI.userCache[user.id!!] = user
}
}
it.messages?.forEach { message ->
addUserIfUnknown(message.author ?: return@forEach)
if (!RevoltAPI.messageCache.containsKey(message.id)) {
RevoltAPI.messageCache[message.id!!] = message
}
messages.add(message)
}
it.members?.forEach { member ->
if (!RevoltAPI.members.hasMember(member.id!!.server, member.id.user)) {
RevoltAPI.members.setMember(member.id.server, member)
}
}
}
if (!didInitialChannelFetch) didInitialChannelFetch = true
val newItems = messages.filter {
if (ignoreExisting) {
items.none { m ->
when (m) {
is ChannelScreenItem.RegularMessage -> m.message.id == it.id
is ChannelScreenItem.ProspectiveMessage -> m.message.id == it.id
is ChannelScreenItem.SystemMessage -> m.message.id == it.id
is ChannelScreenItem.FailedMessage -> m.message.id == it.id
else -> false
}
}
} else {
true
}
}.map {
when {
it.system != null -> ChannelScreenItem.SystemMessage(it)
else -> ChannelScreenItem.RegularMessage(it)
}
}
// Place items according to whether above/below/around was specified.
// TODO: Aditionally, place LoadTriggers at the beginning and end of the list.
val newItemsWithPosition = when {
before != null -> items + newItems
after != null -> newItems + items
// TODO around, which should place the new items in the middle of the list
else -> newItems
}
updateItems(newItemsWithPosition)
} catch (e: Exception) {
Log.e("ChannelScreen2ViewModel", "Failed to fetch messages", e)
}
}
}
}
suspend fun ackMessage(messageId: String) {
ackChannel(channel?.id ?: return, messageId)
}
suspend fun startListening() {
viewModelScope.launch {
withContext(RevoltAPI.realtimeContext) {
flow {
while (true) {
emit(RevoltAPI.wsFrameChannel.receive())
}
}.onEach {
when (it) {
is MessageFrame -> {
if (it.channel != channel?.id) return@onEach
it.author?.let { userId ->
if (RevoltAPI.userCache[userId] == null) {
RevoltAPI.userCache[userId] = fetchUser(userId)
}
}
channel?.server?.let { serverId ->
try {
it.author?.let { userId ->
fetchMember(serverId, userId)
}
} catch (e: Exception) {
Log.e("ChannelScreen2ViewModel", "Failed to fetch member", e)
}
}
if (didInitialChannelFetch) { // this check is so that we don't end up with a message that arrives at the same time as the initial fetch in front of the loading indicator
val newItem = when {
it.system != null -> ChannelScreenItem.SystemMessage(it)
else -> ChannelScreenItem.RegularMessage(it)
}
updateItems(listOf(newItem) + items.filter { m ->
if (m is ChannelScreenItem.ProspectiveMessage) {
m.message.id != it.nonce
} else {
true
}
})
}
it.id?.let { mid -> ackMessage(mid) }
}
is MessageDeleteFrame -> {
if (it.channel != channel?.id) return@onEach
val newRenderableMessages =
items.filter { m ->
if (m is ChannelScreenItem.RegularMessage) {
m.message.id != it.id
} else {
true
}
}
updateItems(newRenderableMessages)
}
is MessageUpdateFrame -> {
if (it.channel != channel?.id) return@onEach
val messageFrame =
RevoltJson.decodeFromJsonElement(MessageFrame.serializer(), it.data)
val currentMessage = items.find { m ->
m is ChannelScreenItem.RegularMessage && m.message.id == it.id
}
if (currentMessage == null) return@onEach
if (messageFrame.author != null) {
addUserIfUnknown(messageFrame.author)
}
updateItems(
items.map { m ->
if (m is ChannelScreenItem.RegularMessage && m.message.id == it.id) {
ChannelScreenItem.RegularMessage(
m.message.mergeWithPartial(messageFrame)
)
} else {
m
}
}
)
}
is MessageAppendFrame -> {
if (it.channel != channel?.id) return@onEach
val hasMessage = items.any { currentMsg ->
currentMsg is ChannelScreenItem.RegularMessage && currentMsg.message.id == it.id
}
if (!hasMessage) return@onEach
updateItems(
items.map { currentMsg ->
if (currentMsg is ChannelScreenItem.RegularMessage && currentMsg.message.id == it.id) {
RevoltAPI.messageCache[it.id]?.let { m ->
ChannelScreenItem.RegularMessage(m)
} ?: return@map currentMsg
} else {
currentMsg
}
}
)
}
is MessageReactFrame -> {
if (it.channel_id != channel?.id) return@onEach
val hasMessage = items
.filterIsInstance<ChannelScreenItem.RegularMessage>()
.any { msg ->
msg.message.id == it.id
}
if (!hasMessage) return@onEach
updateItems(
items.map { currentMsg ->
if (currentMsg is ChannelScreenItem.RegularMessage && currentMsg.message.id == it.id) {
RevoltAPI.messageCache[it.id]?.let { m ->
ChannelScreenItem.RegularMessage(m)
} ?: return@map currentMsg
} else {
currentMsg
}
}
)
}
is MessageUnreactFrame -> {
if (it.channel_id != channel?.id) return@onEach
val hasMessage = items
.filterIsInstance<ChannelScreenItem.RegularMessage>()
.any { msg ->
msg.message.id == it.id
}
if (!hasMessage) return@onEach
updateItems(
items.map { currentMsg ->
if (currentMsg is ChannelScreenItem.RegularMessage && currentMsg.message.id == it.id) {
RevoltAPI.messageCache[it.id]?.let { m ->
ChannelScreenItem.RegularMessage(m)
} ?: return@map currentMsg
} else {
currentMsg
}
}
)
}
is ChannelStartTypingFrame -> {
if (it.id != channel?.id) return@onEach
if (typingUsers.contains(it.user)) return@onEach
if (it.user == RevoltAPI.selfId) return@onEach
addUserIfUnknown(it.user)
typingUsers.add(it.user)
}
is ChannelStopTypingFrame -> {
if (it.id != channel?.id) return@onEach
if (!typingUsers.contains(it.user)) return@onEach
typingUsers.remove(it.user)
}
is ChannelDeleteFrame -> {
if (it.id != channel?.id) return@onEach
// FIXME This is UI logic from the view model. Too bad!
ActionChannel.send(
Action.ChatNavigate(
ChatRouterDestination.NoCurrentChannel(
channel?.server ?: return@onEach
)
)
)
}
is RealtimeSocketFrames.Reconnected -> {
Log.d("ChannelScreen", "Reconnected to WS.")
loadMessages(50, ignoreExisting = true)
startListening()
}
}
}.catch {
Log.e("ChannelScreen", "Failed to receive WS frame", it)
}.launchIn(this)
}
}
viewModelScope.launch {
withContext(Dispatchers.Main) {
UiCallbacks.uiCallbackFlow.onEach {
Log.d("ChannelScreen", "Received UI callback: $it")
when (it) {
is UiCallback.ReplyToMessage -> {
val message = items.find { m ->
m is ChannelScreenItem.RegularMessage && m.message.id == it.messageId
} as? ChannelScreenItem.RegularMessage ?: return@onEach
val shouldMention = kvStorage.getBoolean("mentionOnReply") ?: false
draftReplyTo.add(
SendMessageReply(message.message.id ?: return@onEach, shouldMention)
)
}
is UiCallback.EditMessage -> {
editingMessage = it.messageId
val message = items.find { m ->
m is ChannelScreenItem.RegularMessage && m.message.id == it.messageId
} as? ChannelScreenItem.RegularMessage ?: return@onEach
putDraftContent(message.message.content ?: "")
this@ChannelScreen2ViewModel.draftAttachments.clear()
draftReplyTo.clear()
}
}
}.catch {
Log.e("ChannelScreen", "Failed to receive UI callback", it)
}.launchIn(this)
}
}
}
private suspend fun updateItems(newItems: List<ChannelScreenItem>) {
// Spec https://wiki.rvlt.gg/index.php/Text_Channel_(UI)#Message_Grouping_Algorithm
val innerItems = newItems.toMutableStateList()
// Let L be the list of messages ordered from newest to oldest
val allItemsThatAreMessages =
innerItems.filterIsInstance<ChannelScreenItem.RegularMessage>()
// Let E be the list of elements to be rendered
val allItems = innerItems
val groupedItems = mutableListOf<ChannelScreenItem>()
// For each message M in L:
allItems.forEachIndexed { index, m ->
// [Deviation from spec: if M is not a [Regular/System]Message we just put it in the list...]
if (m !is ChannelScreenItem.RegularMessage && m !is ChannelScreenItem.SystemMessage) {
groupedItems.add(m)
Log.d("ChannelScreen2ViewModel", "Non-regular message: $m. Skipping grouping.")
return@forEachIndexed
}
val message = when (m) {
is ChannelScreenItem.RegularMessage -> m.message
is ChannelScreenItem.SystemMessage -> m.message
else -> null
}
// Let tail be true
var tail = true
// Let date be null
var date: Instant? = null
// Let next be the next item in list L
val next = allItems.getOrNull(index + 1)
// If next is not null:
if (next != null) {
// Let adate and bdate be the times the message M and the next message were created respectively
val adate = message?.id?.let { ULID.asTimestamp(it) }?.let {
Instant.fromEpochMilliseconds(it)
}
val bdate = (next as? ChannelScreenItem.RegularMessage)?.message?.id?.let {
ULID.asTimestamp(it)
}?.let {
Instant.fromEpochMilliseconds(it)
}
// [Deviation from spec: if either adate or bdate is null but next is a RegularMessage we skip this message]
if ((adate == null || bdate == null) && next is ChannelScreenItem.RegularMessage) {
return@forEachIndexed
}
if (adate != null && bdate != null) {
// If adate and bdate are not the same day:
val adateLocal =
adate.toJavaInstant().atZone(ZoneId.systemDefault()).toLocalDate()
val bdateLocal =
bdate.toJavaInstant().atZone(ZoneId.systemDefault()).toLocalDate()
if (!adateLocal.isEqual(bdateLocal)) {
// Let date be adate
date = adate
}
}
val minuteDifference = adate?.let {
bdate?.let { bdate -> it.minus(bdate) }
}?.inWholeMinutes
// [Conditions, which we have extracted into variables for readability]
// Message M and last [spec should say next] have the same author
val authorsMatch =
message?.author == (next as? ChannelScreenItem.RegularMessage)?.message?.author
// The difference between bdate and adate is equal to or over 7 minutes
val closeEnough = minuteDifference != null && minuteDifference >= 7
// The masquerades for message M and last [spec should say next] do not match
val masqueradesMatch =
message?.masquerade == (next as? ChannelScreenItem.RegularMessage)?.message?.masquerade
// [Possible optimisation: in theory we should not need to check for system messages here as they are a separate type of renderable item]
// The message M or last [spec should say next] is a system message
val eitherIsSystem =
message?.system != null || (next as? ChannelScreenItem.RegularMessage)?.message?.system != null
// Message M replies to one or more messages
val messageHasReplies = message?.replies?.isNotEmpty() == true
// Let tail be false if one of the following conditions is satisfied:
if (!authorsMatch || closeEnough || !masqueradesMatch || eitherIsSystem || messageHasReplies) {
tail = false
}
}
// Else if next is null:
else {
// Let tail be false
tail = false
}
// Push the message
groupedItems.add(
when (m) {
is ChannelScreenItem.RegularMessage -> ChannelScreenItem.RegularMessage(
m.message.copy(
tail = tail
)
)
is ChannelScreenItem.SystemMessage -> ChannelScreenItem.SystemMessage(
m.message.copy(
tail = tail
)
)
else -> m
}
)
// [Deviation from spec: we first push the message, then the date, to preserve UI order]
// If date is not null:
if (date != null) {
// Push the date
groupedItems.add(ChannelScreenItem.DateDivider(date))
}
}
withContext(Dispatchers.Main) {
items.clear()
items.addAll(groupedItems)
}
}
}

View File

@ -1,632 +0,0 @@
package chat.revolt.screens.chat.views.channel
import android.util.Log
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.toMutableStateList
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import chat.revolt.R
import chat.revolt.api.RevoltAPI
import chat.revolt.api.RevoltJson
import chat.revolt.api.internals.ChannelUtils
import chat.revolt.api.internals.MessageProcessor
import chat.revolt.api.internals.PermissionBit
import chat.revolt.api.internals.Roles
import chat.revolt.api.internals.SpecialUsers
import chat.revolt.api.internals.ULID
import chat.revolt.api.internals.hasPermission
import chat.revolt.api.realtime.RealtimeSocket
import chat.revolt.api.realtime.RealtimeSocketFrames
import chat.revolt.api.realtime.frames.receivable.ChannelDeleteFrame
import chat.revolt.api.realtime.frames.receivable.ChannelStartTypingFrame
import chat.revolt.api.realtime.frames.receivable.ChannelStopTypingFrame
import chat.revolt.api.realtime.frames.receivable.MessageAppendFrame
import chat.revolt.api.realtime.frames.receivable.MessageDeleteFrame
import chat.revolt.api.realtime.frames.receivable.MessageFrame
import chat.revolt.api.realtime.frames.receivable.MessageReactFrame
import chat.revolt.api.realtime.frames.receivable.MessageUnreactFrame
import chat.revolt.api.realtime.frames.receivable.MessageUpdateFrame
import chat.revolt.api.routes.channel.SendMessageReply
import chat.revolt.api.routes.channel.ackChannel
import chat.revolt.api.routes.channel.editMessage
import chat.revolt.api.routes.channel.fetchMessagesFromChannel
import chat.revolt.api.routes.channel.fetchSingleChannel
import chat.revolt.api.routes.channel.sendMessage
import chat.revolt.api.routes.microservices.autumn.FileArgs
import chat.revolt.api.routes.microservices.autumn.MAX_ATTACHMENTS_PER_MESSAGE
import chat.revolt.api.routes.microservices.autumn.uploadToAutumn
import chat.revolt.api.routes.server.fetchMember
import chat.revolt.api.routes.user.addUserIfUnknown
import chat.revolt.api.schemas.Channel
import chat.revolt.api.schemas.Message
import chat.revolt.callbacks.Action
import chat.revolt.callbacks.ActionChannel
import chat.revolt.callbacks.UiCallback
import chat.revolt.callbacks.UiCallbacks
import io.ktor.http.ContentType
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
const val MAX_MESSAGE_LENGTH = 2000
sealed class BottomPane {
data object None : BottomPane()
data object InbuiltMediaPicker : BottomPane()
data object EmojiPicker : BottomPane()
}
class ChannelScreenViewModel : ViewModel() {
var activeChannel by mutableStateOf<Channel?>(null)
var activeChannelId by mutableStateOf<String?>(null)
var renderableMessages = mutableStateListOf<Message>()
var typingUsers = mutableStateListOf<String>()
var isSendingMessage by mutableStateOf(false)
var hasNoMoreMessages by mutableStateOf(false)
var pendingMessageContent by mutableStateOf("")
var textSelection by mutableStateOf(0 to 0)
var pendingReplies = mutableStateListOf<SendMessageReply>()
var pendingAttachments = mutableStateListOf<FileArgs>()
var currentBottomPane by mutableStateOf<BottomPane>(BottomPane.None)
var pendingUploadProgress by mutableFloatStateOf(0f)
var editingMessage by mutableStateOf<String?>(null)
var denyMessageField by mutableStateOf(false)
var denyMessageFieldReasonResource by mutableIntStateOf(R.string.message_field_denied_generic)
var showAgeGate by mutableStateOf(false)
private fun popAttachmentBatch() {
pendingAttachments =
pendingAttachments.drop(MAX_ATTACHMENTS_PER_MESSAGE).toMutableStateList()
}
private fun setRenderableMessages(messages: List<Message>) {
renderableMessages.clear()
renderableMessages.addAll(messages)
}
private fun addReply(reply: SendMessageReply) {
if (pendingReplies.any { it.id == reply.id }) return
pendingReplies.add(reply)
}
private var lastSentBeginTyping: Instant? = null
private fun startTyping() {
if (editingMessage != null) return
if (lastSentBeginTyping != null) {
val diff = Clock.System.now() - lastSentBeginTyping!!
if (diff.inWholeSeconds < 1) return
}
viewModelScope.launch {
withContext(RevoltAPI.realtimeContext) {
activeChannelId?.let {
RealtimeSocket.beginTyping(it)
}
}
}
lastSentBeginTyping = Clock.System.now()
}
private var stopTypingJob: Job? = null
private fun queueStopTyping() {
stopTypingJob = viewModelScope.launch {
delay(5000)
stopTyping()
}
}
private fun stopTyping() {
if (editingMessage != null) return
viewModelScope.launch {
withContext(RevoltAPI.realtimeContext) {
activeChannelId?.let {
RealtimeSocket.endTyping(it)
}
}
}
}
fun updatePendingMessageContent(newContent: String) {
pendingMessageContent = newContent
if (newContent.isNotBlank()) {
startTyping()
stopTypingJob?.cancel()
queueStopTyping()
} else {
stopTyping()
}
}
fun toggleReplyMentionFor(reply: SendMessageReply) {
val index = pendingReplies.indexOf(reply)
val newReply = SendMessageReply(
reply.id,
!reply.mention
)
pendingReplies[index] = newReply
}
private fun clearInReplyTo() {
pendingReplies.clear()
}
fun fetchOlderMessages() {
if (activeChannelId == null) return
viewModelScope.launch {
val messages = arrayListOf<Message>()
fetchMessagesFromChannel(
activeChannelId!!,
limit = 50,
includeUsers = true,
before = if (renderableMessages.isNotEmpty()) {
renderableMessages.last().id
} else {
null
}
).let {
if (it.messages.isNullOrEmpty() || it.messages.size < 50) {
hasNoMoreMessages = true
}
it.messages?.forEach { message ->
addUserIfUnknown(message.author ?: return@forEach)
if (!RevoltAPI.messageCache.containsKey(message.id)) {
RevoltAPI.messageCache[message.id!!] = message
}
messages.add(message)
}
it.users?.forEach { user ->
if (!RevoltAPI.userCache.containsKey(user.id)) {
RevoltAPI.userCache[user.id!!] = user
}
}
it.members?.forEach { member ->
if (!RevoltAPI.members.hasMember(member.id!!.server, member.id.user)) {
RevoltAPI.members.setMember(member.id.server, member)
}
}
}
regroupMessages(renderableMessages + messages)
}
}
fun fetchChannel(id: String) {
viewModelScope.launch {
if (id !in RevoltAPI.channelCache) {
val channel = fetchSingleChannel(id)
activeChannel = channel
RevoltAPI.channelCache[id] = channel
} else {
activeChannel = RevoltAPI.channelCache[id]
}
if (activeChannel?.lastMessageID != null) {
ackNewest()
} else {
Log.d("ChannelScreen", "No last message ID, not acking.")
}
}
}
fun sendPendingMessage() {
if (editingMessage != null) {
editPendingMessage()
return
}
if (isSendingMessage) return
isSendingMessage = true
viewModelScope.launch {
val attachmentIds = arrayListOf<String>()
val takenAttachments = pendingAttachments.take(MAX_ATTACHMENTS_PER_MESSAGE)
val totalTaken = takenAttachments.size
takenAttachments.forEachIndexed { index, it ->
try {
val id = uploadToAutumn(
it.file,
it.filename,
"attachments",
ContentType.parse(it.contentType),
onProgress = { current, total ->
pendingUploadProgress =
((current.toFloat() / total.toFloat()) / totalTaken.toFloat()) + (index.toFloat() / totalTaken.toFloat())
}
)
Log.d("ChannelScreen", "Uploaded attachment with id $id")
attachmentIds.add(id)
} catch (e: Exception) {
Log.e("ChannelScreen", "Failed to upload attachment", e)
return@launch
}
}
sendMessage(
activeChannel!!.id!!,
MessageProcessor.processOutgoing(
pendingMessageContent.trimIndent(),
activeChannel?.server
),
attachments = if (attachmentIds.isEmpty()) null else attachmentIds,
replies = pendingReplies
)
updatePendingMessageContent("")
hasNoMoreMessages = false
isSendingMessage = false
pendingUploadProgress = 0f
popAttachmentBatch()
clearInReplyTo()
}
}
private fun editPendingMessage() {
isSendingMessage = true
viewModelScope.launch {
editMessage(
channelId = activeChannel!!.id!!,
messageId = editingMessage!!,
newContent = pendingMessageContent.trimIndent()
)
updatePendingMessageContent("")
isSendingMessage = false
}
cancelEditingMessage()
}
private suspend fun regroupMessages(newMessages: List<Message> = renderableMessages) {
val groupedMessages = mutableMapOf<String, Message>()
// 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!!))
val minuteDifference = (dateA - dateB).inWholeMinutes
if (
message.author != next.author ||
minuteDifference >= 7 ||
message.masquerade != next.masquerade ||
message.system != null || next.system != null ||
message.replies != null
) {
tail = false
}
} else {
tail = false
}
if (groupedMessages.containsKey(message.id!!)) return@forEach
groupedMessages[message.id] = message.copy(tail = tail)
}
withContext(Dispatchers.Main) {
setRenderableMessages(groupedMessages.values.toList())
}
}
suspend fun listenForWsFrames() {
withContext(RevoltAPI.realtimeContext) {
flow {
while (true) {
emit(RevoltAPI.wsFrameChannel.receive())
}
}.onEach {
when (it) {
is MessageFrame -> {
if (it.channel != activeChannel?.id) return@onEach
addUserIfUnknown(it.author!!)
activeChannel?.server?.let { s ->
try {
fetchMember(s, it.author)
} catch (e: Exception) {
Log.e("ChannelScreen", "Failed to fetch member", e)
}
}
regroupMessages(listOf(it) + renderableMessages)
ackNewest()
}
is MessageUpdateFrame -> {
if (it.channel != activeChannel?.id) return@onEach
val messageFrame =
RevoltJson.decodeFromJsonElement(MessageFrame.serializer(), it.data)
renderableMessages.find { currentMsg ->
currentMsg.id == it.id
} ?: return@onEach // Message not found, ignore.
if (messageFrame.author != null) {
addUserIfUnknown(messageFrame.author)
}
regroupMessages(
renderableMessages.map { currentMsg ->
if (currentMsg.id == it.id) {
currentMsg.mergeWithPartial(messageFrame)
} else {
currentMsg
}
}
)
}
is MessageAppendFrame -> {
if (it.channel != activeChannel?.id) return@onEach
val hasMessage = renderableMessages.any { currentMsg ->
currentMsg.id == it.id
}
if (!hasMessage) return@onEach
regroupMessages(
renderableMessages.map { currentMsg ->
if (currentMsg.id == it.id) {
RevoltAPI.messageCache[it.id] ?: currentMsg
} else {
currentMsg
}
}
)
}
is MessageReactFrame -> {
if (it.channel_id != activeChannel?.id) return@onEach
val hasMessage = renderableMessages.any { currentMsg ->
currentMsg.id == it.id
}
if (!hasMessage) return@onEach
regroupMessages(
renderableMessages.map { currentMsg ->
if (currentMsg.id == it.id) {
RevoltAPI.messageCache[it.id] ?: currentMsg
} else {
currentMsg
}
}
)
}
is MessageUnreactFrame -> {
if (it.channel_id != activeChannel?.id) return@onEach
val hasMessage = renderableMessages.any { currentMsg ->
currentMsg.id == it.id
}
if (!hasMessage) return@onEach
regroupMessages(
renderableMessages.map { currentMsg ->
if (currentMsg.id == it.id) {
RevoltAPI.messageCache[it.id] ?: currentMsg
} else {
currentMsg
}
}
)
}
is MessageDeleteFrame -> {
if (it.channel != activeChannel?.id) return@onEach
val newRenderableMessages = renderableMessages.filter { currentMsg ->
currentMsg.id != it.id
}
renderableMessages.clear()
renderableMessages.addAll(newRenderableMessages)
regroupMessages(newRenderableMessages)
}
is ChannelStartTypingFrame -> {
if (it.id != activeChannel?.id) return@onEach
if (typingUsers.contains(it.user)) return@onEach
if (it.user == RevoltAPI.selfId) return@onEach
addUserIfUnknown(it.user)
typingUsers.add(it.user)
}
is ChannelStopTypingFrame -> {
if (it.id != activeChannel?.id) return@onEach
if (!typingUsers.contains(it.user)) return@onEach
typingUsers.remove(it.user)
}
is ChannelDeleteFrame -> {
// activeChannelId is used deliberately because it doesn't become null when the channel is deleted.
if (it.id != activeChannelId) return@onEach
// FIXME This is UI logic from the view model. Too bad!
ActionChannel.send(Action.ChatNavigate("no_current_channel"))
}
is RealtimeSocketFrames.Reconnected -> {
Log.d("ChannelScreen", "Reconnected to WS.")
listenForWsFrames()
}
}
}.catch {
Log.e("ChannelScreen", "Failed to receive WS frame", it)
}.launchIn(this)
}
}
suspend fun listenForUiCallbacks() {
withContext(Dispatchers.Main) {
UiCallbacks.uiCallbackFlow.onEach {
when (it) {
is UiCallback.ReplyToMessage -> {
addReply(
SendMessageReply(
id = it.messageId,
mention = false
)
)
}
is UiCallback.EditMessage -> {
editingMessage = it.messageId
val message = renderableMessages.find { msg ->
msg.id == it.messageId
} ?: return@onEach
updatePendingMessageContent(message.content ?: "")
textSelection =
(message.content?.length ?: 0) to (message.content?.length ?: 0)
}
}
}.catch {
Log.e("ChannelScreen", "Failed to receive UI callback", it)
}.launchIn(this)
}
}
private var debouncedChannelAck: Job? = null
private fun ackNewest() {
if (debouncedChannelAck?.isActive == true) {
debouncedChannelAck?.cancel()
Log.d("ChannelScreen", "Cancelling channel ack")
}
if (activeChannel?.lastMessageID == null) return
RevoltAPI.unreads.processExternalAck(
activeChannel!!.id!!,
activeChannel!!.lastMessageID!!
)
debouncedChannelAck = viewModelScope.launch {
delay(1000)
if (activeChannel?.lastMessageID == null) return@launch
ackChannel(activeChannel!!.id!!, activeChannel!!.lastMessageID!!)
Log.d("ChannelScreen", "Acking channel")
}
}
fun replyToMessage(message: Message) {
addReply(
SendMessageReply(
id = message.id!!,
mention = false
)
)
}
fun cancelEditingMessage() {
editingMessage = null
updatePendingMessageContent("")
}
fun putAtCursorPosition(content: String) {
val currentContent = pendingMessageContent
val currentSelection = textSelection
// if out of bounds, just append
if (currentSelection.first > currentContent.length) {
updatePendingMessageContent(currentContent + content)
textSelection =
currentSelection.first + content.length to currentSelection.first + content.length
return
}
val newContent = currentContent.substring(0, currentSelection.first) +
content +
currentContent.substring(currentSelection.second)
updatePendingMessageContent(newContent)
textSelection =
currentSelection.first + content.length to currentSelection.first + content.length
}
suspend fun doInitialChecks() {
checkShouldShowAgeGate()
checkShouldDenyMessageField()
}
private fun checkShouldShowAgeGate() {
if (activeChannel == null) return
showAgeGate = activeChannel!!.nsfw == true
}
private suspend fun checkShouldDenyMessageField() {
if (activeChannel == null) return
val selfUser = RevoltAPI.userCache[RevoltAPI.selfId] ?: return
val selfMember = if (activeChannel!!.server == null) {
null
} else {
activeChannel?.server?.let { RevoltAPI.members.getMember(it, selfUser.id!!) }
?: fetchMember(activeChannel!!.server!!, selfUser.id!!)
}
val hasPermission =
Roles.permissionFor(activeChannel!!, selfUser, selfMember)
.hasPermission(PermissionBit.SendMessage)
val partnerId = ChannelUtils.resolveDMPartner(activeChannel!!)
denyMessageField = when {
partnerId == SpecialUsers.PLATFORM_MODERATION_USER -> true
!hasPermission -> true
else -> false
}
denyMessageFieldReasonResource = when {
partnerId == SpecialUsers.PLATFORM_MODERATION_USER -> R.string.message_field_denied_platform_moderation
!hasPermission -> R.string.message_field_denied_no_permission
else -> R.string.message_field_denied_generic
}
}
}

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:height="24dp"
android:width="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#ffffff"
android:pathData="M18 13.09C17.47 13.18 16.97 13.34 16.5 13.55V6H18V13.09M12.5 21.5C10.29 21.5 8.5 19.71 8.5 17.5V5C8.5 3.62 9.62 2.5 11 2.5S13.5 3.62 13.5 5V15.5C13.5 16.05 13.05 16.5 12.5 16.5S11.5 16.05 11.5 15.5V6H10V15.5C10 16.88 11.12 18 12.5 18C12.71 18 12.91 17.97 13.1 17.92C13.35 16.58 14.03 15.41 15 14.54V5C15 2.79 13.21 1 11 1S7 2.79 7 5V17.5C7 20.54 9.46 23 12.5 23C13.13 23 13.73 22.89 14.29 22.7C13.97 22.29 13.7 21.84 13.5 21.36C13.17 21.44 12.84 21.5 12.5 21.5M15 18V20H23V18H15Z" />
</vector>

View File

@ -99,6 +99,9 @@
<string name="reply_mention_off">\@ off</string>
<string name="too_many_replies">You can only reply to %1$d messages at a time.</string>
<string name="attachment_preview_remove">Remove</string>
<string name="attachment_preview_close">Close</string>
<string name="emoji_category_smileys">Smileys &amp; Emotions</string>
<string name="emoji_category_people">People &amp; Body</string>
<string name="emoji_category_animals">Animals &amp; Nature</string>
@ -184,10 +187,14 @@
<string name="message_field_denied_no_permission">You don\'t have permission to send messages in this channel.</string>
<string name="message_field_denied_platform_moderation">This is a trusted channel for notices from our moderation team. You can\'t send messages here.</string>
<string name="message_field_editing_message">Editing message</string>
<string name="message_field_editing_message_cancel_alt">Cancel editing</string>
<string name="reply_message_not_cached">Unknown message</string>
<string name="reply_message_empty_has_attachments">Sent attachments</string>
<string name="message_blocked">Blocked message</string>
<string name="message_failed_to_send">Failed to send, long press for options</string>
<string name="system_message_ownership_changed_alt">Ownership changed</string>
<string name="system_message_channel_icon_changed_alt">Channel icon changed</string>