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 fd0ebfc580..3eb4ddedfa 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 @@ -134,6 +134,26 @@ class ChatModel(val controller: ChatController) { } } + fun removeChatItem(cInfo: ChatInfo, cItem: ChatItem) { + // update previews + val i = getChatIndex(cInfo.id) + val chat: Chat + if (i >= 0) { + chat = chats[i] + val pItem = chat.chatItems.last() + if (pItem.id == cItem.id) { + chats[i] = chat.copy(chatItems = arrayListOf(cItem)) + } + } + // remove from current chat + if (chatId.value == cInfo.id) { + val itemIndex = chatItems.indexOfFirst { it.id == cItem.id } + if (itemIndex >= 0) { + chatItems.removeAt(itemIndex) + } + } + } + fun markChatItemsRead(cInfo: ChatInfo) { val chatIdx = getChatIndex(cInfo.id) // update current chat @@ -490,6 +510,20 @@ data class ChatItem ( if (chatDir is CIDirection.GroupRcv) chatDir.groupMember.memberProfile.displayName else null + val isMsgContent: Boolean get() = + when (content) { + is CIContent.SndMsgContent -> true + is CIContent.RcvMsgContent -> true + else -> false + } + + val isDeletedContent: Boolean get() = + when (content) { + is CIContent.SndDeleted -> true + is CIContent.RcvDeleted -> true + else -> false + } + companion object { fun getSampleData( id: Long = 1, @@ -508,6 +542,20 @@ data class ChatItem ( content = CIContent.SndMsgContent(msgContent = MsgContent.MCText(text)), quotedItem = quotedItem ) + + fun getDeletedContentSampleData( + id: Long = 1, + dir: CIDirection = CIDirection.DirectRcv(), + ts: Instant = Clock.System.now(), + text: String = "this item is deleted", + status: CIStatus = CIStatus.RcvRead() + ) = + ChatItem( + chatDir = dir, + meta = CIMeta.getSample(id, ts, text, status, false, false, false), + content = CIContent.RcvDeleted(deleteMode = CIDeleteMode.cidmBroadcast), + quotedItem = null + ) } } @@ -598,6 +646,12 @@ sealed class CIStatus { class RcvRead: CIStatus() } +@Serializable +enum class CIDeleteMode(val deleteMode: String) { + @SerialName("internal") cidmInternal("internal"), + @SerialName("broadcast") cidmBroadcast("broadcast"); +} + interface ItemContent { val text: String } @@ -616,6 +670,16 @@ sealed class CIContent: ItemContent { override val text get() = msgContent.text } + @Serializable @SerialName("sndDeleted") + class SndDeleted(val deleteMode: CIDeleteMode): CIContent() { + override val text get() = "deleted" + } + + @Serializable @SerialName("rcvDeleted") + class RcvDeleted(val deleteMode: CIDeleteMode): CIContent() { + override val text get() = "deleted" + } + @Serializable @SerialName("sndFileInvitation") class SndFileInvitation(val fileId: Long, val filePath: String): CIContent() { override val text get() = "sending files is not supported yet" 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 e6ee092ff3..2f80a0f5e9 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 @@ -147,17 +147,17 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt return null } - suspend fun apiUpdateMessage(type: ChatType, id: Long, itemId: Long, mc: MsgContent): AChatItem? { - val r = sendCmd(CC.ApiUpdateMessage(type, id, itemId, mc)) + suspend fun apiUpdateChatItem(type: ChatType, id: Long, itemId: Long, mc: MsgContent): AChatItem? { + val r = sendCmd(CC.ApiUpdateChatItem(type, id, itemId, mc)) if (r is CR.ChatItemUpdated) return r.chatItem - Log.e(TAG, "apiUpdateMessage bad response: ${r.responseType} ${r.details}") + Log.e(TAG, "apiUpdateChatItem bad response: ${r.responseType} ${r.details}") return null } - suspend fun apiDeleteMessage(type: ChatType, id: Long, itemId: Long, mode: MsgDeleteMode): AChatItem? { - val r = sendCmd(CC.ApiDeleteMessage(type, id, itemId, mode)) - if (r is CR.ChatItemDeleted) return r.chatItem - Log.e(TAG, "apiDeleteMessage bad response: ${r.responseType} ${r.details}") + suspend fun apiDeleteChatItem(type: ChatType, id: Long, itemId: Long, mode: CIDeleteMode): AChatItem? { + val r = sendCmd(CC.ApiDeleteChatItem(type, id, itemId, mode)) + if (r is CR.ChatItemDeleted) return r.toChatItem + Log.e(TAG, "apiDeleteChatItem bad response: ${r.responseType} ${r.details}") return null } @@ -176,7 +176,7 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt Log.e(TAG, "setUserSMPServers bad response: ${r.responseType} ${r.details}") AlertManager.shared.showAlertMsg( "Error saving SMP servers", - "Make sure SMP server addresses are in correct format, line separated and are not duplicated" + "Make sure SMP server addresses are in correct format, line separated and are not duplicated." ) false } @@ -196,7 +196,7 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt r is CR.SentConfirmation || r is CR.SentInvitation -> return true r is CR.ContactAlreadyExists -> { AlertManager.shared.showAlertMsg("Contact already exists", - "You are already connected to ${r.contact.displayName} via this link" + "You are already connected to ${r.contact.displayName} via this link." ) return false } @@ -223,7 +223,7 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt if (e is ChatError.ChatErrorChat && e.errorType is ChatErrorType.ContactGroups) { AlertManager.shared.showAlertMsg( "Can't delete contact!", - "Contact ${e.errorType.contact.displayName} cannot be deleted, it is a member of the group(s) ${e.errorType.groupNames}" + "Contact ${e.errorType.contact.displayName} cannot be deleted, it is a member of the group(s) ${e.errorType.groupNames}." ) } } @@ -336,7 +336,11 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt is CR.ChatItemStatusUpdated -> { val cInfo = r.chatItem.chatInfo val cItem = r.chatItem.chatItem - if (chatModel.upsertChatItem(cInfo, cItem)) { + var res = false + if (!cItem.isDeletedContent) { + res = chatModel.upsertChatItem(cInfo, cItem) + } + if (res) { ntfManager.notifyMessageReceived(cInfo, cItem) } } @@ -348,7 +352,14 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt } } is CR.ChatItemDeleted -> { - // TODO + val cInfo = r.toChatItem.chatInfo + val cItem = r.toChatItem.chatItem + if (cItem.meta.itemDeleted) { + chatModel.removeChatItem(cInfo, cItem) + } else { + // currently only broadcast deletion of rcv message can be received, and only this case should happen + chatModel.upsertChatItem(cInfo, cItem) + } } else -> Log.d(TAG , "unsupported event: ${r.responseType}") @@ -450,11 +461,6 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt } } -enum class MsgDeleteMode(val mode: String) { - Broadcast("broadcast"), - Internal("internal"); -} - // ChatCommand sealed class CC { class Console(val cmd: String): CC() @@ -465,8 +471,8 @@ sealed class CC { class ApiGetChat(val type: ChatType, val id: Long): CC() class ApiSendMessage(val type: ChatType, val id: Long, val mc: MsgContent): CC() class ApiSendMessageQuote(val type: ChatType, val id: Long, val itemId: Long, val mc: MsgContent): CC() - class ApiUpdateMessage(val type: ChatType, val id: Long, val itemId: Long, val mc: MsgContent): CC() - class ApiDeleteMessage(val type: ChatType, val id: Long, val itemId: Long, val mode: MsgDeleteMode): CC() + class ApiUpdateChatItem(val type: ChatType, val id: Long, val itemId: Long, val mc: MsgContent): CC() + class ApiDeleteChatItem(val type: ChatType, val id: Long, val itemId: Long, val mode: CIDeleteMode): CC() class GetUserSMPServers(): CC() class SetUserSMPServers(val smpServers: List): CC() class AddContact: CC() @@ -489,8 +495,8 @@ sealed class CC { is ApiGetChat -> "/_get chat ${chatRef(type, id)} count=100" is ApiSendMessage -> "/_send ${chatRef(type, id)} ${mc.cmdString}" is ApiSendMessageQuote -> "/_send_quote ${chatRef(type, id)} $itemId ${mc.cmdString}" - is ApiUpdateMessage -> "/_update item ${chatRef(type, id)} $itemId ${mc.cmdString}" - is ApiDeleteMessage -> "/_delete item ${chatRef(type, id)} $itemId $mode" + is ApiUpdateChatItem -> "/_update item ${chatRef(type, id)} $itemId ${mc.cmdString}" + is ApiDeleteChatItem -> "/_delete item ${chatRef(type, id)} $itemId ${mode.deleteMode}" is GetUserSMPServers -> "/smp_servers" is SetUserSMPServers -> "/smp_servers ${smpServersStr(smpServers)}" is AddContact -> "/connect" @@ -514,8 +520,8 @@ sealed class CC { is ApiGetChat -> "apiGetChat" is ApiSendMessage -> "apiSendMessage" is ApiSendMessageQuote -> "apiSendMessageQuote" - is ApiUpdateMessage -> "apiUpdateMessage" - is ApiDeleteMessage -> "apiDeleteMessage" + is ApiUpdateChatItem -> "apiUpdateChatItem" + is ApiDeleteChatItem -> "apiDeleteChatItem" is GetUserSMPServers -> "getUserSMPServers" is SetUserSMPServers -> "setUserSMPServers" is AddContact -> "addContact" @@ -601,7 +607,7 @@ sealed class CR { @Serializable @SerialName("newChatItem") class NewChatItem(val chatItem: AChatItem): CR() @Serializable @SerialName("chatItemStatusUpdated") class ChatItemStatusUpdated(val chatItem: AChatItem): CR() @Serializable @SerialName("chatItemUpdated") class ChatItemUpdated(val chatItem: AChatItem): CR() - @Serializable @SerialName("chatItemDeleted") class ChatItemDeleted(val chatItem: AChatItem): CR() + @Serializable @SerialName("chatItemDeleted") class ChatItemDeleted(val deletedChatItem: AChatItem, val toChatItem: AChatItem): CR() @Serializable @SerialName("cmdOk") class CmdOk: CR() @Serializable @SerialName("chatCmdError") class ChatCmdError(val chatError: ChatError): CR() @Serializable @SerialName("chatError") class ChatRespError(val chatError: ChatError): CR() @@ -682,7 +688,7 @@ sealed class CR { is NewChatItem -> json.encodeToString(chatItem) is ChatItemStatusUpdated -> json.encodeToString(chatItem) is ChatItemUpdated -> json.encodeToString(chatItem) - is ChatItemDeleted -> json.encodeToString(chatItem) + is ChatItemDeleted -> "deletedChatItem:\n${json.encodeToString(deletedChatItem)}\ntoChatItem:\n${json.encodeToString(toChatItem)}" is CmdOk -> noDetails() is ChatCmdError -> chatError.string is ChatRespError -> chatError.string 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 d49030aab7..be69980eaf 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 @@ -77,7 +77,7 @@ fun ChatView(chatModel: ChatModel) { val cInfo = chat.chatInfo val ei = editingItem.value if (ei != null) { - val updatedItem = chatModel.controller.apiUpdateMessage( + val updatedItem = chatModel.controller.apiUpdateChatItem( type = cInfo.chatType, id = cInfo.apiId, itemId = ei.meta.itemId, @@ -98,7 +98,19 @@ fun ChatView(chatModel: ChatModel) { quotedItem.value = null } }, - resetMessage = { msg.value = "" } + resetMessage = { msg.value = "" }, + deleteMessage = { itemId, mode -> + withApi { + val cInfo = chat.chatInfo + val toItem = chatModel.controller.apiDeleteChatItem( + type = cInfo.chatType, + id = cInfo.apiId, + itemId = itemId, + mode = mode + ) + if (toItem != null) chatModel.removeChatItem(cInfo, toItem.chatItem) + } + } ) } } @@ -115,7 +127,8 @@ fun ChatLayout( info: () -> Unit, openDirectChat: (Long) -> Unit, sendMessage: (String) -> Unit, - resetMessage: () -> Unit + resetMessage: () -> Unit, + deleteMessage: (Long, CIDeleteMode) -> Unit ) { Surface( Modifier @@ -129,7 +142,7 @@ fun ChatLayout( modifier = Modifier.navigationBarsWithImePadding() ) { contentPadding -> Box(Modifier.padding(contentPadding)) { - ChatItemsList(user, chat, chatItems, msg, quotedItem, editingItem, openDirectChat) + ChatItemsList(user, chat, chatItems, msg, quotedItem, editingItem, openDirectChat, deleteMessage) } } } @@ -201,7 +214,8 @@ fun ChatItemsList( msg: MutableState, quotedItem: MutableState, editingItem: MutableState, - openDirectChat: (Long) -> Unit + openDirectChat: (Long) -> Unit, + deleteMessage: (Long, CIDeleteMode) -> Unit ) { val listState = rememberLazyListState() val keyboardState by getKeyboardState() @@ -224,7 +238,11 @@ fun ChatItemsList( if (contactId == null) { MemberImage(member) } else { - Box(Modifier.clip(CircleShape).clickable { openDirectChat(contactId) }) { + Box( + Modifier + .clip(CircleShape) + .clickable { openDirectChat(contactId) } + ) { MemberImage(member) } } @@ -232,20 +250,22 @@ fun ChatItemsList( } else { Spacer(Modifier.size(42.dp)) } - ChatItemView(user, cItem, msg, quotedItem, editingItem, cxt, uriHandler, showMember = showMember) + ChatItemView(user, cItem, msg, quotedItem, editingItem, cxt, uriHandler, showMember = showMember, deleteMessage = deleteMessage) } } else { Box(Modifier.padding(start = 86.dp, end = 12.dp)) { - ChatItemView(user, cItem, msg, quotedItem, editingItem, cxt, uriHandler) + ChatItemView(user, cItem, msg, quotedItem, editingItem, cxt, uriHandler, deleteMessage = deleteMessage) } } } else { // direct message val sent = cItem.chatDir.sent - Box(Modifier.padding( - start = if (sent) 76.dp else 12.dp, - end = if (sent) 12.dp else 76.dp, - )) { - ChatItemView(user, cItem, msg, quotedItem, editingItem, cxt, uriHandler) + Box( + Modifier.padding( + start = if (sent) 76.dp else 12.dp, + end = if (sent) 12.dp else 76.dp, + ) + ) { + ChatItemView(user, cItem, msg, quotedItem, editingItem, cxt, uriHandler, deleteMessage = deleteMessage) } } } @@ -261,9 +281,7 @@ fun ChatItemsList( fun showMemberImage(member: GroupMember, prevItem: ChatItem?): Boolean { return prevItem == null || prevItem.chatDir is CIDirection.GroupSnd || - ( prevItem.chatDir is CIDirection.GroupRcv && - prevItem.chatDir.groupMember.groupMemberId != member.groupMemberId - ) + (prevItem.chatDir is CIDirection.GroupRcv && prevItem.chatDir.groupMember.groupMemberId != member.groupMemberId) } @Composable @@ -287,14 +305,15 @@ fun PreviewChatLayout() { ChatItem.getSampleData( 2, CIDirection.DirectRcv(), Clock.System.now(), "hello" ), - ChatItem.getSampleData( - 3, CIDirection.DirectSnd(), Clock.System.now(), "hello" - ), + ChatItem.getDeletedContentSampleData(3), ChatItem.getSampleData( 4, CIDirection.DirectSnd(), Clock.System.now(), "hello" ), ChatItem.getSampleData( - 5, CIDirection.DirectRcv(), Clock.System.now(), "hello" + 5, CIDirection.DirectSnd(), Clock.System.now(), "hello" + ), + ChatItem.getSampleData( + 6, CIDirection.DirectRcv(), Clock.System.now(), "hello" ) ) ChatLayout( @@ -312,7 +331,8 @@ fun PreviewChatLayout() { info = {}, openDirectChat = {}, sendMessage = {}, - resetMessage = {} + resetMessage = {}, + deleteMessage = { _, _ -> } ) } } @@ -328,14 +348,15 @@ fun PreviewGroupChatLayout() { ChatItem.getSampleData( 2, CIDirection.GroupRcv(GroupMember.sampleData), Clock.System.now(), "hello" ), + ChatItem.getDeletedContentSampleData(3), ChatItem.getSampleData( - 3, CIDirection.GroupRcv(GroupMember.sampleData), Clock.System.now(), "hello" + 4, CIDirection.GroupRcv(GroupMember.sampleData), Clock.System.now(), "hello" ), ChatItem.getSampleData( - 4, CIDirection.GroupSnd(), Clock.System.now(), "hello" + 5, CIDirection.GroupSnd(), Clock.System.now(), "hello" ), ChatItem.getSampleData( - 5, CIDirection.GroupRcv(GroupMember.sampleData), Clock.System.now(), "hello" + 6, CIDirection.GroupRcv(GroupMember.sampleData), Clock.System.now(), "hello" ) ) ChatLayout( @@ -353,7 +374,8 @@ fun PreviewGroupChatLayout() { info = {}, openDirectChat = {}, sendMessage = {}, - resetMessage = {} + resetMessage = {}, + deleteMessage = { _, _ -> } ) } } 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 0517b0ab10..ec00538bce 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 @@ -22,13 +22,16 @@ fun CIMetaView(chatItem: ChatItem) { horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically ) { - if (chatItem.meta.itemEdited) { - Icon( - Icons.Filled.Edit, - modifier = Modifier.height(12.dp), - contentDescription = "Edited", - tint = HighOrLowlight, - ) + if (!chatItem.isDeletedContent) { + if (chatItem.meta.itemEdited) { + Icon( + Icons.Filled.Edit, + modifier = Modifier.height(12.dp), + contentDescription = "Edited", + tint = HighOrLowlight, + ) + } + // TODO status } Text( chatItem.timestampText, @@ -58,3 +61,11 @@ fun PreviewCIMetaViewEdited() { ) ) } + +@Preview +@Composable +fun PreviewCIMetaViewDeletedContent() { + CIMetaView( + chatItem = ChatItem.getDeletedContentSampleData() + ) +} 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 bf36647140..20db037d42 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 @@ -9,6 +9,7 @@ import androidx.compose.material.icons.outlined.* import androidx.compose.runtime.* 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.platform.LocalContext import androidx.compose.ui.platform.UriHandler @@ -17,8 +18,7 @@ import androidx.compose.ui.unit.dp import chat.simplex.app.model.* import chat.simplex.app.ui.theme.HighOrLowlight import chat.simplex.app.ui.theme.SimpleXTheme -import chat.simplex.app.views.helpers.copyText -import chat.simplex.app.views.helpers.shareText +import chat.simplex.app.views.helpers.* import kotlinx.datetime.Clock @Composable @@ -31,6 +31,7 @@ fun ChatItemView( cxt: Context, uriHandler: UriHandler? = null, showMember: Boolean = false, + deleteMessage: (Long, CIDeleteMode) -> Unit ) { val sent = cItem.chatDir.sent val alignment = if (sent) Alignment.CenterEnd else Alignment.CenterStart @@ -42,52 +43,96 @@ fun ChatItemView( contentAlignment = alignment, ) { Column(Modifier.combinedClickable(onLongClick = { showMenu = true }, onClick = {})) { - if (cItem.quotedItem == null && isShortEmoji(cItem.content.text)) { - EmojiItemView(cItem) - } else { - FramedItemView(user, cItem, uriHandler, showMember = showMember) + if (cItem.isMsgContent) { + if (cItem.quotedItem == null && isShortEmoji(cItem.content.text)) { + EmojiItemView(cItem) + } else { + FramedItemView(user, cItem, uriHandler, showMember = showMember) + } + } else if (cItem.isDeletedContent) { + DeletedItemView(cItem, showMember = showMember) } - DropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) { - ItemAction("Reply", Icons.Outlined.Reply, onClick = { - editingItem.value = null - quotedItem.value = cItem - showMenu = false - }) - ItemAction("Share", Icons.Outlined.Share, onClick = { - shareText(cxt, cItem.content.text) - showMenu = false - }) - ItemAction("Copy", Icons.Outlined.ContentCopy, onClick = { - copyText(cxt, cItem.content.text) - showMenu = false - }) -// if (cItem.chatDir.sent && cItem.meta.editable) { -// ItemAction("Edit", Icons.Filled.Edit, onClick = { -// quotedItem.value = null -// editingItem.value = cItem -// msg.value = cItem.content.text -// showMenu = false -// }) -// } + if (cItem.isMsgContent) { + DropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) { + ItemAction("Reply", Icons.Outlined.Reply, onClick = { + editingItem.value = null + quotedItem.value = cItem + showMenu = false + }) + ItemAction("Share", Icons.Outlined.Share, onClick = { + shareText(cxt, cItem.content.text) + showMenu = false + }) + ItemAction("Copy", Icons.Outlined.ContentCopy, onClick = { + copyText(cxt, cItem.content.text) + showMenu = false + }) + if (cItem.chatDir.sent && cItem.meta.editable) { + ItemAction("Edit", Icons.Filled.Edit, onClick = { + quotedItem.value = null + editingItem.value = cItem + msg.value = cItem.content.text + showMenu = false + }) + } + ItemAction( + "Delete", + Icons.Outlined.Delete, + onClick = { + showMenu = false + deleteMessageAlertDialog(cItem, deleteMessage = deleteMessage) + }, + color = Color.Red + ) + } } } } } @Composable -private fun ItemAction(text: String, icon: ImageVector, onClick: () -> Unit) { +private fun ItemAction(text: String, icon: ImageVector, onClick: () -> Unit, color: Color = HighOrLowlight) { DropdownMenuItem(onClick) { Row { Text( - text, modifier = Modifier + text, + modifier = Modifier .fillMaxWidth() - .weight(1F) + .weight(1F), + color = color ) - Icon(icon, text, tint = HighOrLowlight) + Icon(icon, text, tint = color) } } } +fun deleteMessageAlertDialog(chatItem: ChatItem, deleteMessage: (Long, CIDeleteMode) -> Unit) { + AlertManager.shared.showAlertDialogButtons( + title = "Delete message?", + text = "Message will be deleted - this cannot be undone!", + buttons = { + Row( + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 2.dp), + horizontalArrangement = Arrangement.End, + ) { + Button(onClick = { + deleteMessage(chatItem.id, CIDeleteMode.cidmInternal) + AlertManager.shared.hideAlert() + }) { Text("For me only") } + if (chatItem.meta.editable) { + Spacer(Modifier.padding(horizontal = 4.dp)) + Button(onClick = { + deleteMessage(chatItem.id, CIDeleteMode.cidmBroadcast) + AlertManager.shared.hideAlert() + }) { Text("For everyone") } + } + } + } + ) +} + @Preview @Composable fun PreviewChatItemView() { @@ -100,7 +145,24 @@ fun PreviewChatItemView() { msg = remember { mutableStateOf("") }, quotedItem = remember { mutableStateOf(null) }, editingItem = remember { mutableStateOf(null) }, - cxt = LocalContext.current + cxt = LocalContext.current, + deleteMessage = { _, _ -> } + ) + } +} + +@Preview +@Composable +fun PreviewChatItemViewDeletedContent() { + SimpleXTheme { + ChatItemView( + User.sampleData, + ChatItem.getDeletedContentSampleData(), + msg = remember { mutableStateOf("") }, + quotedItem = remember { mutableStateOf(null) }, + editingItem = remember { mutableStateOf(null) }, + cxt = LocalContext.current, + deleteMessage = { _, _ -> } ) } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/DeletedItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/DeletedItemView.kt new file mode 100644 index 0000000000..849550e557 --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/DeletedItemView.kt @@ -0,0 +1,56 @@ +package chat.simplex.app.views.chat.item + +import android.content.res.Configuration +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.* +import androidx.compose.runtime.Composable +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.tooling.preview.* +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import chat.simplex.app.model.* +import chat.simplex.app.ui.theme.HighOrLowlight +import chat.simplex.app.ui.theme.SimpleXTheme + +@Composable +fun DeletedItemView(ci: ChatItem, showMember: Boolean = false) { + Surface( + shape = RoundedCornerShape(18.dp), + color = ReceivedColorLight, + ) { + Row( + Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + verticalAlignment = Alignment.Bottom + ) { + Text( + buildAnnotatedString { + appendSender(this, if (showMember) ci.memberDisplayName else null, true) + withStyle(SpanStyle(fontStyle = FontStyle.Italic, color = HighOrLowlight)) { append(ci.content.text) } + }, + style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp), + modifier = Modifier.padding(end = 8.dp) + ) + CIMetaView(ci) + } + } +} + +@Preview(showBackground = true) +@Preview( + uiMode = Configuration.UI_MODE_NIGHT_YES, + name = "Dark Mode" +) +@Composable +fun PreviewDeletedItemView() { + SimpleXTheme { + DeletedItemView( + ChatItem.getDeletedContentSampleData() + ) + } +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt index fa25259b37..fbd8b235a5 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt @@ -43,7 +43,7 @@ suspend fun openChat(chatModel: ChatModel, cInfo: ChatInfo) { fun contactRequestAlertDialog(contactRequest: ChatInfo.ContactRequest, chatModel: ChatModel) { AlertManager.shared.showAlertDialog( title = "Accept connection request?", - text = "If you choose to reject sender will NOT be notified", + text = "If you choose to reject sender will NOT be notified.", confirmText = "Accept", onConfirm = { withApi { diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/AlertManager.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/AlertManager.kt index f431e68e67..a746df1925 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/AlertManager.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/AlertManager.kt @@ -21,6 +21,22 @@ class AlertManager { alertView.value = null } + fun showAlertDialogButtons( + title: String, + text: String? = null, + buttons: @Composable () -> Unit, + ) { + val alertText: (@Composable () -> Unit)? = if (text == null) null else { -> Text(text) } + showAlert { + AlertDialog( + onDismissRequest = this::hideAlert, + title = { Text(title) }, + text = alertText, + buttons = buttons + ) + } + } + fun showAlertDialog( title: String, text: String? = null, diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SMPServers.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SMPServers.kt index 8c593e1fb0..f9a6b4c9ce 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SMPServers.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SMPServers.kt @@ -61,7 +61,7 @@ fun SMPServersView(chatModel: ChatModel) { if (userSMPServers.isNotEmpty()) { AlertManager.shared.showAlertMsg( title = "Use SimpleX Chat servers?", - text = "Saved SMP servers will be removed", + text = "Saved SMP servers will be removed.", confirmText = "Confirm", onConfirm = { saveSMPServers(listOf()) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserAddressView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserAddressView.kt index 7c40fad557..486260220a 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserAddressView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserAddressView.kt @@ -33,7 +33,7 @@ fun UserAddressView(chatModel: ChatModel) { deleteAddress = { AlertManager.shared.showAlertMsg( title = "Delete address?", - text = "All your contacts will remain connected", + text = "All your contacts will remain connected.", confirmText = "Delete", onConfirm = { withApi { diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 550dff5398..1d52b2ab40 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -135,6 +135,23 @@ final class ChatModel: ObservableObject { return res } } + + func removeChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) { + // update previews + if let chat = getChat(cInfo.id) { + if let pItem = chat.chatItems.last, pItem.id == cItem.id { + chat.chatItems = [cItem] + } + } + // remove from current chat + if chatId == cInfo.id { + if let i = chatItems.firstIndex(where: { $0.id == cItem.id }) { + _ = withAnimation { + self.chatItems.remove(at: i) + } + } + } + } func markChatItemsRead(_ cInfo: ChatInfo) { // update preview @@ -571,6 +588,22 @@ struct ChatItem: Identifiable, Decodable { if case .rcvNew = meta.itemStatus { return true } return false } + + func isMsgContent() -> Bool { + switch content { + case .sndMsgContent: return true + case .rcvMsgContent: return true + default: return false + } + } + + func isDeletedContent() -> Bool { + switch content { + case .sndDeleted: return true + case .rcvDeleted: return true + default: return false + } + } var memberDisplayName: String? { get { @@ -584,10 +617,19 @@ struct ChatItem: Identifiable, Decodable { static func getSample (_ id: Int64, _ dir: CIDirection, _ ts: Date, _ text: String, _ status: CIStatus = .sndNew, quotedItem: CIQuote? = nil, _ itemDeleted: Bool = false, _ itemEdited: Bool = false, _ editable: Bool = true) -> ChatItem { ChatItem( - chatDir: dir, - meta: CIMeta.getSample(id, ts, text, status, itemDeleted, itemEdited, editable), - content: .sndMsgContent(msgContent: .text(text)), - quotedItem: quotedItem + chatDir: dir, + meta: CIMeta.getSample(id, ts, text, status, itemDeleted, itemEdited, editable), + content: .sndMsgContent(msgContent: .text(text)), + quotedItem: quotedItem + ) + } + + static func getDeletedContentSample (_ id: Int64 = 1, dir: CIDirection = .directRcv, _ ts: Date = .now, _ text: String = "this item is deleted", _ status: CIStatus = .rcvRead) -> ChatItem { + ChatItem( + chatDir: dir, + meta: CIMeta.getSample(id, ts, text, status, false, false, false), + content: .rcvDeleted(deleteMode: .cidmBroadcast), + quotedItem: nil ) } } @@ -655,6 +697,11 @@ enum CIStatus: Decodable { case rcvRead } +enum CIDeleteMode: String, Decodable { + case cidmBroadcast = "broadcast" + case cidmInternal = "internal" +} + protocol ItemContent { var text: String { get } } @@ -662,6 +709,8 @@ protocol ItemContent { enum CIContent: Decodable, ItemContent { case sndMsgContent(msgContent: MsgContent) case rcvMsgContent(msgContent: MsgContent) + case sndDeleted(deleteMode: CIDeleteMode) + case rcvDeleted(deleteMode: CIDeleteMode) case sndFileInvitation(fileId: Int64, filePath: String) case rcvFileInvitation(rcvFileTransfer: RcvFileTransfer) @@ -670,6 +719,8 @@ enum CIContent: Decodable, ItemContent { switch self { case let .sndMsgContent(mc): return mc.text case let .rcvMsgContent(mc): return mc.text + case .sndDeleted: return "deleted" + case .rcvDeleted: return "deleted" case .sndFileInvitation: return "sending files is not supported yet" case .rcvFileInvitation: return "receiving files is not supported yet" } diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 0f0386bf18..2182bc13c7 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -15,11 +15,6 @@ private var chatController: chat_ctrl? private let jsonDecoder = getJSONDecoder() private let jsonEncoder = getJSONEncoder() -enum MsgDeleteMode: String { - case mdBroadcast = "broadcast" - case mdInternal = "internal" -} - enum ChatCommand { case showActiveUser case createActiveUser(profile: Profile) @@ -28,8 +23,8 @@ enum ChatCommand { case apiGetChat(type: ChatType, id: Int64) case apiSendMessage(type: ChatType, id: Int64, msg: MsgContent) case apiSendMessageQuote(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent) - case apiUpdateMessage(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent) - case apiDeleteMessage(type: ChatType, id: Int64, itemId: Int64, mode: MsgDeleteMode) + case apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent) + case apiDeleteChatItem(type: ChatType, id: Int64, itemId: Int64, mode: CIDeleteMode) case getUserSMPServers case setUserSMPServers(smpServers: [String]) case addContact @@ -54,8 +49,8 @@ enum ChatCommand { case let .apiGetChat(type, id): return "/_get chat \(ref(type, id)) count=100" case let .apiSendMessage(type, id, mc): return "/_send \(ref(type, id)) \(mc.cmdString)" case let .apiSendMessageQuote(type, id, itemId, mc): return "/_send_quote \(ref(type, id)) \(itemId) \(mc.cmdString)" - case let .apiUpdateMessage(type, id, itemId, mc): return "/_update item \(ref(type, id)) \(itemId) \(mc.cmdString)" - case let .apiDeleteMessage(type, id, itemId, mode): return "/_delete item \(ref(type, id)) \(itemId) \(mode.rawValue)" + case let .apiUpdateChatItem(type, id, itemId, mc): return "/_update item \(ref(type, id)) \(itemId) \(mc.cmdString)" + case let .apiDeleteChatItem(type, id, itemId, mode): return "/_delete item \(ref(type, id)) \(itemId) \(mode.rawValue)" case .getUserSMPServers: return "/smp_servers" case let .setUserSMPServers(smpServers): return "/smp_servers \(smpServersStr(smpServers: smpServers))" case .addContact: return "/connect" @@ -83,8 +78,8 @@ enum ChatCommand { case .apiGetChat: return "apiGetChat" case .apiSendMessage: return "apiSendMessage" case .apiSendMessageQuote: return "apiSendMessageQuote" - case .apiUpdateMessage: return "apiUpdateMessage" - case .apiDeleteMessage: return "apiDeleteMessage" + case .apiUpdateChatItem: return "apiUpdateChatItem" + case .apiDeleteChatItem: return "apiDeleteChatItem" case .getUserSMPServers: return "getUserSMPServers" case .setUserSMPServers: return "setUserSMPServers" case .addContact: return "addContact" @@ -148,7 +143,7 @@ enum ChatResponse: Decodable, Error { case newChatItem(chatItem: AChatItem) case chatItemStatusUpdated(chatItem: AChatItem) case chatItemUpdated(chatItem: AChatItem) - case chatItemDeleted(chatItem: AChatItem) + case chatItemDeleted(deletedChatItem: AChatItem, toChatItem: AChatItem) case cmdOk case chatCmdError(chatError: ChatError) case chatError(chatError: ChatError) @@ -231,7 +226,7 @@ enum ChatResponse: Decodable, Error { case let .newChatItem(chatItem): return String(describing: chatItem) case let .chatItemStatusUpdated(chatItem): return String(describing: chatItem) case let .chatItemUpdated(chatItem): return String(describing: chatItem) - case let .chatItemDeleted(chatItem): return String(describing: chatItem) + case let .chatItemDeleted(deletedChatItem, toChatItem): return "deletedChatItem:\n\(String(describing: deletedChatItem))\ntoChatItem:\n\(String(describing: toChatItem))" case .cmdOk: return noDetails case let .chatCmdError(chatError): return String(describing: chatError) case let .chatError(chatError): return String(describing: chatError) @@ -410,15 +405,15 @@ func apiSendMessage(type: ChatType, id: Int64, quotedItemId: Int64?, msg: MsgCon throw r } -func apiUpdateMessage(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent) async throws -> ChatItem { - let r = await chatSendCmd(.apiUpdateMessage(type: type, id: id, itemId: itemId, msg: msg), bgDelay: msgDelay) +func apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent) async throws -> ChatItem { + let r = await chatSendCmd(.apiUpdateChatItem(type: type, id: id, itemId: itemId, msg: msg), bgDelay: msgDelay) if case let .chatItemUpdated(aChatItem) = r { return aChatItem.chatItem } throw r } -func apiDeleteMessage(type: ChatType, id: Int64, itemId: Int64, mode: MsgDeleteMode) async throws -> ChatItem { - let r = await chatSendCmd(.apiDeleteMessage(type: type, id: id, itemId: itemId, mode: mode), bgDelay: msgDelay) - if case let .chatItemUpdated(aChatItem) = r { return aChatItem.chatItem } +func apiDeleteChatItem(type: ChatType, id: Int64, itemId: Int64, mode: CIDeleteMode) async throws -> ChatItem { + let r = await chatSendCmd(.apiDeleteChatItem(type: type, id: id, itemId: itemId, mode: mode), bgDelay: msgDelay) + if case let .chatItemDeleted(_, toChatItem) = r { return toChatItem.chatItem } throw r } @@ -633,7 +628,11 @@ func processReceivedMsg(_ res: ChatResponse) { case let .chatItemStatusUpdated(aChatItem): let cInfo = aChatItem.chatInfo let cItem = aChatItem.chatItem - if chatModel.upsertChatItem(cInfo, cItem) { + var res = false + if !cItem.isDeletedContent() { + res = chatModel.upsertChatItem(cInfo, cItem) + } + if res { NtfManager.shared.notifyMessageReceived(cInfo, cItem) } else if let endTask = chatModel.messageDelivery[cItem.id] { switch cItem.meta.itemStatus { @@ -649,9 +648,15 @@ func processReceivedMsg(_ res: ChatResponse) { if chatModel.upsertChatItem(cInfo, cItem) { NtfManager.shared.notifyMessageReceived(cInfo, cItem) } - case .chatItemDeleted(_): - // TODO let .chatItemDeleted(aChatItem) - return + case let .chatItemDeleted(_, toChatItem): + let cInfo = toChatItem.chatInfo + let cItem = toChatItem.chatItem + if cItem.meta.itemDeleted { + chatModel.removeChatItem(cInfo, cItem) + } else { + // currently only broadcast deletion of rcv message can be received, and only this case should happen + _ = chatModel.upsertChatItem(cInfo, cItem) + } default: logger.debug("unsupported event: \(res.responseType)") } @@ -784,7 +789,8 @@ enum ChatErrorType: Decodable { case fileRcvChunk(message: String) case fileInternal(message: String) case invalidQuote - case invalidMessageUpdate + case invalidChatItemUpdate + case invalidChatItemDelete case agentVersion case commandError(message: String) } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift index 80ed91e072..738e94ad1b 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift @@ -13,20 +13,22 @@ struct CIMetaView: View { var body: some View { HStack(alignment: .center, spacing: 4) { - if chatItem.meta.itemEdited { - statusImage("pencil", .secondary, 9) - } + if !chatItem.isDeletedContent() { + if chatItem.meta.itemEdited { + statusImage("pencil", .secondary, 9) + } - switch chatItem.meta.itemStatus { - case .sndSent: - statusImage("checkmark", .secondary) - case .sndErrorAuth: - statusImage("multiply", .red) - case .sndError: - statusImage("exclamationmark.triangle.fill", .yellow) - case .rcvNew: - statusImage("circlebadge.fill", Color.accentColor) - default: EmptyView() + switch chatItem.meta.itemStatus { + case .sndSent: + statusImage("checkmark", .secondary) + case .sndErrorAuth: + statusImage("multiply", .red) + case .sndError: + statusImage("exclamationmark.triangle.fill", .yellow) + case .rcvNew: + statusImage("circlebadge.fill", Color.accentColor) + default: EmptyView() + } } chatItem.timestampText @@ -49,6 +51,8 @@ struct CIMetaView_Previews: PreviewProvider { return Group { CIMetaView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent)) CIMetaView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent, false, true)) + CIMetaView(chatItem: ChatItem.getDeletedContentSample()) } + .previewLayout(.fixed(width: 360, height: 100)) } } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/DeletedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/DeletedItemView.swift new file mode 100644 index 0000000000..ae58e84cd8 --- /dev/null +++ b/apps/ios/Shared/Views/Chat/ChatItem/DeletedItemView.swift @@ -0,0 +1,51 @@ +// +// FramedItemView.swift +// SimpleX +// +// Created by JRoberts on 04/02/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +struct DeletedItemView: View { + @Environment(\.colorScheme) var colorScheme + var chatItem: ChatItem + var showMember = false + + var body: some View { + HStack(alignment: .bottom, spacing: 0) { + if showMember, let member = chatItem.memberDisplayName { + Text(member).fontWeight(.medium) + Text(": ") + } + Text(chatItem.content.text) + .foregroundColor(.secondary) + .italic() + CIMetaView(chatItem: chatItem) + .padding(.horizontal, 12) + } + .padding(.leading, 12) + .padding(.vertical, 6) + .background(Color(uiColor: .tertiarySystemGroupedBackground)) + .cornerRadius(18) + .textSelection(.disabled) +// .background(Color(uiColor: .systemBackground)) +// .overlay( +// RoundedRectangle(cornerRadius: 18) +// .stroke(.quaternary, lineWidth: 1) +// ) + } +} + +struct DeletedItemView_Previews: PreviewProvider { + static var previews: some View { + Group { + DeletedItemView(chatItem: ChatItem.getDeletedContentSample()) + DeletedItemView( + chatItem: ChatItem.getDeletedContentSample(dir: .groupRcv(groupMember: GroupMember.sampleData)), + showMember: true + ) + } + .previewLayout(.fixed(width: 360, height: 200)) + } +} diff --git a/apps/ios/Shared/Views/Chat/ChatItemView.swift b/apps/ios/Shared/Views/Chat/ChatItemView.swift index 037366156f..275350a852 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemView.swift @@ -13,10 +13,14 @@ struct ChatItemView: View { var showMember = false var body: some View { - if (chatItem.quotedItem == nil && isShortEmoji(chatItem.content.text)) { - EmojiItemView(chatItem: chatItem) - } else { - FramedItemView(chatItem: chatItem, showMember: showMember) + if chatItem.isMsgContent() { + if (chatItem.quotedItem == nil && isShortEmoji(chatItem.content.text)) { + EmojiItemView(chatItem: chatItem) + } else { + FramedItemView(chatItem: chatItem, showMember: showMember) + } + } else if chatItem.isDeletedContent() { + DeletedItemView(chatItem: chatItem, showMember: showMember) } } } @@ -29,6 +33,7 @@ struct ChatItemView_Previews: PreviewProvider { ChatItemView(chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂")) ChatItemView(chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂")) ChatItemView(chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂🙂")) + ChatItemView(chatItem: ChatItem.getDeletedContentSample()) } .previewLayout(.fixed(width: 360, height: 70)) } diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 17c8b7a555..4519d8d91b 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -17,9 +17,11 @@ struct ChatView: View { @State var message: String = "" @State var quotedItem: ChatItem? = nil @State var editingItem: ChatItem? = nil + @State var deletingItem: ChatItem? = nil @State private var inProgress: Bool = false @FocusState private var keyboardVisible: Bool @State private var showChatInfo = false + @State private var showDeleteMessage = false var body: some View { let cInfo = chat.chatInfo @@ -118,27 +120,46 @@ struct ChatView: View { let alignment: Alignment = ci.chatDir.sent ? .trailing : .leading return ChatItemView(chatItem: ci, showMember: showMember) .contextMenu { - Button { - withAnimation { - editingItem = nil - quotedItem = ci + if ci.isMsgContent() { + Button { + withAnimation { + editingItem = nil + quotedItem = ci + } + } label: { Label("Reply", systemImage: "arrowshape.turn.up.left") } + Button { + showShareSheet(items: [ci.content.text]) + } label: { Label("Share", systemImage: "square.and.arrow.up") } + Button { + UIPasteboard.general.string = ci.content.text + } label: { Label("Copy", systemImage: "doc.on.doc") } + if ci.meta.editable { + Button { + withAnimation { + quotedItem = nil + editingItem = ci + message = ci.content.text + } + } label: { Label("Edit", systemImage: "square.and.pencil") } } - } label: { Label("Reply", systemImage: "arrowshape.turn.up.left") } - Button { - showShareSheet(items: [ci.content.text]) - } label: { Label("Share", systemImage: "square.and.arrow.up") } - Button { - UIPasteboard.general.string = ci.content.text - } label: { Label("Copy", systemImage: "doc.on.doc") } -// if (ci.chatDir.sent && ci.meta.editable) { -// Button { -// withAnimation { -// quotedItem = nil -// editingItem = ci -// message = ci.content.text -// } -// } label: { Label("Edit", systemImage: "square.and.pencil") } -// } + Button(role: .destructive) { + showDeleteMessage = true + deletingItem = ci + } label: { + Label("Delete", systemImage: "trash") + } + } + } + .confirmationDialog("Delete message?", isPresented: $showDeleteMessage, titleVisibility: .visible) { + Button("Delete for me", role: .destructive) { + deleteMessage(.cidmInternal) + } + if let di = deletingItem { + if di.meta.editable { + Button("Delete for everyone",role: .destructive) { deleteMessage(.cidmBroadcast) + } + } + } } .frame(maxWidth: maxWidth, maxHeight: .infinity, alignment: alignment) .frame(minWidth: 0, maxWidth: .infinity, alignment: alignment) @@ -185,7 +206,7 @@ struct ChatView: View { logger.debug("ChatView sendMessage: in Task") do { if let ei = editingItem { - let chatItem = try await apiUpdateMessage( + let chatItem = try await apiUpdateChatItem( type: chat.chatInfo.chatType, id: chat.chatInfo.apiId, itemId: ei.id, @@ -212,6 +233,29 @@ struct ChatView: View { } } } + + func deleteMessage(_ mode: CIDeleteMode) { + logger.debug("ChatView deleteMessage") + Task { + logger.debug("ChatView deleteMessage: in Task") + do { + if let di = deletingItem { + let toItem = try await apiDeleteChatItem( + type: chat.chatInfo.chatType, + id: chat.chatInfo.apiId, + itemId: di.id, + mode: mode + ) + DispatchQueue.main.async { + deletingItem = nil + let _ = chatModel.removeChatItem(chat.chatInfo, toItem) + } + } + } catch { + logger.error("ChatView.deleteMessage error: \(error.localizedDescription)") + } + } + } } struct ChatView_Previews: PreviewProvider { @@ -222,11 +266,12 @@ struct ChatView_Previews: PreviewProvider { ChatItem.getSample(1, .directSnd, .now, "hello"), ChatItem.getSample(2, .directRcv, .now, "hi"), ChatItem.getSample(3, .directRcv, .now, "hi there"), - ChatItem.getSample(4, .directRcv, .now, "hello again"), - ChatItem.getSample(5, .directSnd, .now, "hi there!!!"), - ChatItem.getSample(6, .directSnd, .now, "how are you?"), - ChatItem.getSample(7, .directSnd, .now, "👍👍👍👍"), - ChatItem.getSample(8, .directSnd, .now, "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.") + ChatItem.getDeletedContentSample(4), + ChatItem.getSample(5, .directRcv, .now, "hello again"), + ChatItem.getSample(6, .directSnd, .now, "hi there!!!"), + ChatItem.getSample(7, .directSnd, .now, "how are you?"), + ChatItem.getSample(8, .directSnd, .now, "👍👍👍👍"), + ChatItem.getSample(9, .directSnd, .now, "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.") ] return ChatView(chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: [])) .environmentObject(chatModel) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 710cf2d87c..7289445c04 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -116,6 +116,13 @@ 640F50E427CF991C001E05C2 /* SMPServers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 640F50E227CF991C001E05C2 /* SMPServers.swift */; }; 64AA1C6927EE10C800AC7277 /* ContextItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6827EE10C800AC7277 /* ContextItemView.swift */; }; 64AA1C6A27EE10C800AC7277 /* ContextItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6827EE10C800AC7277 /* ContextItemView.swift */; }; + 64AA1C6C27F3537400AC7277 /* DeletedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */; }; + 64AA1C6D27F3537400AC7277 /* DeletedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */; }; + 64C2576927F45A42007F8541 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C2576427F45A42007F8541 /* libgmpxx.a */; }; + 64C2576A27F45A42007F8541 /* libHSsimplex-chat-1.4.0-35IBkEJuAyg38MasSQs4Ou-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C2576527F45A42007F8541 /* libHSsimplex-chat-1.4.0-35IBkEJuAyg38MasSQs4Ou-ghc8.10.7.a */; }; + 64C2576B27F45A42007F8541 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C2576627F45A42007F8541 /* libgmp.a */; }; + 64C2576C27F45A42007F8541 /* libHSsimplex-chat-1.4.0-35IBkEJuAyg38MasSQs4Ou.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C2576727F45A42007F8541 /* libHSsimplex-chat-1.4.0-35IBkEJuAyg38MasSQs4Ou.a */; }; + 64C2576D27F45A42007F8541 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C2576827F45A42007F8541 /* libffi.a */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -199,6 +206,12 @@ 5CEACCEC27DEA495000BD591 /* MsgContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MsgContentView.swift; sourceTree = ""; }; 640F50E227CF991C001E05C2 /* SMPServers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMPServers.swift; sourceTree = ""; }; 64AA1C6827EE10C800AC7277 /* ContextItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextItemView.swift; sourceTree = ""; }; + 64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeletedItemView.swift; sourceTree = ""; }; + 64C2576427F45A42007F8541 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; + 64C2576527F45A42007F8541 /* libHSsimplex-chat-1.4.0-35IBkEJuAyg38MasSQs4Ou-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.4.0-35IBkEJuAyg38MasSQs4Ou-ghc8.10.7.a"; sourceTree = ""; }; + 64C2576627F45A42007F8541 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; + 64C2576727F45A42007F8541 /* libHSsimplex-chat-1.4.0-35IBkEJuAyg38MasSQs4Ou.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.4.0-35IBkEJuAyg38MasSQs4Ou.a"; sourceTree = ""; }; + 64C2576827F45A42007F8541 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -431,6 +444,7 @@ 5CE4407827ADB701007B033A /* EmojiItemView.swift */, 5CEACCEC27DEA495000BD591 /* MsgContentView.swift */, 5C3A88D027DF57800060F1C2 /* FramedItemView.swift */, + 64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */, ); path = ChatItem; sourceTree = ""; @@ -653,6 +667,7 @@ 5C2E260727A2941F00F70299 /* SimpleXAPI.swift in Sources */, 5CB924D427A853F100ACCCDD /* SettingsButton.swift in Sources */, 5C5F2B7027EBC704006A9D5F /* ProfileImage.swift in Sources */, + 64AA1C6C27F3537400AC7277 /* DeletedItemView.swift in Sources */, 5CE4407227ADB1D0007B033A /* Emoji.swift in Sources */, 5C1A4C1E27A715B700EAD5AD /* ChatItemView.swift in Sources */, 64AA1C6927EE10C800AC7277 /* ContextItemView.swift in Sources */, @@ -704,6 +719,7 @@ 5C2E260827A2941F00F70299 /* SimpleXAPI.swift in Sources */, 5CB924D527A853F100ACCCDD /* SettingsButton.swift in Sources */, 5C5F2B7127EBC704006A9D5F /* ProfileImage.swift in Sources */, + 64AA1C6D27F3537400AC7277 /* DeletedItemView.swift in Sources */, 5CE4407327ADB1D0007B033A /* Emoji.swift in Sources */, 5C1A4C1F27A715B700EAD5AD /* ChatItemView.swift in Sources */, 64AA1C6A27EE10C800AC7277 /* ContextItemView.swift in Sources */,