feat: message context sheets and message replying

This commit is contained in:
Infi 2023-02-09 01:13:52 +01:00
parent 9c26a7ab93
commit 2db44900ae
15 changed files with 530 additions and 34 deletions

View File

@ -24,6 +24,7 @@ const val REVOLT_SUPPORT = "https://support.revolt.chat"
const val REVOLT_MARKETING = "https://revolt.chat"
const val REVOLT_FILES = "https://autumn.revolt.chat"
const val REVOLT_JANUARY = "https://jan.revolt.chat"
const val REVOLT_APP = "https://app.revolt.chat"
const val REVOLT_WEBSOCKET = "wss://ws.revolt.chat"
fun asJanuaryProxyUrl(url: String): String {

View File

@ -0,0 +1,28 @@
package chat.revolt.callbacks
/**
* Callbacks for UI events, such as when a user selects "reply" on a message, so that the
* channel screen can add a reply to the message, for example.
*
* We do this by having a singleton object that contains all the receivers, and then
* the UI can set the callbacks to whatever it wants.
*/
object UiCallbacks {
interface CallbackReceiver {
fun onQueueMessageForReply(messageId: String)
}
var receivers = mutableListOf<CallbackReceiver>()
fun registerReceiver(receiver: CallbackReceiver) {
receivers.add(receiver)
}
fun unregisterReceiver(receiver: CallbackReceiver) {
receivers.remove(receiver)
}
fun emitQueueMessageForReply(messageId: String) {
receivers.forEach { it.onQueueMessageForReply(messageId) }
}
}

View File

@ -1,7 +1,6 @@
package chat.revolt.components.chat
import android.net.Uri
import android.widget.Toast
import androidx.browser.customtabs.CustomTabsIntent
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
@ -11,14 +10,11 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.revolt.R
import chat.revolt.api.REVOLT_FILES
import chat.revolt.api.RevoltAPI
import chat.revolt.api.asJanuaryProxyUrl
@ -53,11 +49,12 @@ fun formatLongAsTime(time: Long): String {
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun Message(
message: MessageSchema
message: MessageSchema,
truncate: Boolean = false,
onMessageContextMenu: () -> Unit = {},
) {
val author = RevoltAPI.userCache[message.author] ?: return CircularProgressIndicator()
val context = LocalContext.current
val clipboardManager = LocalClipboardManager.current
Column {
if (message.tail == false) {
@ -80,17 +77,7 @@ fun Message(
.combinedClickable(
onClick = {},
onLongClick = {
if (message.content != null && message.content.isNotEmpty()) {
clipboardManager.setText(AnnotatedString(message.content))
Toast
.makeText(
context,
context.getString(R.string.copied),
Toast.LENGTH_SHORT
)
.show()
}
onMessageContextMenu()
}
)
.padding(horizontal = 10.dp)
@ -145,6 +132,8 @@ fun Message(
message.content?.let {
Text(
text = Renderer.annotateMarkdown(it),
maxLines = if (truncate) 1 else Int.MAX_VALUE,
overflow = TextOverflow.Ellipsis
)
}

View File

@ -1,4 +1,4 @@
package chat.revolt.components.screens.settings
package chat.revolt.components.generic
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@ -21,7 +21,7 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@Composable
fun SettingsCategory(
fun SheetClickable(
icon: @Composable (Modifier) -> Unit,
label: @Composable (TextStyle) -> Unit,
onClick: () -> Unit,
@ -30,7 +30,7 @@ fun SettingsCategory(
Row(
modifier = Modifier
.clip(MaterialTheme.shapes.medium)
.background(MaterialTheme.colorScheme.surface)
.background(MaterialTheme.colorScheme.background)
.clickable(onClick = onClick)
.padding(all = 4.dp)
.fillMaxWidth()
@ -40,7 +40,7 @@ fun SettingsCategory(
icon(Modifier.padding(end = 16.dp))
label(
MaterialTheme.typography.bodyMedium.copy(
color = MaterialTheme.colorScheme.onSurface,
color = MaterialTheme.colorScheme.onBackground,
fontWeight = FontWeight.SemiBold,
)
)
@ -51,7 +51,7 @@ fun SettingsCategory(
@Preview
@Composable
fun SettingsCategoryPreview() {
SettingsCategory(
SheetClickable(
icon = { modifier ->
Icon(
modifier = modifier,

View File

@ -0,0 +1,69 @@
package chat.revolt.components.screens.chat
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import chat.revolt.api.routes.channel.SendMessageReply
import chat.revolt.components.chat.InReplyTo
@Composable
fun ManageableReply(
reply: SendMessageReply,
onToggleMention: () -> Unit,
onRemove: () -> Unit,
) {
// TODO Revamp this. Placeholder design ("functional" but extremely ugly)
Row(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp))
.horizontalScroll(rememberScrollState())
.padding(horizontal = 8.dp, vertical = 4.dp)
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Remove reply",
modifier = Modifier
.clickable {
onRemove()
}
)
InReplyTo(
messageId = reply.id,
withMention = reply.mention,
modifier = Modifier.weight(1f)
) {
onToggleMention()
}
}
}
@Composable
fun ReplyManager(
replies: List<SendMessageReply>,
onToggleMention: (SendMessageReply) -> Unit,
onRemove: (SendMessageReply) -> Unit,
) {
Column {
replies.forEach { reply ->
ManageableReply(
reply = reply,
onToggleMention = { onToggleMention(reply) },
onRemove = { onRemove(reply) }
)
}
}
}

View File

@ -33,6 +33,7 @@ import chat.revolt.api.schemas.ChannelType
import chat.revolt.components.chat.DisconnectedNotice
import chat.revolt.components.screens.chat.*
import chat.revolt.screens.chat.sheets.ChannelInfoSheet
import chat.revolt.screens.chat.sheets.MessageContextSheet
import chat.revolt.screens.chat.views.ChannelScreen
import chat.revolt.screens.chat.views.HomeScreen
import com.google.accompanist.navigation.material.ExperimentalMaterialNavigationApi
@ -228,6 +229,7 @@ fun ChatRouterScreen(topNav: NavController, viewModel: ChatRouterViewModel = vie
)
}
}
bottomSheet("channel/{channelId}/info") { backStackEntry ->
val channelId = backStackEntry.arguments?.getString("channelId")
if (channelId != null) {
@ -237,6 +239,15 @@ fun ChatRouterScreen(topNav: NavController, viewModel: ChatRouterViewModel = vie
)
}
}
bottomSheet("message/{messageId}/menu") { backStackEntry ->
val messageId = backStackEntry.arguments?.getString("messageId")
if (messageId != null) {
MessageContextSheet(
navController = navController,
messageId = messageId
)
}
}
}
}
}

View File

@ -0,0 +1,276 @@
package chat.revolt.screens.chat.sheets
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import chat.revolt.R
import chat.revolt.api.REVOLT_APP
import chat.revolt.api.RevoltAPI
import chat.revolt.callbacks.UiCallbacks
import chat.revolt.components.chat.Message
import chat.revolt.components.generic.SheetClickable
import chat.revolt.api.schemas.Message as MessageSchema
@Composable
fun MessageContextSheet(
navController: NavController,
messageId: String,
) {
val message = RevoltAPI.messageCache[messageId]
if (message == null) {
navController.popBackStack()
return
}
val context = LocalContext.current
val clipboardManager = LocalClipboardManager.current
Surface {
Column(
modifier = Modifier
.padding(horizontal = 16.dp, vertical = 8.dp)
.verticalScroll(rememberScrollState()),
) {
Box(
modifier = Modifier
.clip(MaterialTheme.shapes.medium)
.background(MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp))
.padding(bottom = 8.dp)
) {
Message(
message = message.mergeWithPartial(MessageSchema(tail = false)),
truncate = true
)
}
Spacer(modifier = Modifier.height(8.dp))
SheetClickable(
icon = { modifier ->
Icon(
painter = painterResource(id = R.drawable.ic_reply_24dp),
contentDescription = null,
modifier = modifier
)
},
label = { style ->
Text(
text = stringResource(id = R.string.message_context_sheet_actions_reply),
style = style
)
},
) {
UiCallbacks.emitQueueMessageForReply(messageId)
navController.popBackStack()
}
SheetClickable(
icon = { modifier ->
Icon(
painter = painterResource(id = R.drawable.ic_hamburger_plus_24dp),
contentDescription = null,
modifier = modifier
)
},
label = { style ->
Text(
text = stringResource(id = R.string.message_context_sheet_actions_react),
style = style
)
},
) {
Toast.makeText(
context,
context.getString(R.string.comingsoon_toast),
Toast.LENGTH_SHORT
).show()
navController.popBackStack()
}
SheetClickable(
icon = { modifier ->
Icon(
painter = painterResource(id = R.drawable.ic_content_copy_24dp),
contentDescription = null,
modifier = modifier
)
},
label = { style ->
Text(
text = stringResource(id = R.string.message_context_sheet_actions_copy),
style = style
)
},
) {
if (message.content == null || message.content.isEmpty()) {
Toast.makeText(
context,
context.getString(R.string.message_context_sheet_actions_copy_failed_empty),
Toast.LENGTH_SHORT
).show()
navController.popBackStack()
return@SheetClickable
}
clipboardManager.setText(AnnotatedString(message.content))
Toast.makeText(
context,
context.getString(R.string.copied),
Toast.LENGTH_SHORT
).show()
navController.popBackStack()
}
SheetClickable(
icon = { modifier ->
Icon(
painter = painterResource(id = R.drawable.ic_link_variant_24dp),
contentDescription = null,
modifier = modifier
)
},
label = { style ->
Text(
text = stringResource(id = R.string.message_context_sheet_actions_copy_link),
style = style
)
},
) {
if (message.content == null || message.content.isEmpty()) {
Toast.makeText(
context,
context.getString(R.string.message_context_sheet_actions_copy_failed_empty),
Toast.LENGTH_SHORT
).show()
navController.popBackStack()
return@SheetClickable
}
val server = RevoltAPI.serverCache.values.find { server ->
server.channels?.contains(message.channel) ?: false
}
val messageLink =
"$REVOLT_APP/server/${server?.id}/channel/${message.channel}/${message.id}"
clipboardManager.setText(AnnotatedString(messageLink))
Toast.makeText(
context,
context.getString(R.string.message_context_sheet_actions_copy_link_copied),
Toast.LENGTH_SHORT
).show()
navController.popBackStack()
}
SheetClickable(
icon = { modifier ->
Icon(
painter = painterResource(id = R.drawable.ic_content_copy_id_24dp),
contentDescription = null,
modifier = modifier
)
},
label = { style ->
Text(
text = stringResource(id = R.string.message_context_sheet_actions_copy_id),
style = style
)
},
) {
if (message.id == null) return@SheetClickable
clipboardManager.setText(AnnotatedString(message.id))
Toast.makeText(
context,
context.getString(R.string.message_context_sheet_actions_copy_id_copied),
Toast.LENGTH_SHORT
).show()
navController.popBackStack()
}
SheetClickable(
icon = { modifier ->
Icon(
imageVector = Icons.Default.Edit,
contentDescription = null,
modifier = modifier
)
},
label = { style ->
Text(
text = stringResource(id = R.string.message_context_sheet_actions_edit),
style = style
)
},
) {
Toast.makeText(
context,
context.getString(R.string.comingsoon_toast),
Toast.LENGTH_SHORT
).show()
navController.popBackStack()
}
SheetClickable(
icon = { modifier ->
Icon(
imageVector = Icons.Default.Delete,
contentDescription = null,
modifier = modifier
)
},
label = { style ->
Text(
text = stringResource(id = R.string.message_context_sheet_actions_delete),
style = style
)
},
) {
Toast.makeText(
context,
context.getString(R.string.comingsoon_toast),
Toast.LENGTH_SHORT
).show()
navController.popBackStack()
}
SheetClickable(
icon = { modifier ->
Icon(
painter = painterResource(id = R.drawable.ic_flag_24dp),
contentDescription = null,
modifier = modifier
)
},
label = { style ->
Text(
text = stringResource(id = R.string.message_context_sheet_actions_report),
style = style
)
},
) {
Toast.makeText(
context,
context.getString(R.string.comingsoon_toast),
Toast.LENGTH_SHORT
).show()
navController.popBackStack()
}
}
}
}

View File

@ -36,6 +36,7 @@ import chat.revolt.api.realtime.RealtimeSocket
import chat.revolt.api.realtime.frames.receivable.ChannelStartTypingFrame
import chat.revolt.api.realtime.frames.receivable.ChannelStopTypingFrame
import chat.revolt.api.realtime.frames.receivable.MessageFrame
import chat.revolt.api.routes.channel.SendMessageReply
import chat.revolt.api.routes.channel.fetchMessagesFromChannel
import chat.revolt.api.routes.channel.sendMessage
import chat.revolt.api.routes.microservices.autumn.FileArgs
@ -43,10 +44,12 @@ import chat.revolt.api.routes.microservices.autumn.MAX_ATTACHMENTS_PER_MESSAGE
import chat.revolt.api.routes.microservices.autumn.uploadToAutumn
import chat.revolt.api.routes.user.addUserIfUnknown
import chat.revolt.api.schemas.Channel
import chat.revolt.callbacks.UiCallbacks
import chat.revolt.components.chat.Message
import chat.revolt.components.chat.MessageField
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 io.ktor.http.*
import kotlinx.coroutines.flow.distinctUntilChanged
@ -60,9 +63,9 @@ class ChannelScreenViewModel : ViewModel() {
val channel: Channel?
get() = _channel
private var _callback = mutableStateOf<RealtimeSocket.ChannelCallback?>(null)
private val callback: RealtimeSocket.ChannelCallback?
get() = _callback.value
private var _channelCallback = mutableStateOf<RealtimeSocket.ChannelCallback?>(null)
private val channelCallback: RealtimeSocket.ChannelCallback?
get() = _channelCallback.value
private var _renderableMessages = mutableStateListOf<MessageSchema>()
val renderableMessages: List<MessageSchema>
@ -114,6 +117,36 @@ class ChannelScreenViewModel : ViewModel() {
_sendingMessage = sending
}
private var _replies = mutableStateListOf<SendMessageReply>()
val replies: List<SendMessageReply>
get() = _replies
fun addInReplyTo(reply: SendMessageReply) {
_replies.add(reply)
}
fun removeReply(reply: SendMessageReply) {
_replies.remove(reply)
}
fun toggleReplyMentionFor(reply: SendMessageReply) {
val index = _replies.indexOf(reply)
val newReply = SendMessageReply(
reply.id,
!reply.mention
)
_replies[index] = newReply
}
private fun clearInReplyTo() {
_replies.clear()
}
private var _uiCallbackReceiver = mutableStateOf<UiCallbacks.CallbackReceiver?>(null)
val uiCallbackReceiver: UiCallbacks.CallbackReceiver?
get() = _uiCallbackReceiver.value
inner class ChannelScreenCallback : RealtimeSocket.ChannelCallback {
override fun onMessage(message: MessageFrame) {
viewModelScope.launch {
@ -145,9 +178,20 @@ class ChannelScreenViewModel : ViewModel() {
}
}
private fun registerCallback() {
_callback.value = ChannelScreenCallback()
RealtimeSocket.registerChannelCallback(channel!!.id!!, callback!!)
inner class UiCallbackReceiver : UiCallbacks.CallbackReceiver {
override fun onQueueMessageForReply(messageId: String) {
viewModelScope.launch {
addInReplyTo(SendMessageReply(messageId, true))
}
}
}
private fun registerCallbacks() {
_channelCallback.value = ChannelScreenCallback()
RealtimeSocket.registerChannelCallback(channel!!.id!!, channelCallback!!)
_uiCallbackReceiver.value = UiCallbackReceiver()
UiCallbacks.registerReceiver(uiCallbackReceiver!!)
}
fun fetchMessages() {
@ -218,7 +262,7 @@ class ChannelScreenViewModel : ViewModel() {
Log.e("ChannelScreen", "Channel $id not in cache, for now this is fatal!") // FIXME
}
registerCallback()
registerCallbacks()
}
fun sendPendingMessage() {
@ -246,11 +290,13 @@ class ChannelScreenViewModel : ViewModel() {
sendMessage(
channel!!.id!!,
messageContent,
attachments = if (attachmentIds.isEmpty()) null else attachmentIds
attachments = if (attachmentIds.isEmpty()) null else attachmentIds,
replies = replies
)
_messageContent = ""
popAttachmentBatch()
clearInReplyTo()
setSendingMessage(false)
}
}
@ -340,6 +386,7 @@ fun ChannelScreen(
DisposableEffect(channelId) {
onDispose {
RealtimeSocket.unregisterChannelCallback(channelId)
viewModel.uiCallbackReceiver?.let { UiCallbacks.unregisterReceiver(it) }
}
}
@ -411,7 +458,9 @@ fun ChannelScreen(
}
items(viewModel.renderableMessages) { message ->
Message(message)
Message(message) {
navController.navigate("message/${message.id}/menu")
}
}
item {
@ -466,6 +515,14 @@ fun ChannelScreen(
)
}
AnimatedVisibility(visible = viewModel.replies.isNotEmpty()) {
ReplyManager(
replies = viewModel.replies,
onRemove = viewModel::removeReply,
onToggleMention = viewModel::toggleReplyMentionFor
)
}
AnimatedVisibility(visible = viewModel.attachments.isNotEmpty()) {
AttachmentManager(
attachments = viewModel.attachments,

View File

@ -15,7 +15,7 @@ import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import chat.revolt.R
import chat.revolt.components.generic.PageHeader
import chat.revolt.components.screens.settings.SettingsCategory
import chat.revolt.components.generic.SheetClickable
@Composable
fun SettingsScreen(
@ -37,7 +37,7 @@ fun SettingsScreen(
.fillMaxSize()
.padding(10.dp)
) {
SettingsCategory(
SheetClickable(
icon = { modifier ->
Icon(
painter = painterResource(id = R.drawable.ic_palette_24dp),
@ -56,7 +56,7 @@ fun SettingsScreen(
navController.navigate("settings/appearance")
}
SettingsCategory(
SheetClickable(
icon = { modifier ->
Icon(
imageVector = Icons.Default.Info,

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:height="24dp"
android:width="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M19,21H8V7H19M19,5H8A2,2 0 0,0 6,7V21A2,2 0 0,0 8,23H19A2,2 0 0,0 21,21V7A2,2 0 0,0 19,5M16,1H4A2,2 0 0,0 2,3V17H4V3H16V1Z"
android:fillColor="#ffffff" />
</vector>

View File

@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#ffffff"
android:pathData="M4,1A2,2 0,0 0,2 3L2,17L4,17L4,3L16,3L16,1L4,1zM8,5A2,2 0,0 0,6 7L6,21A2,2 0,0 0,8 23L12,23L12,21L8,21L8,7L19,7L19,13.693L21,13.693L21,7A2,2 0,0 0,19 5L8,5z"/>
<path
android:fillColor="#ffffff"
android:pathData="m16.297,15.593v1.176h-0.588v3.527h0.588v1.176h-2.351v-1.176h0.588v-3.527h-0.588v-1.176h2.351m3.527,0C20.477,15.593 21,16.122 21,16.769v3.527c0,0.653 -0.523,1.176 -1.176,1.176h-2.351v-5.878m2.351,1.176h-1.176v3.527h1.176z"
android:strokeWidth="0.587848"/>
</vector>

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:height="24dp"
android:width="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#ffffff"
android:pathData="M14.4,6L14,4H5V21H7V14H12.6L13,16H20V6H14.4Z" />
</vector>

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:height="24dp"
android:width="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#ffffff"
android:pathData="M21 9H3C3 9 3 3 12 3S21 9 21 9M13.35 17H3V18C3 19.66 4.34 21 6 21H13.35C13.13 20.37 13 19.7 13 19C13 18.3 13.13 17.63 13.35 17M21.86 13.73C21.95 13.5 22 13.26 22 13C22 11.9 21.11 11 20 11H11L8.5 13L6 11H4C2.9 11 2 11.9 2 13S2.9 15 4 15H14.54C15.64 13.78 17.23 13 19 13C20.04 13 21 13.26 21.86 13.73M20 18V15H18V18H15V20H18V23H20V20H23V18H20Z" />
</vector>

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:height="24dp"
android:width="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#ffffff"
android:pathData="M10,9V5L3,12L10,19V14.9C15,14.9 18.5,16.5 21,20C20,15 17,10 10,9Z" />
</vector>

View File

@ -61,6 +61,7 @@
<string name="comingsoon_heading">Gah, you found me!</string>
<string name="comingsoon_body">The feature you are trying to access is not ready yet, but we are steadily working on polishing it to perfection..</string>
<string name="comingsoon_toast">Sorry, this feature is not ready yet.</string>
<string name="typing_blank"><!-- this is a hack to prevent the typing indicator from showing typing_several when it's animating away --></string>
<string name="typing_one">%1$s is typing…</string>
@ -116,6 +117,21 @@
<string name="channel_info_sheet_description">Channel description</string>
<string name="channel_info_sheet_description_empty">There hasn\'t been a description set for this channel yet.</string>
<string name="message_context_sheet_actions_copy">Copy</string>
<string name="message_context_sheet_actions_copy_failed_empty">Message is empty, nothing to copy</string>
<string name="message_context_sheet_actions_reply">Reply</string>
<string name="message_context_sheet_actions_edit">Edit</string>
<string name="message_context_sheet_actions_delete">Delete</string>
<string name="message_context_sheet_actions_delete_confirm">Are you sure you want to delete this message?</string>
<string name="message_context_sheet_actions_delete_confirm_yes">Yes</string>
<string name="message_context_sheet_actions_delete_confirm_no">No</string>
<string name="message_context_sheet_actions_copy_id">Copy ID</string>
<string name="message_context_sheet_actions_copy_id_copied">Copied message ID to clipboard</string>
<string name="message_context_sheet_actions_copy_link">Copy link</string>
<string name="message_context_sheet_actions_copy_link_copied">Copied message link to clipboard</string>
<string name="message_context_sheet_actions_react">React</string>
<string name="message_context_sheet_actions_report">Report</string>
<string name="settings_appearance">Appearance</string>
<string name="settings_appearance_theme">Theme</string>
<string name="settings_appearance_theme_none">System</string>