android, desktop: inline reports (#5485)

* simple send and receive

* fix sending reason enum via api

* trim ""

* report preview and msg display

* adding support for moderator (not active)

* disable all bulk actions for reports

* progress on context menu

* make delete messages and block fn suspend

* block and moderate

* fixes and code cleanup

* never show report on own messages

* minor code improvements

* supportedRoles -> selectableRoles

* remove paddings on msg not allowed and other overlapping views, change color

* reports: disables attachments, cleans previews and stops lives

* disable report on lives

* refactor

* reports - enable delete for self on bulk actions

* text

* select report context menu

* ios: text

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
This commit is contained in:
Diogo
2025-01-08 20:07:32 +00:00
committed by GitHub
co-authored by Evgeny Poberezkin
parent 7e344b3ee8
commit 7281255480
16 changed files with 534 additions and 94 deletions
+1 -1
View File
@@ -1706,7 +1706,7 @@ struct ChatView: View {
AlertManager.shared.showAlert(
Alert(
title: Text("Archive report?"),
message: Text("The report will be archived for all moderators and reporter."),
message: Text("The report will be archived for you."),
primaryButton: .destructive(Text("Archive")) {
deletingItem = cItem
deleteMessage(.cidmInternalMark, moderate: false)
@@ -1638,7 +1638,7 @@ data class GroupMember (
fun canChangeRoleTo(groupInfo: GroupInfo): List<GroupMemberRole>? =
if (!canBeRemoved(groupInfo)) null
else groupInfo.membership.memberRole.let { userRole ->
GroupMemberRole.values().filter { it <= userRole && it != GroupMemberRole.Author }
GroupMemberRole.selectableRoles.filter { it <= userRole }
}
fun canBlockForAll(groupInfo: GroupInfo): Boolean {
@@ -1689,13 +1689,19 @@ enum class GroupMemberRole(val memberRole: String) {
@SerialName("observer") Observer("observer"), // order matters in comparisons
@SerialName("author") Author("author"),
@SerialName("member") Member("member"),
@SerialName("moderator") Moderator("moderator"),
@SerialName("admin") Admin("admin"),
@SerialName("owner") Owner("owner");
companion object {
val selectableRoles: List<GroupMemberRole> = listOf(Observer, Member, Admin, Owner)
}
val text: String get() = when (this) {
Observer -> generalGetString(MR.strings.group_member_role_observer)
Author -> generalGetString(MR.strings.group_member_role_author)
Member -> generalGetString(MR.strings.group_member_role_member)
Moderator -> generalGetString(MR.strings.group_member_role_moderator)
Admin -> generalGetString(MR.strings.group_member_role_admin)
Owner -> generalGetString(MR.strings.group_member_role_owner)
}
@@ -2116,6 +2122,12 @@ data class ChatItem (
else -> true
}
val isReport: Boolean get() = when (content) {
is CIContent.SndMsgContent, is CIContent.RcvMsgContent ->
content.msgContent is MsgContent.MCReport
else -> false
}
val canBeDeletedForSelf: Boolean
get() = (content.msgContent != null && !meta.isLive) || meta.itemDeleted != null || isDeletedContent || mergeCategory != null || showLocalDelete
@@ -2946,6 +2958,19 @@ class CIQuote (
null -> null
}
fun memberToModerate(chatInfo: ChatInfo): GroupMember? {
return if (chatInfo is ChatInfo.Group && chatDir is CIDirection.GroupRcv) {
val m = chatInfo.groupInfo.membership
if (m.memberRole >= GroupMemberRole.Moderator && m.memberRole >= chatDir.groupMember.memberRole) {
chatDir.groupMember
} else {
null
}
} else {
null
}
}
companion object {
fun getSample(itemId: Long?, sentAt: Instant, text: String, chatDir: CIDirection?): CIQuote =
CIQuote(chatDir = chatDir, itemId = itemId, sentAt = sentAt, content = MsgContent.MCText(text))
@@ -3589,6 +3614,19 @@ sealed class ReportReason {
@Serializable @SerialName("profile") object Profile: ReportReason()
@Serializable @SerialName("other") object Other: ReportReason()
@Serializable @SerialName("unknown") data class Unknown(val type: String): ReportReason()
companion object {
val supportedReasons: List<ReportReason> = listOf(Spam, Illegal, Community, Profile, Other)
}
val text: String get() = when (this) {
Spam -> generalGetString(MR.strings.report_reason_spam)
Illegal -> generalGetString(MR.strings.report_reason_illegal)
Community -> generalGetString(MR.strings.report_reason_community)
Profile -> generalGetString(MR.strings.report_reason_profile)
Other -> generalGetString(MR.strings.report_reason_other)
is Unknown -> type
}
}
object ReportReasonSerializer : KSerializer<ReportReason> {
@@ -3330,7 +3330,7 @@ sealed class CC {
val msgs = json.encodeToString(composedMessages)
"/_create *$noteFolderId json $msgs"
}
is ApiReportMessage -> "/_report #$groupId $chatItemId reason=$reportReason $reportText"
is ApiReportMessage -> "/_report #$groupId $chatItemId reason=${json.encodeToString(reportReason).trim('"')} $reportText"
is ApiUpdateChatItem -> "/_update item ${chatRef(type, id)} $itemId live=${onOff(live)} ${mc.cmdString}"
is ApiDeleteChatItem -> "/_delete item ${chatRef(type, id)} ${itemIds.joinToString(",")} ${mode.deleteMode}"
is ApiDeleteMemberChatItem -> "/_delete member item #$groupId ${itemIds.joinToString(",")}"
@@ -301,41 +301,41 @@ fun ChatView(staleChatId: State<String?>, onComposed: suspend (chatId: String) -
}
},
deleteMessage = { itemId, mode ->
withBGApi {
val toDeleteItem = chatModel.chatItems.value.firstOrNull { it.id == itemId }
val toModerate = toDeleteItem?.memberToModerate(chatInfo)
val groupInfo = toModerate?.first
val groupMember = toModerate?.second
val deletedChatItem: ChatItem?
val toChatItem: ChatItem?
val r = if (mode == CIDeleteMode.cidmBroadcast && groupInfo != null && groupMember != null) {
chatModel.controller.apiDeleteMemberChatItems(
chatRh,
groupId = groupInfo.groupId,
itemIds = listOf(itemId)
)
} else {
chatModel.controller.apiDeleteChatItems(
chatRh,
type = chatInfo.chatType,
id = chatInfo.apiId,
itemIds = listOf(itemId),
mode = mode
)
}
val deleted = r?.firstOrNull()
if (deleted != null) {
deletedChatItem = deleted.deletedChatItem.chatItem
toChatItem = deleted.toChatItem?.chatItem
withChats {
if (toChatItem != null) {
upsertChatItem(chatRh, chatInfo, toChatItem)
} else {
removeChatItem(chatRh, chatInfo, deletedChatItem)
}
val toDeleteItem = chatModel.chatItems.value.firstOrNull { it.id == itemId }
val toModerate = toDeleteItem?.memberToModerate(chatInfo)
val groupInfo = toModerate?.first
val groupMember = toModerate?.second
val deletedChatItem: ChatItem?
val toChatItem: ChatItem?
val r = if (mode == CIDeleteMode.cidmBroadcast && groupInfo != null && groupMember != null) {
chatModel.controller.apiDeleteMemberChatItems(
chatRh,
groupId = groupInfo.groupId,
itemIds = listOf(itemId)
)
} else {
chatModel.controller.apiDeleteChatItems(
chatRh,
type = chatInfo.chatType,
id = chatInfo.apiId,
itemIds = listOf(itemId),
mode = mode
)
}
val deleted = r?.firstOrNull()
if (deleted != null) {
deletedChatItem = deleted.deletedChatItem.chatItem
toChatItem = deleted.toChatItem?.chatItem
withChats {
if (toChatItem != null) {
upsertChatItem(chatRh, chatInfo, toChatItem)
} else {
removeChatItem(chatRh, chatInfo, deletedChatItem)
}
}
}
deleted
},
deleteMessages = { itemIds -> deleteMessages(chatRh, chatInfo, itemIds, false, moderate = false) },
receiveFile = { fileId ->
@@ -599,7 +599,7 @@ fun ChatLayout(
info: () -> Unit,
showMemberInfo: (GroupInfo, GroupMember) -> Unit,
loadMessages: suspend (ChatId, ChatPagination, ActiveChatState, visibleItemIndexesNonReversed: () -> IntRange) -> Unit,
deleteMessage: (Long, CIDeleteMode) -> Unit,
deleteMessage: suspend (Long, CIDeleteMode) -> ChatItemDeletion?,
deleteMessages: (List<Long>) -> Unit,
receiveFile: (Long) -> Unit,
cancelFile: (Long) -> Unit,
@@ -946,7 +946,7 @@ fun BoxScope.ChatItemsList(
showMemberInfo: (GroupInfo, GroupMember) -> Unit,
showChatInfo: () -> Unit,
loadMessages: suspend (ChatId, ChatPagination, ActiveChatState, visibleItemIndexesNonReversed: () -> IntRange) -> Unit,
deleteMessage: (Long, CIDeleteMode) -> Unit,
deleteMessage: suspend (Long, CIDeleteMode) -> ChatItemDeletion?,
deleteMessages: (List<Long>) -> Unit,
receiveFile: (Long) -> Unit,
cancelFile: (Long) -> Unit,
@@ -2434,7 +2434,7 @@ fun PreviewChatLayout() {
info = {},
showMemberInfo = { _, _ -> },
loadMessages = { _, _, _, _ -> },
deleteMessage = { _, _ -> },
deleteMessage = { _, _ -> null },
deleteMessages = { _ -> },
receiveFile = { _ -> },
cancelFile = {},
@@ -2507,7 +2507,7 @@ fun PreviewGroupChatLayout() {
info = {},
showMemberInfo = { _, _ -> },
loadMessages = { _, _, _, _ -> },
deleteMessage = { _, _ -> },
deleteMessage = { _, _ -> null },
deleteMessages = {},
receiveFile = { _ -> },
cancelFile = {},
@@ -11,6 +11,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.layout.onSizeChanged
@@ -51,6 +52,7 @@ sealed class ComposeContextItem {
@Serializable class QuotedItem(val chatItem: ChatItem): ComposeContextItem()
@Serializable class EditingItem(val chatItem: ChatItem): ComposeContextItem()
@Serializable class ForwardingItems(val chatItems: List<ChatItem>, val fromChatInfo: ChatInfo): ComposeContextItem()
@Serializable class ReportedItem(val chatItem: ChatItem, val reason: ReportReason): ComposeContextItem()
}
@Serializable
@@ -89,13 +91,28 @@ data class ComposeState(
is ComposeContextItem.ForwardingItems -> true
else -> false
}
val reporting: Boolean
get() = when (contextItem) {
is ComposeContextItem.ReportedItem -> true
else -> false
}
val submittingValidReport: Boolean
get() = when (contextItem) {
is ComposeContextItem.ReportedItem -> {
when (contextItem.reason) {
is ReportReason.Other -> message.isNotEmpty()
else -> true
}
}
else -> false
}
val sendEnabled: () -> Boolean
get() = {
val hasContent = when (preview) {
is ComposePreview.MediaPreview -> true
is ComposePreview.VoicePreview -> true
is ComposePreview.FilePreview -> true
else -> message.isNotEmpty() || forwarding || liveMessage != null
else -> message.isNotEmpty() || forwarding || liveMessage != null || submittingValidReport
}
hasContent && !inProgress
}
@@ -119,7 +136,7 @@ data class ComposeState(
val attachmentDisabled: Boolean
get() {
if (editing || forwarding || liveMessage != null || inProgress) return true
if (editing || forwarding || liveMessage != null || inProgress || reporting) return true
return when (preview) {
ComposePreview.NoPreview -> false
is ComposePreview.CLinkPreview -> false
@@ -136,6 +153,12 @@ data class ComposeState(
is ComposePreview.FilePreview -> true
}
val placeholder: String
get() = when (contextItem) {
is ComposeContextItem.ReportedItem -> contextItem.reason.text
else -> generalGetString(MR.strings.compose_message_placeholder)
}
val empty: Boolean
get() = message.isEmpty() && preview is ComposePreview.NoPreview && contextItem is ComposeContextItem.NoContextItem
@@ -489,6 +512,19 @@ fun ComposeView(
}
}
suspend fun sendReport(reportReason: ReportReason, chatItemId: Long): List<ChatItem>? {
val cItems = chatModel.controller.apiReportMessage(chat.remoteHostId, chat.chatInfo.apiId, chatItemId, reportReason, msgText)
if (cItems != null) {
withChats {
cItems.forEach { chatItem ->
addChatItem(chat.remoteHostId, chat.chatInfo, chatItem.chatItem)
}
}
}
return cItems?.map { it.chatItem }
}
suspend fun sendMemberContactInvitation() {
val mc = checkLinkPreview()
val contact = chatModel.controller.apiSendMemberContactInvitation(chat.remoteHostId, chat.chatInfo.apiId, mc)
@@ -554,6 +590,8 @@ fun ComposeView(
} else if (liveMessage != null && liveMessage.sent) {
val updatedMessage = updateMessage(liveMessage.chatItem, chat, live)
sent = if (updatedMessage != null) listOf(updatedMessage) else null
} else if (cs.contextItem is ComposeContextItem.ReportedItem) {
sent = sendReport(cs.contextItem.reason, cs.contextItem.chatItem.id)
} else {
val msgs: ArrayList<MsgContent> = ArrayList()
val files: ArrayList<CryptoFile> = ArrayList()
@@ -835,14 +873,33 @@ fun ComposeView(
@Composable
fun MsgNotAllowedView(reason: String, icon: Painter) {
val color = MaterialTheme.appColors.receivedMessage
Row(Modifier.padding(top = 5.dp).fillMaxWidth().background(color).padding(horizontal = DEFAULT_PADDING_HALF, vertical = DEFAULT_PADDING_HALF * 1.5f), verticalAlignment = Alignment.CenterVertically) {
val color = MaterialTheme.appColors.receivedQuote
Row(Modifier.fillMaxWidth().background(color).padding(horizontal = DEFAULT_PADDING_HALF, vertical = DEFAULT_PADDING_HALF * 1.5f), verticalAlignment = Alignment.CenterVertically) {
Icon(icon, null, tint = MaterialTheme.colors.secondary)
Spacer(Modifier.width(DEFAULT_PADDING_HALF))
Text(reason, fontStyle = FontStyle.Italic)
}
}
@Composable
fun ReportReasonView(reason: ReportReason) {
val reportText = when (reason) {
is ReportReason.Spam -> generalGetString(MR.strings.report_compose_reason_header_spam)
is ReportReason.Illegal -> generalGetString(MR.strings.report_compose_reason_header_illegal)
is ReportReason.Profile -> generalGetString(MR.strings.report_compose_reason_header_profile)
is ReportReason.Community -> generalGetString(MR.strings.report_compose_reason_header_community)
is ReportReason.Other -> generalGetString(MR.strings.report_compose_reason_header_other)
is ReportReason.Unknown -> null // should never happen
}
if (reportText != null) {
val color = MaterialTheme.appColors.receivedQuote
Row(Modifier.fillMaxWidth().background(color).padding(horizontal = DEFAULT_PADDING_HALF, vertical = DEFAULT_PADDING_HALF * 1.5f), verticalAlignment = Alignment.CenterVertically) {
Text(reportText, fontStyle = FontStyle.Italic, fontSize = 12.sp)
}
}
}
@Composable
fun contextItemView() {
when (val contextItem = composeState.value.contextItem) {
@@ -856,6 +913,9 @@ fun ComposeView(
is ComposeContextItem.ForwardingItems -> ContextItemView(contextItem.chatItems, painterResource(MR.images.ic_forward), showSender = false, chatType = chat.chatInfo.chatType) {
composeState.value = composeState.value.copy(contextItem = ComposeContextItem.NoContextItem)
}
is ComposeContextItem.ReportedItem -> ContextItemView(listOf(contextItem.chatItem), painterResource(MR.images.ic_flag), chatType = chat.chatInfo.chatType, contextIconColor = Color.Red) {
composeState.value = composeState.value.copy(contextItem = ComposeContextItem.NoContextItem)
}
}
}
@@ -893,6 +953,10 @@ fun ComposeView(
if (nextSendGrpInv.value) {
ComposeContextInvitingContactMemberView()
}
val ctx = composeState.value.contextItem
if (ctx is ComposeContextItem.ReportedItem) {
ReportReasonView(ctx.reason)
}
val simplexLinkProhibited = hasSimplexLink.value && !chat.groupFeatureEnabled(GroupFeature.SimplexLinks)
val fileProhibited = composeState.value.attachmentPreview && !chat.groupFeatureEnabled(GroupFeature.Files)
val voiceProhibited = composeState.value.preview is ComposePreview.VoicePreview && !chat.chatInfo.featureEnabled(ChatFeature.Voice)
@@ -1050,7 +1114,7 @@ fun ComposeView(
sendButtonColor = sendButtonColor,
timedMessageAllowed = timedMessageAllowed,
customDisappearingMessageTimePref = chatModel.controller.appPrefs.customDisappearingMessageTime,
placeholder = stringResource(MR.strings.compose_message_placeholder),
placeholder = composeState.value.placeholder,
sendMessage = { ttl ->
sendMessage(ttl)
resetLinkPreview()
@@ -12,6 +12,7 @@ import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.runtime.*
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.unit.dp
@@ -31,6 +32,7 @@ fun ContextItemView(
contextIcon: Painter,
showSender: Boolean = true,
chatType: ChatType,
contextIconColor: Color = MaterialTheme.colors.secondary,
cancelContextItem: () -> Unit,
) {
val sentColor = MaterialTheme.appColors.sentMessage
@@ -85,7 +87,6 @@ fun ContextItemView(
Row(
Modifier
.padding(top = 8.dp)
.background(if (sent) sentColor else receivedColor),
verticalAlignment = Alignment.CenterVertically
) {
@@ -103,8 +104,8 @@ fun ContextItemView(
.height(20.dp)
.width(20.dp),
contentDescription = stringResource(MR.strings.icon_descr_context),
tint = MaterialTheme.colors.secondary,
)
tint = contextIconColor,
)
if (contextItems.count() == 1) {
val contextItem = contextItems[0]
@@ -138,10 +138,10 @@ private fun recheckItems(chatInfo: ChatInfo,
for (ci in chatItems) {
if (selected.contains(ci.id)) {
rDeleteEnabled = rDeleteEnabled && ci.canBeDeletedForSelf
rDeleteForEveryoneEnabled = rDeleteForEveryoneEnabled && ci.meta.deletable && !ci.localNote
rOnlyOwnGroupItems = rOnlyOwnGroupItems && ci.chatDir is CIDirection.GroupSnd
rModerateEnabled = rModerateEnabled && ci.content.msgContent != null && ci.memberToModerate(chatInfo) != null
rForwardEnabled = rForwardEnabled && ci.content.msgContent != null && ci.meta.itemDeleted == null && !ci.isLiveDummy
rDeleteForEveryoneEnabled = rDeleteForEveryoneEnabled && ci.meta.deletable && !ci.localNote && !ci.isReport
rOnlyOwnGroupItems = rOnlyOwnGroupItems && ci.chatDir is CIDirection.GroupSnd && !ci.isReport
rModerateEnabled = rModerateEnabled && ci.content.msgContent != null && ci.memberToModerate(chatInfo) != null && !ci.isReport
rForwardEnabled = rForwardEnabled && ci.content.msgContent != null && ci.meta.itemDeleted == null && !ci.isLiveDummy && !ci.isReport
rSelectedChatItems.add(ci.id) // we are collecting new selected items here to account for any changes in chat items list
}
}
@@ -74,7 +74,7 @@ fun SendMsgView(
}
}
val showVoiceButton = !nextSendGrpInv && cs.message.isEmpty() && showVoiceRecordIcon && !composeState.value.editing &&
!composeState.value.forwarding && cs.liveMessage == null && (cs.preview is ComposePreview.NoPreview || recState.value is RecordingState.Started)
!composeState.value.forwarding && cs.liveMessage == null && (cs.preview is ComposePreview.NoPreview || recState.value is RecordingState.Started) && (cs.contextItem !is ComposeContextItem.ReportedItem)
val showDeleteTextButton = rememberSaveable { mutableStateOf(false) }
val sendMsgButtonDisabled = !sendMsgEnabled || !cs.sendEnabled() ||
(!allowedVoiceByPrefs && cs.preview is ComposePreview.VoicePreview) ||
@@ -125,6 +125,9 @@ fun SendMsgView(
}
when {
progressByTimeout -> ProgressIndicator()
cs.contextItem is ComposeContextItem.ReportedItem -> {
SendMsgButton(painterResource(MR.images.ic_check_filled), sendButtonSize, sendButtonAlpha, sendButtonColor, !sendMsgButtonDisabled, sendMessage)
}
showVoiceButton && sendMsgEnabled -> {
Row(verticalAlignment = Alignment.CenterVertically) {
val stopRecOnNextClick = remember { mutableStateOf(false) }
@@ -209,8 +209,8 @@ private fun RoleSelectionRow(groupInfo: GroupInfo, selectedRole: MutableState<Gr
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
val values = GroupMemberRole.values()
.filter { it <= groupInfo.membership.memberRole && it != GroupMemberRole.Author }
val values = GroupMemberRole.selectableRoles
.filter { it <= groupInfo.membership.memberRole }
.map { it to it.text }
ExposedDropDownSettingRow(
generalGetString(MR.strings.new_member_role),
@@ -747,13 +747,13 @@ fun updateMemberSettings(rhId: Long?, gInfo: GroupInfo, member: GroupMember, mem
}
}
fun blockForAllAlert(rhId: Long?, gInfo: GroupInfo, mem: GroupMember) {
fun blockForAllAlert(rhId: Long?, gInfo: GroupInfo, mem: GroupMember, blockMember: () -> Unit = { withBGApi { blockMemberForAll(rhId, gInfo, mem, true) } }) {
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.block_for_all_question),
text = generalGetString(MR.strings.block_member_desc).format(mem.chatViewName),
confirmText = generalGetString(MR.strings.block_for_all),
onConfirm = {
blockMemberForAll(rhId, gInfo, mem, true)
blockMember()
},
destructive = true,
)
@@ -765,17 +765,15 @@ fun unblockForAllAlert(rhId: Long?, gInfo: GroupInfo, mem: GroupMember) {
text = generalGetString(MR.strings.unblock_member_desc).format(mem.chatViewName),
confirmText = generalGetString(MR.strings.unblock_for_all),
onConfirm = {
blockMemberForAll(rhId, gInfo, mem, false)
withBGApi { blockMemberForAll(rhId, gInfo, mem, false) }
},
)
}
fun blockMemberForAll(rhId: Long?, gInfo: GroupInfo, member: GroupMember, blocked: Boolean) {
withBGApi {
val updatedMember = ChatController.apiBlockMemberForAll(rhId, gInfo.groupId, member.groupMemberId, blocked)
withChats {
upsertGroupMember(rhId, gInfo, updatedMember)
}
suspend fun blockMemberForAll(rhId: Long?, gInfo: GroupInfo, member: GroupMember, blocked: Boolean) {
val updatedMember = ChatController.apiBlockMemberForAll(rhId, gInfo.groupId, member.groupMemberId, blocked)
withChats {
upsertGroupMember(rhId, gInfo, updatedMember)
}
}
@@ -1,5 +1,6 @@
package chat.simplex.common.views.chat.item
import SectionItemView
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.foundation.*
import androidx.compose.foundation.interaction.HoverInteraction
@@ -20,14 +21,18 @@ import androidx.compose.ui.text.*
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.*
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatModel.controller
import chat.simplex.common.model.ChatModel.currentUser
import chat.simplex.common.model.ChatModel.withChats
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.chat.*
import chat.simplex.common.views.chat.group.blockForAllAlert
import chat.simplex.common.views.chat.group.blockMemberForAll
import chat.simplex.common.views.helpers.*
import chat.simplex.res.MR
import kotlinx.datetime.Clock
@@ -72,7 +77,7 @@ fun ChatItemView(
selectedChatItems: MutableState<Set<Long>?>,
fillMaxWidth: Boolean = true,
selectChatItem: () -> Unit,
deleteMessage: (Long, CIDeleteMode) -> Unit,
deleteMessage: suspend (Long, CIDeleteMode) -> ChatItemDeletion?,
deleteMessages: (List<Long>) -> Unit,
receiveFile: (Long) -> Unit,
cancelFile: (Long) -> Unit,
@@ -108,6 +113,12 @@ fun ChatItemView(
val onLinkLongClick = { _: String -> showMenu.value = true }
val live = remember { derivedStateOf { composeState.value.liveMessage != null } }.value
val deleteMessageAsync: (Long, CIDeleteMode) -> Unit = { id, mode ->
withBGApi {
deleteMessage(id, mode)
}
}
Box(
modifier = if (fillMaxWidth) Modifier.fillMaxWidth() else Modifier,
contentAlignment = alignment,
@@ -282,7 +293,7 @@ fun ChatItemView(
@Composable
fun DeleteItemMenu() {
DefaultDropdownMenu(showMenu) {
DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages)
DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessageAsync, deleteMessages)
if (cItem.canBeDeletedForSelf) {
Divider()
SelectItemAction(showMenu, selectChatItem)
@@ -295,7 +306,36 @@ fun ChatItemView(
val saveFileLauncher = rememberSaveFileLauncher(ciFile = cItem.file)
when {
// cItem.id check is a special case for live message chat item which has negative ID while not sent yet
cItem.content.msgContent != null && cItem.id >= 0 -> {
cItem.isReport && cItem.meta.itemDeleted == null && cInfo is ChatInfo.Group -> {
DefaultDropdownMenu(showMenu) {
if (cItem.chatDir is CIDirection.GroupSnd) {
DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessageAsync, deleteMessages)
} else {
ArchiveReportItemAction(cItem, showMenu, deleteMessageAsync)
val qItem = cItem.quotedItem
if (qItem != null) {
ModerateReportItemAction(rhId, cInfo, cItem, qItem, showMenu, deleteMessage)
val rMember = qItem.memberToModerate(cInfo)
if (rMember != null && !rMember.blockedByAdmin && rMember.canBlockForAll(cInfo.groupInfo)) {
BlockMemberAction(
rhId,
chatInfo = cInfo,
groupInfo = cInfo.groupInfo,
cItem = cItem,
reportedItem = qItem,
member = rMember,
showMenu = showMenu,
deleteMessage = deleteMessage
)
}
}
Divider()
SelectItemAction(showMenu, selectChatItem)
}
}
}
cItem.content.msgContent != null && cItem.id >= 0 && !cItem.isReport -> {
DefaultDropdownMenu(showMenu) {
if (cInfo.featureEnabled(ChatFeature.Reactions) && cItem.allowAddReaction) {
MsgReactionsMenu()
@@ -381,11 +421,15 @@ fun ChatItemView(
CancelFileItemAction(cItem.file.fileId, showMenu, cancelFile = cancelFile, cancelAction = cItem.file.cancelAction)
}
if (!(live && cItem.meta.isLive) && !preview) {
DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages)
DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessageAsync, deleteMessages)
}
val groupInfo = cItem.memberToModerate(cInfo)?.first
if (groupInfo != null && cItem.chatDir !is CIDirection.GroupSnd) {
ModerateItemAction(cItem, questionText = moderateMessageQuestionText(cInfo.featureEnabled(ChatFeature.FullDelete), 1), showMenu, deleteMessage)
if (cItem.chatDir !is CIDirection.GroupSnd) {
val groupInfo = cItem.memberToModerate(cInfo)?.first
if (groupInfo != null) {
ModerateItemAction(cItem, questionText = moderateMessageQuestionText(cInfo.featureEnabled(ChatFeature.FullDelete), 1), showMenu, deleteMessageAsync)
} else if (cItem.meta.itemDeleted == null && cInfo is ChatInfo.Group && cInfo.groupInfo.membership.memberRole < GroupMemberRole.Moderator && !live) {
ReportItemAction(cItem, composeState, showMenu)
}
}
if (cItem.canBeDeletedForSelf) {
Divider()
@@ -403,7 +447,7 @@ fun ChatItemView(
ExpandItemAction(revealed, showMenu, reveal)
}
ItemInfoAction(cInfo, cItem, showItemDetails, showMenu)
DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages)
DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessageAsync, deleteMessages)
if (cItem.canBeDeletedForSelf) {
Divider()
SelectItemAction(showMenu, selectChatItem)
@@ -413,7 +457,7 @@ fun ChatItemView(
cItem.isDeletedContent -> {
DefaultDropdownMenu(showMenu) {
ItemInfoAction(cInfo, cItem, showItemDetails, showMenu)
DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages)
DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessageAsync, deleteMessages)
if (cItem.canBeDeletedForSelf) {
Divider()
SelectItemAction(showMenu, selectChatItem)
@@ -427,7 +471,7 @@ fun ChatItemView(
} else {
ExpandItemAction(revealed, showMenu, reveal)
}
DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages)
DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessageAsync, deleteMessages)
if (cItem.canBeDeletedForSelf) {
Divider()
SelectItemAction(showMenu, selectChatItem)
@@ -436,7 +480,7 @@ fun ChatItemView(
}
else -> {
DefaultDropdownMenu(showMenu) {
DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages)
DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessageAsync, deleteMessages)
if (selectedChatItems.value == null) {
Divider()
SelectItemAction(showMenu, selectChatItem)
@@ -453,7 +497,7 @@ fun ChatItemView(
RevealItemAction(revealed, showMenu, reveal)
}
ItemInfoAction(cInfo, cItem, showItemDetails, showMenu)
DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages)
DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessageAsync, deleteMessages)
if (cItem.canBeDeletedForSelf) {
Divider()
SelectItemAction(showMenu, selectChatItem)
@@ -487,7 +531,7 @@ fun ChatItemView(
DeletedItemView(cItem, cInfo.timedMessagesTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp)
DefaultDropdownMenu(showMenu) {
ItemInfoAction(cInfo, cItem, showItemDetails, showMenu)
DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages)
DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessageAsync, deleteMessages)
if (cItem.canBeDeletedForSelf) {
Divider()
SelectItemAction(showMenu, selectChatItem)
@@ -544,7 +588,7 @@ fun ChatItemView(
MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, revealed, showViaProxy = showViaProxy, showTimestamp = showTimestamp)
DefaultDropdownMenu(showMenu) {
ItemInfoAction(cInfo, cItem, showItemDetails, showMenu)
DeleteItemAction(cItem, revealed, showMenu, questionText = generalGetString(MR.strings.delete_message_cannot_be_undone_warning), deleteMessage, deleteMessages)
DeleteItemAction(cItem, revealed, showMenu, questionText = generalGetString(MR.strings.delete_message_cannot_be_undone_warning), deleteMessageAsync, deleteMessages)
if (cItem.canBeDeletedForSelf) {
Divider()
SelectItemAction(showMenu, selectChatItem)
@@ -778,7 +822,7 @@ fun ModerateItemAction(
painterResource(MR.images.ic_flag),
onClick = {
showMenu.value = false
moderateMessageAlertDialog(cItem, questionText, deleteMessage = deleteMessage)
moderateMessageAlertDialog(cItem.id, questionText, deleteMessage = deleteMessage)
},
color = Color.Red
)
@@ -847,6 +891,183 @@ private fun ShrinkItemAction(revealed: State<Boolean>, showMenu: MutableState<Bo
)
}
@Composable
private fun ReportItemAction(
cItem: ChatItem,
composeState: MutableState<ComposeState>,
showMenu: MutableState<Boolean>,
) {
ItemAction(
stringResource(MR.strings.report_verb),
painterResource(MR.images.ic_flag),
onClick = {
AlertManager.shared.showAlertDialogButtons(
title = generalGetString(MR.strings.report_reason_alert_title),
buttons = {
ReportReason.supportedReasons.forEach { reason ->
SectionItemView({
if (composeState.value.editing) {
composeState.value = ComposeState(
contextItem = ComposeContextItem.ReportedItem(cItem, reason),
useLinkPreviews = false,
preview = ComposePreview.NoPreview,
)
} else {
composeState.value = composeState.value.copy(
contextItem = ComposeContextItem.ReportedItem(cItem, reason),
useLinkPreviews = false,
preview = ComposePreview.NoPreview,
)
}
AlertManager.shared.hideAlert()
}) {
Text(reason.text, Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error)
}
}
SectionItemView({
AlertManager.shared.hideAlert()
}) {
Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
}
)
showMenu.value = false
},
color = Color.Red
)
}
@Composable
private fun ModerateReportItemAction(
rhId: Long?,
chatInfo: ChatInfo,
cItem: ChatItem,
reportedItem: CIQuote,
showMenu: MutableState<Boolean>,
deleteMessage: suspend (Long, CIDeleteMode) -> ChatItemDeletion?
) {
ItemAction(
stringResource(MR.strings.moderate_verb),
painterResource(MR.images.ic_flag),
onClick = {
withBGApi {
val reportedMessageId = getLocalIdForReportedMessage(rhId, chatInfo, reportedItem, cItem.id)
if (reportedMessageId != null) {
moderateMessageAlertDialog(
reportedMessageId,
questionText = moderateMessageQuestionText(chatInfo.featureEnabled(ChatFeature.FullDelete), 1),
deleteMessage = { id, m ->
withApi {
val deleted = deleteMessage(id, m)
if (deleted != null) {
deleteMessage(cItem.id, CIDeleteMode.cidmInternalMark)
}
}
},
)
}
}
showMenu.value = false
},
color = Color.Red
)
}
@Composable
private fun BlockMemberAction(
rhId: Long?,
chatInfo: ChatInfo,
groupInfo: GroupInfo,
cItem: ChatItem,
reportedItem: CIQuote,
member: GroupMember,
showMenu: MutableState<Boolean>,
deleteMessage: suspend (Long, CIDeleteMode) -> ChatItemDeletion?
) {
ItemAction(
stringResource(MR.strings.block_member_button),
painterResource(MR.images.ic_back_hand),
onClick = {
AlertManager.shared.showAlertDialogButtonsColumn(
title = generalGetString(MR.strings.report_block_and_moderate_title),
buttons = {
SectionItemView({
AlertManager.shared.hideAlert()
withBGApi {
val reportedMessageId = getLocalIdForReportedMessage(rhId, chatInfo, reportedItem, cItem.id)
if (reportedMessageId != null) {
blockAndModerateAlertDialog(
rhId,
reportedMessageId = reportedMessageId,
reportId = cItem.id,
gInfo = groupInfo,
mem = member,
deleteMessage = deleteMessage,
)
}
}
}) {
Text(generalGetString(MR.strings.report_block_and_moderate_block_and_moderate_action), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error)
}
SectionItemView({
AlertManager.shared.hideAlert()
withBGApi {
val reportedMessageId = getLocalIdForReportedMessage(rhId, chatInfo, reportedItem, cItem.id)
if (reportedMessageId != null) {
blockForAllAlert(rhId, gInfo = groupInfo, mem = member, blockMember = {
withBGApi {
try {
blockMemberForAll(
rhId,
gInfo = groupInfo,
member = member,
blocked = true
)
deleteMessage(reportedMessageId, CIDeleteMode.cidmInternalMark)
} catch (ex: Exception) {
Log.e(TAG, "BlockMemberAction block and moderate ${ex.message}")
}
}
})
}
}
}) {
Text(generalGetString(MR.strings.report_block_and_moderate_only_block_action), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error)
}
SectionItemView({
AlertManager.shared.hideAlert()
}) {
Text(generalGetString(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
}
)
showMenu.value = false
},
color = Color.Red
)
}
@Composable
private fun ArchiveReportItemAction(cItem: ChatItem, showMenu: MutableState<Boolean>, deleteMessage: (Long, CIDeleteMode) -> Unit) {
ItemAction(
stringResource(MR.strings.archive_verb),
painterResource(MR.images.ic_inventory_2),
onClick = {
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.report_archive_alert_title),
text = generalGetString(MR.strings.report_archive_alert_desc),
onConfirm = {
deleteMessage(cItem.id, CIDeleteMode.cidmInternalMark)
},
destructive = true,
confirmText = generalGetString(MR.strings.archive_verb),
)
showMenu.value = false
},
color = Color.Red
)
}
@Composable
fun ItemAction(text: String, icon: Painter, color: Color = Color.Unspecified, onClick: () -> Unit) {
val finalColor = if (color == Color.Unspecified) {
@@ -1133,7 +1354,7 @@ fun deleteMessageAlertDialog(chatItem: ChatItem, questionText: String, deleteMes
deleteMessage(chatItem.id, CIDeleteMode.cidmInternal)
AlertManager.shared.hideAlert()
}) { Text(stringResource(MR.strings.for_me_only), color = MaterialTheme.colors.error) }
if (chatItem.meta.deletable && !chatItem.localNote) {
if (chatItem.meta.deletable && !chatItem.localNote && !chatItem.isReport) {
Spacer(Modifier.padding(horizontal = 4.dp))
TextButton(onClick = {
deleteMessage(chatItem.id, CIDeleteMode.cidmBroadcast)
@@ -1180,14 +1401,14 @@ fun moderateMessageQuestionText(fullDeleteAllowed: Boolean, count: Int): String
}
}
fun moderateMessageAlertDialog(chatItem: ChatItem, questionText: String, deleteMessage: (Long, CIDeleteMode) -> Unit) {
fun moderateMessageAlertDialog(chatItemId: Long, questionText: String, deleteMessage: (Long, CIDeleteMode) -> Unit) {
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.delete_member_message__question),
text = questionText,
confirmText = generalGetString(MR.strings.delete_verb),
destructive = true,
onConfirm = {
deleteMessage(chatItem.id, CIDeleteMode.cidmBroadcast)
deleteMessage(chatItemId, CIDeleteMode.cidmBroadcast)
}
)
}
@@ -1202,8 +1423,59 @@ fun moderateMessagesAlertDialog(itemIds: List<Long>, questionText: String, delet
)
}
private fun blockAndModerateAlertDialog(
rhId: Long?,
reportedMessageId: Long,
reportId: Long,
gInfo: GroupInfo,
mem: GroupMember,
deleteMessage: suspend (Long, CIDeleteMode) -> ChatItemDeletion?
) {
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.report_block_and_moderate_confirmation_title),
text = generalGetString(
if (gInfo.fullGroupPreferences.fullDelete.on) MR.strings.report_block_and_moderate_confirmation_desc_full_delete else MR.strings.report_block_and_moderate_confirmation_desc_full_delete).format(mem.chatViewName),
confirmText = generalGetString(MR.strings.report_block_and_moderate_confirmation_ok),
onConfirm = {
withBGApi {
try {
val deleted = deleteMessage(reportedMessageId, CIDeleteMode.cidmBroadcast)
if (deleted != null) {
blockMemberForAll(rhId, gInfo, mem, true)
deleteMessage(reportId, CIDeleteMode.cidmInternalMark)
}
} catch (ex: Exception) {
Log.e(TAG, "blockAndModerateAlertDialog block and moderate ${ex.message}")
}
}
},
destructive = true,
)
}
expect fun copyItemToClipboard(cItem: ChatItem, clipboard: ClipboardManager)
private suspend fun getLocalIdForReportedMessage(
rhId: Long?,
chatInfo: ChatInfo,
reportedItem: CIQuote,
itemId: Long): Long? {
if (reportedItem.itemId != null) {
return reportedItem.itemId
}
val item = apiLoadSingleMessage(rhId, chatInfo.chatType, chatInfo.apiId, itemId)
if (item?.quotedItem?.itemId != null) {
withChats {
updateChatItem(chatInfo, item)
}
return item.quotedItem.itemId
} else {
showQuotedItemDoesNotExistAlert()
return null
}
}
@Preview
@Composable
fun PreviewChatItemView(
@@ -1221,7 +1493,7 @@ fun PreviewChatItemView(
range = remember { mutableStateOf(0..1) },
selectedChatItems = remember { mutableStateOf(setOf()) },
selectChatItem = {},
deleteMessage = { _, _ -> },
deleteMessage = { _, _ -> null },
deleteMessages = { _ -> },
receiveFile = { _ -> },
cancelFile = {},
@@ -1267,7 +1539,7 @@ fun PreviewChatItemViewDeletedContent() {
range = remember { mutableStateOf(0..1) },
selectedChatItems = remember { mutableStateOf(setOf()) },
selectChatItem = {},
deleteMessage = { _, _ -> },
deleteMessage = { _, _ -> null },
deleteMessages = { _ -> },
receiveFile = { _ -> },
cancelFile = {},
@@ -88,7 +88,7 @@ fun FramedItemView(
}
@Composable
fun FramedItemHeader(caption: String, italic: Boolean, icon: Painter? = null, pad: Boolean = false) {
fun FramedItemHeader(caption: String, italic: Boolean, icon: Painter? = null, pad: Boolean = false, iconColor: Color? = null) {
val sentColor = MaterialTheme.appColors.sentQuote
val receivedColor = MaterialTheme.appColors.receivedQuote
Row(
@@ -104,7 +104,7 @@ fun FramedItemView(
icon,
caption,
Modifier.size(18.dp),
tint = if (isInDarkTheme()) FileDark else FileLight
tint = iconColor ?: if (isInDarkTheme()) FileDark else FileLight
)
}
Text(
@@ -216,7 +216,18 @@ fun FramedItemView(
.padding(start = if (tailRendered) msgTailWidthDp else 0.dp, end = if (sent && tailRendered) msgTailWidthDp else 0.dp)
) {
PriorityLayout(Modifier, CHAT_IMAGE_LAYOUT_ID) {
if (ci.meta.itemDeleted != null) {
if (ci.isReport) {
if (ci.meta.itemDeleted == null) {
FramedItemHeader(
stringResource(if (ci.chatDir.sent) MR.strings.report_item_visibility_submitter else MR.strings.report_item_visibility_moderators),
true,
painterResource(MR.images.ic_flag),
iconColor = Color.Red
)
} else {
FramedItemHeader(stringResource(MR.strings.report_item_archived), true, painterResource(MR.images.ic_flag))
}
} else if (ci.meta.itemDeleted != null) {
when (ci.meta.itemDeleted) {
is CIDeleted.Moderated -> {
FramedItemHeader(String.format(stringResource(MR.strings.moderated_item_description), ci.meta.itemDeleted.byGroupMember.chatViewName), true, painterResource(MR.images.ic_flag))
@@ -288,6 +299,14 @@ fun FramedItemView(
CIMarkdownText(ci, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp)
}
}
is MsgContent.MCReport -> {
val prefix = buildAnnotatedString {
withStyle(SpanStyle(color = Color.Red, fontStyle = FontStyle.Italic)) {
append(if (mc.text.isEmpty()) mc.reason.text else "${mc.reason.text}: ")
}
}
CIMarkdownText(ci, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp, prefix = prefix)
}
else -> CIMarkdownText(ci, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp)
}
}
@@ -315,13 +334,14 @@ fun CIMarkdownText(
onLinkLongClick: (link: String) -> Unit = {},
showViaProxy: Boolean,
showTimestamp: Boolean,
prefix: AnnotatedString? = null
) {
Box(Modifier.padding(vertical = 7.dp, horizontal = 12.dp)) {
val text = if (ci.meta.isLive) ci.content.msgContent?.text ?: ci.text else ci.text
MarkdownText(
text, if (text.isEmpty()) emptyList() else ci.formattedText, toggleSecrets = true,
meta = ci.meta, chatTTL = chatTTL, linkMode = linkMode,
uriHandler = uriHandler, senderBold = true, onLinkLongClick = onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp
uriHandler = uriHandler, senderBold = true, onLinkLongClick = onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp, prefix = prefix
)
}
}
@@ -67,7 +67,7 @@ private fun MergedMarkedDeletedText(chatItem: ChatItem, revealed: State<Boolean>
}
val total = moderated + blocked + blockedByAdmin + deleted
if (total <= 1)
markedDeletedText(chatItem.meta)
markedDeletedText(chatItem)
else if (total == moderated)
stringResource(MR.strings.moderated_items_description).format(total, moderatedBy.joinToString(", "))
else if (total == blockedByAdmin)
@@ -77,7 +77,7 @@ private fun MergedMarkedDeletedText(chatItem: ChatItem, revealed: State<Boolean>
else
stringResource(MR.strings.marked_deleted_items_description).format(total)
} else {
markedDeletedText(chatItem.meta)
markedDeletedText(chatItem)
}
Text(
@@ -91,10 +91,11 @@ private fun MergedMarkedDeletedText(chatItem: ChatItem, revealed: State<Boolean>
)
}
fun markedDeletedText(meta: CIMeta): String =
when (meta.itemDeleted) {
fun markedDeletedText(cItem: ChatItem): String =
if (cItem.meta.itemDeleted != null && cItem.isReport) generalGetString(MR.strings.report_item_archived)
else when (cItem.meta.itemDeleted) {
is CIDeleted.Moderated ->
String.format(generalGetString(MR.strings.moderated_item_description), meta.itemDeleted.byGroupMember.displayName)
String.format(generalGetString(MR.strings.moderated_item_description), cItem.meta.itemDeleted.byGroupMember.displayName)
is CIDeleted.Blocked ->
generalGetString(MR.strings.blocked_item_description)
is CIDeleted.BlockedByAdmin ->
@@ -71,7 +71,8 @@ fun MarkdownText (
inlineContent: Pair<AnnotatedString.Builder.() -> Unit, Map<String, InlineTextContent>>? = null,
onLinkLongClick: (link: String) -> Unit = {},
showViaProxy: Boolean = false,
showTimestamp: Boolean = true
showTimestamp: Boolean = true,
prefix: AnnotatedString? = null
) {
val textLayoutDirection = remember (text) {
if (isRtl(text.subSequence(0, kotlin.math.min(50, text.length)))) LayoutDirection.Rtl else LayoutDirection.Ltr
@@ -123,6 +124,7 @@ fun MarkdownText (
val annotatedText = buildAnnotatedString {
inlineContent?.first?.invoke(this)
appendSender(this, sender, senderBold)
if (prefix != null) append(prefix)
if (text is String) append(text)
else if (text is AnnotatedString) append(text)
if (meta?.isLive == true) {
@@ -136,6 +138,7 @@ fun MarkdownText (
val annotatedText = buildAnnotatedString {
inlineContent?.first?.invoke(this)
appendSender(this, sender, senderBold)
if (prefix != null) append(prefix)
for ((i, ft) in formattedText.withIndex()) {
if (ft.format == null) append(ft.text)
else if (toggleSecrets && ft.format is Format.Secret) {
@@ -21,6 +21,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.input.pointer.pointerHoverIcon
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.*
import androidx.compose.ui.unit.*
import chat.simplex.common.ui.theme.*
@@ -174,13 +175,23 @@ fun ChatPreviewView(
val (text: CharSequence, inlineTextContent) = when {
chatModelDraftChatId == chat.id && chatModelDraft != null -> remember(chatModelDraft) { chatModelDraft.message to messageDraft(chatModelDraft, sp20) }
ci.meta.itemDeleted == null -> ci.text to null
else -> markedDeletedText(ci.meta) to null
else -> markedDeletedText(ci) to null
}
val formattedText = when {
chatModelDraftChatId == chat.id && chatModelDraft != null -> null
ci.meta.itemDeleted == null -> ci.formattedText
else -> null
}
val prefix = when (val mc = ci.content.msgContent) {
is MsgContent.MCReport ->
buildAnnotatedString {
withStyle(SpanStyle(color = Color.Red, fontStyle = FontStyle.Italic)) {
append(if (text.isEmpty()) mc.reason.text else "${mc.reason.text}: ")
}
}
else -> null
}
MarkdownText(
text,
formattedText,
@@ -202,6 +213,7 @@ fun ChatPreviewView(
),
inlineContent = inlineTextContent,
modifier = Modifier.fillMaxWidth(),
prefix = prefix
)
}
} else {
@@ -37,6 +37,9 @@
<string name="marked_deleted_items_description">%d messages marked deleted</string>
<string name="moderated_item_description">moderated by %s</string>
<string name="moderated_items_description">%1$d messages moderated by %2$s</string>
<string name="report_item_visibility_submitter">Only you and moderators see it</string>
<string name="report_item_visibility_moderators">Only sender and moderators see it</string>
<string name="report_item_archived">archived report</string>
<string name="blocked_item_description">blocked</string>
<string name="blocked_by_admin_item_description">blocked by admin</string>
<string name="blocked_items_description">%d messages blocked</string>
@@ -94,6 +97,13 @@
<string name="simplex_link_mode_browser">Via browser</string>
<string name="simplex_link_mode_browser_warning">Opening the link in the browser may reduce connection privacy and security. Untrusted SimpleX links will be red.</string>
<!-- Reports - ChatModel.kt -->
<string name="report_reason_spam">Spam</string>
<string name="report_reason_illegal">Inappropriate content</string>
<string name="report_reason_community">Community guidelines violation</string>
<string name="report_reason_profile">Inappropriate profile</string>
<string name="report_reason_other">Another reason</string>
<!-- SimpleXAPI.kt -->
<string name="error_saving_smp_servers">Error saving SMP servers</string>
<string name="error_saving_xftp_servers">Error saving XFTP servers</string>
@@ -292,6 +302,16 @@
<string name="message_delivery_error_desc">Most likely this contact has deleted the connection with you.</string>
<string name="message_deleted_or_not_received_error_title">No message</string>
<string name="message_deleted_or_not_received_error_desc">This message was deleted or not received yet.</string>
<string name="report_reason_alert_title">Report reason?</string>
<string name="report_archive_alert_title">Archive report?</string>
<string name="report_archive_alert_desc">The report will be archived for you.</string>
<string name="report_block_and_moderate_title">Block and moderate?</string>
<string name="report_block_and_moderate_block_and_moderate_action">Block and moderate</string>
<string name="report_block_and_moderate_only_block_action">Only block</string>
<string name="report_block_and_moderate_confirmation_title">Delete member message and block?</string>
<string name="report_block_and_moderate_confirmation_desc_full_delete">The message will be deleted for all members.\nAll new messages from %1$s will be hidden!</string>
<string name="report_block_and_moderate_confirmation_desc_mark_delete">The message will be marked as moderated for all members.\nAll new messages from %1$s will be hidden!</string>
<string name="report_block_and_moderate_confirmation_ok">Delete and block</string>
<!-- CIStatus errors -->
<string name="ci_status_other_error">Error: %1$s</string>
@@ -317,6 +337,7 @@
<string name="edit_verb">Edit</string>
<string name="info_menu">Info</string>
<string name="search_verb">Search</string>
<string name="archive_verb">Archive</string>
<string name="sent_message">Sent message</string>
<string name="received_message">Received message</string>
<string name="edit_history">History</string>
@@ -334,6 +355,7 @@
<string name="hide_verb">Hide</string>
<string name="allow_verb">Allow</string>
<string name="moderate_verb">Moderate</string>
<string name="report_verb">Report</string>
<string name="select_verb">Select</string>
<string name="expand_verb">Expand</string>
<string name="delete_message__question">Delete message?</string>
@@ -448,6 +470,11 @@
<string name="maximum_message_size_reached_text">Please reduce the message size and send again.</string>
<string name="maximum_message_size_reached_non_text">Please reduce the message size or remove media and send again.</string>
<string name="maximum_message_size_reached_forwarding">You can copy and reduce the message size to send it.</string>
<string name="report_compose_reason_header_spam">Report spam: only group moderators will see it.</string>
<string name="report_compose_reason_header_profile">Report member profile: only group moderators will see it.</string>
<string name="report_compose_reason_header_community">Report violation: only group moderators will see it.</string>
<string name="report_compose_reason_header_illegal">Report content: only group moderators will see it.</string>
<string name="report_compose_reason_header_other">Report other: only group moderators will see it.</string>
<!-- Images - chat.simplex.app.views.chat.item.CIImageView.kt -->
<string name="image_descr">Image</string>
@@ -1529,6 +1556,7 @@
<string name="group_member_role_observer">observer</string>
<string name="group_member_role_author">author</string>
<string name="group_member_role_member">member</string>
<string name="group_member_role_moderator">moderator</string>
<string name="group_member_role_admin">admin</string>
<string name="group_member_role_owner">owner</string>