From c0105d135c3fbd87da514bb229bd6a91e2393998 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Tue, 7 Mar 2023 00:58:44 +0300 Subject: [PATCH] android: UI to moderate messages to other members (#1982) * android: UI to moderate messages to other members * do not show moderate button on moderated, show alert * changed item * limiting number of lines in header * limit text height --- .../java/chat/simplex/app/model/ChatModel.kt | 15 +++++ .../java/chat/simplex/app/model/SimpleXAPI.kt | 10 +++ .../chat/simplex/app/views/chat/ChatView.kt | 41 ++++++++---- .../simplex/app/views/chat/item/CIMetaView.kt | 3 +- .../app/views/chat/item/ChatItemView.kt | 64 ++++++++++++++++--- .../app/views/chat/item/FramedItemView.kt | 15 +++-- .../views/chat/item/MarkedDeletedItemView.kt | 28 +++++--- .../app/src/main/res/values/strings.xml | 5 ++ 8 files changed, 144 insertions(+), 37 deletions(-) diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt index 990bd1a0de..076f2c7c9b 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt @@ -1218,9 +1218,24 @@ data class ChatItem ( when (content) { is CIContent.SndDeleted -> true is CIContent.RcvDeleted -> true + is CIContent.SndModerated -> true + is CIContent.RcvModerated -> true else -> false } + fun memberToModerate(chatInfo: ChatInfo): Pair? { + return if (chatInfo is ChatInfo.Group && chatDir is CIDirection.GroupRcv) { + val m = chatInfo.groupInfo.membership + if (m.memberRole >= GroupMemberRole.Admin && m.memberRole >= chatDir.groupMember.memberRole && meta.itemDeleted == null) { + chatInfo.groupInfo to chatDir.groupMember + } else { + null + } + } else { + null + } + } + private val showNtfDir: Boolean get() = !chatDir.sent val showNotification: Boolean get() = diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt index c1571262f0..4f962f4653 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt @@ -517,6 +517,13 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a return null } + suspend fun apiDeleteMemberChatItem(groupId: Long, groupMemberId: Long, itemId: Long): Pair? { + val r = sendCmd(CC.ApiDeleteMemberChatItem(groupId, groupMemberId, itemId)) + if (r is CR.ChatItemDeleted) return r.deletedChatItem.chatItem to r.toChatItem?.chatItem + Log.e(TAG, "apiDeleteMemberChatItem bad response: ${r.responseType} ${r.details}") + return null + } + private suspend fun getUserSMPServers(): Pair, List>? { val userId = chatModel.currentUser.value?.userId ?: run { Log.e(TAG, "getUserSMPServers: no current user") @@ -1744,6 +1751,7 @@ sealed class CC { class ApiSendMessage(val type: ChatType, val id: Long, val file: String?, val quotedItemId: Long?, val mc: MsgContent, val live: Boolean): CC() class ApiUpdateChatItem(val type: ChatType, val id: Long, val itemId: Long, val mc: MsgContent, val live: Boolean): CC() class ApiDeleteChatItem(val type: ChatType, val id: Long, val itemId: Long, val mode: CIDeleteMode): CC() + class ApiDeleteMemberChatItem(val groupId: Long, val groupMemberId: Long, val itemId: Long): CC() class ApiNewGroup(val userId: Long, val groupProfile: GroupProfile): CC() class ApiAddMember(val groupId: Long, val contactId: Long, val memberRole: GroupMemberRole): CC() class ApiJoinGroup(val groupId: Long): CC() @@ -1819,6 +1827,7 @@ sealed class CC { is ApiSendMessage -> "/_send ${chatRef(type, id)} live=${onOff(live)} json ${json.encodeToString(ComposedMessage(file, quotedItemId, mc))}" is ApiUpdateChatItem -> "/_update item ${chatRef(type, id)} $itemId live=${onOff(live)} ${mc.cmdString}" is ApiDeleteChatItem -> "/_delete item ${chatRef(type, id)} $itemId ${mode.deleteMode}" + is ApiDeleteMemberChatItem -> "/_delete member item #$groupId $groupMemberId $itemId" is ApiNewGroup -> "/_group $userId ${json.encodeToString(groupProfile)}" is ApiAddMember -> "/_add #$groupId $contactId ${memberRole.memberRole}" is ApiJoinGroup -> "/_join #$groupId" @@ -1895,6 +1904,7 @@ sealed class CC { is ApiSendMessage -> "apiSendMessage" is ApiUpdateChatItem -> "apiUpdateChatItem" is ApiDeleteChatItem -> "apiDeleteChatItem" + is ApiDeleteMemberChatItem -> "apiDeleteMemberChatItem" is ApiNewGroup -> "apiNewGroup" is ApiAddMember -> "apiAddMember" is ApiJoinGroup -> "apiJoinGroup" diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt index c9cb6d6f4f..9b266c6db8 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt @@ -193,19 +193,34 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) { deleteMessage = { itemId, mode -> withApi { val cInfo = chat.chatInfo - val r = chatModel.controller.apiDeleteChatItem( - type = cInfo.chatType, - id = cInfo.apiId, - itemId = itemId, - mode = mode - ) - if (r != null) { - val toChatItem = r.toChatItem - if (toChatItem == null) { - chatModel.removeChatItem(cInfo, r.deletedChatItem.chatItem) - } else { - chatModel.upsertChatItem(cInfo, toChatItem.chatItem) - } + val toDeleteItem = chatModel.chatItems.firstOrNull { it.id == itemId } + val toModerate = toDeleteItem?.memberToModerate(chat.chatInfo) + val groupInfo = toModerate?.first + val groupMember = toModerate?.second + val deletedChatItem: ChatItem? + val toChatItem: ChatItem? + if (groupInfo != null && groupMember != null) { + val r = chatModel.controller.apiDeleteMemberChatItem( + groupId = groupInfo.groupId, + groupMemberId = groupMember.groupMemberId, + itemId = itemId + ) + deletedChatItem = r?.first + toChatItem = r?.second + } else { + val r = chatModel.controller.apiDeleteChatItem( + type = cInfo.chatType, + id = cInfo.apiId, + itemId = itemId, + mode = mode + ) + deletedChatItem = r?.deletedChatItem?.chatItem + toChatItem = r?.toChatItem?.chatItem + } + if (toChatItem == null && deletedChatItem != null) { + chatModel.removeChatItem(cInfo, deletedChatItem) + } else if (toChatItem != null) { + chatModel.upsertChatItem(cInfo, toChatItem) } } }, diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIMetaView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIMetaView.kt index c8ff1c6aa7..67b4f54a4d 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIMetaView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIMetaView.kt @@ -11,6 +11,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -58,7 +59,7 @@ private fun CIMetaText(meta: CIMeta, chatTTL: Int?, color: Color) { StatusIconText(Icons.Filled.Circle, Color.Transparent) Spacer(Modifier.width(4.dp)) } - Text(meta.timestampText, color = color, fontSize = 13.sp) + Text(meta.timestampText, color = color, fontSize = 13.sp, maxLines = 1, overflow = TextOverflow.Ellipsis) } // the conditions in this function should match CIMetaText diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt index f1b2c58ca0..dee77e4a4d 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt @@ -91,6 +91,14 @@ fun ChatItemView( } } + fun moderateMessageQuestionText(): String { + return if (fullDeleteAllowed) { + generalGetString(R.string.moderate_message_will_be_deleted_warning) + } else { + generalGetString(R.string.moderate_message_will_be_marked_warning) + } + } + @Composable fun MsgContentItemDropdownMenu() { DropdownMenu( @@ -153,6 +161,10 @@ fun ChatItemView( if (!(live && cItem.meta.isLive)) { DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage) } + val groupInfo = cItem.memberToModerate(cInfo)?.first + if (groupInfo != null) { + ModerateItemAction(cItem, questionText = moderateMessageQuestionText(), showMenu, deleteMessage) + } } } @@ -163,14 +175,16 @@ fun ChatItemView( onDismissRequest = { showMenu.value = false }, Modifier.width(220.dp) ) { - ItemAction( - stringResource(R.string.reveal_verb), - Icons.Outlined.Visibility, - onClick = { - revealed.value = true - showMenu.value = false - } - ) + if (!cItem.isDeletedContent) { + ItemAction( + stringResource(R.string.reveal_verb), + Icons.Outlined.Visibility, + onClick = { + revealed.value = true + showMenu.value = false + } + ) + } DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage) } } @@ -238,8 +252,8 @@ fun ChatItemView( is CIContent.SndGroupFeature -> CIChatFeatureView(cItem, c.groupFeature, c.preference.enable.iconColor) is CIContent.RcvChatFeatureRejected -> CIChatFeatureView(cItem, c.feature, Color.Red) is CIContent.RcvGroupFeatureRejected -> CIChatFeatureView(cItem, c.groupFeature, Color.Red) - is CIContent.SndModerated -> DeletedItem() - is CIContent.RcvModerated -> DeletedItem() + is CIContent.SndModerated -> MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, showMember = showMember) + is CIContent.RcvModerated -> MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, showMember = showMember) is CIContent.InvalidJSON -> CIInvalidJSONView(c.json) } } @@ -264,6 +278,24 @@ fun DeleteItemAction( ) } +@Composable +fun ModerateItemAction( + cItem: ChatItem, + questionText: String, + showMenu: MutableState, + deleteMessage: (Long, CIDeleteMode) -> Unit +) { + ItemAction( + stringResource(R.string.moderate_verb), + Icons.Outlined.Flag, + onClick = { + showMenu.value = false + moderateMessageAlertDialog(cItem, questionText, deleteMessage = deleteMessage) + }, + color = Color.Red + ) +} + @Composable fun ItemAction(text: String, icon: ImageVector, onClick: () -> Unit, color: Color = MaterialTheme.colors.onBackground) { DropdownMenuItem(onClick) { @@ -308,6 +340,18 @@ fun deleteMessageAlertDialog(chatItem: ChatItem, questionText: String, deleteMes ) } +fun moderateMessageAlertDialog(chatItem: ChatItem, questionText: String, deleteMessage: (Long, CIDeleteMode) -> Unit) { + AlertManager.shared.showAlertDialog( + title = generalGetString(R.string.delete_member_message__question), + text = questionText, + confirmText = generalGetString(R.string.delete_verb), + destructive = true, + onConfirm = { + deleteMessage(chatItem.id, CIDeleteMode.cidmBroadcast) + } + ) +} + private fun showMsgDeliveryErrorAlert(description: String) { AlertManager.shared.showAlertMsg( title = generalGetString(R.string.message_delivery_error_title), diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt index 8b0ac1da3f..9400a1267e 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt @@ -7,6 +7,7 @@ import androidx.compose.material.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.Flag import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -20,6 +21,7 @@ import androidx.compose.ui.platform.UriHandler import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.* import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.* import androidx.compose.ui.unit.* import androidx.compose.ui.util.fastMap @@ -75,10 +77,7 @@ fun FramedItemView( Modifier .background(if (sent) SentQuoteColorLight else ReceivedQuoteColorLight) .fillMaxWidth() - .padding(start = 8.dp) - .padding(end = 12.dp) - .padding(top = 6.dp) - .padding(bottom = if (ci.quotedItem == null) 6.dp else 0.dp), + .padding(start = 8.dp, top = 6.dp, end = 12.dp, bottom = if (ci.quotedItem == null) 6.dp else 0.dp), horizontalArrangement = Arrangement.spacedBy(4.dp), ) { if (icon != null) { @@ -96,6 +95,8 @@ fun FramedItemView( } }, style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp), + maxLines = 1, + overflow = TextOverflow.Ellipsis ) } } @@ -166,7 +167,11 @@ fun FramedItemView( Column(Modifier.width(IntrinsicSize.Max)) { PriorityLayout(Modifier, CHAT_IMAGE_LAYOUT_ID) { if (ci.meta.itemDeleted != null) { - FramedItemHeader(stringResource(R.string.marked_deleted_description), true, Icons.Outlined.Delete) + if (ci.meta.itemDeleted is CIDeleted.Moderated) { + FramedItemHeader(String.format(stringResource(R.string.moderated_item_description), ci.meta.itemDeleted.byGroupMember.chatViewName), true, Icons.Outlined.Flag) + } else { + FramedItemHeader(stringResource(R.string.marked_deleted_description), true, Icons.Outlined.Delete) + } } else if (ci.meta.isLive) { FramedItemHeader(stringResource(R.string.live), false) } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/MarkedDeletedItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/MarkedDeletedItemView.kt index 91aa44e77e..31a570b3ae 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/MarkedDeletedItemView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/MarkedDeletedItemView.kt @@ -10,6 +10,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.* import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -30,19 +31,30 @@ fun MarkedDeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, showMember: Bool Modifier.padding(horizontal = 12.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically ) { - Text( - buildAnnotatedString { - // appendSender(this, if (showMember) ci.memberDisplayName else null, true) // TODO font size - withStyle(SpanStyle(fontSize = 12.sp, fontStyle = FontStyle.Italic, color = HighOrLowlight)) { append(generalGetString(R.string.marked_deleted_description)) } - }, - style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp), - modifier = Modifier.padding(end = 8.dp) - ) + if (ci.meta.itemDeleted is CIDeleted.Moderated) { + MarkedDeletedText(String.format(generalGetString(R.string.moderated_item_description), ci.meta.itemDeleted.byGroupMember.chatViewName)) + } else { + MarkedDeletedText(generalGetString(R.string.marked_deleted_description)) + } CIMetaView(ci, timedMessagesTTL) } } } +@Composable +private fun MarkedDeletedText(text: String) { + Text( + buildAnnotatedString { + // appendSender(this, if (showMember) ci.memberDisplayName else null, true) // TODO font size + withStyle(SpanStyle(fontSize = 12.sp, fontStyle = FontStyle.Italic, color = HighOrLowlight)) { append(text) } + }, + style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp), + modifier = Modifier.padding(end = 8.dp), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) +} + @Preview(showBackground = true) @Preview( uiMode = Configuration.UI_MODE_NIGHT_YES, diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml index 4028dc75a4..239911b734 100644 --- a/apps/android/app/src/main/res/values/strings.xml +++ b/apps/android/app/src/main/res/values/strings.xml @@ -22,6 +22,7 @@ deleted marked deleted + moderated by %s sending files is not supported yet receiving files is not supported yet you @@ -182,9 +183,13 @@ Reveal Hide Allow + Moderate Delete message? Message will be deleted - this cannot be undone! Message will be marked for deletion. The recipient(s) will be able to reveal this message. + Delete member message? + The message will be deleted for all members. + The message will be marked as moderated for all members. Delete for me For everyone