diff --git a/app/build.gradle b/app/build.gradle index 3bf186cc..2e9b93cb 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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 { diff --git a/app/src/main/java/chat/revolt/activities/ShareTargetActivity.kt b/app/src/main/java/chat/revolt/activities/ShareTargetActivity.kt index 797bda36..e69ec96a 100644 --- a/app/src/main/java/chat/revolt/activities/ShareTargetActivity.kt +++ b/app/src/main/java/chat/revolt/activities/ShareTargetActivity.kt @@ -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() var attachmentsUploading by mutableStateOf(false) var attachmentProgress by mutableFloatStateOf(0f) - var activeBottomPane by mutableStateOf(BottomPane.None) + var activeBottomPane by mutableStateOf(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( diff --git a/app/src/main/java/chat/revolt/api/routes/channel/Channel.kt b/app/src/main/java/chat/revolt/api/routes/channel/Channel.kt index 6587c92a..4446d52e 100644 --- a/app/src/main/java/chat/revolt/api/routes/channel/Channel.kt +++ b/app/src/main/java/chat/revolt/api/routes/channel/Channel.kt @@ -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? = null, - attachments: List? = null + attachments: List? = 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() diff --git a/app/src/main/java/chat/revolt/callbacks/ActionChannel.kt b/app/src/main/java/chat/revolt/callbacks/ActionChannel.kt index 04d935a0..982321d5 100644 --- a/app/src/main/java/chat/revolt/callbacks/ActionChannel.kt +++ b/app/src/main/java/chat/revolt/callbacks/ActionChannel.kt @@ -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() } diff --git a/app/src/main/java/chat/revolt/components/chat/DateDivider.kt b/app/src/main/java/chat/revolt/components/chat/DateDivider.kt new file mode 100644 index 00000000..90936709 --- /dev/null +++ b/app/src/main/java/chat/revolt/components/chat/DateDivider.kt @@ -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 + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/components/chat/Message.kt b/app/src/main/java/chat/revolt/components/chat/Message.kt index 09b7624e..eb7bb890 100644 --- a/app/src/main/java/chat/revolt/components/chat/Message.kt +++ b/app/src/main/java/chat/revolt/components/chat/Message.kt @@ -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) } } diff --git a/app/src/main/java/chat/revolt/components/chat/NativeMessageField.kt b/app/src/main/java/chat/revolt/components/chat/NativeMessageField.kt index 47b713d8..3cb35221 100644 --- a/app/src/main/java/chat/revolt/components/chat/NativeMessageField.kt +++ b/app/src/main/java/chat/revolt/components/chat/NativeMessageField.kt @@ -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) diff --git a/app/src/main/java/chat/revolt/components/emoji/EmojiPicker.kt b/app/src/main/java/chat/revolt/components/emoji/EmojiPicker.kt index ca5c525f..c1c35c55 100644 --- a/app/src/main/java/chat/revolt/components/emoji/EmojiPicker.kt +++ b/app/src/main/java/chat/revolt/components/emoji/EmojiPicker.kt @@ -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)) + } } } } diff --git a/app/src/main/java/chat/revolt/components/markdown/MarkdownText.kt b/app/src/main/java/chat/revolt/components/markdown/MarkdownText.kt index 251d4276..d12a2b15 100644 --- a/app/src/main/java/chat/revolt/components/markdown/MarkdownText.kt +++ b/app/src/main/java/chat/revolt/components/markdown/MarkdownText.kt @@ -64,8 +64,6 @@ object MarkdownTextRegularExpressions { val Channel = Regex("<#([0-9A-Z]{26})>") val CustomEmote = Regex(":([0-9A-Z]{26}):") val Timestamp = Regex("") - val UrlFallback = - Regex("?") } /** @@ -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)) } diff --git a/app/src/main/java/chat/revolt/components/media/InbuiltMediaPicker.kt b/app/src/main/java/chat/revolt/components/media/InbuiltMediaPicker.kt index ad1db479..3736e89c 100644 --- a/app/src/main/java/chat/revolt/components/media/InbuiltMediaPicker.kt +++ b/app/src/main/java/chat/revolt/components/media/InbuiltMediaPicker.kt @@ -102,6 +102,7 @@ fun InbuiltMediaPicker( onClose: () -> Unit, onMediaSelected: (Media) -> Unit, pendingMedia: List, + 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 diff --git a/app/src/main/java/chat/revolt/components/screens/chat/AttachmentManager.kt b/app/src/main/java/chat/revolt/components/screens/chat/AttachmentManager.kt index eab138d6..3b4d8491 100644 --- a/app/src/main/java/chat/revolt/components/screens/chat/AttachmentManager.kt +++ b/app/src/main/java/chat/revolt/components/screens/chat/AttachmentManager.kt @@ -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, 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(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)) } diff --git a/app/src/main/java/chat/revolt/components/screens/chat/ReplyManager.kt b/app/src/main/java/chat/revolt/components/screens/chat/ReplyManager.kt index 76a1064d..e3c5012e 100644 --- a/app/src/main/java/chat/revolt/components/screens/chat/ReplyManager.kt +++ b/app/src/main/java/chat/revolt/components/screens/chat/ReplyManager.kt @@ -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) ) diff --git a/app/src/main/java/chat/revolt/components/screens/chat/TypingIndicator.kt b/app/src/main/java/chat/revolt/components/screens/chat/TypingIndicator.kt index e8cd4277..398f0fc0 100644 --- a/app/src/main/java/chat/revolt/components/screens/chat/TypingIndicator.kt +++ b/app/src/main/java/chat/revolt/components/screens/chat/TypingIndicator.kt @@ -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, amount: Int = 3) { +fun StackedUserAvatars(users: List, 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, amount: Int = 3) { } @Composable -fun TypingIndicator(users: List) { +fun TypingIndicator(users: List, serverId: String?) { fun typingMessageResource(): Int { return when (users.size) { 0 -> R.string.typing_blank @@ -77,24 +79,21 @@ fun TypingIndicator(users: List) { 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, diff --git a/app/src/main/java/chat/revolt/components/screens/chat/drawer/channel/ChannelList.kt b/app/src/main/java/chat/revolt/components/screens/chat/drawer/channel/ChannelList.kt index e2b1d853..cfda7a8f 100644 --- a/app/src/main/java/chat/revolt/components/screens/chat/drawer/channel/ChannelList.kt +++ b/app/src/main/java/chat/revolt/components/screens/chat/drawer/channel/ChannelList.kt @@ -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 = diff --git a/app/src/main/java/chat/revolt/components/screens/settings/UserButtons.kt b/app/src/main/java/chat/revolt/components/screens/settings/UserButtons.kt index ab5beb50..b81a86d2 100644 --- a/app/src/main/java/chat/revolt/components/screens/settings/UserButtons.kt +++ b/app/src/main/java/chat/revolt/components/screens/settings/UserButtons.kt @@ -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( diff --git a/app/src/main/java/chat/revolt/internals/extensions/Permissions.kt b/app/src/main/java/chat/revolt/internals/extensions/Permissions.kt index c94e59cd..5f39b1af 100644 --- a/app/src/main/java/chat/revolt/internals/extensions/Permissions.kt +++ b/app/src/main/java/chat/revolt/internals/extensions/Permissions.kt @@ -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 diff --git a/app/src/main/java/chat/revolt/persistence/KVStorage.kt b/app/src/main/java/chat/revolt/persistence/KVStorage.kt index cdb0f3f2..f42f28ef 100644 --- a/app/src/main/java/chat/revolt/persistence/KVStorage.kt +++ b/app/src/main/java/chat/revolt/persistence/KVStorage.kt @@ -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 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)) diff --git a/app/src/main/java/chat/revolt/screens/chat/ChatRouterScreen.kt b/app/src/main/java/chat/revolt/screens/chat/ChatRouterScreen.kt index a0287f8c..bb28d1d7 100644 --- a/app/src/main/java/chat/revolt/screens/chat/ChatRouterScreen.kt +++ b/app/src/main/java/chat/revolt/screens/chat/ChatRouterScreen.kt @@ -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(null) - var currentRoute by mutableStateOf("home") + var currentDestination by mutableStateOf(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 - ) - } - } } } } diff --git a/app/src/main/java/chat/revolt/screens/chat/dialogs/safety/ReportMessageDialog.kt b/app/src/main/java/chat/revolt/screens/chat/dialogs/safety/ReportMessageDialog.kt index efdb6d6c..9660f1c6 100644 --- a/app/src/main/java/chat/revolt/screens/chat/dialogs/safety/ReportMessageDialog.kt +++ b/app/src/main/java/chat/revolt/screens/chat/dialogs/safety/ReportMessageDialog.kt @@ -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") ) { diff --git a/app/src/main/java/chat/revolt/screens/chat/dialogs/safety/ReportServerDialog.kt b/app/src/main/java/chat/revolt/screens/chat/dialogs/safety/ReportServerDialog.kt index 98127255..5d1af59c 100644 --- a/app/src/main/java/chat/revolt/screens/chat/dialogs/safety/ReportServerDialog.kt +++ b/app/src/main/java/chat/revolt/screens/chat/dialogs/safety/ReportServerDialog.kt @@ -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") ) { diff --git a/app/src/main/java/chat/revolt/screens/chat/dialogs/safety/ReportUserDialog.kt b/app/src/main/java/chat/revolt/screens/chat/dialogs/safety/ReportUserDialog.kt index 724cfade..f4a9eac0 100644 --- a/app/src/main/java/chat/revolt/screens/chat/dialogs/safety/ReportUserDialog.kt +++ b/app/src/main/java/chat/revolt/screens/chat/dialogs/safety/ReportUserDialog.kt @@ -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") ) { diff --git a/app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreen.kt b/app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreen.kt deleted file mode 100644 index b5698bb1..00000000 --- a/app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreen.kt +++ /dev/null @@ -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(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) - } - } - } - } -} diff --git a/app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreen2.kt b/app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreen2.kt new file mode 100644 index 00000000..76d3df94 --- /dev/null +++ b/app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreen2.kt @@ -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(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()) + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreen2ViewModel.kt b/app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreen2ViewModel.kt new file mode 100644 index 00000000..81cd4897 --- /dev/null +++ b/app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreen2ViewModel.kt @@ -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() + var typingUsers = mutableStateListOf() + + var channel by mutableStateOf(null) + var activePane by mutableStateOf(ChannelScreenActivePane.None) + var keyboardHeight by mutableIntStateOf(0) + + var draftContent by mutableStateOf("") + var draftAttachments = mutableStateListOf() + var draftReplyTo = mutableStateListOf() + 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(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() + 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() + + 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() + .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() + .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) { + // 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() + // Let E be the list of elements to be rendered + val allItems = innerItems + + val groupedItems = mutableListOf() + + // 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) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreenViewModel.kt b/app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreenViewModel.kt deleted file mode 100644 index 51179ff2..00000000 --- a/app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreenViewModel.kt +++ /dev/null @@ -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(null) - var activeChannelId by mutableStateOf(null) - - var renderableMessages = mutableStateListOf() - var typingUsers = mutableStateListOf() - - var isSendingMessage by mutableStateOf(false) - var hasNoMoreMessages by mutableStateOf(false) - - var pendingMessageContent by mutableStateOf("") - var textSelection by mutableStateOf(0 to 0) - var pendingReplies = mutableStateListOf() - var pendingAttachments = mutableStateListOf() - - var currentBottomPane by mutableStateOf(BottomPane.None) - - var pendingUploadProgress by mutableFloatStateOf(0f) - - var editingMessage by mutableStateOf(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) { - 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() - - 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() - 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 = renderableMessages) { - val groupedMessages = mutableMapOf() - - // 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 - } - } -} diff --git a/app/src/main/res/drawable/ic_paperclip_minus_24dp.xml b/app/src/main/res/drawable/ic_paperclip_minus_24dp.xml new file mode 100644 index 00000000..e1dc434b --- /dev/null +++ b/app/src/main/res/drawable/ic_paperclip_minus_24dp.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ecd7c776..55e6e34c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -99,6 +99,9 @@ \@ off You can only reply to %1$d messages at a time. + Remove + Close + Smileys & Emotions People & Body Animals & Nature @@ -184,10 +187,14 @@ You don\'t have permission to send messages in this channel. This is a trusted channel for notices from our moderation team. You can\'t send messages here. + Editing message + Cancel editing + Unknown message Sent attachments Blocked message + Failed to send, long press for options Ownership changed Channel icon changed