diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index a141a53b4c..66148034df 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -2175,8 +2175,14 @@ struct ChatView: View { ) } .confirmationDialog("Delete message?", isPresented: $showDeleteMessage, titleVisibility: .visible) { - Button("Delete for me", role: .destructive) { - deleteMessage(.cidmInternal, moderate: false) + if publicGroupEditor(chat) { + Button("Delete from history", role: .destructive) { + deleteMessage(.cidmHistory, moderate: false) + } + } else { + Button("Delete for me", role: .destructive) { + deleteMessage(.cidmInternal, moderate: false) + } } if let di = deletingItem, di.meta.deletable && !di.localNote && !di.isReport { Button(broadcastDeleteButtonText(chat), role: .destructive) { @@ -2185,8 +2191,14 @@ struct ChatView: View { } } .confirmationDialog(deleteMessagesTitle, isPresented: $showDeleteMessages, titleVisibility: .visible) { - Button("Delete for me", role: .destructive) { - deleteMessages(chat, deletingItems, moderate: false) + if publicGroupEditor(chat) { + Button("Delete from history", role: .destructive) { + deleteMessages(chat, deletingItems, .cidmHistory, moderate: false) + } + } else { + Button("Delete for me", role: .destructive) { + deleteMessages(chat, deletingItems, moderate: false) + } } } .confirmationDialog(archivingReports?.count == 1 ? "Archive report?" : "Archive \(archivingReports?.count ?? 0) reports?", isPresented: $showArchivingReports, titleVisibility: .visible) { @@ -2817,6 +2829,9 @@ struct ChatView: View { } } catch { logger.error("ChatView.deleteMessage error: \(error)") + await MainActor.run { + showAlert(NSLocalizedString("Error deleting message", comment: "alert title"), message: responseError(error)) + } } } } @@ -2963,6 +2978,14 @@ class FloatingButtonModel: ObservableObject { } +private func publicGroupEditor(_ chat: Chat) -> Bool { + if case let .group(groupInfo, _) = chat.chatInfo { + groupInfo.useRelays && groupInfo.membership.memberRole >= .moderator + } else { + false + } +} + private func broadcastDeleteButtonText(_ chat: Chat) -> LocalizedStringKey { chat.chatInfo.featureEnabled(.fullDelete) ? "Delete for everyone" : "Mark deleted for everyone" } @@ -3010,6 +3033,9 @@ private func deleteMessages(_ chat: Chat, _ deletingItems: [Int64], _ mode: CIDe await onSuccess() } catch { logger.error("ChatView.deleteMessages error: \(error.localizedDescription)") + await MainActor.run { + showAlert(NSLocalizedString("Error deleting message", comment: "alert title"), message: responseError(error)) + } } } } diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 1dfa477c91..a5a35ba5c0 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -4091,6 +4091,7 @@ public enum CIDeleteMode: String, Decodable, Hashable { case cidmBroadcast = "broadcast" case cidmInternal = "internal" case cidmInternalMark = "internalMark" + case cidmHistory = "history" } protocol ItemContent { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index 80b68f37a5..1b1d403521 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -3735,7 +3735,8 @@ sealed class CIForwardedFrom { enum class CIDeleteMode(val deleteMode: String) { @SerialName("internal") cidmInternal("internal"), @SerialName("internalMark") cidmInternalMark("internalMark"), - @SerialName("broadcast") cidmBroadcast("broadcast"); + @SerialName("broadcast") cidmBroadcast("broadcast"), + @SerialName("history") cidmHistory("history"); } interface ItemContent { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index bc8e4dc3f3..e23b76b025 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -1213,14 +1213,14 @@ object ChatController { suspend fun apiDeleteChatItems(rh: Long?, type: ChatType, id: Long, scope: GroupChatScope?, itemIds: List, mode: CIDeleteMode): List? { val r = sendCmd(rh, CC.ApiDeleteChatItem(type, id, scope, itemIds, mode)) if (r is API.Result && r.res is CR.ChatItemsDeleted) return r.res.chatItemDeletions - Log.e(TAG, "apiDeleteChatItem bad response: ${r.responseType} ${r.details}") + apiErrorAlert("apiDeleteChatItems", generalGetString(MR.strings.error_deleting_message), r) return null } suspend fun apiDeleteMemberChatItems(rh: Long?, groupId: Long, itemIds: List): List? { val r = sendCmd(rh, CC.ApiDeleteMemberChatItem(groupId, itemIds)) if (r is API.Result && r.res is CR.ChatItemsDeleted) return r.res.chatItemDeletions - Log.e(TAG, "apiDeleteMemberChatItem bad response: ${r.responseType} ${r.details}") + apiErrorAlert("apiDeleteMemberChatItems", generalGetString(MR.strings.error_deleting_message), r) return null } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index af58996393..f42969a73f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -317,6 +317,7 @@ fun ChatView( itemIds.sorted(), questionText = questionText, forAll = canDeleteForAll, + editorial = publicGroupEditor(chatInfo), deleteMessages = { ids, forAll -> deleteMessages(chatRh, chatInfo, ids, forAll, moderate = false) { selectedChatItems.value = null @@ -3351,7 +3352,9 @@ private fun deleteMessages(chatRh: Long?, chatInfo: ChatInfo, itemIds: List +fun publicGroupEditor(chatInfo: ChatInfo): Boolean = + chatInfo is ChatInfo.Group && chatInfo.groupInfo.useRelays && chatInfo.groupInfo.membership.memberRole >= GroupMemberRole.Moderator + private fun keyForItem(item: ChatItem): ChatViewItemKey = ChatViewItemKey(item.id, item.meta.createdAt.toEpochMilliseconds()) private fun ViewConfiguration.bigTouchSlop(slop: Float = 50f) = object: ViewConfiguration { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt index 15b0a12822..64288d9055 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt @@ -374,7 +374,7 @@ fun ChatItemView( @Composable fun DeleteItemMenu() { DefaultDropdownMenu(showMenu) { - DeleteItemAction(chatsCtx, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + DeleteItemAction(chatsCtx, cInfo, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) if (cItem.canBeDeletedForSelf) { Divider() SelectItemAction(showMenu, selectChatItem) @@ -392,7 +392,7 @@ fun ChatItemView( if (cItem.chatDir !is CIDirection.GroupSnd && cInfo.groupInfo.membership.memberRole >= GroupMemberRole.Moderator) { ArchiveReportItemAction(cItem.id, cInfo.groupInfo.membership.memberActive, showMenu, archiveReports) } - DeleteItemAction(chatsCtx, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages, buttonText = stringResource(MR.strings.delete_report)) + DeleteItemAction(chatsCtx, cInfo, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages, buttonText = stringResource(MR.strings.delete_report)) Divider() SelectItemAction(showMenu, selectChatItem) } @@ -482,7 +482,7 @@ fun ChatItemView( CancelFileItemAction(cItem.file.fileId, showMenu, cancelFile = cancelFile, cancelAction = cItem.file.cancelAction) } if (!(live && cItem.meta.isLive) && !preview) { - DeleteItemAction(chatsCtx, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + DeleteItemAction(chatsCtx, cInfo, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) } if (cItem.chatDir !is CIDirection.GroupSnd) { val groupInfo = cItem.memberToModerate(cInfo)?.first @@ -508,7 +508,7 @@ fun ChatItemView( ExpandItemAction(revealed, showMenu, reveal) } ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) - DeleteItemAction(chatsCtx, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + DeleteItemAction(chatsCtx, cInfo, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) if (cItem.canBeDeletedForSelf) { Divider() SelectItemAction(showMenu, selectChatItem) @@ -518,7 +518,7 @@ fun ChatItemView( cItem.isDeletedContent -> { DefaultDropdownMenu(showMenu) { ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) - DeleteItemAction(chatsCtx, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + DeleteItemAction(chatsCtx, cInfo, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) if (cItem.canBeDeletedForSelf) { Divider() SelectItemAction(showMenu, selectChatItem) @@ -532,7 +532,7 @@ fun ChatItemView( } else { ExpandItemAction(revealed, showMenu, reveal) } - DeleteItemAction(chatsCtx, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + DeleteItemAction(chatsCtx, cInfo, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) if (cItem.canBeDeletedForSelf) { Divider() SelectItemAction(showMenu, selectChatItem) @@ -541,7 +541,7 @@ fun ChatItemView( } else -> { DefaultDropdownMenu(showMenu) { - DeleteItemAction(chatsCtx, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + DeleteItemAction(chatsCtx, cInfo, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) if (selectedChatItems.value == null) { Divider() SelectItemAction(showMenu, selectChatItem) @@ -558,7 +558,7 @@ fun ChatItemView( RevealItemAction(revealed, showMenu, reveal) } ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) - DeleteItemAction(chatsCtx, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + DeleteItemAction(chatsCtx, cInfo, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) if (cItem.canBeDeletedForSelf) { Divider() SelectItemAction(showMenu, selectChatItem) @@ -587,7 +587,7 @@ fun ChatItemView( DeletedItemView(cItem, cInfo.timedMessagesTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp) DefaultDropdownMenu(showMenu) { ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) - DeleteItemAction(chatsCtx, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + DeleteItemAction(chatsCtx, cInfo, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) if (cItem.canBeDeletedForSelf) { Divider() SelectItemAction(showMenu, selectChatItem) @@ -661,7 +661,7 @@ fun ChatItemView( ExpandItemAction(revealed, showMenu, reveal) } ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) - DeleteItemAction(chatsCtx, cItem, revealed, showMenu, questionText = generalGetString(MR.strings.delete_message_cannot_be_undone_warning), deleteMessage, deleteMessages) + DeleteItemAction(chatsCtx, cInfo, cItem, revealed, showMenu, questionText = generalGetString(MR.strings.delete_message_cannot_be_undone_warning), deleteMessage, deleteMessages) if (cItem.canBeDeletedForSelf) { Divider() SelectItemAction(showMenu, selectChatItem) @@ -866,6 +866,7 @@ fun ItemInfoAction( @Composable fun DeleteItemAction( chatsCtx: ChatModel.ChatsContext, + cInfo: ChatInfo, cItem: ChatItem, revealed: State, showMenu: MutableState, @@ -898,13 +899,13 @@ fun DeleteItemAction( deleteMessages = { ids, _ -> deleteMessages(ids) } ) } else { - deleteMessageAlertDialog(cItem, questionText, deleteMessage = deleteMessage) + deleteMessageAlertDialog(cItem, questionText, cInfo, deleteMessage = deleteMessage) } } else { - deleteMessageAlertDialog(cItem, questionText, deleteMessage = deleteMessage) + deleteMessageAlertDialog(cItem, questionText, cInfo, deleteMessage = deleteMessage) } } else { - deleteMessageAlertDialog(cItem, questionText, deleteMessage = deleteMessage) + deleteMessageAlertDialog(cItem, questionText, cInfo, deleteMessage = deleteMessage) } }, color = Color.Red @@ -1371,7 +1372,9 @@ fun cancelFileAlertDialog(fileId: Long, cancelFile: (Long) -> Unit, cancelAction ) } -fun deleteMessageAlertDialog(chatItem: ChatItem, questionText: String, deleteMessage: (Long, CIDeleteMode) -> Unit) { +fun deleteMessageAlertDialog(chatItem: ChatItem, questionText: String, chatInfo: ChatInfo, deleteMessage: (Long, CIDeleteMode) -> Unit) { + val canDeleteForEveryone = chatItem.meta.deletable && !chatItem.localNote && !chatItem.isReport + val editorial = publicGroupEditor(chatInfo) AlertManager.shared.showAlertDialogButtons( title = generalGetString(MR.strings.delete_message__question), text = questionText, @@ -1382,11 +1385,18 @@ fun deleteMessageAlertDialog(chatItem: ChatItem, questionText: String, deleteMes .padding(horizontal = 8.dp, vertical = 2.dp), horizontalArrangement = Arrangement.Center, ) { - TextButton(onClick = { - 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 && !chatItem.isReport) { + if (editorial) { + TextButton(onClick = { + deleteMessage(chatItem.id, CIDeleteMode.cidmHistory) + AlertManager.shared.hideAlert() + }) { Text(stringResource(MR.strings.from_history), color = MaterialTheme.colors.error) } + } else { + TextButton(onClick = { + deleteMessage(chatItem.id, CIDeleteMode.cidmInternal) + AlertManager.shared.hideAlert() + }) { Text(stringResource(MR.strings.for_me_only), color = MaterialTheme.colors.error) } + } + if (canDeleteForEveryone) { Spacer(Modifier.padding(horizontal = 4.dp)) TextButton(onClick = { deleteMessage(chatItem.id, CIDeleteMode.cidmBroadcast) @@ -1398,7 +1408,7 @@ fun deleteMessageAlertDialog(chatItem: ChatItem, questionText: String, deleteMes ) } -fun deleteMessagesAlertDialog(itemIds: List, questionText: String, forAll: Boolean, deleteMessages: (List, Boolean) -> Unit) { +fun deleteMessagesAlertDialog(itemIds: List, questionText: String, forAll: Boolean, editorial: Boolean = false, deleteMessages: (List, Boolean) -> Unit) { AlertManager.shared.showAlertDialogButtons( title = generalGetString(MR.strings.delete_messages__question).format(itemIds.size), text = questionText, @@ -1412,7 +1422,7 @@ fun deleteMessagesAlertDialog(itemIds: List, questionText: String, forAll: TextButton(onClick = { deleteMessages(itemIds, false) AlertManager.shared.hideAlert() - }) { Text(stringResource(MR.strings.for_me_only), color = MaterialTheme.colors.error) } + }) { Text(stringResource(if (editorial) MR.strings.from_history else MR.strings.for_me_only), color = MaterialTheme.colors.error) } if (forAll) { TextButton(onClick = { diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index 75d917575d..7304625945 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -207,6 +207,7 @@ Error deleting group Error deleting private notes Error deleting contact request + Error deleting message Error deleting pending contact connection Error changing address Error aborting address change @@ -424,6 +425,7 @@ The messages will be marked as moderated for all members. Delete for me For everyone + From history Stop file Stop sending file? Sending file will be stopped. diff --git a/bots/api/COMMANDS.md b/bots/api/COMMANDS.md index 5761f303bd..2d804ccaa9 100644 --- a/bots/api/COMMANDS.md +++ b/bots/api/COMMANDS.md @@ -374,7 +374,7 @@ Delete message. **Syntax**: ``` -/_delete item [,...] broadcast|internal|internalMark +/_delete item [,...] broadcast|internal|internalMark|history ``` ```javascript diff --git a/bots/api/TYPES.md b/bots/api/TYPES.md index 2cd61778d5..4bd924dc31 100644 --- a/bots/api/TYPES.md +++ b/bots/api/TYPES.md @@ -589,6 +589,7 @@ ChatBanner: - "broadcast" - "internal" - "internalMark" +- "history" --- diff --git a/packages/simplex-chat-client/types/typescript/src/types.ts b/packages/simplex-chat-client/types/typescript/src/types.ts index 3c391766a7..64a8b49502 100644 --- a/packages/simplex-chat-client/types/typescript/src/types.ts +++ b/packages/simplex-chat-client/types/typescript/src/types.ts @@ -527,6 +527,7 @@ export enum CIDeleteMode { Broadcast = "broadcast", Internal = "internal", InternalMark = "internalMark", + History = "history", } export type CIDeleted = CIDeleted.Deleted | CIDeleted.Blocked | CIDeleted.BlockedByAdmin | CIDeleted.Moderated diff --git a/packages/simplex-chat-python/src/simplex_chat/types/_types.py b/packages/simplex-chat-python/src/simplex_chat/types/_types.py index b5c448948a..8fd700a8a2 100644 --- a/packages/simplex-chat-python/src/simplex_chat/types/_types.py +++ b/packages/simplex-chat-python/src/simplex_chat/types/_types.py @@ -370,7 +370,7 @@ CIContent = ( CIContent_Tag = Literal["sndMsgContent", "rcvMsgContent", "sndDeleted", "rcvDeleted", "sndCall", "rcvCall", "rcvIntegrityError", "rcvDecryptionError", "rcvMsgError", "rcvGroupInvitation", "sndGroupInvitation", "rcvDirectEvent", "rcvGroupEvent", "sndGroupEvent", "rcvConnEvent", "sndConnEvent", "rcvChatFeature", "sndChatFeature", "rcvChatPreference", "sndChatPreference", "rcvGroupFeature", "sndGroupFeature", "rcvChatFeatureRejected", "rcvGroupFeatureRejected", "sndModerated", "rcvModerated", "rcvBlocked", "sndDirectE2EEInfo", "rcvDirectE2EEInfo", "sndGroupE2EEInfo", "rcvGroupE2EEInfo", "chatBanner"] -CIDeleteMode = Literal["broadcast", "internal", "internalMark"] +CIDeleteMode = Literal["broadcast", "internal", "internalMark", "history"] class CIDeleted_deleted(TypedDict): type: Literal["deleted"] diff --git a/plans/2026-05-11-channel-owner-unlimited-delete.md b/plans/2026-05-11-channel-owner-unlimited-delete.md index 588d82683b..29461f3ebd 100644 --- a/plans/2026-05-11-channel-owner-unlimited-delete.md +++ b/plans/2026-05-11-channel-owner-unlimited-delete.md @@ -1,36 +1,59 @@ -# Channel owner unlimited delete +# Channel editorial delete from history ## Problem Channel owners cannot delete content older than 24 hours. The limit makes sense in p2p groups (no authority, each member holds independent copy). In channels, the owner is the authority - their content, their right to remove it. +## Design + +Rather than bypassing the 24-hour time limit for broadcast deletes, we add a new delete mode: "delete from history." The relay removes the message from its store but does not forward the deletion to subscribers. Subscribers who already received the message keep it - the operation cleans the relay's history, not the subscriber's device. + +This is the right separation: within 24 hours, broadcast delete reaches subscribers and removes from relays. After 24 hours, history delete cleans relays only - no retroactive rewriting of subscriber devices. + ## Changes -### PR 1 - subscriber side (release first) +### Protocol.hs -**Subscriber.hs:2168** - `rcvItemDeletable` check on `CIGroupRcv` path. When the sender has editorial role in a channel (`useRelays' gInfo && memberRole' mem >= GRModerator`), skip the time check. +`XMsgDel` gains `onlyHistory :: Bool` field. Defaults to `False` (backward compatible - old clients don't send it, parser defaults missing field to `False`). Encoded only when `True` via `justTrue`. -**Subscriber.hs:1912** - `rcvItemDeletable` itself doesn't need to change - the caller skips it. +### CIContent.hs -### PR 2 - owner side (release after subscribers update) +New `CIDMHistory` delete mode alongside `CIDMBroadcast`, `CIDMInternal`, `CIDMInternalMark`. -**Messages.hs:535** - `deletable'` returns `True` without time check when the item is in a channel where the user's role >= GRModerator. Needs group context threaded in, or a separate check at the call site. +### Commands.hs -**Commands.hs:790** - `assertDeletable` for `APIDeleteChatItem` - skip time check for channel editorial roles (>= GRModerator). +`APIDeleteChatItem` group path: +- `CIDMHistory` - validates `publicGroupEditor` (channel + role >= GRModerator), sends `XMsgDel` with `onlyHistory = True`, no time check. Rejected for non-channels and insufficient role. +- `CIDMInternal` - rejected for channel editorial roles (they should use `CIDMHistory` instead). +- `CIDMBroadcast` - unchanged (24-hour time check applies to everyone). -**UI (iOS + Kotlin)** - remove the "delete for me / delete for everyone" question for channel owners/admins/moderators. In channels, editorial deletion is always for everyone. The "delete for me only" option makes no sense for a publication. +### Subscriber.hs -### Strings +Relay `processEvent`: when `XMsgDel` has `onlyHistory = True`, processes the delete locally (marks item in relay's store) but returns `Nothing` for the delivery task - no forwarding to subscribers. -`group_members_can_delete_channel` - "(24 hours)" should note that this limit applies to subscribers, not owners. Owners can delete at any time. +Subscriber `processForwardedMsg`: ignores the `onlyHistory` flag (wildcard match) - if a message somehow arrives, process as normal delete. -Other `(24 hours)` strings are for direct chats and p2p groups - unchanged. +### Types.hs -### Scope +`publicGroupEditor :: GroupInfo -> GroupMember -> Bool` - shared predicate for channel editorial role check. -In channels, owners/admins/moderators (role >= GRModerator) get unlimited delete of their own content - they are the editorial team. Members/subscribers stay on 24-hour limit. Moderation of others' content is already unlimited (separate code path, no time check). +### UI (iOS + Kotlin) -## Release order +Delete dialog for channel editorial roles (owner/admin/moderator): +- First button: "Delete from history" (iOS) / "From history" (Kotlin) - always available, sends `CIDMHistory` +- Second button: "For everyone" - only within 24-hour window, sends `CIDMBroadcast` +- No "Delete for me" option for editorial roles -1. PR 1 ships. Old owners can't send old deletions yet, so nothing changes for subscribers. New subscribers are ready. -2. PR 2 ships. Owners can now delete old content. New subscribers honor it. Old subscribers silently ignore (message stays - acceptable degradation, not an error after PR 1). +Batch selection follows the same logic - "From history" replaces "Delete for me" for editorial roles. + +API error handling: `apiDeleteChatItems` and `apiDeleteMemberChatItems` now show error alerts on failure (was silently swallowed on Android). + +## Backward compatibility + +- Old relays: don't recognize `onlyHistory`, process `XMsgDel` normally (forward to subscribers). Acceptable - the delete reaches subscribers, which is more than the owner intended but not harmful. +- Old subscribers: `onlyHistory` field is ignored (not in their parser, and the relay won't forward it anyway). +- Old owners: never send `onlyHistory = True`, behavior unchanged. + +## Test + +`testChannelMessageDeleteFromHistory` - owner sends message, deletes with `history` mode, verifies relay processes locally but subscribers don't receive deletion, verifies `internal` mode is rejected for channel editorial roles. diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 9438636587..3f66579969 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -748,11 +748,12 @@ processChatCommand vr nm = \case deletions <- case mode of CIDMInternal -> deleteDirectCIs user ct items CIDMInternalMark -> markDirectCIsDeleted user ct items =<< liftIO getCurrentTime + CIDMHistory -> throwChatError CEInvalidChatItemDelete CIDMBroadcast -> do assertDeletable items assertDirectAllowed user MDSnd ct XMsgDel_ let msgIds = itemsMsgIds items - events = map (\msgId -> XMsgDel msgId Nothing Nothing) msgIds + events = map (\msgId -> XMsgDel msgId Nothing Nothing False) msgIds forM_ (L.nonEmpty events) $ \events' -> sendDirectContactMessages user ct events' if featureAllowed SCFFullDelete forUser ct @@ -764,8 +765,9 @@ processChatCommand vr nm = \case -- TODO [knocking] check scope for all items? chatScopeInfo <- mapM (getChatScopeInfo vr user) scope deletions <- case mode of - CIDMInternal -> do - deleteGroupCIs user gInfo chatScopeInfo items Nothing =<< liftIO getCurrentTime + CIDMInternal + | publicGroupEditor gInfo (membership gInfo) -> throwChatError CEInvalidChatItemDelete + | otherwise -> deleteGroupCIs user gInfo chatScopeInfo items Nothing =<< liftIO getCurrentTime CIDMInternalMark -> do markGroupCIsDeleted user gInfo chatScopeInfo items Nothing =<< liftIO getCurrentTime CIDMBroadcast -> do @@ -773,9 +775,15 @@ processChatCommand vr nm = \case assertDeletable items assertUserGroupRole gInfo GRObserver -- can still delete messages sent earlier let msgIds = itemsMsgIds items - events = L.nonEmpty $ map (\msgId -> XMsgDel msgId Nothing $ toMsgScope gInfo <$> chatScopeInfo) msgIds + events = L.nonEmpty $ map (\msgId -> XMsgDel msgId Nothing (toMsgScope gInfo <$> chatScopeInfo) False) msgIds + mapM_ (sendGroupMessages user gInfo Nothing False recipients) events + delGroupChatItems user gInfo chatScopeInfo items False + CIDMHistory -> do + unless (publicGroupEditor gInfo (membership gInfo)) $ throwChatError CEInvalidChatItemDelete + recipients <- getGroupRecipients vr user gInfo chatScopeInfo groupKnockingVersion + let msgIds = itemsMsgIds items + events = L.nonEmpty $ map (\msgId -> XMsgDel msgId Nothing (toMsgScope gInfo <$> chatScopeInfo) True) msgIds mapM_ (sendGroupMessages user gInfo Nothing False recipients) events - -- TODO delGroupChatItems sends deletion events too. Are they needed? delGroupChatItems user gInfo chatScopeInfo items False pure $ CRChatItemsDeleted user deletions True False CTLocal -> do @@ -817,6 +825,7 @@ processChatCommand vr nm = \case deletions <- case mode of CIDMInternal -> deleteGroupCIs user gInfo Nothing items Nothing =<< liftIO getCurrentTime CIDMInternalMark -> markGroupCIsDeleted user gInfo Nothing items Nothing =<< liftIO getCurrentTime + CIDMHistory -> throwChatError CEInvalidChatItemDelete CIDMBroadcast -> do ms <- withFastStore' $ \db -> getGroupModerators db vr user gInfo let recipients = filter memberCurrent ms @@ -3814,7 +3823,7 @@ processChatCommand vr nm = \case assertDeletable gInfo items assertUserGroupRole gInfo GRModerator let msgMemIds = itemsMsgMemIds gInfo items - events = L.nonEmpty $ map (\(msgId, memId) -> XMsgDel msgId memId $ toMsgScope gInfo <$> chatScopeInfo) msgMemIds + events = L.nonEmpty $ map (\(msgId, memId) -> XMsgDel msgId memId (toMsgScope gInfo <$> chatScopeInfo) False) msgMemIds mapM_ (sendGroupMessages_ user gInfo ms) events delGroupChatItems user gInfo chatScopeInfo items True where diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 771964b031..6c960c3ce8 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -511,7 +511,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = XMsgNew mc -> newContentMessage ct'' mc msg msgMeta XMsgFileDescr sharedMsgId fileDescr -> messageFileDescription ct'' sharedMsgId fileDescr XMsgUpdate sharedMsgId mContent _ ttl live _msgScope _ -> messageUpdate ct'' sharedMsgId mContent msg msgMeta ttl live - XMsgDel sharedMsgId _ _ -> messageDelete ct'' sharedMsgId msg msgMeta + XMsgDel sharedMsgId _ _ _ -> messageDelete ct'' sharedMsgId msg msgMeta XMsgReact sharedMsgId _ _ reaction add -> directMsgReaction ct'' sharedMsgId reaction add msg msgMeta -- TODO discontinue XFile XFile fInv -> processFileInvitation' ct'' fInv msg msgMeta @@ -999,7 +999,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = checkSendAsGroup asGroup_ $ memberCanSend (Just m'') msgScope $ groupMessageUpdate gInfo' (Just m'') sharedMsgId mContent mentions msgScope msg brokerTs ttl live asGroup_ - XMsgDel sharedMsgId memberId_ scope_ -> groupMessageDelete gInfo' (Just m'') sharedMsgId memberId_ scope_ msg brokerTs + XMsgDel sharedMsgId memberId_ scope_ onlyHistory -> + groupMessageDelete gInfo' (Just m'') sharedMsgId memberId_ scope_ onlyHistory msg brokerTs XMsgReact sharedMsgId memberId scope_ reaction add -> groupMsgReaction gInfo' m'' sharedMsgId memberId scope_ reaction add msg brokerTs -- TODO discontinue XFile XFile fInv -> Nothing <$ processGroupFileInvitation' gInfo' m'' fInv msg brokerTs @@ -2172,26 +2173,30 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = toView $ CEvtChatItemNotChanged user (AChatItem SCTGroup SMDRcv (GroupChat gInfo scopeInfo) ci) pure Nothing - groupMessageDelete :: GroupInfo -> Maybe GroupMember -> SharedMsgId -> Maybe MemberId -> Maybe MsgScope -> RcvMessage -> UTCTime -> CM (Maybe DeliveryTaskContext) - groupMessageDelete gInfo@GroupInfo {membership} m_ sharedMsgId sndMemberId_ scope_ rcvMsg brokerTs = + groupMessageDelete :: GroupInfo -> Maybe GroupMember -> SharedMsgId -> Maybe MemberId -> Maybe MsgScope -> Bool -> RcvMessage -> UTCTime -> CM (Maybe DeliveryTaskContext) + groupMessageDelete gInfo@GroupInfo {membership} m_ sharedMsgId sndMemberId_ scope_ onlyHistory rcvMsg brokerTs = findItem >>= \case Right cci@(CChatItem _ ci@ChatItem {chatDir}) -> case (chatDir, m_) of (CIGroupRcv mem, Just m@GroupMember {memberId}) -> let msgMemberId = fromMaybe memberId sndMemberId_ + isAuthor = sameMemberId memberId mem in case sndMemberId_ of -- regular deletion Nothing - | sameMemberId memberId mem && (publicGroupItemDeletable mem || rcvItemDeletable ci brokerTs) -> + | isAuthor && onlyHistory && publicGroupEditor gInfo m -> + delete cci False Nothing $> Nothing + | isAuthor && not onlyHistory && rcvItemDeletable ci brokerTs -> delete cci False Nothing | otherwise -> messageError "x.msg.del: member attempted invalid message delete" $> Nothing -- moderation (not limited by time) Just _ - | sameMemberId memberId mem && msgMemberId == memberId -> + | isAuthor && msgMemberId == memberId -> delete cci False (Just m) | otherwise -> moderate m mem cci (CIChannelRcv, _) - | isNothing sndMemberId_ && isOwner -> delete cci True Nothing + | isNothing sndMemberId_ && isOwner -> + (if onlyHistory then ($> Nothing) else id) $ delete cci True Nothing | otherwise -> messageError "x.msg.del: invalid channel message delete" $> Nothing (CIGroupSnd, Just m) -> moderate m membership cci _ -> messageError "x.msg.del: invalid message deletion" $> Nothing @@ -2217,7 +2222,6 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = messageError ("x.msg.del: channel message not found, " <> tshow e) $> Nothing where isOwner = maybe True (\m -> memberRole' m == GROwner) m_ - publicGroupItemDeletable mem = useRelays' gInfo && memberRole' mem >= GRModerator RcvMessage {msgId} = rcvMsg findItem = do let tryMemberLookup mId = @@ -3387,7 +3391,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = XMsgFileDescr sharedMsgId fileDescr -> void $ groupMessageFileDescription gInfo author_ sharedMsgId fileDescr XMsgUpdate sharedMsgId mContent mentions ttl live msgScope asGroup_ -> void $ memberCanSend author_ msgScope $ groupMessageUpdate gInfo author_ sharedMsgId mContent mentions msgScope rcvMsg msgTs ttl live asGroup_ - XMsgDel sharedMsgId memId scope_ -> void $ groupMessageDelete gInfo author_ sharedMsgId memId scope_ rcvMsg msgTs + XMsgDel sharedMsgId memId scope_ _ -> void $ groupMessageDelete gInfo author_ sharedMsgId memId scope_ False rcvMsg msgTs XMsgReact sharedMsgId memId scope_ reaction add -> withAuthor XMsgReact_ $ \author -> void $ groupMsgReaction gInfo author sharedMsgId memId scope_ reaction add rcvMsg msgTs XFileCancel sharedMsgId -> void $ xFileCancelGroup gInfo author_ sharedMsgId XInfo p -> withAuthor XInfo_ $ \author -> void $ xInfoMember gInfo author p rcvMsg msgTs diff --git a/src/Simplex/Chat/Messages/CIContent.hs b/src/Simplex/Chat/Messages/CIContent.hs index 2dc751d6bb..dcc34de6da 100644 --- a/src/Simplex/Chat/Messages/CIContent.hs +++ b/src/Simplex/Chat/Messages/CIContent.hs @@ -105,7 +105,7 @@ msgDirectionIntP = \case 1 -> Just MDSnd _ -> Nothing -data CIDeleteMode = CIDMBroadcast | CIDMInternal | CIDMInternalMark +data CIDeleteMode = CIDMBroadcast | CIDMInternal | CIDMInternalMark | CIDMHistory deriving (Show) instance StrEncoding CIDeleteMode where @@ -113,11 +113,13 @@ instance StrEncoding CIDeleteMode where CIDMBroadcast -> "broadcast" CIDMInternal -> "internal" CIDMInternalMark -> "internalMark" + CIDMHistory -> "history" strP = A.takeTill (== ' ') >>= \case "broadcast" -> pure CIDMBroadcast "internal" -> pure CIDMInternal "internalMark" -> pure CIDMInternalMark + "history" -> pure CIDMHistory _ -> fail "bad CIDeleteMode" instance ToJSON CIDeleteMode where @@ -132,6 +134,7 @@ ciDeleteModeToText = \case CIDMBroadcast -> "this item is deleted (broadcast)" CIDMInternal -> "this item is deleted (locally)" CIDMInternalMark -> "this item is deleted (locally)" + CIDMHistory -> "this item is deleted (from history)" -- This type is used both in API and in DB, so we use different JSON encodings for the database and for the API -- ! Nested sum types also have to use different encodings for database and API diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 02efa7064c..3436a64132 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -423,7 +423,7 @@ data ChatMsgEvent (e :: MsgEncoding) where XMsgNew :: MsgContainer -> ChatMsgEvent 'Json XMsgFileDescr :: {msgId :: SharedMsgId, fileDescr :: FileDescr} -> ChatMsgEvent 'Json XMsgUpdate :: {msgId :: SharedMsgId, content :: MsgContent, mentions :: Map MemberName MsgMention, ttl :: Maybe Int, live :: Maybe Bool, scope :: Maybe MsgScope, asGroup :: Maybe Bool} -> ChatMsgEvent 'Json - XMsgDel :: {msgId :: SharedMsgId, memberId :: Maybe MemberId, scope :: Maybe MsgScope} -> ChatMsgEvent 'Json + XMsgDel :: {msgId :: SharedMsgId, memberId :: Maybe MemberId, scope :: Maybe MsgScope, onlyHistory :: Bool} -> ChatMsgEvent 'Json XMsgDeleted :: ChatMsgEvent 'Json XMsgReact :: {msgId :: SharedMsgId, memberId :: Maybe MemberId, scope :: Maybe MsgScope, reaction :: MsgReaction, add :: Bool} -> ChatMsgEvent 'Json XFile :: FileInvitation -> ChatMsgEvent 'Json -- TODO discontinue @@ -1288,7 +1288,7 @@ appJsonToCM AppMessageJson {v, msgId, event, params} = do scope <- opt "scope" asGroup <- opt "asGroup" pure XMsgUpdate {msgId = msgId', content, mentions, ttl, live, scope, asGroup} - XMsgDel_ -> XMsgDel <$> p "msgId" <*> opt "memberId" <*> opt "scope" + XMsgDel_ -> XMsgDel <$> p "msgId" <*> opt "memberId" <*> opt "scope" <*> (fromMaybe False <$> opt "onlyHistory") XMsgDeleted_ -> pure XMsgDeleted XMsgReact_ -> XMsgReact <$> p "msgId" <*> opt "memberId" <*> opt "scope" <*> p "reaction" <*> p "add" XFile_ -> XFile <$> p "file" @@ -1366,7 +1366,7 @@ chatToAppMessage chatMsg@ChatMessage {chatVRange, msgId, chatMsgEvent} = case en _ -> JM.empty XMsgFileDescr msgId' fileDescr -> o ["msgId" .= msgId', "fileDescr" .= fileDescr] XMsgUpdate {msgId = msgId', content, mentions, ttl, live, scope, asGroup} -> o $ ("asGroup" .=? asGroup) $ ("ttl" .=? ttl) $ ("live" .=? live) $ ("scope" .=? scope) $ ("mentions" .=? nonEmptyMap mentions) ["msgId" .= msgId', "content" .= content] - XMsgDel msgId' memberId scope -> o $ ("memberId" .=? memberId) $ ("scope" .=? scope) ["msgId" .= msgId'] + XMsgDel msgId' memberId scope onlyHistory -> o $ ("memberId" .=? memberId) $ ("scope" .=? scope) $ ("onlyHistory" .=? justTrue onlyHistory) ["msgId" .= msgId'] XMsgDeleted -> JM.empty XMsgReact msgId' memberId scope reaction add -> o $ ("memberId" .=? memberId) $ ("scope" .=? scope) ["msgId" .= msgId', "reaction" .= reaction, "add" .= add] XFile fileInv -> o ["file" .= fileInv] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt index 3a15c44bd8..de1e0a093d 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt @@ -1197,9 +1197,9 @@ Query: UPDATE connections SET smp_agent_version = ? WHERE conn_id = ? Plan: SEARCH connections USING PRIMARY KEY (conn_id=?) -Query: UPDATE deleted_snd_chunk_replicas SET delay = ?, retries = retries + 1, updated_at = ? WHERE deleted_snd_chunk_replica_id = ? +Query: UPDATE connections SET smp_agent_version = ?, pq_support = ?, enable_ntfs = ? WHERE conn_id = ? Plan: -SEARCH deleted_snd_chunk_replicas USING INTEGER PRIMARY KEY (rowid=?) +SEARCH connections USING PRIMARY KEY (conn_id=?) Query: UPDATE messages SET msg_body = x'' WHERE conn_id = ? AND internal_id = ? Plan: @@ -1209,6 +1209,10 @@ Query: UPDATE ratchets SET ratchet_state = ? WHERE conn_id = ? Plan: SEARCH ratchets USING PRIMARY KEY (conn_id=?) +Query: UPDATE rcv_file_chunk_replicas SET delay = ?, retries = retries + 1, updated_at = ? WHERE rcv_file_chunk_replica_id = ? +Plan: +SEARCH rcv_file_chunk_replicas USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE rcv_file_chunk_replicas SET received = 1, updated_at = ? WHERE rcv_file_chunk_replica_id = ? Plan: SEARCH rcv_file_chunk_replicas USING INTEGER PRIMARY KEY (rowid=?) diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt index ba0302b662..f4590f48c9 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -1405,14 +1405,6 @@ SEARCH users USING INTEGER PRIMARY KEY (rowid=?) INDEX 2 SEARCH users USING INDEX sqlite_autoindex_users_1 (contact_id=?) -Query: - SELECT COUNT(1) FROM group_members - WHERE group_id = ? AND member_role = ? - AND member_status IN (?,?,?,?,?,?,?) - -Plan: -SEARCH group_members USING INDEX idx_group_members_group_id_index_in_group (group_id=?) - Query: SELECT agent_conn_id FROM ( SELECT @@ -4642,6 +4634,15 @@ Query: Plan: +Query: + INSERT INTO connections ( + user_id, agent_conn_id, conn_status, conn_type, contact_conn_initiated, + group_member_id, via_short_link_contact, custom_user_profile_id, via_group_link, + created_at, updated_at, to_subscribe + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + +Plan: + Query: INSERT INTO connections ( user_id, agent_conn_id, conn_status, conn_type, contact_conn_initiated, @@ -4927,6 +4928,16 @@ Query: Plan: SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) +Query: + UPDATE connections + SET via_contact_uri = ?, via_contact_uri_hash = ?, group_link_id = ?, + conn_chat_version = ?, pq_support = ?, pq_encryption = ?, + updated_at = ? + WHERE user_id = ? AND connection_id = ? + +Plan: +SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE connections_sync SET should_sync = 0, last_sync_ts = ? @@ -5405,6 +5416,26 @@ SCAN chat_items USING COVERING INDEX idx_chat_items_group_member_id SEARCH p USING INTEGER PRIMARY KEY (rowid=?) SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JOIN +Query: + SELECT + m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, + m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + m.created_at, m.updated_at, + m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, + c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id, + c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version + FROM group_members m + JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) + LEFT JOIN connections c ON c.group_member_id = m.group_member_id + JOIN group_member_status_predicates sp ON m.member_status = sp.member_status WHERE m.group_id = ? AND m.relay_link = ? AND sp.current_member = 1 +Plan: +SEARCH m USING INDEX idx_group_members_group_id_index_in_group (group_id=?) +SEARCH sp USING INDEX sqlite_autoindex_group_member_status_predicates_1 (member_status=?) +SEARCH p USING INTEGER PRIMARY KEY (rowid=?) +SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JOIN + Query: SELECT m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, @@ -5500,25 +5531,6 @@ SEARCH m USING INDEX sqlite_autoindex_group_members_1 (group_id=? AND member_id= SEARCH p USING INTEGER PRIMARY KEY (rowid=?) SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JOIN -Query: - SELECT - m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, - m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, - m.created_at, m.updated_at, - m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, - c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, - c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id, - c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, - c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version - FROM group_members m - JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) - LEFT JOIN connections c ON c.group_member_id = m.group_member_id - WHERE m.group_id = ? AND m.relay_link = ? -Plan: -SEARCH m USING INDEX idx_group_members_group_id_index_in_group (group_id=?) -SEARCH p USING INTEGER PRIMARY KEY (rowid=?) -SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JOIN - Query: SELECT m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, @@ -6590,6 +6602,11 @@ Query: SELECT COUNT(1) FROM group_members WHERE member_role = 'owner' AND member Plan: SCAN group_members +Query: SELECT COUNT(1) FROM group_members m JOIN group_member_status_predicates sp ON m.member_status = sp.member_status WHERE m.group_id = ? AND m.member_role = ? AND sp.current_member = 1 +Plan: +SEARCH m USING INDEX idx_group_members_group_id_index_in_group (group_id=?) +SEARCH sp USING INDEX sqlite_autoindex_group_member_status_predicates_1 (member_status=?) + Query: SELECT COUNT(1) FROM groups WHERE user_id = ? AND chat_item_ttl > 0 Plan: SEARCH groups USING INDEX sqlite_autoindex_groups_2 (user_id=?) diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index b068e6b679..e2efcdf6d6 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -494,6 +494,9 @@ data GroupInfo = GroupInfo useRelays' :: GroupInfo -> Bool useRelays' GroupInfo {useRelays} = isTrue useRelays +publicGroupEditor :: GroupInfo -> GroupMember -> Bool +publicGroupEditor gInfo mem = useRelays' gInfo && memberRole' mem >= GRModerator + groupId' :: GroupInfo -> GroupId groupId' GroupInfo {groupId} = groupId diff --git a/tests/Bots/DirectoryTests.hs b/tests/Bots/DirectoryTests.hs index 68acec0493..7fdd34061f 100644 --- a/tests/Bots/DirectoryTests.hs +++ b/tests/Bots/DirectoryTests.hs @@ -7,7 +7,6 @@ module Bots.DirectoryTests where import ChatClient -import ChatTests.ChatRelays (withRelay) import ChatTests.DBUtils import ChatTests.Groups (memberJoinChannel, prepareChannel1Relay) import ChatTests.Utils diff --git a/tests/ChatTests/ChatRelays.hs b/tests/ChatTests/ChatRelays.hs index 58fe1074ef..4b09347dcf 100644 --- a/tests/ChatTests/ChatRelays.hs +++ b/tests/ChatTests/ChatRelays.hs @@ -325,9 +325,6 @@ testShareChannelChannel ps = getTermLine2 :: TestCC -> IO (String, String) getTermLine2 c = (,) <$> getTermLine c <*> getTermLine c -withRelay :: HasCallStack => TestParams -> (TestCC -> IO ()) -> IO () -withRelay ps = withNewTestChatOpts ps relayTestOpts "relay" relayProfile - -- Create a public group with relay=1, wait for relay to join createChannelWithRelay :: HasCallStack => String -> TestCC -> TestCC -> IO () createChannelWithRelay gName owner relay = do diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 467c2d8bdd..6ceb3c2cbe 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -275,6 +275,7 @@ chatGroupTests = do describe "channel message operations" $ do it "should update channel message" testChannelMessageUpdate it "should delete channel message" testChannelMessageDelete + it "should delete channel message from history" testChannelMessageDeleteFromHistory it "should send and receive channel message file" testChannelMessageFile it "should cancel channel message file" testChannelMessageFileCancel it "should quote channel message" testChannelMessageQuote @@ -8457,7 +8458,7 @@ testSupportPreferenceGroup = testSupportPreferenceChannel :: HasCallStack => TestParams -> IO () testSupportPreferenceChannel ps = withNewTestChat ps "alice" aliceProfile $ \alice -> - withNewTestChatOpts ps relayTestOpts "relay" relayProfile $ \relay -> + withNewTestChatOpts ps relayTestOpts "relay" chatRelayProfile $ \relay -> withNewTestChat ps "bob" bobProfile $ \bob -> withNewTestChat ps "cath" cathProfile $ \cath -> do (shortLink, fullLink) <- prepareChannel1Relay "team" alice relay @@ -9950,7 +9951,7 @@ testChannelCreateDeletedRelay ps = testChannelSupportScope :: HasCallStack => TestParams -> IO () testChannelSupportScope ps = withNewTestChat ps "alice" aliceProfile $ \alice -> - withNewTestChatOpts ps relayTestOpts "relay" relayProfile $ \relay -> + withNewTestChatOpts ps relayTestOpts "relay" chatRelayProfile $ \relay -> withNewTestChat ps "cath" cathProfile $ \cath -> withNewTestChat ps "dan" danProfile $ \dan -> do (shortLink, fullLink) <- prepareChannel1Relay "team" alice relay @@ -10027,6 +10028,37 @@ testChannelMessageDelete ps = bob <# "#team> [marked deleted] hello" [cath, dan, eve] *<# "#team> [marked deleted] hello" -- TODO show as forwarded +testChannelMessageDeleteFromHistory :: HasCallStack => TestParams -> IO () +testChannelMessageDeleteFromHistory ps = + testChat4 aliceProfile bobProfile cathProfile danProfile test ps + where + test alice bob cath dan = withRelay ps $ \relay -> do + (shortLink, fullLink) <- prepareChannel1Relay "team" alice relay + memberJoinChannel "team" [relay] [alice] shortLink fullLink bob + memberJoinChannel "team" [relay] [alice] shortLink fullLink cath + + alice #> "#team hello" + relay <# "#team> hello" + [bob, cath] *<# "#team> hello [>>]" + + -- owner deletes from history (relay processes locally but doesn't forward) + msgId <- lastItemId alice + alice #$> ("/_delete item #1 " <> msgId <> " history", id, "message marked deleted") + relay <# "#team> [marked deleted] hello" + + -- subscribers don't receive deletion - next message arrives cleanly + alice #> "#team still here" + relay <# "#team> still here" + [bob, cath] *<# "#team> still here [>>]" + + -- internal delete rejected for channel owner + msgId2 <- lastItemId alice + alice ##> ("/_delete item #1 " <> msgId2 <> " internal") + alice <## "cannot delete this item" + + memberJoinChannel "team" [relay] [alice] shortLink fullLink dan + dan <# "#team> still here [>>]" + testChannelMessageFile :: HasCallStack => TestParams -> IO () testChannelMessageFile ps = withNewTestChat ps "alice" aliceProfile $ \alice -> diff --git a/tests/ChatTests/Utils.hs b/tests/ChatTests/Utils.hs index d42e833c39..4b28229348 100644 --- a/tests/ChatTests/Utils.hs +++ b/tests/ChatTests/Utils.hs @@ -81,8 +81,8 @@ frankProfile = mkProfile "frank" "Frank" Nothing businessProfile :: Profile businessProfile = mkProfile "biz" "Biz Inc" Nothing -relayProfile :: Profile -relayProfile = mkProfile "relay" "Relay" Nothing +chatRelayProfile :: Profile +chatRelayProfile = mkProfile "relay" "Relay" Nothing mkProfile :: T.Text -> T.Text -> Maybe ImageData -> Profile mkProfile displayName descr image = Profile {displayName, fullName = "", shortDescr = Just descr, image, contactLink = Nothing, peerType = Nothing, preferences = defaultPrefs} @@ -149,6 +149,9 @@ runTestCfg3 aliceCfg bobCfg cathCfg runTest ps = withNewTestChatCfg ps cathCfg "cath" cathProfile $ \cath -> runTest alice bob cath +withRelay :: HasCallStack => TestParams -> (TestCC -> IO ()) -> IO () +withRelay ps = withNewTestChatOpts ps relayTestOpts "relay" chatRelayProfile + -- | test sending direct messages (<##>) :: HasCallStack => TestCC -> TestCC -> IO () cc1 <##> cc2 = do diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs index 01399f6bbb..aef41e90d2 100644 --- a/tests/ProtocolTests.hs +++ b/tests/ProtocolTests.hs @@ -191,7 +191,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do #==# XMsgUpdate (SharedMsgId "\1\2\3\4") (MCText "hello") [] Nothing Nothing Nothing Nothing it "x.msg.del" $ "{\"v\":\"1\",\"event\":\"x.msg.del\",\"params\":{\"msgId\":\"AQIDBA==\"}}" - #==# XMsgDel (SharedMsgId "\1\2\3\4") Nothing Nothing + #==# XMsgDel (SharedMsgId "\1\2\3\4") Nothing Nothing False it "x.msg.deleted" $ "{\"v\":\"1\",\"event\":\"x.msg.deleted\",\"params\":{}}" #==# XMsgDeleted