refactor(ChannelScreen): introduce channel screen 2
Signed-off-by: Infi <infi@infi.sh>
This commit is contained in:
parent
1d7bc309a3
commit
7550d01c8b
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ import chat.revolt.components.screens.chat.AttachmentManager
|
|||
import chat.revolt.components.screens.chat.drawer.server.DrawerChannel
|
||||
import chat.revolt.components.screens.chat.drawer.server.DrawerChannelIconType
|
||||
import chat.revolt.persistence.KVStorage
|
||||
import chat.revolt.screens.chat.views.channel.BottomPane
|
||||
import chat.revolt.screens.chat.views.channel.ChannelScreenActivePane
|
||||
import chat.revolt.ui.theme.RevoltTheme
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
|
|
@ -174,7 +174,7 @@ class ShareTargetScreenViewModel @Inject constructor(
|
|||
var attachments = mutableStateListOf<FileArgs>()
|
||||
var attachmentsUploading by mutableStateOf(false)
|
||||
var attachmentProgress by mutableFloatStateOf(0f)
|
||||
var activeBottomPane by mutableStateOf<BottomPane>(BottomPane.None)
|
||||
var activeBottomPane by mutableStateOf<ChannelScreenActivePane>(ChannelScreenActivePane.None)
|
||||
|
||||
suspend fun isLoggedIn(): Boolean {
|
||||
return kvStorage.get("sessionToken") != null
|
||||
|
|
@ -411,10 +411,10 @@ fun ShareTargetScreen(
|
|||
onAddAttachment = {},
|
||||
onCommitAttachment = {},
|
||||
onPickEmoji = {
|
||||
if (viewModel.activeBottomPane is BottomPane.EmojiPicker) {
|
||||
viewModel.activeBottomPane = BottomPane.None
|
||||
if (viewModel.activeBottomPane is ChannelScreenActivePane.EmojiPicker) {
|
||||
viewModel.activeBottomPane = ChannelScreenActivePane.None
|
||||
} else {
|
||||
viewModel.activeBottomPane = BottomPane.EmojiPicker
|
||||
viewModel.activeBottomPane = ChannelScreenActivePane.EmojiPicker
|
||||
}
|
||||
},
|
||||
onSendMessage = {
|
||||
|
|
@ -436,9 +436,9 @@ fun ShareTargetScreen(
|
|||
channelName = RevoltAPI.channelCache[selectedChannel]?.name ?: "",
|
||||
)
|
||||
|
||||
AnimatedVisibility(viewModel.activeBottomPane is BottomPane.EmojiPicker) {
|
||||
BackHandler(enabled = viewModel.activeBottomPane == BottomPane.EmojiPicker) {
|
||||
viewModel.activeBottomPane = BottomPane.None
|
||||
AnimatedVisibility(viewModel.activeBottomPane is ChannelScreenActivePane.EmojiPicker) {
|
||||
BackHandler(enabled = viewModel.activeBottomPane == ChannelScreenActivePane.EmojiPicker) {
|
||||
viewModel.activeBottomPane = ChannelScreenActivePane.None
|
||||
}
|
||||
|
||||
Column(
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import chat.revolt.api.schemas.MessagesInChannel
|
|||
import chat.revolt.api.schemas.User
|
||||
import io.ktor.client.request.delete
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.request.header
|
||||
import io.ktor.client.request.parameter
|
||||
import io.ktor.client.request.patch
|
||||
import io.ktor.client.request.post
|
||||
|
|
@ -94,25 +95,25 @@ data class CreateInviteResponse(
|
|||
val channel: String,
|
||||
)
|
||||
|
||||
suspend
|
||||
|
||||
fun sendMessage(
|
||||
suspend fun sendMessage(
|
||||
channelId: String,
|
||||
content: String,
|
||||
nonce: String? = ULID.makeNext(),
|
||||
nonce: String = ULID.makeNext(),
|
||||
replies: List<SendMessageReply>? = null,
|
||||
attachments: List<String>? = null
|
||||
attachments: List<String>? = null,
|
||||
idempotencyKey: String = ULID.makeNext()
|
||||
): String {
|
||||
val response = RevoltHttp.post("/channels/$channelId/messages") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(
|
||||
SendMessageBody(
|
||||
content = content,
|
||||
nonce = nonce ?: ULID.makeNext(),
|
||||
nonce = nonce,
|
||||
replies = replies ?: emptyList(),
|
||||
attachments = attachments
|
||||
)
|
||||
)
|
||||
header("Idempotency-Key", idempotencyKey)
|
||||
}
|
||||
.bodyAsText()
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,8 +64,6 @@ object MarkdownTextRegularExpressions {
|
|||
val Channel = Regex("<#([0-9A-Z]{26})>")
|
||||
val CustomEmote = Regex(":([0-9A-Z]{26}):")
|
||||
val Timestamp = Regex("<t:([0-9]+?)(:[tTDfFR])?>")
|
||||
val UrlFallback =
|
||||
Regex("<?https?://(www\\.)?[-a-zA-Z0-9@:%._+~#=]{2,256}\\.[a-z]{2,4}\\b([-a-zA-Z0-9@:%_+.~#?&/=]*)>?")
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -82,7 +80,6 @@ fun annotateText(node: AstNode): AnnotatedString {
|
|||
val channels = MarkdownTextRegularExpressions.Channel.findAll(text)
|
||||
val customEmotes = MarkdownTextRegularExpressions.CustomEmote.findAll(text)
|
||||
val timestamps = MarkdownTextRegularExpressions.Timestamp.findAll(text)
|
||||
val urls = MarkdownTextRegularExpressions.UrlFallback.findAll(text)
|
||||
|
||||
var lastIndex = 0
|
||||
for (mention in mentions) {
|
||||
|
|
@ -170,26 +167,6 @@ fun annotateText(node: AstNode): AnnotatedString {
|
|||
lastIndex = timestamp.range.last + 1
|
||||
}
|
||||
|
||||
// Yes, cmark should handle this, but for gTLDs like .chat it doesn't.
|
||||
// As a service with a .chat TLD, this is a problem. Duct tape fix, their fault.
|
||||
for (url in urls) {
|
||||
append(text.substring(lastIndex, url.range.first))
|
||||
pushStringAnnotation(
|
||||
tag = Annotations.URL.tag,
|
||||
annotation = url.value
|
||||
)
|
||||
pushStyle(
|
||||
LocalTextStyle.current.toSpanStyle()
|
||||
.copy(
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
)
|
||||
append(url.value)
|
||||
pop()
|
||||
pop()
|
||||
lastIndex = url.range.last + 1
|
||||
}
|
||||
|
||||
append(text.substring(lastIndex, text.length))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -102,6 +102,7 @@ fun InbuiltMediaPicker(
|
|||
onClose: () -> Unit,
|
||||
onMediaSelected: (Media) -> Unit,
|
||||
pendingMedia: List<String>,
|
||||
modifier: Modifier = Modifier,
|
||||
disabled: Boolean = false
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
|
@ -265,9 +266,9 @@ fun InbuiltMediaPicker(
|
|||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight(0.5f)
|
||||
.fillMaxHeight()
|
||||
.background(MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp)),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
|
|
|
|||
|
|
@ -1,44 +1,156 @@
|
|||
package chat.revolt.components.screens.chat
|
||||
|
||||
import android.text.format.Formatter
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.ProgressIndicatorDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.material3.surfaceColorAtElevation
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.revolt.R
|
||||
import chat.revolt.api.routes.microservices.autumn.FileArgs
|
||||
import chat.revolt.components.generic.RemoteImage
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
|
||||
@Composable
|
||||
fun FilePreviewSheet(
|
||||
args: FileArgs,
|
||||
canRemove: Boolean,
|
||||
onRemove: () -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
Column(
|
||||
Modifier.padding(start = 16.dp, end = 16.dp, bottom = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
if (args.contentType.startsWith("image/") || args.contentType.startsWith("video/")) {
|
||||
RemoteImage(
|
||||
url = args.file.toURI().toURL().toString(),
|
||||
contentScale = ContentScale.Fit,
|
||||
description = null,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f)
|
||||
)
|
||||
}
|
||||
Text(
|
||||
args.filename,
|
||||
fontWeight = FontWeight.Medium,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Text(
|
||||
Formatter.formatFileSize(context, args.file.length()),
|
||||
color = LocalContentColor.current.copy(alpha = 0.6f),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(onClick = {
|
||||
onDismiss()
|
||||
}, modifier = Modifier.weight(1f)) {
|
||||
Icon(Icons.Default.Close, contentDescription = null)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(stringResource(R.string.attachment_preview_close))
|
||||
}
|
||||
if (canRemove) {
|
||||
TextButton(onClick = {
|
||||
onRemove()
|
||||
}, modifier = Modifier.weight(1f)) {
|
||||
Icon(
|
||||
painterResource(R.drawable.ic_paperclip_minus_24dp),
|
||||
contentDescription = null
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(stringResource(R.string.attachment_preview_remove))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AttachmentManager(
|
||||
attachments: List<FileArgs>,
|
||||
uploading: Boolean,
|
||||
uploadProgress: Float = 0f,
|
||||
onRemove: (FileArgs) -> Unit,
|
||||
canRemove: Boolean = true
|
||||
canRemove: Boolean = true,
|
||||
canPreview: Boolean = true
|
||||
) {
|
||||
var showPreviewSheet by remember { mutableStateOf(false) }
|
||||
var previewingAttachment by remember { mutableStateOf<FileArgs?>(null) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
if (showPreviewSheet) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
ModalBottomSheet(onDismissRequest = {
|
||||
showPreviewSheet = false
|
||||
}, sheetState = sheetState) {
|
||||
previewingAttachment?.let {
|
||||
FilePreviewSheet(
|
||||
args = it,
|
||||
canRemove = canRemove,
|
||||
onRemove = {
|
||||
onRemove(it)
|
||||
scope.launch {
|
||||
sheetState.hide()
|
||||
showPreviewSheet = false
|
||||
}
|
||||
},
|
||||
onDismiss = {
|
||||
scope.launch {
|
||||
sheetState.hide()
|
||||
showPreviewSheet = false
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val animatedProgress by animateFloatAsState(
|
||||
targetValue = uploadProgress,
|
||||
animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec,
|
||||
|
|
@ -61,7 +173,10 @@ fun AttachmentManager(
|
|||
.padding(4.dp)
|
||||
.clip(MaterialTheme.shapes.small)
|
||||
.clickable {
|
||||
onRemove(attachment)
|
||||
if (canPreview) {
|
||||
previewingAttachment = attachment
|
||||
showPreviewSheet = true
|
||||
}
|
||||
}
|
||||
.background(
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
|
|
@ -70,13 +185,6 @@ fun AttachmentManager(
|
|||
.padding(8.dp)
|
||||
) {
|
||||
Text(attachment.filename, maxLines = 1)
|
||||
if (canRemove) {
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = stringResource(R.string.remove_attachment_alt)
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -12,12 +12,10 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
|
@ -25,23 +23,27 @@ import androidx.compose.ui.unit.sp
|
|||
import chat.revolt.R
|
||||
import chat.revolt.activities.RevoltTweenFloat
|
||||
import chat.revolt.activities.RevoltTweenInt
|
||||
import chat.revolt.api.REVOLT_FILES
|
||||
import chat.revolt.api.RevoltAPI
|
||||
import chat.revolt.api.schemas.User
|
||||
import chat.revolt.components.generic.UserAvatar
|
||||
|
||||
@Composable
|
||||
fun StackedUserAvatars(users: List<String>, amount: Int = 3) {
|
||||
fun StackedUserAvatars(users: List<String>, amount: Int = 3, serverId: String?) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(16.dp + (8.dp * minOf(users.size, amount)), 16.dp)
|
||||
) {
|
||||
users.take(amount).forEachIndexed { index, userId ->
|
||||
val user = RevoltAPI.userCache[userId]
|
||||
val maybeMember = serverId?.let { RevoltAPI.members.getMember(serverId, userId) }
|
||||
|
||||
UserAvatar(
|
||||
avatar = user?.avatar,
|
||||
userId = userId,
|
||||
username = user?.let { User.resolveDefaultName(it) }
|
||||
?: stringResource(id = R.string.unknown),
|
||||
rawUrl = maybeMember?.avatar?.let { "$REVOLT_FILES/avatars/${it.id}?max_side=256" },
|
||||
size = 16.dp,
|
||||
modifier = Modifier
|
||||
.offset(
|
||||
|
|
@ -53,7 +55,7 @@ fun StackedUserAvatars(users: List<String>, amount: Int = 3) {
|
|||
}
|
||||
|
||||
@Composable
|
||||
fun TypingIndicator(users: List<String>) {
|
||||
fun TypingIndicator(users: List<String>, serverId: String?) {
|
||||
fun typingMessageResource(): Int {
|
||||
return when (users.size) {
|
||||
0 -> R.string.typing_blank
|
||||
|
|
@ -77,24 +79,21 @@ fun TypingIndicator(users: List<String>) {
|
|||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(
|
||||
RoundedCornerShape(
|
||||
topStart = 16.dp,
|
||||
topEnd = 16.dp
|
||||
)
|
||||
)
|
||||
.background(MaterialTheme.colorScheme.background.copy(alpha = 0.9f))
|
||||
.padding(top = 4.dp, start = 16.dp, end = 16.dp)
|
||||
.padding(vertical = 8.dp, horizontal = 16.dp)
|
||||
) {
|
||||
StackedUserAvatars(users = users)
|
||||
StackedUserAvatars(users = users, serverId = serverId)
|
||||
|
||||
Text(
|
||||
text = stringResource(
|
||||
id = typingMessageResource(),
|
||||
users.joinToString {
|
||||
RevoltAPI.userCache[it]?.let { u ->
|
||||
User.resolveDefaultName(u)
|
||||
} ?: it
|
||||
users.joinToString { userId ->
|
||||
RevoltAPI.userCache[userId]?.let { u ->
|
||||
val maybeMember =
|
||||
serverId?.let { RevoltAPI.members.getMember(serverId, userId) }
|
||||
|
||||
maybeMember?.nickname ?: User.resolveDefaultName(u)
|
||||
} ?: userId
|
||||
}
|
||||
),
|
||||
fontSize = 12.sp,
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -7,9 +7,9 @@ import androidx.datastore.preferences.core.edit
|
|||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
|
||||
val Context.revoltKVStorage: DataStore<Preferences> by preferencesDataStore(name = "revolt_kv")
|
||||
|
||||
|
|
@ -39,6 +39,16 @@ class KVStorage @Inject constructor(
|
|||
return dataStore.data.firstOrNull()?.get(stringPreferencesKey(key))?.toBoolean()
|
||||
}
|
||||
|
||||
suspend fun set(key: String, value: Int) {
|
||||
dataStore.edit { preferences ->
|
||||
preferences[stringPreferencesKey(key)] = value.toString()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getInt(key: String): Int? {
|
||||
return dataStore.data.firstOrNull()?.get(stringPreferencesKey(key))?.toInt()
|
||||
}
|
||||
|
||||
suspend fun remove(key: String) {
|
||||
dataStore.edit { preferences ->
|
||||
preferences.remove(stringPreferencesKey(key))
|
||||
|
|
|
|||
|
|
@ -62,18 +62,12 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
|||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.dialog
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import chat.revolt.R
|
||||
import chat.revolt.api.RevoltAPI
|
||||
import chat.revolt.api.internals.ChannelUtils
|
||||
import chat.revolt.api.internals.DirectMessages
|
||||
import chat.revolt.api.realtime.DisconnectionState
|
||||
import chat.revolt.api.realtime.RealtimeSocket
|
||||
import chat.revolt.api.routes.server.fetchMembers
|
||||
import chat.revolt.api.schemas.ChannelType
|
||||
import chat.revolt.api.schemas.User
|
||||
import chat.revolt.api.settings.SyncedSettings
|
||||
|
|
@ -96,7 +90,7 @@ import chat.revolt.screens.chat.dialogs.safety.ReportUserDialog
|
|||
import chat.revolt.screens.chat.views.FriendsScreen
|
||||
import chat.revolt.screens.chat.views.HomeScreen
|
||||
import chat.revolt.screens.chat.views.NoCurrentChannelScreen
|
||||
import chat.revolt.screens.chat.views.channel.ChannelScreen
|
||||
import chat.revolt.screens.chat.views.channel.ChannelScreen2
|
||||
import chat.revolt.sheets.AddServerSheet
|
||||
import chat.revolt.sheets.ChangelogSheet
|
||||
import chat.revolt.sheets.EmoteInfoSheet
|
||||
|
|
@ -116,15 +110,49 @@ import kotlinx.coroutines.flow.distinctUntilChanged
|
|||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
sealed class ChatRouterDestination {
|
||||
data object Home : ChatRouterDestination()
|
||||
data object Friends : ChatRouterDestination()
|
||||
data class Channel(val channelId: String) : ChatRouterDestination()
|
||||
data class NoCurrentChannel(val serverId: String?) : ChatRouterDestination()
|
||||
|
||||
fun asSerialisedString(): String {
|
||||
return when (this) {
|
||||
is Home -> "home"
|
||||
is Friends -> "friends"
|
||||
is Channel -> "channel/$channelId"
|
||||
is NoCurrentChannel -> "no_current_channel/$serverId"
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
val default = Home
|
||||
val defaultForDMList = Home
|
||||
|
||||
fun fromString(destination: String): ChatRouterDestination {
|
||||
return when {
|
||||
destination == "home" -> Home
|
||||
destination == "friends" -> Friends
|
||||
destination.startsWith("no_current_channel/") -> NoCurrentChannel(
|
||||
destination.removePrefix(
|
||||
"no_current_channel/"
|
||||
)
|
||||
)
|
||||
|
||||
destination.startsWith("channel/") -> Channel(destination.removePrefix("channel/"))
|
||||
else -> default
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@HiltViewModel
|
||||
@SuppressLint("StaticFieldLeak")
|
||||
class ChatRouterViewModel @Inject constructor(
|
||||
private val kvStorage: KVStorage,
|
||||
@ApplicationContext val context: Context
|
||||
) : ViewModel() {
|
||||
var currentServer by mutableStateOf("home")
|
||||
var currentChannel by mutableStateOf<String?>(null)
|
||||
var currentRoute by mutableStateOf("home")
|
||||
var currentDestination by mutableStateOf<ChatRouterDestination>(ChatRouterDestination.default)
|
||||
var sidebarSparkDisplayed by mutableStateOf(true)
|
||||
var latestChangelogRead by mutableStateOf(true)
|
||||
var latestChangelog by mutableStateOf("")
|
||||
|
|
@ -133,9 +161,8 @@ class ChatRouterViewModel @Inject constructor(
|
|||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
currentServer = kvStorage.get("currentServer") ?: "home"
|
||||
currentChannel = kvStorage.get("currentChannel")
|
||||
currentRoute = kvStorage.get("currentRoute") ?: "home"
|
||||
val current = kvStorage.get("currentDestination")
|
||||
setSaveDestination(ChatRouterDestination.fromString(current ?: ""))
|
||||
|
||||
sidebarSparkDisplayed = if (kvStorage.getBoolean("sidebarSpark") == null) {
|
||||
false
|
||||
|
|
@ -151,27 +178,18 @@ class ChatRouterViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private suspend fun setSaveCurrentServer(serverId: String) {
|
||||
currentServer = serverId
|
||||
|
||||
kvStorage.set("currentServer", serverId)
|
||||
|
||||
if (serverId != "home") fetchMembers(serverId, includeOffline = false, pure = false)
|
||||
}
|
||||
|
||||
private fun setSaveCurrentRoute(route: String) {
|
||||
currentRoute = route
|
||||
fun setSaveDestination(destination: ChatRouterDestination) {
|
||||
currentDestination = destination
|
||||
|
||||
viewModelScope.launch {
|
||||
kvStorage.set("currentRoute", route)
|
||||
}
|
||||
}
|
||||
kvStorage.set("currentDestination", destination.asSerialisedString())
|
||||
|
||||
private fun setSaveCurrentChannel(channelId: String) {
|
||||
currentChannel = channelId
|
||||
|
||||
viewModelScope.launch {
|
||||
kvStorage.set("currentChannel", channelId)
|
||||
if (destination is ChatRouterDestination.Channel) {
|
||||
val server = RevoltAPI.channelCache[destination.channelId]?.server
|
||||
if (server != null) {
|
||||
kvStorage.set("lastChannel/$server", destination.channelId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -180,64 +198,17 @@ class ChatRouterViewModel @Inject constructor(
|
|||
sidebarSparkDisplayed = true
|
||||
}
|
||||
|
||||
fun navigateToServer(serverId: String, navController: NavController) {
|
||||
if (serverId == "home") {
|
||||
navController.navigate("home") {
|
||||
navController.graph.startDestinationRoute?.let { route ->
|
||||
popUpTo(route)
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
setSaveCurrentServer("home")
|
||||
setSaveCurrentRoute("home")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val channelId = RevoltAPI.serverCache[serverId]?.channels?.firstOrNull()
|
||||
|
||||
fun navigateToServer(serverId: String) {
|
||||
viewModelScope.launch {
|
||||
setSaveCurrentServer(serverId)
|
||||
}
|
||||
val savedLastChannel = kvStorage.get("lastChannel/$serverId")
|
||||
val channelId =
|
||||
savedLastChannel ?: RevoltAPI.serverCache[serverId]?.channels?.firstOrNull()
|
||||
|
||||
if (channelId != null) {
|
||||
navigateToChannel(channelId, navController)
|
||||
} else {
|
||||
navController.navigate("no_current_channel") {
|
||||
navController.graph.startDestinationRoute?.let { route ->
|
||||
popUpTo(route)
|
||||
}
|
||||
if (channelId != null) {
|
||||
setSaveDestination(ChatRouterDestination.Channel(channelId))
|
||||
} else {
|
||||
setSaveDestination(ChatRouterDestination.NoCurrentChannel(serverId))
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
setSaveCurrentRoute("no_current_channel")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun navigateToChannel(channelId: String, navController: NavController, pure: Boolean = false) {
|
||||
if (!pure) setSaveCurrentChannel(channelId)
|
||||
|
||||
navController.navigate("channel/$channelId") {
|
||||
navController.graph.startDestinationRoute?.let { route ->
|
||||
popUpTo(route)
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
setSaveCurrentRoute("channel/$channelId")
|
||||
}
|
||||
}
|
||||
|
||||
fun navigateToSpecial(destination: String, navController: NavController) {
|
||||
navController.navigate(destination) {
|
||||
navController.graph.startDestinationRoute?.let { route ->
|
||||
popUpTo(route)
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
setSaveCurrentRoute(destination)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -255,8 +226,6 @@ fun ChatRouterScreen(
|
|||
val context = LocalContext.current
|
||||
val view = LocalView.current
|
||||
|
||||
val navController = rememberNavController()
|
||||
|
||||
val showSidebarSpark = remember { mutableStateOf(false) }
|
||||
val sidebarSparkComposition by rememberLottieComposition(
|
||||
LottieCompositionSpec.RawRes(R.raw.open_settings_tutorial)
|
||||
|
|
@ -294,6 +263,15 @@ fun ChatRouterScreen(
|
|||
var voiceChannelOverlay by remember { mutableStateOf(false) }
|
||||
var voiceChannelOverlayChannelId by remember { mutableStateOf("") }
|
||||
|
||||
var showReportUser by remember { mutableStateOf(false) }
|
||||
var reportUserTarget by remember { mutableStateOf("") }
|
||||
|
||||
var showReportMessage by remember { mutableStateOf(false) }
|
||||
var reportMessageTarget by remember { mutableStateOf("") }
|
||||
|
||||
var showReportServer by remember { mutableStateOf(false) }
|
||||
var reportServerTarget by remember { mutableStateOf("") }
|
||||
|
||||
val toggleDrawerLambda = remember {
|
||||
{
|
||||
scope.launch {
|
||||
|
|
@ -306,6 +284,20 @@ fun ChatRouterScreen(
|
|||
}
|
||||
}
|
||||
|
||||
val currentServer = remember(viewModel.currentDestination) {
|
||||
when (viewModel.currentDestination) {
|
||||
is ChatRouterDestination.Channel -> {
|
||||
RevoltAPI.channelCache[(viewModel.currentDestination as ChatRouterDestination.Channel).channelId]?.server
|
||||
}
|
||||
|
||||
is ChatRouterDestination.NoCurrentChannel -> {
|
||||
(viewModel.currentDestination as ChatRouterDestination.NoCurrentChannel).serverId
|
||||
}
|
||||
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(drawerState) {
|
||||
snapshotFlow { drawerState.currentValue }
|
||||
.distinctUntilChanged()
|
||||
|
|
@ -318,16 +310,6 @@ fun ChatRouterScreen(
|
|||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(viewModel.currentChannel) {
|
||||
snapshotFlow { viewModel.currentChannel }
|
||||
.distinctUntilChanged()
|
||||
.collect { channelId ->
|
||||
if (channelId != null) {
|
||||
viewModel.navigateToChannel(channelId, navController, pure = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(viewModel.sidebarSparkDisplayed) {
|
||||
snapshotFlow { viewModel.sidebarSparkDisplayed }
|
||||
.distinctUntilChanged()
|
||||
|
|
@ -383,12 +365,7 @@ fun ChatRouterScreen(
|
|||
return@let
|
||||
}
|
||||
|
||||
if (resolvedChannel.server != null) {
|
||||
viewModel.navigateToServer(resolvedChannel.server, navController)
|
||||
} else {
|
||||
viewModel.navigateToServer("home", navController)
|
||||
}
|
||||
viewModel.navigateToChannel(action.channelId, navController)
|
||||
viewModel.setSaveDestination(ChatRouterDestination.Channel(action.channelId))
|
||||
}
|
||||
|
||||
is Action.LinkInfo -> {
|
||||
|
|
@ -412,7 +389,17 @@ fun ChatRouterScreen(
|
|||
}
|
||||
|
||||
is Action.ChatNavigate -> {
|
||||
navController.navigate(action.route)
|
||||
viewModel.setSaveDestination(action.destination)
|
||||
}
|
||||
|
||||
is Action.ReportUser -> {
|
||||
reportUserTarget = action.userId
|
||||
showReportUser = true
|
||||
}
|
||||
|
||||
is Action.ReportMessage -> {
|
||||
reportMessageTarget = action.messageId
|
||||
showReportMessage = true
|
||||
}
|
||||
|
||||
is Action.OpenVoiceChannelOverlay -> {
|
||||
|
|
@ -501,8 +488,7 @@ fun ChatRouterScreen(
|
|||
TextButton(onClick = {
|
||||
showPlatformModDMHint = false
|
||||
DirectMessages.getPlatformModerationDM()?.id?.let {
|
||||
viewModel.navigateToServer("home", navController)
|
||||
viewModel.navigateToChannel(it, navController)
|
||||
viewModel.setSaveDestination(ChatRouterDestination.Channel(it))
|
||||
}
|
||||
}) {
|
||||
Text(stringResource(id = R.string.notice_platform_mod_dm_acknowledge))
|
||||
|
|
@ -563,7 +549,8 @@ fun ChatRouterScreen(
|
|||
showServerContextSheet = false
|
||||
},
|
||||
onReportServer = {
|
||||
navController.navigate("report/server/${serverContextSheetTarget}")
|
||||
reportServerTarget = currentServer ?: ""
|
||||
showReportServer = true
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -589,6 +576,27 @@ fun ChatRouterScreen(
|
|||
}
|
||||
}
|
||||
|
||||
if (showReportUser) {
|
||||
ReportUserDialog(
|
||||
onDismiss = { showReportUser = false },
|
||||
userId = reportUserTarget
|
||||
)
|
||||
}
|
||||
|
||||
if (showReportMessage) {
|
||||
ReportMessageDialog(
|
||||
onDismiss = { showReportMessage = false },
|
||||
messageId = reportMessageTarget
|
||||
)
|
||||
}
|
||||
|
||||
if (showReportServer) {
|
||||
ReportServerDialog(
|
||||
onDismiss = { showReportServer = false },
|
||||
serverId = reportServerTarget
|
||||
)
|
||||
}
|
||||
|
||||
if (showChannelUnavailableAlert) {
|
||||
AlertDialog(
|
||||
onDismissRequest = {
|
||||
|
|
@ -717,7 +725,7 @@ fun ChatRouterScreen(
|
|||
Sidebar(
|
||||
viewModel = viewModel,
|
||||
topNav = topNav,
|
||||
navController = navController,
|
||||
currentServer = currentServer,
|
||||
onShowStatusSheet = {
|
||||
showStatusSheet = true
|
||||
},
|
||||
|
|
@ -735,16 +743,11 @@ fun ChatRouterScreen(
|
|||
)
|
||||
}
|
||||
ChannelNavigator(
|
||||
navController = navController,
|
||||
dest = viewModel.currentDestination,
|
||||
topNav = topNav,
|
||||
useDrawer = false,
|
||||
toggleDrawer = {
|
||||
toggleDrawerLambda()
|
||||
},
|
||||
onShowUserContextSheet = { target, server ->
|
||||
userContextSheetTarget = target
|
||||
userContextSheetServer = server
|
||||
showUserContextSheet = true
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -759,7 +762,7 @@ fun ChatRouterScreen(
|
|||
Sidebar(
|
||||
viewModel = viewModel,
|
||||
topNav = topNav,
|
||||
navController = navController,
|
||||
currentServer = currentServer,
|
||||
onShowStatusSheet = {
|
||||
showStatusSheet = true
|
||||
},
|
||||
|
|
@ -781,18 +784,13 @@ fun ChatRouterScreen(
|
|||
content = {
|
||||
Row(Modifier.fillMaxSize()) {
|
||||
ChannelNavigator(
|
||||
navController = navController,
|
||||
dest = viewModel.currentDestination,
|
||||
topNav = topNav,
|
||||
useDrawer = true,
|
||||
toggleDrawer = {
|
||||
toggleDrawerLambda()
|
||||
},
|
||||
drawerState = drawerState,
|
||||
onShowUserContextSheet = { target, server ->
|
||||
userContextSheetTarget = target
|
||||
userContextSheetServer = server
|
||||
showUserContextSheet = true
|
||||
}
|
||||
drawerState = drawerState
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -804,8 +802,8 @@ fun ChatRouterScreen(
|
|||
@Composable
|
||||
fun Sidebar(
|
||||
viewModel: ChatRouterViewModel,
|
||||
currentServer: String?,
|
||||
topNav: NavController,
|
||||
navController: NavHostController,
|
||||
drawerState: DrawerState? = null,
|
||||
onShowStatusSheet: () -> Unit,
|
||||
onShowServerContextSheet: (String) -> Unit,
|
||||
|
|
@ -839,7 +837,7 @@ fun Sidebar(
|
|||
size = 48.dp,
|
||||
presenceSize = 16.dp,
|
||||
onClick = {
|
||||
viewModel.navigateToServer("home", navController)
|
||||
viewModel.setSaveDestination(ChatRouterDestination.defaultForDMList)
|
||||
},
|
||||
onLongClick = onShowStatusSheet,
|
||||
modifier = Modifier
|
||||
|
|
@ -854,14 +852,7 @@ fun Sidebar(
|
|||
size = 48.dp,
|
||||
onClick = {
|
||||
it.id?.let { id ->
|
||||
viewModel.navigateToServer(
|
||||
"home",
|
||||
navController
|
||||
)
|
||||
viewModel.navigateToChannel(
|
||||
id,
|
||||
navController
|
||||
)
|
||||
viewModel.setSaveDestination(ChatRouterDestination.Channel(id))
|
||||
}
|
||||
},
|
||||
icon = it.icon,
|
||||
|
|
@ -898,13 +889,10 @@ fun Sidebar(
|
|||
presenceSize = 16.dp,
|
||||
onClick = {
|
||||
it.id?.let { id ->
|
||||
viewModel.navigateToServer(
|
||||
"home",
|
||||
navController
|
||||
)
|
||||
viewModel.navigateToChannel(
|
||||
id,
|
||||
navController
|
||||
viewModel.setSaveDestination(
|
||||
ChatRouterDestination.Channel(
|
||||
id
|
||||
)
|
||||
)
|
||||
}
|
||||
},
|
||||
|
|
@ -952,10 +940,7 @@ fun Sidebar(
|
|||
onShowServerContextSheet(server.id)
|
||||
}
|
||||
) {
|
||||
viewModel.navigateToServer(
|
||||
server.id,
|
||||
navController
|
||||
)
|
||||
viewModel.navigateToServer(server.id)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -993,20 +978,17 @@ fun Sidebar(
|
|||
}
|
||||
|
||||
Crossfade(
|
||||
targetState = viewModel.currentServer,
|
||||
targetState = currentServer,
|
||||
label = "Channel List"
|
||||
) {
|
||||
ChannelList(
|
||||
serverId = it,
|
||||
currentDestination = viewModel.currentRoute,
|
||||
currentChannel = viewModel.currentChannel,
|
||||
onChannelClick = { channelId ->
|
||||
viewModel.navigateToChannel(channelId, navController)
|
||||
scope.launch { drawerState?.close() }
|
||||
},
|
||||
onSpecialClick = { destination ->
|
||||
viewModel.navigateToSpecial(destination, navController)
|
||||
scope.launch { drawerState?.close() }
|
||||
currentDestination = viewModel.currentDestination,
|
||||
onDestinationChange = { destination ->
|
||||
viewModel.setSaveDestination(destination)
|
||||
scope.launch {
|
||||
drawerState?.close()
|
||||
}
|
||||
},
|
||||
onServerSheetOpenFor = { target ->
|
||||
onShowServerContextSheet(target)
|
||||
|
|
@ -1019,21 +1001,21 @@ fun Sidebar(
|
|||
|
||||
@Composable
|
||||
fun ChannelNavigator(
|
||||
navController: NavHostController,
|
||||
dest: ChatRouterDestination,
|
||||
topNav: NavController,
|
||||
useDrawer: Boolean,
|
||||
toggleDrawer: () -> Unit,
|
||||
drawerState: DrawerState? = null,
|
||||
onShowUserContextSheet: (String, String?) -> Unit
|
||||
drawerState: DrawerState? = null
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
BackHandler(enabled = useDrawer) {
|
||||
toggleDrawer()
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
NavHost(navController = navController, startDestination = "home") {
|
||||
composable("home") {
|
||||
BackHandler(enabled = useDrawer) {
|
||||
toggleDrawer()
|
||||
}
|
||||
when (dest) {
|
||||
is ChatRouterDestination.Home -> {
|
||||
HomeScreen(
|
||||
navController = topNav,
|
||||
useDrawer = useDrawer,
|
||||
|
|
@ -1041,10 +1023,7 @@ fun ChannelNavigator(
|
|||
)
|
||||
}
|
||||
|
||||
composable("friends") {
|
||||
BackHandler(enabled = useDrawer) {
|
||||
toggleDrawer()
|
||||
}
|
||||
is ChatRouterDestination.Friends -> {
|
||||
FriendsScreen(
|
||||
topNav = topNav,
|
||||
useDrawer = useDrawer,
|
||||
|
|
@ -1052,70 +1031,25 @@ fun ChannelNavigator(
|
|||
)
|
||||
}
|
||||
|
||||
composable("channel/{channelId}") { backStackEntry ->
|
||||
BackHandler(enabled = useDrawer) {
|
||||
toggleDrawer()
|
||||
}
|
||||
|
||||
val channelId = backStackEntry.arguments?.getString("channelId")
|
||||
if (channelId != null) {
|
||||
ChannelScreen(
|
||||
navController = navController,
|
||||
channelId = channelId,
|
||||
onToggleDrawer = {
|
||||
scope.launch {
|
||||
if (drawerState?.isOpen == true) {
|
||||
drawerState.close()
|
||||
} else {
|
||||
drawerState?.open()
|
||||
}
|
||||
is ChatRouterDestination.Channel -> {
|
||||
ChannelScreen2(
|
||||
channelId = dest.channelId,
|
||||
onToggleDrawer = {
|
||||
scope.launch {
|
||||
if (drawerState?.isOpen == true) {
|
||||
drawerState.close()
|
||||
} else {
|
||||
drawerState?.open()
|
||||
}
|
||||
},
|
||||
onUserSheetOpenFor = { target, server ->
|
||||
onShowUserContextSheet(target, server)
|
||||
},
|
||||
useDrawer = useDrawer
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
useDrawer = useDrawer
|
||||
)
|
||||
}
|
||||
|
||||
composable("no_current_channel") {
|
||||
BackHandler(enabled = useDrawer) {
|
||||
toggleDrawer()
|
||||
}
|
||||
|
||||
is ChatRouterDestination.NoCurrentChannel -> {
|
||||
NoCurrentChannelScreen(useDrawer = useDrawer, onDrawerClicked = toggleDrawer)
|
||||
}
|
||||
|
||||
dialog("report/message/{messageId}") { backStackEntry ->
|
||||
val messageId = backStackEntry.arguments?.getString("messageId")
|
||||
if (messageId != null) {
|
||||
ReportMessageDialog(
|
||||
navController = navController,
|
||||
messageId = messageId
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
dialog("report/server/{serverId}") { backStackEntry ->
|
||||
val serverId = backStackEntry.arguments?.getString("serverId")
|
||||
if (serverId != null) {
|
||||
ReportServerDialog(
|
||||
navController = navController,
|
||||
serverId = serverId
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
dialog("report/user/{userId}") { backStackEntry ->
|
||||
val userId = backStackEntry.arguments?.getString("userId")
|
||||
if (userId != null) {
|
||||
ReportUserDialog(
|
||||
navController = navController,
|
||||
userId = userId
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -1,653 +0,0 @@
|
|||
package chat.revolt.screens.chat.views.channel
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.provider.MediaStore
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.SmallFloatingActionButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.material3.surfaceColorAtElevation
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavController
|
||||
import chat.revolt.R
|
||||
import chat.revolt.activities.RevoltTweenDp
|
||||
import chat.revolt.activities.RevoltTweenFloat
|
||||
import chat.revolt.activities.RevoltTweenInt
|
||||
import chat.revolt.api.RevoltAPI
|
||||
import chat.revolt.api.internals.ChannelUtils
|
||||
import chat.revolt.api.routes.channel.react
|
||||
import chat.revolt.api.routes.microservices.autumn.FileArgs
|
||||
import chat.revolt.api.schemas.Channel
|
||||
import chat.revolt.api.schemas.ChannelType
|
||||
import chat.revolt.api.settings.FeatureFlags
|
||||
import chat.revolt.callbacks.Action
|
||||
import chat.revolt.callbacks.ActionChannel
|
||||
import chat.revolt.components.chat.Message
|
||||
import chat.revolt.components.chat.NativeMessageField
|
||||
import chat.revolt.components.chat.SystemMessage
|
||||
import chat.revolt.components.emoji.EmojiPicker
|
||||
import chat.revolt.components.media.InbuiltMediaPicker
|
||||
import chat.revolt.components.screens.chat.AttachmentManager
|
||||
import chat.revolt.components.screens.chat.ChannelHeader
|
||||
import chat.revolt.components.screens.chat.ReplyManager
|
||||
import chat.revolt.components.screens.chat.TypingIndicator
|
||||
import chat.revolt.sheets.ChannelInfoSheet
|
||||
import chat.revolt.sheets.MessageContextSheet
|
||||
import chat.revolt.sheets.ReactSheet
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import java.io.FileNotFoundException
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ChannelScreen(
|
||||
navController: NavController,
|
||||
channelId: String,
|
||||
onToggleDrawer: () -> Unit,
|
||||
onUserSheetOpenFor: (String, String?) -> Unit,
|
||||
useDrawer: Boolean,
|
||||
viewModel: ChannelScreenViewModel = viewModel()
|
||||
) {
|
||||
val channel = viewModel.activeChannel
|
||||
|
||||
val context = LocalContext.current
|
||||
val lazyListState = rememberLazyListState()
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
var channelInfoSheetShown by remember { mutableStateOf(false) }
|
||||
|
||||
var messageContextSheetShown by remember { mutableStateOf(false) }
|
||||
var messageContextSheetTarget by remember { mutableStateOf("") }
|
||||
|
||||
var reactSheetShown by remember { mutableStateOf(false) }
|
||||
var reactSheetTarget by remember { mutableStateOf("") }
|
||||
|
||||
val focusManager = LocalFocusManager.current
|
||||
|
||||
fun processFileUri(uri: Uri, pickerIdentifier: String? = null) {
|
||||
DocumentFile.fromSingleUri(context, uri)?.let { file ->
|
||||
val mFile = File(context.cacheDir, file.name ?: "attachment")
|
||||
|
||||
mFile.outputStream().use { output ->
|
||||
@Suppress("Recycle")
|
||||
context.contentResolver.openInputStream(uri)?.copyTo(output)
|
||||
}
|
||||
|
||||
// If the file is already pending and was picked from the inbuilt picker, remove it.
|
||||
// This is so you can "toggle" the file in the picker.
|
||||
// If the file was picked via DocumentsUI we don't want toggling functionality as
|
||||
// if you specifically opened it from DocumentsUI you probably want to send it anyway.
|
||||
if (
|
||||
pickerIdentifier != null &&
|
||||
viewModel.pendingAttachments.any { it.pickerIdentifier == pickerIdentifier }
|
||||
) {
|
||||
viewModel.pendingAttachments.removeIf { it.pickerIdentifier == pickerIdentifier }
|
||||
return
|
||||
}
|
||||
|
||||
viewModel.pendingAttachments.add(
|
||||
FileArgs(
|
||||
file = mFile,
|
||||
contentType = file.type ?: "application/octet-stream",
|
||||
filename = file.name ?: "attachment",
|
||||
pickerIdentifier = pickerIdentifier
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val pickFileLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.OpenMultipleDocuments()
|
||||
) { uriList ->
|
||||
uriList.let { list ->
|
||||
list.forEach { uri ->
|
||||
processFileUri(uri)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val capturedPhotoUri = rememberSaveable { mutableStateOf<Uri?>(null) }
|
||||
val pickCameraLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.TakePicture()
|
||||
) { uriUpdated ->
|
||||
if (uriUpdated) {
|
||||
capturedPhotoUri.value?.let { uri ->
|
||||
processFileUri(uri)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val scrollDownFABPadding by animateDpAsState(
|
||||
if (viewModel.typingUsers.isNotEmpty()) 25.dp else 0.dp,
|
||||
animationSpec = RevoltTweenDp,
|
||||
label = "ScrollDownFABPadding"
|
||||
)
|
||||
|
||||
LaunchedEffect(channelId) {
|
||||
viewModel.activeChannelId = channelId
|
||||
viewModel.fetchChannel(channelId)
|
||||
|
||||
coroutineScope.launch {
|
||||
viewModel.listenForWsFrames()
|
||||
}
|
||||
|
||||
coroutineScope.launch {
|
||||
viewModel.listenForUiCallbacks()
|
||||
}
|
||||
}
|
||||
|
||||
if (channelInfoSheetShown) {
|
||||
val channelInfoSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
|
||||
ModalBottomSheet(
|
||||
sheetState = channelInfoSheetState,
|
||||
onDismissRequest = {
|
||||
channelInfoSheetShown = false
|
||||
}
|
||||
) {
|
||||
ChannelInfoSheet(
|
||||
channelId = channelId,
|
||||
onHideSheet = {
|
||||
channelInfoSheetState.hide()
|
||||
channelInfoSheetShown = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (messageContextSheetShown) {
|
||||
val messageContextSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
|
||||
ModalBottomSheet(
|
||||
sheetState = messageContextSheetState,
|
||||
onDismissRequest = {
|
||||
messageContextSheetShown = false
|
||||
}
|
||||
) {
|
||||
MessageContextSheet(
|
||||
messageId = messageContextSheetTarget,
|
||||
onHideSheet = {
|
||||
messageContextSheetState.hide()
|
||||
messageContextSheetShown = false
|
||||
},
|
||||
onReportMessage = {
|
||||
navController.navigate("report/message/$messageContextSheetTarget")
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (reactSheetShown) {
|
||||
val reactSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
|
||||
ModalBottomSheet(
|
||||
sheetState = reactSheetState,
|
||||
onDismissRequest = {
|
||||
reactSheetShown = false
|
||||
}
|
||||
) {
|
||||
ReactSheet(reactSheetTarget) {
|
||||
if (it == null) return@ReactSheet
|
||||
|
||||
coroutineScope.launch {
|
||||
react(channelId, reactSheetTarget, it)
|
||||
reactSheetState.hide()
|
||||
reactSheetShown = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.imePadding()
|
||||
.windowInsetsPadding(WindowInsets.navigationBars)
|
||||
) {
|
||||
ChannelHeader(
|
||||
channel = channel ?: Channel(
|
||||
id = channelId,
|
||||
name = stringResource(R.string.loading),
|
||||
channelType = ChannelType.TextChannel
|
||||
),
|
||||
onChannelClick = {
|
||||
channelInfoSheetShown = true
|
||||
},
|
||||
onToggleDrawer = onToggleDrawer,
|
||||
useDrawer = useDrawer
|
||||
)
|
||||
|
||||
if (FeatureFlags.mediaConversationsGranted && channel?.channelType == ChannelType.VoiceChannel) {
|
||||
Button(onClick = {
|
||||
coroutineScope.launch {
|
||||
ActionChannel.send(
|
||||
Action.OpenVoiceChannelOverlay(
|
||||
channelId
|
||||
)
|
||||
)
|
||||
}
|
||||
}) {
|
||||
Text("Open voice channel overlay")
|
||||
}
|
||||
}
|
||||
|
||||
val isScrolledToBottom = remember(lazyListState) {
|
||||
derivedStateOf {
|
||||
lazyListState.firstVisibleItemIndex <= 6
|
||||
}
|
||||
}
|
||||
|
||||
val isScrolledToTop = remember {
|
||||
derivedStateOf {
|
||||
val layoutInfo = lazyListState.layoutInfo
|
||||
val totalItemsNumber = layoutInfo.totalItemsCount
|
||||
val lastVisibleItemIndex =
|
||||
(layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0) + 1
|
||||
val buffer = 6
|
||||
|
||||
lastVisibleItemIndex > (totalItemsNumber - buffer)
|
||||
}
|
||||
}
|
||||
|
||||
val isMessageTooLong by remember(viewModel.pendingMessageContent) {
|
||||
derivedStateOf {
|
||||
viewModel.pendingMessageContent.length > MAX_MESSAGE_LENGTH
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(isScrolledToTop) {
|
||||
snapshotFlow { isScrolledToTop.value }
|
||||
.distinctUntilChanged()
|
||||
.collect {
|
||||
if (it) {
|
||||
coroutineScope.launch {
|
||||
if (viewModel.hasNoMoreMessages) return@launch
|
||||
viewModel.fetchOlderMessages()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(viewModel.activeChannel, RevoltAPI.channelCache, RevoltAPI.serverCache) {
|
||||
viewModel.doInitialChecks()
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier.weight(1f),
|
||||
contentAlignment = Alignment.BottomEnd
|
||||
) {
|
||||
Crossfade(targetState = viewModel.showAgeGate, label = "Age gate shown/hidden") {
|
||||
if (it) {
|
||||
ChannelScreenAgeGate(
|
||||
onAccept = {
|
||||
viewModel.showAgeGate = false
|
||||
},
|
||||
onDeny = {
|
||||
onToggleDrawer()
|
||||
}
|
||||
)
|
||||
} else {
|
||||
LazyColumn(state = lazyListState, reverseLayout = true) {
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(25.dp))
|
||||
}
|
||||
|
||||
items(
|
||||
items = viewModel.renderableMessages,
|
||||
key = { it.id!! }
|
||||
) { message ->
|
||||
when {
|
||||
message.system != null -> SystemMessage(message)
|
||||
else -> Message(
|
||||
message,
|
||||
onMessageContextMenu = {
|
||||
messageContextSheetShown = true
|
||||
messageContextSheetTarget = message.id ?: ""
|
||||
},
|
||||
onAvatarClick = {
|
||||
message.author?.let { author ->
|
||||
onUserSheetOpenFor(author, channel?.server)
|
||||
}
|
||||
},
|
||||
onNameClick = {
|
||||
val author = message.author?.let { RevoltAPI.userCache[it] }
|
||||
?: return@Message
|
||||
viewModel.putAtCursorPosition("@${author.username}#${author.discriminator}")
|
||||
},
|
||||
canReply = true,
|
||||
onReply = {
|
||||
if (viewModel.pendingReplies.size >= 5) {
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.too_many_replies, 5),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
return@Message
|
||||
}
|
||||
viewModel.replyToMessage(message)
|
||||
},
|
||||
onAddReaction = {
|
||||
message.id?.let {
|
||||
reactSheetShown = true
|
||||
reactSheetTarget = it
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
if (viewModel.hasNoMoreMessages) {
|
||||
Text(
|
||||
text = stringResource(R.string.start_of_conversation),
|
||||
modifier = Modifier
|
||||
.padding(
|
||||
start = 8.dp,
|
||||
end = 8.dp,
|
||||
top = 64.dp,
|
||||
bottom = 32.dp
|
||||
)
|
||||
.fillMaxWidth(),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
} else {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier
|
||||
.padding(16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
androidx.compose.animation.AnimatedVisibility(
|
||||
!isScrolledToBottom.value,
|
||||
enter = slideInVertically(
|
||||
animationSpec = RevoltTweenInt,
|
||||
initialOffsetY = { it }
|
||||
) + fadeIn(animationSpec = RevoltTweenFloat),
|
||||
exit = slideOutVertically(
|
||||
animationSpec = RevoltTweenInt,
|
||||
targetOffsetY = { it }
|
||||
) + fadeOut(animationSpec = RevoltTweenFloat),
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
) {
|
||||
SmallFloatingActionButton(
|
||||
modifier = Modifier
|
||||
.padding(bottom = scrollDownFABPadding)
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(16.dp),
|
||||
onClick = {
|
||||
coroutineScope.launch {
|
||||
lazyListState.animateScrollToItem(0)
|
||||
}
|
||||
},
|
||||
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_arrow_down_24dp),
|
||||
contentDescription = stringResource(R.string.scroll_to_bottom)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!viewModel.showAgeGate) {
|
||||
TypingIndicator(
|
||||
users = viewModel.typingUsers
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.background(MaterialTheme.colorScheme.background.copy(alpha = 0.9f))
|
||||
.padding(
|
||||
start = 16.dp,
|
||||
end = 16.dp,
|
||||
bottom = 8.dp,
|
||||
top = 8.dp
|
||||
)
|
||||
.clip(MaterialTheme.shapes.medium)
|
||||
) {
|
||||
AnimatedVisibility(visible = viewModel.pendingReplies.isNotEmpty() && !viewModel.denyMessageField) {
|
||||
ReplyManager(
|
||||
replies = viewModel.pendingReplies,
|
||||
onRemove = { viewModel.pendingReplies.remove(it) },
|
||||
onToggleMention = viewModel::toggleReplyMentionFor
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedVisibility(visible = viewModel.pendingAttachments.isNotEmpty() && !viewModel.denyMessageField) {
|
||||
AttachmentManager(
|
||||
attachments = viewModel.pendingAttachments,
|
||||
uploading = viewModel.isSendingMessage,
|
||||
uploadProgress = viewModel.pendingUploadProgress,
|
||||
onRemove = { viewModel.pendingAttachments.remove(it) }
|
||||
)
|
||||
}
|
||||
|
||||
Crossfade(
|
||||
viewModel.denyMessageField,
|
||||
label = "denyMessageField switch"
|
||||
) { denyMessageField ->
|
||||
if (denyMessageField) {
|
||||
Text(
|
||||
text = stringResource(id = viewModel.denyMessageFieldReasonResource),
|
||||
modifier = Modifier
|
||||
.padding(8.dp)
|
||||
.fillMaxWidth(),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
} else {
|
||||
NativeMessageField(
|
||||
value = viewModel.pendingMessageContent,
|
||||
onValueChange = viewModel::updatePendingMessageContent,
|
||||
onSendMessage = viewModel::sendPendingMessage,
|
||||
onAddAttachment = {
|
||||
val isTiramisu = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
|
||||
|
||||
when {
|
||||
isTiramisu -> {
|
||||
focusManager.clearFocus()
|
||||
if (viewModel.currentBottomPane == BottomPane.InbuiltMediaPicker) {
|
||||
viewModel.currentBottomPane = BottomPane.None
|
||||
} else {
|
||||
viewModel.currentBottomPane = BottomPane.InbuiltMediaPicker
|
||||
}
|
||||
}
|
||||
|
||||
!isTiramisu -> {
|
||||
pickFileLauncher.launch(arrayOf("*/*"))
|
||||
}
|
||||
}
|
||||
},
|
||||
onCommitAttachment = { uri ->
|
||||
processFileUri(uri)
|
||||
},
|
||||
onPickEmoji = {
|
||||
focusManager.clearFocus()
|
||||
if (viewModel.currentBottomPane == BottomPane.EmojiPicker) {
|
||||
viewModel.currentBottomPane = BottomPane.None
|
||||
} else {
|
||||
viewModel.currentBottomPane = BottomPane.EmojiPicker
|
||||
}
|
||||
},
|
||||
channelType = channel?.channelType ?: ChannelType.TextChannel,
|
||||
channelName = channel?.name
|
||||
?: channel?.let { ChannelUtils.resolveDMName(it) }
|
||||
?: stringResource(
|
||||
R.string.unknown
|
||||
),
|
||||
forceSendButton = viewModel.pendingAttachments.isNotEmpty(),
|
||||
disabled = (viewModel.pendingAttachments.isNotEmpty() && viewModel.isSendingMessage) || viewModel.showAgeGate,
|
||||
channelId = channelId,
|
||||
serverId = channel?.server,
|
||||
editMode = viewModel.editingMessage != null,
|
||||
cancelEdit = viewModel::cancelEditingMessage,
|
||||
failedValidation = isMessageTooLong,
|
||||
onFocusChange = { nowFocused ->
|
||||
if (nowFocused && viewModel.currentBottomPane != BottomPane.None) {
|
||||
viewModel.currentBottomPane = BottomPane.None
|
||||
}
|
||||
},
|
||||
onSelectionChange = {
|
||||
viewModel.textSelection = it
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = viewModel.currentBottomPane == BottomPane.InbuiltMediaPicker
|
||||
) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
InbuiltMediaPicker(
|
||||
onOpenDocumentsUi = {
|
||||
pickFileLauncher.launch(arrayOf("*/*"))
|
||||
viewModel.currentBottomPane = BottomPane.None
|
||||
},
|
||||
onOpenCamera = {
|
||||
// Create a new content URI to store the captured image.
|
||||
val contentResolver = context.contentResolver
|
||||
val contentValues = ContentValues().apply {
|
||||
put(
|
||||
MediaStore.MediaColumns.DISPLAY_NAME,
|
||||
"RVL_${System.currentTimeMillis()}.jpg"
|
||||
)
|
||||
put(MediaStore.MediaColumns.MIME_TYPE, "image/jpeg")
|
||||
put(
|
||||
MediaStore.MediaColumns.RELATIVE_PATH,
|
||||
Environment.DIRECTORY_PICTURES
|
||||
)
|
||||
}
|
||||
|
||||
capturedPhotoUri.value = contentResolver.insert(
|
||||
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
|
||||
contentValues
|
||||
)
|
||||
|
||||
try {
|
||||
pickCameraLauncher.launch(capturedPhotoUri.value)
|
||||
} catch (e: Exception) {
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(
|
||||
R.string.file_picker_chip_camera_none_installed
|
||||
),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
|
||||
viewModel.currentBottomPane = BottomPane.None
|
||||
},
|
||||
onClose = {
|
||||
viewModel.currentBottomPane = BottomPane.None
|
||||
},
|
||||
onMediaSelected = { media ->
|
||||
try {
|
||||
processFileUri(
|
||||
media.uri,
|
||||
pickerIdentifier = media.uri.lastPathSegment
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
if (e is FileNotFoundException) {
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(
|
||||
R.string.file_picker_cannot_attach_file_invalid
|
||||
),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
},
|
||||
pendingMedia = viewModel.pendingAttachments
|
||||
.filterNot { it.pickerIdentifier == null }
|
||||
.map { it.pickerIdentifier!! },
|
||||
disabled = viewModel.isSendingMessage
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(visible = viewModel.currentBottomPane == BottomPane.EmojiPicker) {
|
||||
BackHandler(enabled = viewModel.currentBottomPane == BottomPane.EmojiPicker) {
|
||||
viewModel.currentBottomPane = BottomPane.None
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight(0.5f)
|
||||
.background(MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp))
|
||||
.padding(4.dp)
|
||||
) {
|
||||
EmojiPicker(onEmojiSelected = viewModel::putAtCursorPosition)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,947 @@
|
|||
package chat.revolt.screens.chat.views.channel
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.provider.MediaStore
|
||||
import android.util.DisplayMetrics
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.ExperimentalAnimationApi
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.animateIntAsState
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.ime
|
||||
import androidx.compose.foundation.layout.imeAnimationTarget
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.requiredHeight
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material3.AssistChip
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SmallFloatingActionButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.material3.surfaceColorAtElevation
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.LineHeightStyle
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import chat.revolt.R
|
||||
import chat.revolt.RevoltApplication
|
||||
import chat.revolt.activities.RevoltTweenDp
|
||||
import chat.revolt.activities.RevoltTweenFloat
|
||||
import chat.revolt.activities.RevoltTweenInt
|
||||
import chat.revolt.api.RevoltAPI
|
||||
import chat.revolt.api.internals.ChannelUtils
|
||||
import chat.revolt.api.internals.PermissionBit
|
||||
import chat.revolt.api.internals.has
|
||||
import chat.revolt.api.routes.channel.react
|
||||
import chat.revolt.api.routes.microservices.autumn.FileArgs
|
||||
import chat.revolt.api.schemas.ChannelType
|
||||
import chat.revolt.api.schemas.Message
|
||||
import chat.revolt.callbacks.Action
|
||||
import chat.revolt.callbacks.ActionChannel
|
||||
import chat.revolt.components.chat.DateDivider
|
||||
import chat.revolt.components.chat.Message
|
||||
import chat.revolt.components.chat.NativeMessageField
|
||||
import chat.revolt.components.chat.SystemMessage
|
||||
import chat.revolt.components.emoji.EmojiPicker
|
||||
import chat.revolt.components.generic.UserAvatarWidthPlaceholder
|
||||
import chat.revolt.components.media.InbuiltMediaPicker
|
||||
import chat.revolt.components.screens.chat.AttachmentManager
|
||||
import chat.revolt.components.screens.chat.ChannelIcon
|
||||
import chat.revolt.components.screens.chat.ReplyManager
|
||||
import chat.revolt.components.screens.chat.TypingIndicator
|
||||
import chat.revolt.internals.extensions.rememberChannelPermissions
|
||||
import chat.revolt.internals.extensions.zero
|
||||
import chat.revolt.sheets.ChannelInfoSheet
|
||||
import chat.revolt.sheets.MessageContextSheet
|
||||
import chat.revolt.sheets.ReactSheet
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.Instant
|
||||
import java.io.File
|
||||
import java.io.FileNotFoundException
|
||||
import kotlin.math.max
|
||||
|
||||
sealed class ChannelScreenItem {
|
||||
data class RegularMessage(val message: Message) : ChannelScreenItem()
|
||||
data class ProspectiveMessage(val message: Message) : ChannelScreenItem()
|
||||
data class FailedMessage(val message: Message) : ChannelScreenItem()
|
||||
data class SystemMessage(val message: Message) : ChannelScreenItem()
|
||||
data class DateDivider(val instant: Instant) : ChannelScreenItem()
|
||||
data class LoadTrigger(val after: String?, val before: String?) :
|
||||
ChannelScreenItem()
|
||||
|
||||
data object Loading : ChannelScreenItem()
|
||||
}
|
||||
|
||||
sealed class ChannelScreenActivePane {
|
||||
data object None : ChannelScreenActivePane()
|
||||
data object EmojiPicker : ChannelScreenActivePane()
|
||||
data object AttachmentPicker : ChannelScreenActivePane()
|
||||
}
|
||||
|
||||
private fun pxAsDp(px: Int): Dp {
|
||||
return (
|
||||
px / (
|
||||
RevoltApplication.instance.resources
|
||||
.displayMetrics.densityDpi.toFloat() / DisplayMetrics.DENSITY_DEFAULT
|
||||
)
|
||||
).dp
|
||||
}
|
||||
|
||||
@OptIn(
|
||||
ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class,
|
||||
ExperimentalAnimationApi::class
|
||||
)
|
||||
@Composable
|
||||
fun ChannelScreen2(
|
||||
channelId: String,
|
||||
onToggleDrawer: () -> Unit,
|
||||
useDrawer: Boolean,
|
||||
viewModel: ChannelScreen2ViewModel = hiltViewModel()
|
||||
) {
|
||||
// Setup
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
val context = LocalContext.current
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.startListening()
|
||||
}
|
||||
|
||||
// Load/switch channel
|
||||
|
||||
val channelPermissions by rememberChannelPermissions(channelId, viewModel.ensuredSelfMember)
|
||||
|
||||
LaunchedEffect(channelId) {
|
||||
viewModel.switchChannel(channelId)
|
||||
}
|
||||
|
||||
// Keyboard height
|
||||
|
||||
val imeTarget = WindowInsets.imeAnimationTarget.getBottom(LocalDensity.current)
|
||||
val navigationBarsInset = WindowInsets.navigationBars.getBottom(LocalDensity.current)
|
||||
val imeCurrentInset = WindowInsets.ime.getBottom(LocalDensity.current)
|
||||
var imeInTransition by remember { mutableStateOf(false) }
|
||||
|
||||
var emojiSearchFocused by remember { mutableStateOf(false) }
|
||||
|
||||
val fallbackKeyboardHeight by animateIntAsState(
|
||||
targetValue = if (viewModel.activePane == ChannelScreenActivePane.None && !imeInTransition) navigationBarsInset else viewModel.keyboardHeight,
|
||||
label = "keyboardHeight"
|
||||
)
|
||||
|
||||
LaunchedEffect(imeTarget) {
|
||||
if (imeTarget > 0) {
|
||||
viewModel.updateSaveKeyboardHeight(imeTarget)
|
||||
} else {
|
||||
imeInTransition = false
|
||||
}
|
||||
}
|
||||
|
||||
// Attachment handling
|
||||
|
||||
val processFileUri: (Uri, String?) -> Unit = remember {
|
||||
{ uri, pickerIdentifier ->
|
||||
DocumentFile.fromSingleUri(context, uri)?.let { file ->
|
||||
val mFile = File(context.cacheDir, file.name ?: "attachment")
|
||||
|
||||
mFile.outputStream().use { output ->
|
||||
@Suppress("Recycle")
|
||||
context.contentResolver.openInputStream(uri)?.copyTo(output)
|
||||
}
|
||||
|
||||
// If the file is already pending and was picked from the inbuilt picker, remove it.
|
||||
// This is so you can "toggle" the file in the picker.
|
||||
// If the file was picked via DocumentsUI we don't want toggling functionality as
|
||||
// if you specifically opened it from DocumentsUI you probably want to send it anyway.
|
||||
if (
|
||||
pickerIdentifier != null &&
|
||||
viewModel.draftAttachments.any { it.pickerIdentifier == pickerIdentifier }
|
||||
) {
|
||||
viewModel.draftAttachments.removeIf { it.pickerIdentifier == pickerIdentifier }
|
||||
return@let
|
||||
}
|
||||
|
||||
viewModel.draftAttachments.add(
|
||||
FileArgs(
|
||||
file = mFile,
|
||||
contentType = file.type ?: "application/octet-stream",
|
||||
filename = file.name ?: "attachment",
|
||||
pickerIdentifier = pickerIdentifier
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val pickFileLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.OpenMultipleDocuments()
|
||||
) { uriList ->
|
||||
uriList.let { list ->
|
||||
list.forEach { uri ->
|
||||
processFileUri(uri, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val capturedPhotoUri = rememberSaveable { mutableStateOf<Uri?>(null) }
|
||||
val pickCameraLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.TakePicture()
|
||||
) { uriUpdated ->
|
||||
if (uriUpdated) {
|
||||
capturedPhotoUri.value?.let { uri ->
|
||||
processFileUri(uri, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// UI elements
|
||||
|
||||
val lazyListState = rememberLazyListState()
|
||||
|
||||
val isScrolledToBottom = remember(lazyListState) {
|
||||
derivedStateOf {
|
||||
lazyListState.firstVisibleItemIndex <= 6
|
||||
}
|
||||
}
|
||||
|
||||
val isNearTop = remember(lazyListState) {
|
||||
derivedStateOf {
|
||||
val layoutInfo = lazyListState.layoutInfo
|
||||
val totalItemsNumber = layoutInfo.totalItemsCount
|
||||
val lastVisibleItemIndex =
|
||||
(layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0) + 1
|
||||
val buffer = 6
|
||||
|
||||
lastVisibleItemIndex > (totalItemsNumber - buffer)
|
||||
}
|
||||
}
|
||||
|
||||
val scrollDownFABPadding by animateDpAsState(
|
||||
if (viewModel.typingUsers.isNotEmpty()) 25.dp else 0.dp,
|
||||
animationSpec = RevoltTweenDp,
|
||||
label = "ScrollDownFABPadding"
|
||||
)
|
||||
|
||||
// Load more messages when we reach the top of the list
|
||||
// TODO: Temp - use LoadTrigger instead
|
||||
|
||||
LaunchedEffect(isNearTop) {
|
||||
snapshotFlow { isNearTop.value }
|
||||
.distinctUntilChanged()
|
||||
.collect { isNearTop ->
|
||||
if (isNearTop) {
|
||||
Log.d("ChannelScreen2", "Loading more messages")
|
||||
viewModel.loadMessages(before = viewModel.items.lastOrNull {
|
||||
it is ChannelScreenItem.RegularMessage || it is ChannelScreenItem.SystemMessage
|
||||
}?.let {
|
||||
when (it) {
|
||||
is ChannelScreenItem.RegularMessage -> it.message.id
|
||||
is ChannelScreenItem.SystemMessage -> it.message.id
|
||||
else -> null
|
||||
}
|
||||
}, amount = 50)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sheets
|
||||
|
||||
var channelInfoSheetShown by remember { mutableStateOf(false) }
|
||||
if (channelInfoSheetShown) {
|
||||
val channelInfoSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
|
||||
ModalBottomSheet(
|
||||
sheetState = channelInfoSheetState,
|
||||
onDismissRequest = {
|
||||
channelInfoSheetShown = false
|
||||
}
|
||||
) {
|
||||
ChannelInfoSheet(
|
||||
channelId = channelId,
|
||||
onHideSheet = {
|
||||
channelInfoSheetState.hide()
|
||||
channelInfoSheetShown = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
var messageContextSheetShown by remember { mutableStateOf(false) }
|
||||
var messageContextSheetTarget by remember { mutableStateOf("") }
|
||||
if (messageContextSheetShown) {
|
||||
val messageContextSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
|
||||
ModalBottomSheet(
|
||||
sheetState = messageContextSheetState,
|
||||
onDismissRequest = {
|
||||
messageContextSheetShown = false
|
||||
}
|
||||
) {
|
||||
MessageContextSheet(
|
||||
messageId = messageContextSheetTarget,
|
||||
onHideSheet = {
|
||||
messageContextSheetState.hide()
|
||||
messageContextSheetShown = false
|
||||
},
|
||||
onReportMessage = {
|
||||
scope.launch {
|
||||
ActionChannel.send(Action.ReportMessage(messageContextSheetTarget))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
var reactSheetShown by remember { mutableStateOf(false) }
|
||||
var reactSheetTarget by remember { mutableStateOf("") }
|
||||
if (reactSheetShown) {
|
||||
val reactSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
|
||||
ModalBottomSheet(
|
||||
sheetState = reactSheetState,
|
||||
onDismissRequest = {
|
||||
reactSheetShown = false
|
||||
}
|
||||
) {
|
||||
ReactSheet(reactSheetTarget) {
|
||||
if (it == null) return@ReactSheet
|
||||
|
||||
scope.launch {
|
||||
react(channelId, reactSheetTarget, it)
|
||||
reactSheetState.hide()
|
||||
reactSheetShown = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Begin UI composition
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = WindowInsets(
|
||||
left = 0,
|
||||
right = 0,
|
||||
top = 0,
|
||||
bottom = 0
|
||||
),
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
modifier = Modifier.clickable {
|
||||
channelInfoSheetShown = true
|
||||
},
|
||||
title = {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
viewModel.channel?.let {
|
||||
ChannelIcon(
|
||||
channelType = it.channelType ?: ChannelType.TextChannel,
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.alpha(0.8f)
|
||||
)
|
||||
|
||||
CompositionLocalProvider(
|
||||
LocalTextStyle provides LocalTextStyle.current.copy(
|
||||
fontSize = 20.sp,
|
||||
lineHeightStyle = LineHeightStyle(
|
||||
alignment = LineHeightStyle.Alignment.Bottom,
|
||||
trim = LineHeightStyle.Trim.LastLineBottom
|
||||
)
|
||||
)
|
||||
) {
|
||||
when (it.channelType) {
|
||||
ChannelType.TextChannel, ChannelType.VoiceChannel, ChannelType.Group -> Text(
|
||||
it.name ?: stringResource(R.string.unknown),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
ChannelType.SavedMessages -> Text(
|
||||
stringResource(R.string.channel_notes),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
ChannelType.DirectMessage -> Text(
|
||||
ChannelUtils.resolveDMName(it)
|
||||
?: stringResource(R.string.unknown),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
else -> Text(
|
||||
stringResource(R.string.unknown),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Default.KeyboardArrowRight,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(16.dp)
|
||||
.alpha(0.5f)
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
windowInsets = WindowInsets.zero,
|
||||
navigationIcon = {
|
||||
if (useDrawer) {
|
||||
IconButton(onClick = onToggleDrawer) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Menu,
|
||||
contentDescription = stringResource(id = R.string.menu)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { pv ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(pv)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.weight(1f),
|
||||
contentAlignment = Alignment.BottomCenter
|
||||
) {
|
||||
LazyColumn(
|
||||
state = lazyListState,
|
||||
reverseLayout = true,
|
||||
contentPadding = PaddingValues(top = 16.dp, bottom = 32.dp)
|
||||
) {
|
||||
|
||||
// If we don't have a guaranteed first item, the message list will not scroll
|
||||
// to the bottom when new messages are added. Evil hack to make our other evil
|
||||
// hack (clear/addAll) work. Too bad!
|
||||
item(key = "guaranteed_first") {
|
||||
Box {}
|
||||
}
|
||||
|
||||
items(
|
||||
viewModel.items.size,
|
||||
key = { index ->
|
||||
when (val item = viewModel.items[index]) {
|
||||
is ChannelScreenItem.RegularMessage -> item.message.id!!
|
||||
is ChannelScreenItem.ProspectiveMessage -> item.message.id!!
|
||||
is ChannelScreenItem.FailedMessage -> item.message.id!!
|
||||
is ChannelScreenItem.SystemMessage -> item.message.id!!
|
||||
is ChannelScreenItem.DateDivider -> item.instant.toEpochMilliseconds()
|
||||
is ChannelScreenItem.LoadTrigger -> index
|
||||
is ChannelScreenItem.Loading -> index
|
||||
}
|
||||
},
|
||||
contentType = { index ->
|
||||
when (viewModel.items.getOrNull(index)) {
|
||||
null -> null
|
||||
is ChannelScreenItem.RegularMessage -> "RegularMessage"
|
||||
is ChannelScreenItem.ProspectiveMessage -> "ProspectiveMessage"
|
||||
is ChannelScreenItem.FailedMessage -> "FailedMessage"
|
||||
is ChannelScreenItem.SystemMessage -> "SystemMessage"
|
||||
is ChannelScreenItem.DateDivider -> "DateDivider"
|
||||
is ChannelScreenItem.LoadTrigger -> "LoadTrigger"
|
||||
is ChannelScreenItem.Loading -> "Loading"
|
||||
}
|
||||
}
|
||||
) { index ->
|
||||
when (val item = viewModel.items[index]) {
|
||||
is ChannelScreenItem.RegularMessage -> {
|
||||
Message(
|
||||
message = item.message,
|
||||
onMessageContextMenu = {
|
||||
item.message.id?.let { messageId ->
|
||||
messageContextSheetTarget = messageId
|
||||
messageContextSheetShown = true
|
||||
}
|
||||
},
|
||||
onAvatarClick = {
|
||||
item.message.author?.let { author ->
|
||||
scope.launch {
|
||||
ActionChannel.send(
|
||||
Action.OpenUserSheet(
|
||||
author,
|
||||
viewModel.channel?.server
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
onNameClick = {
|
||||
val author =
|
||||
item.message.author?.let { RevoltAPI.userCache[it] }
|
||||
?: return@Message
|
||||
viewModel.putAtCursorPosition("@${author.username}#${author.discriminator}")
|
||||
},
|
||||
canReply = true,
|
||||
onReply = {
|
||||
item.message.id?.let { messageId ->
|
||||
scope.launch { viewModel.addReplyTo(messageId) }
|
||||
}
|
||||
},
|
||||
onAddReaction = {
|
||||
item.message.id?.let { messageId ->
|
||||
reactSheetTarget = messageId
|
||||
reactSheetShown = true
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
is ChannelScreenItem.ProspectiveMessage -> {
|
||||
Box(Modifier.alpha(0.5f)) {
|
||||
Message(
|
||||
message = item.message,
|
||||
onMessageContextMenu = {
|
||||
// TODO Context menu that allows you to cancel send
|
||||
},
|
||||
onAvatarClick = {},
|
||||
onNameClick = {},
|
||||
canReply = false,
|
||||
onReply = {},
|
||||
onAddReaction = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is ChannelScreenItem.FailedMessage -> {
|
||||
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.error) {
|
||||
Column {
|
||||
Message(
|
||||
message = item.message,
|
||||
onMessageContextMenu = {},
|
||||
onAvatarClick = {},
|
||||
onNameClick = {},
|
||||
canReply = false,
|
||||
onReply = {},
|
||||
onAddReaction = {}
|
||||
)
|
||||
Row {
|
||||
UserAvatarWidthPlaceholder()
|
||||
Text(
|
||||
stringResource(R.string.message_failed_to_send),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.error.copy(alpha = 0.8f),
|
||||
modifier = Modifier.padding(
|
||||
top = 4.dp,
|
||||
bottom = 4.dp,
|
||||
start = 20.dp
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is ChannelScreenItem.SystemMessage -> {
|
||||
SystemMessage(message = item.message)
|
||||
}
|
||||
|
||||
is ChannelScreenItem.DateDivider -> {
|
||||
DateDivider(instant = item.instant)
|
||||
}
|
||||
|
||||
is ChannelScreenItem.LoadTrigger -> {
|
||||
LaunchedEffect(Unit) {
|
||||
Log.d(
|
||||
"ChannelScreen2",
|
||||
"LoadTrigger: After ${item.after} Before ${item.before}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is ChannelScreenItem.Loading -> {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier
|
||||
.padding(16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TypingIndicator(
|
||||
users = viewModel.typingUsers,
|
||||
serverId = viewModel.channel?.server
|
||||
)
|
||||
|
||||
androidx.compose.animation.AnimatedVisibility(
|
||||
!isScrolledToBottom.value,
|
||||
enter = slideInVertically(
|
||||
animationSpec = RevoltTweenInt,
|
||||
initialOffsetY = { it }
|
||||
) + fadeIn(animationSpec = RevoltTweenFloat),
|
||||
exit = slideOutVertically(
|
||||
animationSpec = RevoltTweenInt,
|
||||
targetOffsetY = { it }
|
||||
) + fadeOut(animationSpec = RevoltTweenFloat)
|
||||
) {
|
||||
SmallFloatingActionButton(
|
||||
modifier = Modifier
|
||||
.padding(bottom = scrollDownFABPadding)
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(16.dp),
|
||||
onClick = {
|
||||
scope.launch {
|
||||
lazyListState.animateScrollToItem(0)
|
||||
}
|
||||
},
|
||||
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_arrow_down_24dp),
|
||||
contentDescription = stringResource(R.string.scroll_to_bottom)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.background(MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp))
|
||||
.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
AnimatedContent(
|
||||
targetState = viewModel.denyMessageField,
|
||||
label = "denyMessageField"
|
||||
) { deny ->
|
||||
if (!deny) {
|
||||
Column {
|
||||
AnimatedVisibility(
|
||||
visible = viewModel.draftReplyTo.isNotEmpty() && !viewModel.denyMessageField
|
||||
) {
|
||||
ReplyManager(
|
||||
replies = viewModel.draftReplyTo,
|
||||
onToggleMention = {
|
||||
scope.launch { viewModel.toggleMentionOnReply(it.id) }
|
||||
},
|
||||
onRemove = {
|
||||
viewModel.draftReplyTo.remove(it)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = viewModel.draftAttachments.isNotEmpty() && !viewModel.denyMessageField
|
||||
) {
|
||||
AttachmentManager(
|
||||
attachments = viewModel.draftAttachments,
|
||||
uploading = viewModel.attachmentUploadProgress > 0,
|
||||
uploadProgress = viewModel.attachmentUploadProgress,
|
||||
canRemove = true,
|
||||
canPreview = true,
|
||||
onRemove = {
|
||||
viewModel.draftAttachments.remove(it)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedVisibility(visible = viewModel.editingMessage != null) {
|
||||
Row(Modifier.padding(start = 24.dp, top = 8.dp)) {
|
||||
AssistChip(
|
||||
onClick = {
|
||||
viewModel.editingMessage = null
|
||||
viewModel.putDraftContent("")
|
||||
},
|
||||
label = {
|
||||
Text(stringResource(R.string.message_field_editing_message))
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Edit,
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
trailingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = stringResource(R.string.message_field_editing_message_cancel_alt),
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.alpha(0.8f)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
NativeMessageField(
|
||||
value = viewModel.draftContent,
|
||||
onValueChange = viewModel::putDraftContent,
|
||||
onAddAttachment = {
|
||||
if (viewModel.activePane == ChannelScreenActivePane.AttachmentPicker) {
|
||||
viewModel.activePane = ChannelScreenActivePane.None
|
||||
} else {
|
||||
viewModel.activePane =
|
||||
ChannelScreenActivePane.AttachmentPicker
|
||||
}
|
||||
},
|
||||
onCommitAttachment = {
|
||||
processFileUri(it, null)
|
||||
},
|
||||
onPickEmoji = {
|
||||
if (viewModel.activePane == ChannelScreenActivePane.EmojiPicker) {
|
||||
viewModel.activePane = ChannelScreenActivePane.None
|
||||
} else {
|
||||
viewModel.activePane = ChannelScreenActivePane.EmojiPicker
|
||||
}
|
||||
},
|
||||
onSendMessage = viewModel::sendPendingMessage,
|
||||
channelType = viewModel.channel?.channelType
|
||||
?: ChannelType.TextChannel,
|
||||
channelName = viewModel.channel?.name
|
||||
?: stringResource(R.string.unknown),
|
||||
onFocusChange = { isFocused ->
|
||||
if (isFocused && viewModel.activePane != ChannelScreenActivePane.None) {
|
||||
viewModel.activePane = ChannelScreenActivePane.None
|
||||
imeInTransition = true
|
||||
}
|
||||
},
|
||||
forceSendButton = viewModel.draftAttachments.isNotEmpty(),
|
||||
canAttach = (channelPermissions has PermissionBit.UploadFiles) && viewModel.editingMessage == null,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 32.dp, vertical = 16.dp)
|
||||
) {
|
||||
Text(
|
||||
stringResource(viewModel.denyMessageFieldReasonResource),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (viewModel.activePane == ChannelScreenActivePane.None && !imeInTransition) {
|
||||
Spacer(
|
||||
Modifier
|
||||
.imePadding()
|
||||
.navigationBarsPadding()
|
||||
.background(MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp))
|
||||
)
|
||||
} else {
|
||||
Box(
|
||||
Modifier
|
||||
.heightIn(min = pxAsDp(fallbackKeyboardHeight))
|
||||
) {
|
||||
Box(
|
||||
Modifier.then(
|
||||
if (emojiSearchFocused) {
|
||||
Modifier.requiredHeight(
|
||||
pxAsDp(
|
||||
max(
|
||||
imeCurrentInset * 2,
|
||||
fallbackKeyboardHeight
|
||||
)
|
||||
)
|
||||
)
|
||||
} else {
|
||||
Modifier.requiredHeight(pxAsDp(fallbackKeyboardHeight))
|
||||
}
|
||||
)
|
||||
) {
|
||||
when (viewModel.activePane) {
|
||||
ChannelScreenActivePane.EmojiPicker -> {
|
||||
BackHandler(enabled = viewModel.activePane == ChannelScreenActivePane.EmojiPicker) {
|
||||
viewModel.activePane = ChannelScreenActivePane.None
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
MaterialTheme.colorScheme.surfaceColorAtElevation(
|
||||
1.dp
|
||||
)
|
||||
)
|
||||
.padding(4.dp)
|
||||
.navigationBarsPadding()
|
||||
) {
|
||||
EmojiPicker(
|
||||
onEmojiSelected = viewModel::putAtCursorPosition,
|
||||
bottomInset = pxAsDp(
|
||||
max(
|
||||
imeCurrentInset - navigationBarsInset,
|
||||
0
|
||||
)
|
||||
),
|
||||
onSearchFocus = {
|
||||
emojiSearchFocused = it
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
ChannelScreenActivePane.AttachmentPicker -> {
|
||||
BackHandler(enabled = viewModel.activePane == ChannelScreenActivePane.AttachmentPicker) {
|
||||
viewModel.activePane = ChannelScreenActivePane.None
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
InbuiltMediaPicker(
|
||||
onOpenDocumentsUi = {
|
||||
pickFileLauncher.launch(arrayOf("*/*"))
|
||||
viewModel.activePane = ChannelScreenActivePane.None
|
||||
},
|
||||
onOpenCamera = {
|
||||
// Create a new content URI to store the captured image.
|
||||
val contentResolver = context.contentResolver
|
||||
val contentValues = ContentValues().apply {
|
||||
put(
|
||||
MediaStore.MediaColumns.DISPLAY_NAME,
|
||||
"RVL_${System.currentTimeMillis()}.jpg"
|
||||
)
|
||||
put(
|
||||
MediaStore.MediaColumns.MIME_TYPE,
|
||||
"image/jpeg"
|
||||
)
|
||||
put(
|
||||
MediaStore.MediaColumns.RELATIVE_PATH,
|
||||
Environment.DIRECTORY_PICTURES
|
||||
)
|
||||
}
|
||||
|
||||
capturedPhotoUri.value = contentResolver.insert(
|
||||
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
|
||||
contentValues
|
||||
)
|
||||
|
||||
try {
|
||||
pickCameraLauncher.launch(capturedPhotoUri.value)
|
||||
} catch (e: Exception) {
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(
|
||||
R.string.file_picker_chip_camera_none_installed
|
||||
),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
|
||||
viewModel.activePane = ChannelScreenActivePane.None
|
||||
},
|
||||
onClose = {
|
||||
viewModel.activePane = ChannelScreenActivePane.None
|
||||
},
|
||||
onMediaSelected = { media ->
|
||||
try {
|
||||
processFileUri(
|
||||
media.uri,
|
||||
media.uri.lastPathSegment
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
if (e is FileNotFoundException) {
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(
|
||||
R.string.file_picker_cannot_attach_file_invalid
|
||||
),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
},
|
||||
pendingMedia = viewModel.draftAttachments
|
||||
.filterNot { it.pickerIdentifier == null }
|
||||
.map { it.pickerIdentifier!! },
|
||||
modifier = Modifier
|
||||
.imePadding()
|
||||
.navigationBarsPadding()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
// Do nothing
|
||||
}
|
||||
}
|
||||
}
|
||||
Box(Modifier.imePadding())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,801 @@
|
|||
package chat.revolt.screens.chat.views.channel
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.toMutableStateList
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import chat.revolt.R
|
||||
import chat.revolt.api.RevoltAPI
|
||||
import chat.revolt.api.RevoltJson
|
||||
import chat.revolt.api.internals.ChannelUtils
|
||||
import chat.revolt.api.internals.PermissionBit
|
||||
import chat.revolt.api.internals.Roles
|
||||
import chat.revolt.api.internals.SpecialUsers
|
||||
import chat.revolt.api.internals.ULID
|
||||
import chat.revolt.api.internals.has
|
||||
import chat.revolt.api.realtime.RealtimeSocket
|
||||
import chat.revolt.api.realtime.RealtimeSocketFrames
|
||||
import chat.revolt.api.realtime.frames.receivable.ChannelDeleteFrame
|
||||
import chat.revolt.api.realtime.frames.receivable.ChannelStartTypingFrame
|
||||
import chat.revolt.api.realtime.frames.receivable.ChannelStopTypingFrame
|
||||
import chat.revolt.api.realtime.frames.receivable.MessageAppendFrame
|
||||
import chat.revolt.api.realtime.frames.receivable.MessageDeleteFrame
|
||||
import chat.revolt.api.realtime.frames.receivable.MessageFrame
|
||||
import chat.revolt.api.realtime.frames.receivable.MessageReactFrame
|
||||
import chat.revolt.api.realtime.frames.receivable.MessageUnreactFrame
|
||||
import chat.revolt.api.realtime.frames.receivable.MessageUpdateFrame
|
||||
import chat.revolt.api.routes.channel.SendMessageReply
|
||||
import chat.revolt.api.routes.channel.ackChannel
|
||||
import chat.revolt.api.routes.channel.editMessage
|
||||
import chat.revolt.api.routes.channel.fetchMessagesFromChannel
|
||||
import chat.revolt.api.routes.channel.sendMessage
|
||||
import chat.revolt.api.routes.microservices.autumn.FileArgs
|
||||
import chat.revolt.api.routes.microservices.autumn.MAX_ATTACHMENTS_PER_MESSAGE
|
||||
import chat.revolt.api.routes.microservices.autumn.uploadToAutumn
|
||||
import chat.revolt.api.routes.server.fetchMember
|
||||
import chat.revolt.api.routes.user.addUserIfUnknown
|
||||
import chat.revolt.api.routes.user.fetchUser
|
||||
import chat.revolt.api.schemas.Channel
|
||||
import chat.revolt.api.schemas.Message
|
||||
import chat.revolt.callbacks.Action
|
||||
import chat.revolt.callbacks.ActionChannel
|
||||
import chat.revolt.callbacks.UiCallback
|
||||
import chat.revolt.callbacks.UiCallbacks
|
||||
import chat.revolt.persistence.KVStorage
|
||||
import chat.revolt.screens.chat.ChatRouterDestination
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import io.ktor.http.ContentType
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.Instant
|
||||
import kotlinx.datetime.toJavaInstant
|
||||
import java.time.ZoneId
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class ChannelScreen2ViewModel @Inject constructor(
|
||||
private val kvStorage: KVStorage,
|
||||
) : ViewModel() {
|
||||
var items = mutableStateListOf<ChannelScreenItem>()
|
||||
var typingUsers = mutableStateListOf<String>()
|
||||
|
||||
var channel by mutableStateOf<Channel?>(null)
|
||||
var activePane by mutableStateOf<ChannelScreenActivePane>(ChannelScreenActivePane.None)
|
||||
var keyboardHeight by mutableIntStateOf(0)
|
||||
|
||||
var draftContent by mutableStateOf("")
|
||||
var draftAttachments = mutableStateListOf<FileArgs>()
|
||||
var draftReplyTo = mutableStateListOf<SendMessageReply>()
|
||||
var attachmentUploadProgress by mutableStateOf(0f)
|
||||
|
||||
var endOfChannel by mutableStateOf(false)
|
||||
var didInitialChannelFetch by mutableStateOf(false)
|
||||
|
||||
var ensuredSelfMember by mutableStateOf(false)
|
||||
|
||||
var denyMessageField by mutableStateOf(false)
|
||||
var denyMessageFieldReasonResource by mutableIntStateOf(R.string.typing_blank)
|
||||
|
||||
var editingMessage by mutableStateOf<String?>(null)
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
keyboardHeight = kvStorage.getInt("keyboardHeight") ?: 900 // reasonable default for now
|
||||
}
|
||||
}
|
||||
|
||||
fun switchChannel(id: String) {
|
||||
// Reset state
|
||||
this.channel = RevoltAPI.channelCache[id]
|
||||
this.items = mutableStateListOf(ChannelScreenItem.Loading)
|
||||
this.activePane = ChannelScreenActivePane.None
|
||||
this.typingUsers = mutableStateListOf()
|
||||
this.endOfChannel = false
|
||||
this.didInitialChannelFetch = false
|
||||
this.ensuredSelfMember = false
|
||||
this.denyMessageField = false
|
||||
this.denyMessageFieldReasonResource = R.string.typing_blank
|
||||
this.editingMessage = null
|
||||
|
||||
viewModelScope.launch {
|
||||
draftContent = kvStorage.get("draftContent/$id") ?: ""
|
||||
}
|
||||
this.draftAttachments = mutableStateListOf()
|
||||
this.draftReplyTo = mutableStateListOf()
|
||||
this.attachmentUploadProgress = 0f
|
||||
|
||||
viewModelScope.launch {
|
||||
ensureSelfHasMember()
|
||||
shouldDenyMessageField()
|
||||
}
|
||||
|
||||
this.loadMessages(50)
|
||||
}
|
||||
|
||||
private suspend fun ensureSelfHasMember() {
|
||||
channel?.server?.let { serverId ->
|
||||
RevoltAPI.selfId?.let { selfId ->
|
||||
if (!RevoltAPI.members.hasMember(serverId, selfId)) {
|
||||
fetchMember(serverId, selfId)
|
||||
}
|
||||
|
||||
ensuredSelfMember = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun shouldDenyMessageField() {
|
||||
if (channel == null) return
|
||||
|
||||
val selfUser = RevoltAPI.userCache[RevoltAPI.selfId] ?: return
|
||||
val selfMember = if (channel!!.server == null) {
|
||||
null
|
||||
} else {
|
||||
channel?.server?.let { serverId ->
|
||||
RevoltAPI.members.getMember(serverId, selfUser.id!!) ?: fetchMember(
|
||||
serverId,
|
||||
selfUser.id
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val permission = Roles.permissionFor(channel!!, selfUser, selfMember)
|
||||
val canSend = permission has PermissionBit.SendMessage
|
||||
|
||||
val partnerId = ChannelUtils.resolveDMPartner(channel!!)
|
||||
|
||||
denyMessageField = when {
|
||||
partnerId == SpecialUsers.PLATFORM_MODERATION_USER -> true
|
||||
!canSend -> true
|
||||
else -> false
|
||||
}
|
||||
|
||||
denyMessageFieldReasonResource = when {
|
||||
partnerId == SpecialUsers.PLATFORM_MODERATION_USER -> R.string.message_field_denied_platform_moderation
|
||||
!canSend -> R.string.message_field_denied_no_permission
|
||||
else -> R.string.message_field_denied_generic
|
||||
}
|
||||
}
|
||||
|
||||
fun putAtCursorPosition(text: String) {
|
||||
putDraftContent(draftContent + text)
|
||||
}
|
||||
|
||||
private var lastSentBeginTyping: Instant? = null
|
||||
|
||||
private fun startTyping() {
|
||||
if (editingMessage != null) return
|
||||
if (lastSentBeginTyping != null) {
|
||||
val diff = Clock.System.now() - lastSentBeginTyping!!
|
||||
if (diff.inWholeSeconds < 1) return
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
withContext(RevoltAPI.realtimeContext) {
|
||||
channel?.id?.let {
|
||||
RealtimeSocket.beginTyping(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lastSentBeginTyping = Clock.System.now()
|
||||
}
|
||||
|
||||
private var stopTypingJob: Job? = null
|
||||
|
||||
private fun queueStopTyping() {
|
||||
stopTypingJob = viewModelScope.launch {
|
||||
delay(5000)
|
||||
stopTyping()
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopTyping() {
|
||||
if (editingMessage != null) return
|
||||
viewModelScope.launch {
|
||||
withContext(RevoltAPI.realtimeContext) {
|
||||
channel?.id?.let {
|
||||
RealtimeSocket.endTyping(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun putDraftContent(content: String) {
|
||||
viewModelScope.launch {
|
||||
kvStorage.set("draftContent/${channel?.id}", content)
|
||||
}
|
||||
|
||||
if (editingMessage == null) {
|
||||
if (content.isNotBlank()) {
|
||||
startTyping()
|
||||
stopTypingJob?.cancel()
|
||||
queueStopTyping()
|
||||
} else {
|
||||
stopTyping()
|
||||
}
|
||||
}
|
||||
|
||||
draftContent = content
|
||||
}
|
||||
|
||||
suspend fun addReplyTo(messageId: String) {
|
||||
if (draftReplyTo.any { it.id == messageId }) return
|
||||
val shouldMention = kvStorage.getBoolean("mentionOnReply") ?: false
|
||||
draftReplyTo.add(SendMessageReply(messageId, shouldMention))
|
||||
}
|
||||
|
||||
suspend fun toggleMentionOnReply(messageId: String) {
|
||||
val shouldMention = draftReplyTo.find { it.id == messageId }?.mention ?: false
|
||||
val newItems = draftReplyTo.map {
|
||||
if (it.id == messageId) {
|
||||
it.copy(mention = !shouldMention)
|
||||
} else {
|
||||
it
|
||||
}
|
||||
}
|
||||
draftReplyTo.clear()
|
||||
draftReplyTo.addAll(newItems)
|
||||
kvStorage.set("mentionOnReply", !shouldMention)
|
||||
}
|
||||
|
||||
fun updateSaveKeyboardHeight(height: Int) {
|
||||
viewModelScope.launch {
|
||||
kvStorage.set("keyboardHeight", height)
|
||||
}
|
||||
keyboardHeight = height
|
||||
}
|
||||
|
||||
private suspend fun applyMessageEdit() {
|
||||
try {
|
||||
editMessage(
|
||||
channelId = channel?.id ?: return,
|
||||
messageId = editingMessage ?: return,
|
||||
newContent = draftContent,
|
||||
)
|
||||
putDraftContent("")
|
||||
} catch (e: Exception) {
|
||||
Log.e("ChannelScreen2ViewModel", "Failed to edit message", e)
|
||||
}
|
||||
}
|
||||
|
||||
fun sendPendingMessage() {
|
||||
if (editingMessage != null) {
|
||||
viewModelScope.launch {
|
||||
applyMessageEdit()
|
||||
editingMessage = null
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Immediately, make copies of the draft content and replyTo list, as
|
||||
// 1. they will be cleared
|
||||
// 2. if the user changes the content while the message is being sent we want to persist
|
||||
// the original content
|
||||
val content = draftContent
|
||||
val replyTo = draftReplyTo.toList()
|
||||
|
||||
// First we upload (the next 5) attachments...
|
||||
viewModelScope.launch {
|
||||
val attachmentIds = arrayListOf<String>()
|
||||
val takenAttachments =
|
||||
this@ChannelScreen2ViewModel.draftAttachments.take(MAX_ATTACHMENTS_PER_MESSAGE)
|
||||
val totalTaken = takenAttachments.size
|
||||
|
||||
takenAttachments.forEachIndexed { index, it ->
|
||||
try {
|
||||
val id = uploadToAutumn(
|
||||
it.file,
|
||||
it.filename,
|
||||
"attachments",
|
||||
ContentType.parse(it.contentType),
|
||||
onProgress = { current, total ->
|
||||
attachmentUploadProgress =
|
||||
((current.toFloat() / total.toFloat()) / totalTaken.toFloat()) + (index.toFloat() / totalTaken.toFloat())
|
||||
}
|
||||
)
|
||||
attachmentIds.add(id)
|
||||
} catch (e: Exception) {
|
||||
Log.e("ChannelScreen2ViewModel", "Failed to upload attachment", e)
|
||||
attachmentUploadProgress = 0f
|
||||
// TODO show error message
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
|
||||
val nonce = ULID.makeNext()
|
||||
val prospectiveMessage = Message(
|
||||
id = nonce,
|
||||
channel = channel?.id,
|
||||
author = RevoltAPI.selfId,
|
||||
content = draftContent,
|
||||
nonce = nonce,
|
||||
attachments = listOf(),
|
||||
replies = listOf(),
|
||||
tail = items.firstOrNull()?.let {
|
||||
if (it is ChannelScreenItem.RegularMessage) {
|
||||
it.message.author == RevoltAPI.selfId
|
||||
} else if (it is ChannelScreenItem.ProspectiveMessage) {
|
||||
it.message.author == RevoltAPI.selfId
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} ?: false
|
||||
)
|
||||
|
||||
updateItems(listOf(ChannelScreenItem.ProspectiveMessage(prospectiveMessage)) + items)
|
||||
|
||||
kvStorage.remove("draftContent/${channel?.id}")
|
||||
putDraftContent("")
|
||||
draftReplyTo.clear()
|
||||
attachmentUploadProgress = 0f
|
||||
|
||||
this@ChannelScreen2ViewModel.draftAttachments.removeAll(takenAttachments)
|
||||
|
||||
try {
|
||||
sendMessage(
|
||||
channelId = channel?.id ?: return@launch,
|
||||
content = content,
|
||||
nonce = nonce,
|
||||
replies = replyTo,
|
||||
attachments = attachmentIds,
|
||||
idempotencyKey = ULID.makeNext()
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e("ChannelScreen2ViewModel", "Failed to send message", e)
|
||||
updateItems(listOf(ChannelScreenItem.FailedMessage(prospectiveMessage)) + items.filter { it !is ChannelScreenItem.ProspectiveMessage })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadMessages(
|
||||
amount: Int,
|
||||
before: String? = null,
|
||||
after: String? = null,
|
||||
around: String? = null,
|
||||
ignoreExisting: Boolean = false
|
||||
) {
|
||||
channel?.id?.let { channelId ->
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val messages = arrayListOf<Message>()
|
||||
|
||||
fetchMessagesFromChannel(channelId, amount, true, before, after, around).let {
|
||||
if (it.messages.isNullOrEmpty() || it.messages.size < 50) {
|
||||
endOfChannel = true
|
||||
}
|
||||
|
||||
it.users?.forEach { user ->
|
||||
if (!RevoltAPI.userCache.containsKey(user.id)) {
|
||||
RevoltAPI.userCache[user.id!!] = user
|
||||
}
|
||||
}
|
||||
|
||||
it.messages?.forEach { message ->
|
||||
addUserIfUnknown(message.author ?: return@forEach)
|
||||
if (!RevoltAPI.messageCache.containsKey(message.id)) {
|
||||
RevoltAPI.messageCache[message.id!!] = message
|
||||
}
|
||||
messages.add(message)
|
||||
}
|
||||
|
||||
it.members?.forEach { member ->
|
||||
if (!RevoltAPI.members.hasMember(member.id!!.server, member.id.user)) {
|
||||
RevoltAPI.members.setMember(member.id.server, member)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!didInitialChannelFetch) didInitialChannelFetch = true
|
||||
|
||||
val newItems = messages.filter {
|
||||
if (ignoreExisting) {
|
||||
items.none { m ->
|
||||
when (m) {
|
||||
is ChannelScreenItem.RegularMessage -> m.message.id == it.id
|
||||
is ChannelScreenItem.ProspectiveMessage -> m.message.id == it.id
|
||||
is ChannelScreenItem.SystemMessage -> m.message.id == it.id
|
||||
is ChannelScreenItem.FailedMessage -> m.message.id == it.id
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}.map {
|
||||
when {
|
||||
it.system != null -> ChannelScreenItem.SystemMessage(it)
|
||||
else -> ChannelScreenItem.RegularMessage(it)
|
||||
}
|
||||
}
|
||||
|
||||
// Place items according to whether above/below/around was specified.
|
||||
// TODO: Aditionally, place LoadTriggers at the beginning and end of the list.
|
||||
val newItemsWithPosition = when {
|
||||
before != null -> items + newItems
|
||||
after != null -> newItems + items
|
||||
// TODO around, which should place the new items in the middle of the list
|
||||
else -> newItems
|
||||
}
|
||||
|
||||
updateItems(newItemsWithPosition)
|
||||
} catch (e: Exception) {
|
||||
Log.e("ChannelScreen2ViewModel", "Failed to fetch messages", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun ackMessage(messageId: String) {
|
||||
ackChannel(channel?.id ?: return, messageId)
|
||||
}
|
||||
|
||||
suspend fun startListening() {
|
||||
viewModelScope.launch {
|
||||
withContext(RevoltAPI.realtimeContext) {
|
||||
flow {
|
||||
while (true) {
|
||||
emit(RevoltAPI.wsFrameChannel.receive())
|
||||
}
|
||||
}.onEach {
|
||||
when (it) {
|
||||
is MessageFrame -> {
|
||||
if (it.channel != channel?.id) return@onEach
|
||||
|
||||
it.author?.let { userId ->
|
||||
if (RevoltAPI.userCache[userId] == null) {
|
||||
RevoltAPI.userCache[userId] = fetchUser(userId)
|
||||
}
|
||||
}
|
||||
channel?.server?.let { serverId ->
|
||||
try {
|
||||
it.author?.let { userId ->
|
||||
fetchMember(serverId, userId)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("ChannelScreen2ViewModel", "Failed to fetch member", e)
|
||||
}
|
||||
}
|
||||
|
||||
if (didInitialChannelFetch) { // this check is so that we don't end up with a message that arrives at the same time as the initial fetch in front of the loading indicator
|
||||
val newItem = when {
|
||||
it.system != null -> ChannelScreenItem.SystemMessage(it)
|
||||
else -> ChannelScreenItem.RegularMessage(it)
|
||||
}
|
||||
updateItems(listOf(newItem) + items.filter { m ->
|
||||
if (m is ChannelScreenItem.ProspectiveMessage) {
|
||||
m.message.id != it.nonce
|
||||
} else {
|
||||
true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
it.id?.let { mid -> ackMessage(mid) }
|
||||
}
|
||||
|
||||
is MessageDeleteFrame -> {
|
||||
if (it.channel != channel?.id) return@onEach
|
||||
|
||||
val newRenderableMessages =
|
||||
items.filter { m ->
|
||||
if (m is ChannelScreenItem.RegularMessage) {
|
||||
m.message.id != it.id
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
updateItems(newRenderableMessages)
|
||||
}
|
||||
|
||||
|
||||
is MessageUpdateFrame -> {
|
||||
if (it.channel != channel?.id) return@onEach
|
||||
|
||||
val messageFrame =
|
||||
RevoltJson.decodeFromJsonElement(MessageFrame.serializer(), it.data)
|
||||
|
||||
val currentMessage = items.find { m ->
|
||||
m is ChannelScreenItem.RegularMessage && m.message.id == it.id
|
||||
}
|
||||
if (currentMessage == null) return@onEach
|
||||
|
||||
if (messageFrame.author != null) {
|
||||
addUserIfUnknown(messageFrame.author)
|
||||
}
|
||||
|
||||
updateItems(
|
||||
items.map { m ->
|
||||
if (m is ChannelScreenItem.RegularMessage && m.message.id == it.id) {
|
||||
ChannelScreenItem.RegularMessage(
|
||||
m.message.mergeWithPartial(messageFrame)
|
||||
)
|
||||
} else {
|
||||
m
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
is MessageAppendFrame -> {
|
||||
if (it.channel != channel?.id) return@onEach
|
||||
|
||||
val hasMessage = items.any { currentMsg ->
|
||||
currentMsg is ChannelScreenItem.RegularMessage && currentMsg.message.id == it.id
|
||||
}
|
||||
|
||||
if (!hasMessage) return@onEach
|
||||
|
||||
updateItems(
|
||||
items.map { currentMsg ->
|
||||
if (currentMsg is ChannelScreenItem.RegularMessage && currentMsg.message.id == it.id) {
|
||||
RevoltAPI.messageCache[it.id]?.let { m ->
|
||||
ChannelScreenItem.RegularMessage(m)
|
||||
} ?: return@map currentMsg
|
||||
} else {
|
||||
currentMsg
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
is MessageReactFrame -> {
|
||||
if (it.channel_id != channel?.id) return@onEach
|
||||
|
||||
val hasMessage = items
|
||||
.filterIsInstance<ChannelScreenItem.RegularMessage>()
|
||||
.any { msg ->
|
||||
msg.message.id == it.id
|
||||
}
|
||||
|
||||
if (!hasMessage) return@onEach
|
||||
|
||||
updateItems(
|
||||
items.map { currentMsg ->
|
||||
if (currentMsg is ChannelScreenItem.RegularMessage && currentMsg.message.id == it.id) {
|
||||
RevoltAPI.messageCache[it.id]?.let { m ->
|
||||
ChannelScreenItem.RegularMessage(m)
|
||||
} ?: return@map currentMsg
|
||||
} else {
|
||||
currentMsg
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
is MessageUnreactFrame -> {
|
||||
if (it.channel_id != channel?.id) return@onEach
|
||||
|
||||
val hasMessage = items
|
||||
.filterIsInstance<ChannelScreenItem.RegularMessage>()
|
||||
.any { msg ->
|
||||
msg.message.id == it.id
|
||||
}
|
||||
|
||||
if (!hasMessage) return@onEach
|
||||
|
||||
updateItems(
|
||||
items.map { currentMsg ->
|
||||
if (currentMsg is ChannelScreenItem.RegularMessage && currentMsg.message.id == it.id) {
|
||||
RevoltAPI.messageCache[it.id]?.let { m ->
|
||||
ChannelScreenItem.RegularMessage(m)
|
||||
} ?: return@map currentMsg
|
||||
} else {
|
||||
currentMsg
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
is ChannelStartTypingFrame -> {
|
||||
if (it.id != channel?.id) return@onEach
|
||||
if (typingUsers.contains(it.user)) return@onEach
|
||||
if (it.user == RevoltAPI.selfId) return@onEach
|
||||
|
||||
addUserIfUnknown(it.user)
|
||||
typingUsers.add(it.user)
|
||||
}
|
||||
|
||||
is ChannelStopTypingFrame -> {
|
||||
if (it.id != channel?.id) return@onEach
|
||||
if (!typingUsers.contains(it.user)) return@onEach
|
||||
|
||||
typingUsers.remove(it.user)
|
||||
}
|
||||
|
||||
is ChannelDeleteFrame -> {
|
||||
if (it.id != channel?.id) return@onEach
|
||||
// FIXME This is UI logic from the view model. Too bad!
|
||||
ActionChannel.send(
|
||||
Action.ChatNavigate(
|
||||
ChatRouterDestination.NoCurrentChannel(
|
||||
channel?.server ?: return@onEach
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
is RealtimeSocketFrames.Reconnected -> {
|
||||
Log.d("ChannelScreen", "Reconnected to WS.")
|
||||
loadMessages(50, ignoreExisting = true)
|
||||
startListening()
|
||||
}
|
||||
}
|
||||
}.catch {
|
||||
Log.e("ChannelScreen", "Failed to receive WS frame", it)
|
||||
}.launchIn(this)
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
withContext(Dispatchers.Main) {
|
||||
UiCallbacks.uiCallbackFlow.onEach {
|
||||
Log.d("ChannelScreen", "Received UI callback: $it")
|
||||
|
||||
when (it) {
|
||||
is UiCallback.ReplyToMessage -> {
|
||||
val message = items.find { m ->
|
||||
m is ChannelScreenItem.RegularMessage && m.message.id == it.messageId
|
||||
} as? ChannelScreenItem.RegularMessage ?: return@onEach
|
||||
|
||||
val shouldMention = kvStorage.getBoolean("mentionOnReply") ?: false
|
||||
draftReplyTo.add(
|
||||
SendMessageReply(message.message.id ?: return@onEach, shouldMention)
|
||||
)
|
||||
}
|
||||
|
||||
is UiCallback.EditMessage -> {
|
||||
editingMessage = it.messageId
|
||||
val message = items.find { m ->
|
||||
m is ChannelScreenItem.RegularMessage && m.message.id == it.messageId
|
||||
} as? ChannelScreenItem.RegularMessage ?: return@onEach
|
||||
|
||||
putDraftContent(message.message.content ?: "")
|
||||
this@ChannelScreen2ViewModel.draftAttachments.clear()
|
||||
draftReplyTo.clear()
|
||||
}
|
||||
}
|
||||
}.catch {
|
||||
Log.e("ChannelScreen", "Failed to receive UI callback", it)
|
||||
}.launchIn(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun updateItems(newItems: List<ChannelScreenItem>) {
|
||||
// Spec https://wiki.rvlt.gg/index.php/Text_Channel_(UI)#Message_Grouping_Algorithm
|
||||
val innerItems = newItems.toMutableStateList()
|
||||
// Let L be the list of messages ordered from newest to oldest
|
||||
val allItemsThatAreMessages =
|
||||
innerItems.filterIsInstance<ChannelScreenItem.RegularMessage>()
|
||||
// Let E be the list of elements to be rendered
|
||||
val allItems = innerItems
|
||||
|
||||
val groupedItems = mutableListOf<ChannelScreenItem>()
|
||||
|
||||
// For each message M in L:
|
||||
allItems.forEachIndexed { index, m ->
|
||||
// [Deviation from spec: if M is not a [Regular/System]Message we just put it in the list...]
|
||||
if (m !is ChannelScreenItem.RegularMessage && m !is ChannelScreenItem.SystemMessage) {
|
||||
groupedItems.add(m)
|
||||
Log.d("ChannelScreen2ViewModel", "Non-regular message: $m. Skipping grouping.")
|
||||
return@forEachIndexed
|
||||
}
|
||||
|
||||
val message = when (m) {
|
||||
is ChannelScreenItem.RegularMessage -> m.message
|
||||
is ChannelScreenItem.SystemMessage -> m.message
|
||||
else -> null
|
||||
}
|
||||
|
||||
// Let tail be true
|
||||
var tail = true
|
||||
// Let date be null
|
||||
var date: Instant? = null
|
||||
// Let next be the next item in list L
|
||||
val next = allItems.getOrNull(index + 1)
|
||||
// If next is not null:
|
||||
if (next != null) {
|
||||
// Let adate and bdate be the times the message M and the next message were created respectively
|
||||
val adate = message?.id?.let { ULID.asTimestamp(it) }?.let {
|
||||
Instant.fromEpochMilliseconds(it)
|
||||
}
|
||||
val bdate = (next as? ChannelScreenItem.RegularMessage)?.message?.id?.let {
|
||||
ULID.asTimestamp(it)
|
||||
}?.let {
|
||||
Instant.fromEpochMilliseconds(it)
|
||||
}
|
||||
|
||||
// [Deviation from spec: if either adate or bdate is null but next is a RegularMessage we skip this message]
|
||||
if ((adate == null || bdate == null) && next is ChannelScreenItem.RegularMessage) {
|
||||
return@forEachIndexed
|
||||
}
|
||||
|
||||
if (adate != null && bdate != null) {
|
||||
// If adate and bdate are not the same day:
|
||||
val adateLocal =
|
||||
adate.toJavaInstant().atZone(ZoneId.systemDefault()).toLocalDate()
|
||||
val bdateLocal =
|
||||
bdate.toJavaInstant().atZone(ZoneId.systemDefault()).toLocalDate()
|
||||
if (!adateLocal.isEqual(bdateLocal)) {
|
||||
// Let date be adate
|
||||
date = adate
|
||||
}
|
||||
}
|
||||
|
||||
val minuteDifference = adate?.let {
|
||||
bdate?.let { bdate -> it.minus(bdate) }
|
||||
}?.inWholeMinutes
|
||||
|
||||
// [Conditions, which we have extracted into variables for readability]
|
||||
// Message M and last [spec should say next] have the same author
|
||||
val authorsMatch =
|
||||
message?.author == (next as? ChannelScreenItem.RegularMessage)?.message?.author
|
||||
// The difference between bdate and adate is equal to or over 7 minutes
|
||||
val closeEnough = minuteDifference != null && minuteDifference >= 7
|
||||
// The masquerades for message M and last [spec should say next] do not match
|
||||
val masqueradesMatch =
|
||||
message?.masquerade == (next as? ChannelScreenItem.RegularMessage)?.message?.masquerade
|
||||
// [Possible optimisation: in theory we should not need to check for system messages here as they are a separate type of renderable item]
|
||||
// The message M or last [spec should say next] is a system message
|
||||
val eitherIsSystem =
|
||||
message?.system != null || (next as? ChannelScreenItem.RegularMessage)?.message?.system != null
|
||||
// Message M replies to one or more messages
|
||||
val messageHasReplies = message?.replies?.isNotEmpty() == true
|
||||
|
||||
// Let tail be false if one of the following conditions is satisfied:
|
||||
if (!authorsMatch || closeEnough || !masqueradesMatch || eitherIsSystem || messageHasReplies) {
|
||||
tail = false
|
||||
}
|
||||
}
|
||||
// Else if next is null:
|
||||
else {
|
||||
// Let tail be false
|
||||
tail = false
|
||||
}
|
||||
|
||||
// Push the message
|
||||
groupedItems.add(
|
||||
when (m) {
|
||||
is ChannelScreenItem.RegularMessage -> ChannelScreenItem.RegularMessage(
|
||||
m.message.copy(
|
||||
tail = tail
|
||||
)
|
||||
)
|
||||
|
||||
is ChannelScreenItem.SystemMessage -> ChannelScreenItem.SystemMessage(
|
||||
m.message.copy(
|
||||
tail = tail
|
||||
)
|
||||
)
|
||||
|
||||
else -> m
|
||||
}
|
||||
)
|
||||
// [Deviation from spec: we first push the message, then the date, to preserve UI order]
|
||||
// If date is not null:
|
||||
if (date != null) {
|
||||
// Push the date
|
||||
groupedItems.add(ChannelScreenItem.DateDivider(date))
|
||||
}
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
items.clear()
|
||||
items.addAll(groupedItems)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,632 +0,0 @@
|
|||
package chat.revolt.screens.chat.views.channel
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.toMutableStateList
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import chat.revolt.R
|
||||
import chat.revolt.api.RevoltAPI
|
||||
import chat.revolt.api.RevoltJson
|
||||
import chat.revolt.api.internals.ChannelUtils
|
||||
import chat.revolt.api.internals.MessageProcessor
|
||||
import chat.revolt.api.internals.PermissionBit
|
||||
import chat.revolt.api.internals.Roles
|
||||
import chat.revolt.api.internals.SpecialUsers
|
||||
import chat.revolt.api.internals.ULID
|
||||
import chat.revolt.api.internals.hasPermission
|
||||
import chat.revolt.api.realtime.RealtimeSocket
|
||||
import chat.revolt.api.realtime.RealtimeSocketFrames
|
||||
import chat.revolt.api.realtime.frames.receivable.ChannelDeleteFrame
|
||||
import chat.revolt.api.realtime.frames.receivable.ChannelStartTypingFrame
|
||||
import chat.revolt.api.realtime.frames.receivable.ChannelStopTypingFrame
|
||||
import chat.revolt.api.realtime.frames.receivable.MessageAppendFrame
|
||||
import chat.revolt.api.realtime.frames.receivable.MessageDeleteFrame
|
||||
import chat.revolt.api.realtime.frames.receivable.MessageFrame
|
||||
import chat.revolt.api.realtime.frames.receivable.MessageReactFrame
|
||||
import chat.revolt.api.realtime.frames.receivable.MessageUnreactFrame
|
||||
import chat.revolt.api.realtime.frames.receivable.MessageUpdateFrame
|
||||
import chat.revolt.api.routes.channel.SendMessageReply
|
||||
import chat.revolt.api.routes.channel.ackChannel
|
||||
import chat.revolt.api.routes.channel.editMessage
|
||||
import chat.revolt.api.routes.channel.fetchMessagesFromChannel
|
||||
import chat.revolt.api.routes.channel.fetchSingleChannel
|
||||
import chat.revolt.api.routes.channel.sendMessage
|
||||
import chat.revolt.api.routes.microservices.autumn.FileArgs
|
||||
import chat.revolt.api.routes.microservices.autumn.MAX_ATTACHMENTS_PER_MESSAGE
|
||||
import chat.revolt.api.routes.microservices.autumn.uploadToAutumn
|
||||
import chat.revolt.api.routes.server.fetchMember
|
||||
import chat.revolt.api.routes.user.addUserIfUnknown
|
||||
import chat.revolt.api.schemas.Channel
|
||||
import chat.revolt.api.schemas.Message
|
||||
import chat.revolt.callbacks.Action
|
||||
import chat.revolt.callbacks.ActionChannel
|
||||
import chat.revolt.callbacks.UiCallback
|
||||
import chat.revolt.callbacks.UiCallbacks
|
||||
import io.ktor.http.ContentType
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.Instant
|
||||
|
||||
const val MAX_MESSAGE_LENGTH = 2000
|
||||
|
||||
sealed class BottomPane {
|
||||
data object None : BottomPane()
|
||||
data object InbuiltMediaPicker : BottomPane()
|
||||
data object EmojiPicker : BottomPane()
|
||||
}
|
||||
|
||||
class ChannelScreenViewModel : ViewModel() {
|
||||
var activeChannel by mutableStateOf<Channel?>(null)
|
||||
var activeChannelId by mutableStateOf<String?>(null)
|
||||
|
||||
var renderableMessages = mutableStateListOf<Message>()
|
||||
var typingUsers = mutableStateListOf<String>()
|
||||
|
||||
var isSendingMessage by mutableStateOf(false)
|
||||
var hasNoMoreMessages by mutableStateOf(false)
|
||||
|
||||
var pendingMessageContent by mutableStateOf("")
|
||||
var textSelection by mutableStateOf(0 to 0)
|
||||
var pendingReplies = mutableStateListOf<SendMessageReply>()
|
||||
var pendingAttachments = mutableStateListOf<FileArgs>()
|
||||
|
||||
var currentBottomPane by mutableStateOf<BottomPane>(BottomPane.None)
|
||||
|
||||
var pendingUploadProgress by mutableFloatStateOf(0f)
|
||||
|
||||
var editingMessage by mutableStateOf<String?>(null)
|
||||
|
||||
var denyMessageField by mutableStateOf(false)
|
||||
var denyMessageFieldReasonResource by mutableIntStateOf(R.string.message_field_denied_generic)
|
||||
|
||||
var showAgeGate by mutableStateOf(false)
|
||||
|
||||
private fun popAttachmentBatch() {
|
||||
pendingAttachments =
|
||||
pendingAttachments.drop(MAX_ATTACHMENTS_PER_MESSAGE).toMutableStateList()
|
||||
}
|
||||
|
||||
private fun setRenderableMessages(messages: List<Message>) {
|
||||
renderableMessages.clear()
|
||||
renderableMessages.addAll(messages)
|
||||
}
|
||||
|
||||
private fun addReply(reply: SendMessageReply) {
|
||||
if (pendingReplies.any { it.id == reply.id }) return
|
||||
pendingReplies.add(reply)
|
||||
}
|
||||
|
||||
private var lastSentBeginTyping: Instant? = null
|
||||
|
||||
private fun startTyping() {
|
||||
if (editingMessage != null) return
|
||||
if (lastSentBeginTyping != null) {
|
||||
val diff = Clock.System.now() - lastSentBeginTyping!!
|
||||
if (diff.inWholeSeconds < 1) return
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
withContext(RevoltAPI.realtimeContext) {
|
||||
activeChannelId?.let {
|
||||
RealtimeSocket.beginTyping(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lastSentBeginTyping = Clock.System.now()
|
||||
}
|
||||
|
||||
private var stopTypingJob: Job? = null
|
||||
|
||||
private fun queueStopTyping() {
|
||||
stopTypingJob = viewModelScope.launch {
|
||||
delay(5000)
|
||||
stopTyping()
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopTyping() {
|
||||
if (editingMessage != null) return
|
||||
viewModelScope.launch {
|
||||
withContext(RevoltAPI.realtimeContext) {
|
||||
activeChannelId?.let {
|
||||
RealtimeSocket.endTyping(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updatePendingMessageContent(newContent: String) {
|
||||
pendingMessageContent = newContent
|
||||
if (newContent.isNotBlank()) {
|
||||
startTyping()
|
||||
stopTypingJob?.cancel()
|
||||
queueStopTyping()
|
||||
} else {
|
||||
stopTyping()
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleReplyMentionFor(reply: SendMessageReply) {
|
||||
val index = pendingReplies.indexOf(reply)
|
||||
val newReply = SendMessageReply(
|
||||
reply.id,
|
||||
!reply.mention
|
||||
)
|
||||
|
||||
pendingReplies[index] = newReply
|
||||
}
|
||||
|
||||
private fun clearInReplyTo() {
|
||||
pendingReplies.clear()
|
||||
}
|
||||
|
||||
fun fetchOlderMessages() {
|
||||
if (activeChannelId == null) return
|
||||
|
||||
viewModelScope.launch {
|
||||
val messages = arrayListOf<Message>()
|
||||
|
||||
fetchMessagesFromChannel(
|
||||
activeChannelId!!,
|
||||
limit = 50,
|
||||
includeUsers = true,
|
||||
before = if (renderableMessages.isNotEmpty()) {
|
||||
renderableMessages.last().id
|
||||
} else {
|
||||
null
|
||||
}
|
||||
).let {
|
||||
if (it.messages.isNullOrEmpty() || it.messages.size < 50) {
|
||||
hasNoMoreMessages = true
|
||||
}
|
||||
|
||||
it.messages?.forEach { message ->
|
||||
addUserIfUnknown(message.author ?: return@forEach)
|
||||
if (!RevoltAPI.messageCache.containsKey(message.id)) {
|
||||
RevoltAPI.messageCache[message.id!!] = message
|
||||
}
|
||||
messages.add(message)
|
||||
}
|
||||
|
||||
it.users?.forEach { user ->
|
||||
if (!RevoltAPI.userCache.containsKey(user.id)) {
|
||||
RevoltAPI.userCache[user.id!!] = user
|
||||
}
|
||||
}
|
||||
|
||||
it.members?.forEach { member ->
|
||||
if (!RevoltAPI.members.hasMember(member.id!!.server, member.id.user)) {
|
||||
RevoltAPI.members.setMember(member.id.server, member)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
regroupMessages(renderableMessages + messages)
|
||||
}
|
||||
}
|
||||
|
||||
fun fetchChannel(id: String) {
|
||||
viewModelScope.launch {
|
||||
if (id !in RevoltAPI.channelCache) {
|
||||
val channel = fetchSingleChannel(id)
|
||||
activeChannel = channel
|
||||
RevoltAPI.channelCache[id] = channel
|
||||
} else {
|
||||
activeChannel = RevoltAPI.channelCache[id]
|
||||
}
|
||||
|
||||
if (activeChannel?.lastMessageID != null) {
|
||||
ackNewest()
|
||||
} else {
|
||||
Log.d("ChannelScreen", "No last message ID, not acking.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun sendPendingMessage() {
|
||||
if (editingMessage != null) {
|
||||
editPendingMessage()
|
||||
return
|
||||
}
|
||||
|
||||
if (isSendingMessage) return
|
||||
isSendingMessage = true
|
||||
|
||||
viewModelScope.launch {
|
||||
val attachmentIds = arrayListOf<String>()
|
||||
val takenAttachments = pendingAttachments.take(MAX_ATTACHMENTS_PER_MESSAGE)
|
||||
val totalTaken = takenAttachments.size
|
||||
|
||||
takenAttachments.forEachIndexed { index, it ->
|
||||
try {
|
||||
val id = uploadToAutumn(
|
||||
it.file,
|
||||
it.filename,
|
||||
"attachments",
|
||||
ContentType.parse(it.contentType),
|
||||
onProgress = { current, total ->
|
||||
pendingUploadProgress =
|
||||
((current.toFloat() / total.toFloat()) / totalTaken.toFloat()) + (index.toFloat() / totalTaken.toFloat())
|
||||
}
|
||||
)
|
||||
Log.d("ChannelScreen", "Uploaded attachment with id $id")
|
||||
attachmentIds.add(id)
|
||||
} catch (e: Exception) {
|
||||
Log.e("ChannelScreen", "Failed to upload attachment", e)
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
|
||||
sendMessage(
|
||||
activeChannel!!.id!!,
|
||||
MessageProcessor.processOutgoing(
|
||||
pendingMessageContent.trimIndent(),
|
||||
activeChannel?.server
|
||||
),
|
||||
attachments = if (attachmentIds.isEmpty()) null else attachmentIds,
|
||||
replies = pendingReplies
|
||||
)
|
||||
|
||||
updatePendingMessageContent("")
|
||||
hasNoMoreMessages = false
|
||||
isSendingMessage = false
|
||||
pendingUploadProgress = 0f
|
||||
popAttachmentBatch()
|
||||
clearInReplyTo()
|
||||
}
|
||||
}
|
||||
|
||||
private fun editPendingMessage() {
|
||||
isSendingMessage = true
|
||||
|
||||
viewModelScope.launch {
|
||||
editMessage(
|
||||
channelId = activeChannel!!.id!!,
|
||||
messageId = editingMessage!!,
|
||||
newContent = pendingMessageContent.trimIndent()
|
||||
)
|
||||
|
||||
updatePendingMessageContent("")
|
||||
isSendingMessage = false
|
||||
}
|
||||
|
||||
cancelEditingMessage()
|
||||
}
|
||||
|
||||
private suspend fun regroupMessages(newMessages: List<Message> = renderableMessages) {
|
||||
val groupedMessages = mutableMapOf<String, Message>()
|
||||
|
||||
// Verbatim implementation of https://wiki.rvlt.gg/index.php/Text_Channel_(UI)#Message_Grouping_Algorithm
|
||||
// The exception is the date variable being pushed into cache, we don't need that here.
|
||||
// Keep in mind: Recomposing UI is incredibly cheap in Jetpack Compose.
|
||||
newMessages.forEach { message ->
|
||||
var tail = true
|
||||
|
||||
val next = newMessages.getOrNull(newMessages.indexOf(message) + 1)
|
||||
if (next != null) {
|
||||
val dateA = Instant.fromEpochMilliseconds(ULID.asTimestamp(message.id!!))
|
||||
val dateB = Instant.fromEpochMilliseconds(ULID.asTimestamp(next.id!!))
|
||||
|
||||
val minuteDifference = (dateA - dateB).inWholeMinutes
|
||||
|
||||
if (
|
||||
message.author != next.author ||
|
||||
minuteDifference >= 7 ||
|
||||
message.masquerade != next.masquerade ||
|
||||
message.system != null || next.system != null ||
|
||||
message.replies != null
|
||||
) {
|
||||
tail = false
|
||||
}
|
||||
} else {
|
||||
tail = false
|
||||
}
|
||||
|
||||
if (groupedMessages.containsKey(message.id!!)) return@forEach
|
||||
|
||||
groupedMessages[message.id] = message.copy(tail = tail)
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
setRenderableMessages(groupedMessages.values.toList())
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun listenForWsFrames() {
|
||||
withContext(RevoltAPI.realtimeContext) {
|
||||
flow {
|
||||
while (true) {
|
||||
emit(RevoltAPI.wsFrameChannel.receive())
|
||||
}
|
||||
}.onEach {
|
||||
when (it) {
|
||||
is MessageFrame -> {
|
||||
if (it.channel != activeChannel?.id) return@onEach
|
||||
|
||||
addUserIfUnknown(it.author!!)
|
||||
activeChannel?.server?.let { s ->
|
||||
try {
|
||||
fetchMember(s, it.author)
|
||||
} catch (e: Exception) {
|
||||
Log.e("ChannelScreen", "Failed to fetch member", e)
|
||||
}
|
||||
}
|
||||
regroupMessages(listOf(it) + renderableMessages)
|
||||
ackNewest()
|
||||
}
|
||||
|
||||
is MessageUpdateFrame -> {
|
||||
if (it.channel != activeChannel?.id) return@onEach
|
||||
|
||||
val messageFrame =
|
||||
RevoltJson.decodeFromJsonElement(MessageFrame.serializer(), it.data)
|
||||
|
||||
renderableMessages.find { currentMsg ->
|
||||
currentMsg.id == it.id
|
||||
} ?: return@onEach // Message not found, ignore.
|
||||
|
||||
if (messageFrame.author != null) {
|
||||
addUserIfUnknown(messageFrame.author)
|
||||
}
|
||||
|
||||
regroupMessages(
|
||||
renderableMessages.map { currentMsg ->
|
||||
if (currentMsg.id == it.id) {
|
||||
currentMsg.mergeWithPartial(messageFrame)
|
||||
} else {
|
||||
currentMsg
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
is MessageAppendFrame -> {
|
||||
if (it.channel != activeChannel?.id) return@onEach
|
||||
|
||||
val hasMessage = renderableMessages.any { currentMsg ->
|
||||
currentMsg.id == it.id
|
||||
}
|
||||
|
||||
if (!hasMessage) return@onEach
|
||||
|
||||
regroupMessages(
|
||||
renderableMessages.map { currentMsg ->
|
||||
if (currentMsg.id == it.id) {
|
||||
RevoltAPI.messageCache[it.id] ?: currentMsg
|
||||
} else {
|
||||
currentMsg
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
is MessageReactFrame -> {
|
||||
if (it.channel_id != activeChannel?.id) return@onEach
|
||||
|
||||
val hasMessage = renderableMessages.any { currentMsg ->
|
||||
currentMsg.id == it.id
|
||||
}
|
||||
|
||||
if (!hasMessage) return@onEach
|
||||
|
||||
regroupMessages(
|
||||
renderableMessages.map { currentMsg ->
|
||||
if (currentMsg.id == it.id) {
|
||||
RevoltAPI.messageCache[it.id] ?: currentMsg
|
||||
} else {
|
||||
currentMsg
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
is MessageUnreactFrame -> {
|
||||
if (it.channel_id != activeChannel?.id) return@onEach
|
||||
|
||||
val hasMessage = renderableMessages.any { currentMsg ->
|
||||
currentMsg.id == it.id
|
||||
}
|
||||
|
||||
if (!hasMessage) return@onEach
|
||||
|
||||
regroupMessages(
|
||||
renderableMessages.map { currentMsg ->
|
||||
if (currentMsg.id == it.id) {
|
||||
RevoltAPI.messageCache[it.id] ?: currentMsg
|
||||
} else {
|
||||
currentMsg
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
is MessageDeleteFrame -> {
|
||||
if (it.channel != activeChannel?.id) return@onEach
|
||||
|
||||
val newRenderableMessages = renderableMessages.filter { currentMsg ->
|
||||
currentMsg.id != it.id
|
||||
}
|
||||
|
||||
renderableMessages.clear()
|
||||
renderableMessages.addAll(newRenderableMessages)
|
||||
|
||||
regroupMessages(newRenderableMessages)
|
||||
}
|
||||
|
||||
is ChannelStartTypingFrame -> {
|
||||
if (it.id != activeChannel?.id) return@onEach
|
||||
if (typingUsers.contains(it.user)) return@onEach
|
||||
if (it.user == RevoltAPI.selfId) return@onEach
|
||||
|
||||
addUserIfUnknown(it.user)
|
||||
typingUsers.add(it.user)
|
||||
}
|
||||
|
||||
is ChannelStopTypingFrame -> {
|
||||
if (it.id != activeChannel?.id) return@onEach
|
||||
if (!typingUsers.contains(it.user)) return@onEach
|
||||
|
||||
typingUsers.remove(it.user)
|
||||
}
|
||||
|
||||
is ChannelDeleteFrame -> {
|
||||
// activeChannelId is used deliberately because it doesn't become null when the channel is deleted.
|
||||
if (it.id != activeChannelId) return@onEach
|
||||
// FIXME This is UI logic from the view model. Too bad!
|
||||
ActionChannel.send(Action.ChatNavigate("no_current_channel"))
|
||||
}
|
||||
|
||||
is RealtimeSocketFrames.Reconnected -> {
|
||||
Log.d("ChannelScreen", "Reconnected to WS.")
|
||||
listenForWsFrames()
|
||||
}
|
||||
}
|
||||
}.catch {
|
||||
Log.e("ChannelScreen", "Failed to receive WS frame", it)
|
||||
}.launchIn(this)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun listenForUiCallbacks() {
|
||||
withContext(Dispatchers.Main) {
|
||||
UiCallbacks.uiCallbackFlow.onEach {
|
||||
when (it) {
|
||||
is UiCallback.ReplyToMessage -> {
|
||||
addReply(
|
||||
SendMessageReply(
|
||||
id = it.messageId,
|
||||
mention = false
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
is UiCallback.EditMessage -> {
|
||||
editingMessage = it.messageId
|
||||
val message = renderableMessages.find { msg ->
|
||||
msg.id == it.messageId
|
||||
} ?: return@onEach
|
||||
updatePendingMessageContent(message.content ?: "")
|
||||
textSelection =
|
||||
(message.content?.length ?: 0) to (message.content?.length ?: 0)
|
||||
}
|
||||
}
|
||||
}.catch {
|
||||
Log.e("ChannelScreen", "Failed to receive UI callback", it)
|
||||
}.launchIn(this)
|
||||
}
|
||||
}
|
||||
|
||||
private var debouncedChannelAck: Job? = null
|
||||
private fun ackNewest() {
|
||||
if (debouncedChannelAck?.isActive == true) {
|
||||
debouncedChannelAck?.cancel()
|
||||
|
||||
Log.d("ChannelScreen", "Cancelling channel ack")
|
||||
}
|
||||
|
||||
if (activeChannel?.lastMessageID == null) return
|
||||
|
||||
RevoltAPI.unreads.processExternalAck(
|
||||
activeChannel!!.id!!,
|
||||
activeChannel!!.lastMessageID!!
|
||||
)
|
||||
|
||||
debouncedChannelAck = viewModelScope.launch {
|
||||
delay(1000)
|
||||
if (activeChannel?.lastMessageID == null) return@launch
|
||||
ackChannel(activeChannel!!.id!!, activeChannel!!.lastMessageID!!)
|
||||
|
||||
Log.d("ChannelScreen", "Acking channel")
|
||||
}
|
||||
}
|
||||
|
||||
fun replyToMessage(message: Message) {
|
||||
addReply(
|
||||
SendMessageReply(
|
||||
id = message.id!!,
|
||||
mention = false
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun cancelEditingMessage() {
|
||||
editingMessage = null
|
||||
updatePendingMessageContent("")
|
||||
}
|
||||
|
||||
fun putAtCursorPosition(content: String) {
|
||||
val currentContent = pendingMessageContent
|
||||
val currentSelection = textSelection
|
||||
|
||||
// if out of bounds, just append
|
||||
if (currentSelection.first > currentContent.length) {
|
||||
updatePendingMessageContent(currentContent + content)
|
||||
textSelection =
|
||||
currentSelection.first + content.length to currentSelection.first + content.length
|
||||
return
|
||||
}
|
||||
|
||||
val newContent = currentContent.substring(0, currentSelection.first) +
|
||||
content +
|
||||
currentContent.substring(currentSelection.second)
|
||||
|
||||
updatePendingMessageContent(newContent)
|
||||
textSelection =
|
||||
currentSelection.first + content.length to currentSelection.first + content.length
|
||||
}
|
||||
|
||||
suspend fun doInitialChecks() {
|
||||
checkShouldShowAgeGate()
|
||||
checkShouldDenyMessageField()
|
||||
}
|
||||
|
||||
private fun checkShouldShowAgeGate() {
|
||||
if (activeChannel == null) return
|
||||
showAgeGate = activeChannel!!.nsfw == true
|
||||
}
|
||||
|
||||
private suspend fun checkShouldDenyMessageField() {
|
||||
if (activeChannel == null) return
|
||||
|
||||
val selfUser = RevoltAPI.userCache[RevoltAPI.selfId] ?: return
|
||||
val selfMember = if (activeChannel!!.server == null) {
|
||||
null
|
||||
} else {
|
||||
activeChannel?.server?.let { RevoltAPI.members.getMember(it, selfUser.id!!) }
|
||||
?: fetchMember(activeChannel!!.server!!, selfUser.id!!)
|
||||
}
|
||||
|
||||
val hasPermission =
|
||||
Roles.permissionFor(activeChannel!!, selfUser, selfMember)
|
||||
.hasPermission(PermissionBit.SendMessage)
|
||||
|
||||
val partnerId = ChannelUtils.resolveDMPartner(activeChannel!!)
|
||||
|
||||
denyMessageField = when {
|
||||
partnerId == SpecialUsers.PLATFORM_MODERATION_USER -> true
|
||||
!hasPermission -> true
|
||||
else -> false
|
||||
}
|
||||
|
||||
denyMessageFieldReasonResource = when {
|
||||
partnerId == SpecialUsers.PLATFORM_MODERATION_USER -> R.string.message_field_denied_platform_moderation
|
||||
!hasPermission -> R.string.message_field_denied_no_permission
|
||||
else -> R.string.message_field_denied_generic
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:height="24dp"
|
||||
android:width="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#ffffff"
|
||||
android:pathData="M18 13.09C17.47 13.18 16.97 13.34 16.5 13.55V6H18V13.09M12.5 21.5C10.29 21.5 8.5 19.71 8.5 17.5V5C8.5 3.62 9.62 2.5 11 2.5S13.5 3.62 13.5 5V15.5C13.5 16.05 13.05 16.5 12.5 16.5S11.5 16.05 11.5 15.5V6H10V15.5C10 16.88 11.12 18 12.5 18C12.71 18 12.91 17.97 13.1 17.92C13.35 16.58 14.03 15.41 15 14.54V5C15 2.79 13.21 1 11 1S7 2.79 7 5V17.5C7 20.54 9.46 23 12.5 23C13.13 23 13.73 22.89 14.29 22.7C13.97 22.29 13.7 21.84 13.5 21.36C13.17 21.44 12.84 21.5 12.5 21.5M15 18V20H23V18H15Z" />
|
||||
</vector>
|
||||
|
|
@ -99,6 +99,9 @@
|
|||
<string name="reply_mention_off">\@ off</string>
|
||||
<string name="too_many_replies">You can only reply to %1$d messages at a time.</string>
|
||||
|
||||
<string name="attachment_preview_remove">Remove</string>
|
||||
<string name="attachment_preview_close">Close</string>
|
||||
|
||||
<string name="emoji_category_smileys">Smileys & Emotions</string>
|
||||
<string name="emoji_category_people">People & Body</string>
|
||||
<string name="emoji_category_animals">Animals & Nature</string>
|
||||
|
|
@ -184,10 +187,14 @@
|
|||
<string name="message_field_denied_no_permission">You don\'t have permission to send messages in this channel.</string>
|
||||
<string name="message_field_denied_platform_moderation">This is a trusted channel for notices from our moderation team. You can\'t send messages here.</string>
|
||||
|
||||
<string name="message_field_editing_message">Editing message</string>
|
||||
<string name="message_field_editing_message_cancel_alt">Cancel editing</string>
|
||||
|
||||
<string name="reply_message_not_cached">Unknown message</string>
|
||||
<string name="reply_message_empty_has_attachments">Sent attachments</string>
|
||||
|
||||
<string name="message_blocked">Blocked message</string>
|
||||
<string name="message_failed_to_send">Failed to send, long press for options</string>
|
||||
|
||||
<string name="system_message_ownership_changed_alt">Ownership changed</string>
|
||||
<string name="system_message_channel_icon_changed_alt">Channel icon changed</string>
|
||||
|
|
|
|||
Loading…
Reference in New Issue