From 7497b90e7c93ae08c225c12e1f927b2911451205 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Wed, 13 May 2026 15:24:52 +0100 Subject: [PATCH 01/14] core, ui: allow indefinite deletion from history for public channel/group owners/moderators (#6972) * Revert "core: forward compatible support for owners/admins/moderations deleting channel and public group messages without limitations (#6962)" This reverts commit 08108ebabba8dd0e7bccd6a150fe988dd301ce73. * core, ui: allow indefinite deletion from history for public channel/group owners/moderators * style Co-authored-by: Evgeny * refactor * show error on deletion * better alerts * test * plan * simplify test * bot api docs * refactor * test that removed from history is not delivered to the new subscribers * fix, refactor * fix * rename * rename predicate in UI * rename * do not forward channel deletions from history * remove redundant check --------- Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> --- apps/ios/Shared/Views/Chat/ChatView.swift | 34 +++++++-- apps/ios/SimpleXChat/ChatTypes.swift | 1 + .../chat/simplex/common/model/ChatModel.kt | 3 +- .../chat/simplex/common/model/SimpleXAPI.kt | 4 +- .../simplex/common/views/chat/ChatView.kt | 8 ++- .../common/views/chat/item/ChatItemView.kt | 52 ++++++++------ .../commonMain/resources/MR/base/strings.xml | 2 + bots/api/COMMANDS.md | 2 +- bots/api/TYPES.md | 1 + .../types/typescript/src/types.ts | 1 + .../src/simplex_chat/types/_types.py | 2 +- ...26-05-11-channel-owner-unlimited-delete.md | 55 +++++++++----- src/Simplex/Chat/Library/Commands.hs | 21 ++++-- src/Simplex/Chat/Library/Subscriber.hs | 22 +++--- src/Simplex/Chat/Messages/CIContent.hs | 5 +- src/Simplex/Chat/Protocol.hs | 6 +- .../SQLite/Migrations/agent_query_plans.txt | 8 ++- .../SQLite/Migrations/chat_query_plans.txt | 71 ++++++++++++------- src/Simplex/Chat/Types.hs | 3 + tests/Bots/DirectoryTests.hs | 1 - tests/ChatTests/ChatRelays.hs | 3 - tests/ChatTests/Groups.hs | 36 +++++++++- tests/ChatTests/Utils.hs | 7 +- tests/ProtocolTests.hs | 2 +- 24 files changed, 246 insertions(+), 104 deletions(-) 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 From c82bf0529352c9d99f7578d080eba5514358c080 Mon Sep 17 00:00:00 2001 From: Narasimha-sc <166327228+Narasimha-sc@users.noreply.github.com> Date: Wed, 13 May 2026 15:43:06 +0000 Subject: [PATCH 02/14] android, desktop: hide private notes from share channel picker; hide share via chat on plain groups (#6958) * ui: hide saved messages from share channel picker; hide share via chat on plain groups Co-Authored-By: Claude Opus 4.7 (1M context) * plans: justify share channel link picker filter and group-link button gate Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .../common/views/chat/group/GroupLinkView.kt | 2 +- .../common/views/chatlist/ShareListView.kt | 2 +- plans/2026-05-13-fix-group-link-share.md | 122 ++++++++++++++++++ 3 files changed, 124 insertions(+), 2 deletions(-) create mode 100644 plans/2026-05-13-fix-group-link-share.md diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt index 57ba0fbd88..673f72bb4e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt @@ -258,7 +258,7 @@ fun GroupLinkLayout( iconColor = MaterialTheme.colors.primary, textColor = MaterialTheme.colors.primary, ) - if (shareGroupInfo != null) { + if (shareGroupInfo != null && isChannel) { SettingsActionItem( painterResource(MR.images.ic_forward), stringResource(MR.strings.share_via_chat), diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt index 2be17052ad..96af5337d0 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt @@ -196,7 +196,7 @@ private fun ShareList( val oneHandUI = remember { appPrefs.oneHandUI.state } val chats by remember(search) { derivedStateOf { - val sorted = chatModel.chats.value.toList().filter { it.chatInfo.ready && it.chatInfo.sendMsgEnabled }.sortedByDescending { it.chatInfo is ChatInfo.Local } + val sorted = chatModel.chats.value.toList().filter { it.chatInfo.ready && it.chatInfo.sendMsgEnabled && !(chatModel.sharedContent.value is SharedContent.ChatLink && it.chatInfo is ChatInfo.Local) }.sortedByDescending { it.chatInfo is ChatInfo.Local } filteredChats(mutableStateOf(false), mutableStateOf(null), search, sorted) } } diff --git a/plans/2026-05-13-fix-group-link-share.md b/plans/2026-05-13-fix-group-link-share.md new file mode 100644 index 0000000000..65c9999156 --- /dev/null +++ b/plans/2026-05-13-fix-group-link-share.md @@ -0,0 +1,122 @@ +# Share Channel Link — Filter Saved Messages, Gate Plain Groups (Multiplatform) + +PR: [#6958](https://github.com/simplex-chat/simplex-chat/pull/6958) · branch `nd/fix-group-link-share` · final commit `312072a5e` + +## 1. The bug + +Two failures on the Android/Desktop "Share via chat" flow for a channel link, both ending in a server error string the user sees in a red toast: + +1. **Picking Saved Messages as the destination.** Server returns `chat commandError Failed reading: empty`. The user cannot share a channel link to their own note folder. +2. **Source group is not a channel.** The "Share via chat" button on the link-management screen (`GroupLinkView.kt`) renders for plain groups too. Tapping it produces `chat commandError not a public group`. + +Both are reachable from the multiplatform client only. iOS does not hit either: it uses a different picker filter and gates the button by `publicGroup != nil` (which already implies `useRelays`). + +## 2. Root cause + +### Bug #1 — Saved Messages is not a valid destination for this command + +`APIShareChatMsgContent` is parsed with `sendRefP` (`src/Simplex/Chat/Library/Commands.hs:5426`): + +```haskell +sendRefP = + (A.char '@' $> SRDirect <*> A.decimal) + <|> (A.char '#' $> SRGroup <*> A.decimal <*> optional gcScopeP <*> asGroupP) +``` + +The client emits `*` for `ChatType.Local` (Saved Messages) via the standard `chatType.rawValue` prefix. `sendRefP` has no `*` branch, attoparsec returns `Failed reading: empty`, the handler never runs. + +This is the correct server behaviour. Sharing a channel link to one's own note folder is not a meaningful operation — the user can save the channel link by other means (copy from the channel-link screen). The client offered the destination by accident: the picker (`ShareListView.kt:199`) included `ChatInfo.Local` for every `SharedContent` flavour, including `SharedContent.ChatLink`. + +### Bug #2 — share button rendered for plain groups + +`GroupLinkView.kt:261` renders the share-via-chat button whenever `shareGroupInfo` is non-null: + +```kotlin +if (shareGroupInfo != null) { + SettingsActionItem(painterResource(MR.images.ic_forward), stringResource(MR.strings.share_via_chat), …) +} +``` + +Two callers pass `shareGroupInfo` — `GroupChatInfoView.kt:170` and `ChatView.kt:3207` — both pass it unconditionally as `shareGroupInfo = groupInfo`. So a plain group (`useRelays == false`) ends up with a button whose action calls `APIShareChatMsgContent` against a source the server refuses with `not a public group`. + +The sibling button in `GroupChatInfoView.kt:602` is already wrapped in `if (groupInfo.useRelays) { … if (channelLink != null) … }`. `GroupLinkView` was missing the equivalent gate. + +## 3. Approaches considered + +| # | Approach | Note | +|---|----------|------| +| A | Widen the server: add `SRLocal NoteFolderId` to `SendRef` with a parser branch; replace the `Nothing → throwCmdError "not a public group"` arm with a build-from-short-link path producing an unsigned `MCChat`. | The first commit on this branch (`7d4648b9f`). Widens the protocol surface (a new destination grammar) and the message domain (an unsigned card variant whose recipient story is unspecified), to make reachable a feature the user can achieve another way. Rejected as poor design. | +| B | **Final** — client-side, multiplatform only: filter `ChatInfo.Local` out of the picker for `SharedContent.ChatLink`; add `&& isChannel` to the button gate in `GroupLinkView`. | Two single-line changes. The failing paths become unreachable from the UI. Forward to Saved Messages, all other share flavours, iOS, and Haskell are untouched. | + +Approach B is the smaller fix and aligns the UI with the server's grammar — the picker no longer offers a destination the server refuses, and the button no longer appears where there is no channel link to share. + +## 4. Final implementation + +### 4.1 `ShareListView.kt:199` — exclude Local from the channel-link picker + +```kotlin +val sorted = chatModel.chats.value.toList().filter { it.chatInfo.ready && it.chatInfo.sendMsgEnabled && !(chatModel.sharedContent.value is SharedContent.ChatLink && it.chatInfo is ChatInfo.Local) }.sortedByDescending { it.chatInfo is ChatInfo.Local } +``` + +One clause appended to the existing predicate: `&& !(chatModel.sharedContent.value is SharedContent.ChatLink && it.chatInfo is ChatInfo.Local)`. Reads as "exclude (sharing-link AND local)". Kotlin's `is` binds tighter than `&&`, so the inner parens are only around the AND for `!` to negate. + +`chatModel.sharedContent.value` is read inside the filter lambda, once per chat. Inside `derivedStateOf`, each read registers a Compose dependency — same dependency set as a hoisted `val` would produce, and small enough (`chats.value.size`) that there is no observable cost. The hunk is +1/-1. + +The trailing `sortedByDescending { it.chatInfo is ChatInfo.Local }` is left untouched. It is a no-op when no Locals are present, and removing it would touch a line that does not need to change. + +Other `SharedContent` flavours (`Text`, `Media`, `File`, `Forward`) keep their previous behaviour. Forwarding to Saved Messages still works — the new clause is false when `sharedContent` is not `ChatLink`. + +### 4.2 `GroupLinkView.kt:261` — gate the share button by `isChannel` + +```kotlin +if (shareGroupInfo != null && isChannel) { + SettingsActionItem(painterResource(MR.images.ic_forward), stringResource(MR.strings.share_via_chat), …) +} +``` + +`isChannel` is the existing parameter of this view (declared at line 35 and 175, used for channel-specific rows throughout the file). Both callers already pass `isChannel = groupInfo.useRelays`, so the new clause is equivalent to "render only when `useRelays == true`" — matching the rule for the sibling button in `GroupChatInfoView.kt:602`. The hunk is +1/-1. + +### 4.3 What is *not* changed + +- **Haskell.** `src/Simplex/Chat/Controller.hs` and `src/Simplex/Chat/Library/Commands.hs` stay at master. `SendRef` has no `SRLocal` constructor; `APIShareChatMsgContent` still refuses non-public sources with `not a public group`; `sendRefP` has no `*` branch. The client just never sends those commands now. +- **iOS.** No file under `apps/ios/` is touched. `filterChatsToForwardTo` (`apps/ios/SimpleXChat/ChatUtils.swift:56`) still inserts `.local` at index 0 — iOS's share-channel picker uses the same function as forward, and changing it would touch the forward picker. `GroupLinkView.swift:110` already gates by `groupInfo?.groupProfile.publicGroup != nil`, which already implies `useRelays` on iOS (only channels carry a `publicGroup` profile in practice). Neither failure has been reported on iOS through this flow. +- **`GroupChatInfoView.kt`** `ShareViaChatButton`. Already wrapped in `if (groupInfo.useRelays) { … if (channelLink != null) … }` (lines 602–614). Nothing to change. +- **`ChatItemForwardingView`** equivalent on iOS, `ComposeView.kt` consumer of `SharedContent.ChatLink`, the `apiShareChatMsgContent` API surface, and every other share/forward path. The new filter clause is false outside `SharedContent.ChatLink`, so all other consumers see the same picker. + +## 5. Why this works + +The server is the source of truth for which destinations and which sources are valid for `APIShareChatMsgContent`: + +- Destinations: `@` (direct), `#` (group / scope) — defined by `sendRefP`. Local (`*`) is rejected as a parse failure, by construction. +- Sources: groups with a `publicGroup` profile and `groupLink` — defined by the `Just PublicGroupProfile {…}` arm of `APIShareChatMsgContent`. + +The client's job is to offer choices the server will accept. Bug #1 was an offer mismatch (Local in the destination list); bug #2 was an offer mismatch (button rendered on a source with no `publicGroup`). The fix narrows the client's offers to match the server's grammar — without changing the server, and without adding state that has to be kept in sync. + +Two booleans, two single-line changes. The picker filter clause is false for every `SharedContent` flavour that is not `ChatLink`, so no other share path is affected. The button gate reuses `isChannel`, the existing parameter that the rest of the file already uses for channel-vs-group dispatch. + +## 6. Behaviour changes — full inventory + +1. **Picking Saved Messages in the share-channel-link picker is no longer possible.** This is the bug fix. The destination simply isn't listed. +2. **"Share via chat" in `GroupLinkView` is hidden on plain groups.** Previously rendered but unusable; now correctly hidden. +3. **Forward picker, Media picker, File picker, Text picker — unchanged.** New filter clause is false for every non-`ChatLink` `SharedContent`. +4. **`ShareViaChatButton` in `GroupChatInfoView` — unchanged.** Already gated correctly. + +Nothing else changes. Verified by reading the diff against master line-by-line. + +## 7. Verification + +1. **Linux desktop build** succeeded end-to-end against the current branch tip (`312072a5e`), producing `SimpleX_Chat-x86_64-fix-group-link-share.AppImage` via `bash /home/user/build/linux.sh`. +2. **Diff is exactly two single-line hunks** in two files: + - `apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt | 2 +-` + - `apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt | 2 +-` +3. **Manual on desktop:** + - Open a public channel that is not yours → profile → "Share via chat" → picker shows direct + group destinations only, no "Saved Messages" row → pick a contact → channel-link card appears in compose. + - Open a plain group → group-link management screen → no "Share via chat" button. + - Open a channel's group-link management screen → "Share via chat" button still appears. + - Forward an existing message → picker still shows Saved Messages at the top (regression check). + +## 8. Trade-offs and follow-ups + +1. **iOS retains the bug-#1 path latent.** `filterChatsToForwardTo` inserts `.local` for the channel-link picker on iOS. The same fix as the Kotlin one — pass `includeLocal: false` from `shareChannelPicker` — is a separate, scoped change for an iOS PR. Out of scope here. +2. **`chatModel.sharedContent.value` read inside the filter lambda** evaluates per chat in the predicate, rather than hoisted to a `val` once. Diff minimality wins: the original line was one line, the new line is one line. If profiling ever showed this on a hot path (it does not — `derivedStateOf` and chat-list sizes), hoisting is a trivial follow-up. +3. **The `sortedByDescending { it.chatInfo is ChatInfo.Local }` call** remains in the channel-link path even though there are no Locals to sort. Removing it for that path only would require splitting the chain. Diff minimality: leave it. From 3b4bf92015e3787edc56edabf3dee66693fd5a00 Mon Sep 17 00:00:00 2001 From: Narasimha-sc <166327228+Narasimha-sc@users.noreply.github.com> Date: Wed, 13 May 2026 15:45:39 +0000 Subject: [PATCH 03/14] core: include trailing "_" and "!" characters in links (#6973) * core: include trailing "_" and "!" characters in links * docs: plan for keeping trailing "_" and "!" in links --- ...12-link-trailing-underscore-exclamation.md | 151 ++++++++++++++++++ src/Simplex/Chat/Markdown.hs | 2 + tests/MarkdownTests.hs | 4 + 3 files changed, 157 insertions(+) create mode 100644 plans/2026-05-12-link-trailing-underscore-exclamation.md diff --git a/plans/2026-05-12-link-trailing-underscore-exclamation.md b/plans/2026-05-12-link-trailing-underscore-exclamation.md new file mode 100644 index 0000000000..c92da7e8df --- /dev/null +++ b/plans/2026-05-12-link-trailing-underscore-exclamation.md @@ -0,0 +1,151 @@ +# Links: trailing `_` and `!` dropped from the highlighted link + +Design doc for the fix shipped in PR #6973. + +## Problem + +A bare URL or domain ending in `_` (or `!`) was highlighted as a link only up +to the last non-`_` character — the trailing `_` rendered as plain, +non-clickable text. For example `https://en.wikipedia.org/wiki/The_Lord_of_the_Rings_` +showed `…The_Lord_of_the_Rings` as a blue link followed by a separate, plain +`_`. Reported for `_`; the same defect applies to `!`. + +## Background — how bare links are parsed + +`parseMarkdown` (`src/Simplex/Chat/Markdown.hs`) splits a message into +fragments; a fragment that isn't a recognized markdown construct falls through +`wordP` → `wordMD`, which decides whether the "word" (the run up to the next +space) is a URI / SimpleX link / domain / email. + +To handle the very common case of a link immediately followed by sentence +punctuation — `check out https://simplex.chat.` or `(https://simplex.chat)` — +`wordMD` peels a trailing run of "punctuation" off the word and re-emits it as +`unmarked` text: + +```haskell +where + punct = T.takeWhileEnd isPunctuation' s + s' = T.dropWhileEnd isPunctuation' s + res md' = if T.null punct then md' else md' :|: unmarked punct +``` + +`isPunctuation'` is `Data.Char.isPunctuation` with exemptions for characters +that legitimately *end* a URL: `/` (trailing path separator, e.g. +`https://github.com/simplex-chat/`) and `)` (Wikipedia disambiguation, e.g. +`…/wiki/Servo_(software)`). + +All link-highlighting surfaces derive from the result of this parser: the +desktop/Android UI (`TextItemView.kt`) and iOS UI (`MsgContentView.swift`) +both call `chatParseMarkdown` (FFI into the bundled Haskell core) and style a +`Uri` / `HyperLink` fragment by its whole `text`; the compose-preview path uses +the same function; `Styled.hs` does the terminal rendering. So whatever the +parser puts inside the `Uri` fragment is exactly what gets highlighted, on +every platform. + +## Root cause + +`Data.Char.isPunctuation '_' == True` — `_` is Unicode `ConnectorPunctuation` +(`Pc`). `isPunctuation '!' == True` — `!` is `OtherPunctuation` (`Po`). Neither +was in the `isPunctuation'` exemption list, so a trailing `_` or `!` was always +stripped from the URI text. + +For `https://simplex.chat/page_name_`: + +- `punct = "_"`, `s' = "https://simplex.chat/page_name"` +- output: `Uri "https://simplex.chat/page_name" :|: unmarked "_"` + +The UI highlights the `Uri` fragment by its text, so the `_` lands outside the +blue/clickable span — exactly the reported behaviour. + +## Fix + +Add `_` and `!` to the `isPunctuation'` exemptions, alongside `/` and `)`: + +```haskell +isPunctuation' = \case + '/' -> False + ')' -> False + '_' -> False + '!' -> False + c -> isPunctuation c +``` + +`T.takeWhileEnd isPunctuation'` now stops at a trailing `_`/`!`, so the full +token is kept in `s'` and emitted as a single `Uri` fragment. Anything still +trailing it (`.`, `,`, ` …`) is peeled off as before: + +- `https://simplex.chat/page_name_` → `Uri "https://simplex.chat/page_name_"` +- `https://simplex.chat/page_name_, hello` → `Uri "…/page_name_" :|: unmarked ", hello"` +- `https://simplex.chat/page!` → `Uri "https://simplex.chat/page!"` + +## Why this is the right place + +- `wordMD`/`isPunctuation'` is the single point where bare-link text is + trimmed, and it already encodes "these characters legitimately end a link." + `_` and `!` belong in that list next to `/` and `)`. +- `_` and `!` are RFC 3986–valid URL characters (`_` is in `unreserved`, `!` is + a `sub-delim`); `_` is never sentence-ending punctuation. +- Fixing it in the parser fixes every surface at once (desktop, Android, iOS, + terminal, compose preview), because they all consume the same `FormattedText`. + A UI-layer patch would have to be repeated per platform and would leave + `Styled.hs` wrong. + +## Why a wider change is not in scope + +- The reported bug is fully resolved by the two-line addition to the exemption + `case`. Nothing more is required. +- `isPunctuation'` is shared by the URI, domain and email branches of `wordMD`. + Exempting `_`/`!` for all three is the intended behaviour, with one minor + knock-on: `user@example.com!` now renders as plain text rather than + `Email "user@example.com" :|: unmarked "!"`, because `user@example.com!` + isn't a valid email so the whole token isn't recognized. This is acceptable — + `!` is now treated consistently as part of the token everywhere — and is + preferable to splitting `isPunctuation'` into a URI predicate and an email + predicate, which adds structure for a marginal case. Phones are unaffected + (`phoneP` is a separate parser that doesn't use `isPunctuation'`). +- `good-code-v5.md` — *"Find the minimal change … the smallest structural + modification that achieves the goal."* The smallest modification that + resolves the report is two lines in the exemption `case`. + +## Backward compatibility + +Pure parsing change, no wire-format impact. `FormattedText` keeps the same +shape; only which characters fall inside a `Uri`/`Email` fragment changes. +Messages already stored keep their previously-parsed formatting — re-parsing +happens on compose / receive, not on display of stored items. An old client +receiving a message authored by a fixed client parses the raw text itself and +behaves per its own (older) rule — no incompatibility either way. + +## Verification + +`tests/MarkdownTests.hs`, `describe "text with Uri"` — four cases added: + +- `"https://simplex.chat/page_name_" <==> uri "https://simplex.chat/page_name_"` + — the trailing `_` is part of the link. +- `"https://simplex.chat/page_name_, hello" <==> uri "https://simplex.chat/page_name_" <> ", hello"` + — `_` kept, the `, hello` after it still peeled off. +- `"https://simplex.chat/page!" <==> uri "https://simplex.chat/page!"` +- `"https://simplex.chat/page!, hello" <==> uri "https://simplex.chat/page!" <> ", hello"` + +`MarkdownTests` suite: 38 examples, 0 failures. The existing exemption / peel +coverage is unchanged — `…/simplex-chat/`, `…/wiki/Servo_(software)`, +`https://simplex.chat.` → link + `.`, `https://simplex.chat, hello` → link + +`, hello`, etc. + +Manual sanity (desktop, Linux AppImage build): a message containing +`https://en.wikipedia.org/wiki/The_Lord_of_the_Rings_` highlights the whole URL +including the trailing `_`. + +## Alternatives considered and rejected + +- **Split `isPunctuation'` into a URI predicate and an email predicate** so `!` + is kept only inside URLs. Adds a second predicate and a branch solely to + preserve `Email "x@y.z" :|: unmarked "!"` on `x@y.z!` — a marginal case. The + shared predicate is simpler; rejected. +- **Strip `_`/`!` only when followed by more URL-looking text.** Requires + look-ahead the trailing-trim model doesn't have, for no real benefit — `_` + and `!` aren't sentence punctuation in the first place. +- **Extend the link span over a trailing `_` in the UI layer.** Wrong layer: + the parser is the single source of truth for `FormattedText`, consumed by + three platforms plus the terminal renderer; a UI-only patch would diverge per + platform. diff --git a/src/Simplex/Chat/Markdown.hs b/src/Simplex/Chat/Markdown.hs index 39000fde84..9325de41eb 100644 --- a/src/Simplex/Chat/Markdown.hs +++ b/src/Simplex/Chat/Markdown.hs @@ -302,6 +302,8 @@ markdownP = mconcat <$> A.many' fragmentP isPunctuation' = \case '/' -> False ')' -> False + '_' -> False + '!' -> False c -> isPunctuation c isUri s = T.length s >= 10 && any (`T.isPrefixOf` s) ["http://", "https://", "simplex:/"] -- matches what is likely to be a domain, not all valid domain names diff --git a/tests/MarkdownTests.hs b/tests/MarkdownTests.hs index 54483babec..a82e18f988 100644 --- a/tests/MarkdownTests.hs +++ b/tests/MarkdownTests.hs @@ -227,6 +227,10 @@ textWithUri = describe "text with Uri" do "https://github.com/simplex-chat/ - SimpleX on GitHub" <==> uri "https://github.com/simplex-chat/" <> " - SimpleX on GitHub" -- "SimpleX on GitHub (https://github.com/simplex-chat/)" <==> "SimpleX on GitHub (" <> uri "https://github.com/simplex-chat/" <> ")" "https://en.m.wikipedia.org/wiki/Servo_(software)" <==> uri "https://en.m.wikipedia.org/wiki/Servo_(software)" + "https://simplex.chat/page_name_" <==> uri "https://simplex.chat/page_name_" + "https://simplex.chat/page_name_, hello" <==> uri "https://simplex.chat/page_name_" <> ", hello" + "https://simplex.chat/page!" <==> uri "https://simplex.chat/page!" + "https://simplex.chat/page!, hello" <==> uri "https://simplex.chat/page!" <> ", hello" "example.com" <==> uri "example.com" "example.com." <==> uri "example.com" <> "." "example.com..." <==> uri "example.com" <> "..." From 9584992c8331b3198f5d6d370eaaceb4ab8c037a Mon Sep 17 00:00:00 2001 From: sh <37271604+shumvgolove@users.noreply.github.com> Date: Wed, 13 May 2026 15:51:00 +0000 Subject: [PATCH 04/14] simplex-chat-python: split Client from Bot, add request/response API (#6976) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * simplex-chat-python: split Client from Bot, add request/response API Client is now the base class for SimpleX participants that talk TO services (monitors, probes, automated participants). Bot extends Client with server features (address, auto-accept, welcome, commands). New methods on Client (inherited by Bot): connect_to(link) idempotent contact handshake send_and_wait(id, text) send a message and await the reply events() async iterator over chat events @on_message(contact_id=N) filter by sender in decorators BotProfile renamed to Profile (alias kept). New ContactAlreadyExistsError subclass for cleaner error handling. * simplex-chat-python: narrow event payload type per @on_event tag @client.on_event("contactConnected") now types the handler's event parameter as CEvt.ContactConnected instead of the unnarrowed CEvt.ChatEvent union — mirroring how @on_message narrows by content_type. The 50 overloads are generated by the Haskell codegen into _events.py (as a Protocol class), so new events stay in sync automatically. Client.on_event is exposed as a property typed as that Protocol; the runtime implementation is unchanged. --- bots/src/API/Docs/Generate/Python.hs | 38 +- .../src/simplex_chat/__init__.py | 15 +- .../src/simplex_chat/api.py | 18 +- .../src/simplex_chat/bot.py | 713 ++----------- .../src/simplex_chat/client.py | 955 ++++++++++++++++++ .../src/simplex_chat/filters.py | 9 + .../src/simplex_chat/types/_events.py | 318 +++++- .../tests/test_bot_registration.py | 36 +- .../tests/test_client_and_waiters.py | 616 +++++++++++ .../simplex-chat-python/tests/test_filters.py | 34 +- 10 files changed, 2089 insertions(+), 663 deletions(-) create mode 100644 packages/simplex-chat-python/src/simplex_chat/client.py create mode 100644 packages/simplex-chat-python/tests/test_client_and_waiters.py diff --git a/bots/src/API/Docs/Generate/Python.hs b/bots/src/API/Docs/Generate/Python.hs index a144aa4376..64aa1d1062 100644 --- a/bots/src/API/Docs/Generate/Python.hs +++ b/bots/src/API/Docs/Generate/Python.hs @@ -83,12 +83,48 @@ responsesCodeText = eventsCodeText :: Text eventsCodeText = ("# API Events\n# " <> autoGenerated <> "\n") - <> pythonImports + <> "from __future__ import annotations\n" + <> "from collections.abc import Awaitable, Callable\n" + <> "from typing import Literal, NotRequired, Protocol, TypedDict, overload\n" + <> "from . import _types as T\n" <> unionTypeCodePy moduleMember "T." "ChatEvent" chatEventConstrs + <> onEventProtocolCode chatEventConstrs where chatEventConstrs = L.fromList $ concatMap catEvents chatEventsDocs catEvents CECategory {mainEvents, otherEvents} = map eventType $ mainEvents ++ otherEvents +-- | Render the `OnEventDecorator` Protocol — one `__call__` overload per +-- event tag, narrowing the handler's event parameter from the unnarrowed +-- `ChatEvent` union to the specific tagged TypedDict. Plus a fallback +-- overload for `event: str` that keeps the unnarrowed shape so non-literal +-- tags don't trigger a type error. +-- +-- `Client.on_event` is typed as a `OnEventDecorator` (via a property) so +-- callers get per-tag narrowing without per-tag handwritten overloads +-- in client.py. +onEventProtocolCode :: L.NonEmpty ATUnionMember -> Text +onEventProtocolCode members = + "\n\nclass OnEventDecorator(Protocol):\n" + <> " \"\"\"Per-tag narrowing protocol for ``Client.on_event``.\n" + <> "\n" + <> " ``@client.on_event(\"contactConnected\")`` types the handler's\n" + <> " ``evt`` parameter as :class:`ContactConnected` rather than the\n" + <> " unnarrowed :data:`ChatEvent` union.\n" + <> " \"\"\"\n" + <> foldMap overloadCode (L.toList members) + <> "\n @overload\n" + <> " def __call__(self, event: str, /) -> Callable[\n" + <> " [Callable[[\"ChatEvent\"], Awaitable[None]]],\n" + <> " Callable[[\"ChatEvent\"], Awaitable[None]],\n" + <> " ]: ...\n" + where + overloadCode (ATUnionMember tag _) = + "\n @overload\n" + <> " def __call__(self, event: Literal[\"" <> T.pack tag <> "\"], /) -> Callable[\n" + <> " [Callable[[\"" <> pyConstrName tag <> "\"], Awaitable[None]]],\n" + <> " Callable[[\"" <> pyConstrName tag <> "\"], Awaitable[None]],\n" + <> " ]: ...\n" + typesCodeText :: Text typesCodeText = ("# API Types\n# " <> autoGenerated <> "\n") diff --git a/packages/simplex-chat-python/src/simplex_chat/__init__.py b/packages/simplex-chat-python/src/simplex_chat/__init__.py index dfafef123a..c353b74935 100644 --- a/packages/simplex-chat-python/src/simplex_chat/__init__.py +++ b/packages/simplex-chat-python/src/simplex_chat/__init__.py @@ -1,12 +1,21 @@ """SimpleX Chat — Python client library for chat bots.""" from ._version import __version__ -from .api import ChatApi, ChatCommandError, ConnReqType, Db, PostgresDb, SqliteDb +from .api import ( + ChatApi, + ChatCommandError, + ConnReqType, + ContactAlreadyExistsError, + Db, + PostgresDb, + SqliteDb, +) from .bot import ( Bot, BotCommand, BotProfile, ChatMessage, + Client, CommandHandler, EventHandler, FileMessage, @@ -16,6 +25,7 @@ from .bot import ( MessageHandler, Middleware, ParsedCommand, + Profile, ReportMessage, TextMessage, UnknownMessage, @@ -35,8 +45,10 @@ __all__ = [ "ChatCommandError", "ChatInitError", "ChatMessage", + "Client", "CommandHandler", "ConnReqType", + "ContactAlreadyExistsError", "CryptoArgs", "Db", "EventHandler", @@ -49,6 +61,7 @@ __all__ = [ "MigrationConfirmation", "ParsedCommand", "PostgresDb", + "Profile", "ReportMessage", "SqliteDb", "TextMessage", diff --git a/packages/simplex-chat-python/src/simplex_chat/api.py b/packages/simplex-chat-python/src/simplex_chat/api.py index 8f116c903f..ef37e28384 100644 --- a/packages/simplex-chat-python/src/simplex_chat/api.py +++ b/packages/simplex-chat-python/src/simplex_chat/api.py @@ -40,10 +40,26 @@ def _db_to_migrate_args(db: Db) -> tuple[str, str, _native.Backend]: class ChatCommandError(Exception): + """A chat command returned an unexpected response type. + + `response` is the raw wire response; `response_type` exposes its `type` + discriminator for quick checks. Subclasses cover known recoverable cases + so callers can `except ContactAlreadyExistsError` instead of inspecting + `response.get("type")` themselves. + """ + def __init__(self, message: str, response: CR.ChatResponse): super().__init__(message) self.response = response + @property + def response_type(self) -> str: + return self.response.get("type", "") # type: ignore[return-value] + + +class ContactAlreadyExistsError(ChatCommandError): + """`api_connect`/`api_connect_active_user` was called for an existing contact.""" + class ChatApi: def __init__(self, ctrl: int): @@ -481,7 +497,7 @@ class ChatApi: if r["type"] == "sentInvitation": return "contact" if r["type"] == "contactAlreadyExists": - raise ChatCommandError("contact already exists", r) + raise ContactAlreadyExistsError("contact already exists", r) raise ChatCommandError("connection error", r) async def api_accept_contact_request(self, contact_req_id: int) -> T.Contact: diff --git a/packages/simplex-chat-python/src/simplex_chat/bot.py b/packages/simplex-chat-python/src/simplex_chat/bot.py index 7414f28b87..fb511e2818 100644 --- a/packages/simplex-chat-python/src/simplex_chat/bot.py +++ b/packages/simplex-chat-python/src/simplex_chat/bot.py @@ -1,32 +1,34 @@ -"""User-facing `Bot` API: decorators, filters, Message wrapper, lifecycle.""" +"""`Bot` — Client extended with server-side features (address, auto-accept, commands).""" from __future__ import annotations -import asyncio -import logging -import os -import signal as _signal -from collections.abc import Awaitable, Callable from dataclasses import dataclass -from typing import Any, Generic, Literal, TypeVar, overload from . import util -from .api import ChatApi, Db -from .core import ChatAPIError, MigrationConfirmation -from .filters import compile_message_filter -from .types import CEvt, T - -log = logging.getLogger("simplex_chat") - -C = TypeVar("C", bound="T.MsgContent") - - -@dataclass(slots=True) -class BotProfile: - display_name: str - full_name: str = "" - short_descr: str | None = None - image: str | None = None +from .api import Db +from .client import ( + BotProfile, + ChatMessage, + Client, + CommandHandler, + EventHandler, + FileMessage, + ImageMessage, + LinkMessage, + Message, + MessageHandler, + Middleware, + ParsedCommand, + Profile, + ReportMessage, + TextMessage, + UnknownMessage, + VideoMessage, + VoiceMessage, + log, +) +from .core import MigrationConfirmation +from .types import T @dataclass(slots=True) @@ -35,85 +37,25 @@ class BotCommand: label: str -@dataclass(slots=True, frozen=True) -class ParsedCommand: - keyword: str - args: str +class Bot(Client): + """SimpleX bot — Client extended with server-side features. + On top of `Client` (identity + messaging + connect_to/send_and_wait/events), + a Bot: + - creates and announces its own contact address + - auto-accepts incoming contact requests (configurable) + - advertises a list of slash-commands in its profile preferences + - sets `peerType=bot` and disables calls/voice in profile prefs + - sends a `welcome` message to new contacts via the auto-reply address setting -@dataclass(slots=True, frozen=True) -class Message(Generic[C]): - chat_item: T.AChatItem - content: C - bot: "Bot" - - @property - def chat_info(self) -> T.ChatInfo: - return self.chat_item["chatInfo"] - - @property - def text(self) -> str | None: - c = self.content - if isinstance(c, dict): - return c.get("text") # type: ignore[return-value] - return None - - async def reply(self, text: str) -> "Message[T.MsgContent]": - items = await self.bot.api.api_send_text_reply(self.chat_item, text) - ci = items[0] - content = ci["chatItem"]["content"] - # content is CIContent — snd variant has msgContent; cast for type safety. - msg_content: T.MsgContent = content["msgContent"] # type: ignore[index] - return Message(chat_item=ci, content=msg_content, bot=self.bot) - - async def reply_content(self, content: T.MsgContent) -> "Message[T.MsgContent]": - items = await self.bot.api.api_send_messages( - self.chat_info, [{"msgContent": content, "mentions": {}}] - ) - ci = items[0] - ci_content = ci["chatItem"]["content"] - msg_content: T.MsgContent = ci_content["msgContent"] # type: ignore[index] - return Message(chat_item=ci, content=msg_content, bot=self.bot) - - -# Concrete narrowed aliases — one per MsgContent_ variant in _types.py. -TextMessage = Message[T.MsgContent_text] -LinkMessage = Message[T.MsgContent_link] -ImageMessage = Message[T.MsgContent_image] -VideoMessage = Message[T.MsgContent_video] -VoiceMessage = Message[T.MsgContent_voice] -FileMessage = Message[T.MsgContent_file] -ReportMessage = Message[T.MsgContent_report] -ChatMessage = Message[T.MsgContent_chat] -UnknownMessage = Message[T.MsgContent_unknown] - -MessageHandler = Callable[[Message[Any]], Awaitable[None]] -CommandHandler = Callable[[Message[Any], ParsedCommand], Awaitable[None]] -EventHandler = Callable[[CEvt.ChatEvent], Awaitable[None]] - - -class Middleware: - """Override `__call__` to wrap message handlers with cross-cutting logic. - - `handler` is the next stage in the chain — call it with `(message, data)` - to continue, or skip the call to short-circuit. `data` is a per-dispatch - dict that middleware can use to pass values down the chain. + If you want just identity + messaging without any of that, use `Client` + directly. """ - async def __call__( - self, - handler: Callable[[Message[Any], dict[str, object]], Awaitable[None]], - message: Message[Any], - data: dict[str, object], - ) -> None: - await handler(message, data) - - -class Bot: def __init__( self, *, - profile: BotProfile, + profile: Profile, db: Db, welcome: str | T.MsgContent | None = None, commands: list[BotCommand] | None = None, @@ -124,423 +66,42 @@ class Bot: auto_accept: bool = True, business_address: bool = False, allow_files: bool = False, - use_bot_profile: bool = True, log_contacts: bool = True, log_network: bool = False, ) -> None: - self._profile = profile - self._db = db + super().__init__( + profile=profile, + db=db, + confirm_migrations=confirm_migrations, + update_profile=update_profile, + log_contacts=log_contacts, + log_network=log_network, + ) self._welcome = welcome self._commands = commands or [] - self._confirm_migrations = confirm_migrations - self._opts = { - "create_address": create_address, - "update_address": update_address, - "update_profile": update_profile, - "auto_accept": auto_accept, - "business_address": business_address, - "allow_files": allow_files, - "use_bot_profile": use_bot_profile, - "log_contacts": log_contacts, - "log_network": log_network, - } - self._api: ChatApi | None = None - self._serving = False - self._stop_event = asyncio.Event() - self._message_handlers: list[tuple[Callable[[Message[Any]], bool], MessageHandler]] = [] - self._command_handlers: list[ - tuple[tuple[str, ...], Callable[[Message[Any]], bool], CommandHandler] - ] = [] - self._event_handlers: dict[str, list[EventHandler]] = {} - self._middleware: list[Middleware] = [] - # Track default-handler registration so __aenter__ on a re-used bot - # doesn't accumulate duplicate log/error handlers. - self._defaults_registered = False - - @property - def api(self) -> ChatApi: - if self._api is None: - raise RuntimeError("Bot not initialized — call bot.run() or use `async with bot:`") - return self._api + self._create_address = create_address + self._update_address = update_address + self._auto_accept = auto_accept + self._business_address = business_address + self._allow_files = allow_files # ------------------------------------------------------------------ # - # Decorators + # Profile + address sync (overrides hooks in Client) # ------------------------------------------------------------------ # - @overload - def on_message( - self, *, content_type: Literal["text"], **rest: Any - ) -> Callable[ - [Callable[[TextMessage], Awaitable[None]]], - Callable[[TextMessage], Awaitable[None]], - ]: ... + async def _post_start(self, user: T.User) -> None: + """Bots sync address first, then embed the link in the profile.""" + link = await self._sync_address(user) + await self._maybe_sync_profile(user, contact_link=link) - @overload - def on_message( - self, *, content_type: Literal["link"], **rest: Any - ) -> Callable[ - [Callable[[LinkMessage], Awaitable[None]]], - Callable[[LinkMessage], Awaitable[None]], - ]: ... - - @overload - def on_message( - self, *, content_type: Literal["image"], **rest: Any - ) -> Callable[ - [Callable[[ImageMessage], Awaitable[None]]], - Callable[[ImageMessage], Awaitable[None]], - ]: ... - - @overload - def on_message( - self, *, content_type: Literal["video"], **rest: Any - ) -> Callable[ - [Callable[[VideoMessage], Awaitable[None]]], - Callable[[VideoMessage], Awaitable[None]], - ]: ... - - @overload - def on_message( - self, *, content_type: Literal["voice"], **rest: Any - ) -> Callable[ - [Callable[[VoiceMessage], Awaitable[None]]], - Callable[[VoiceMessage], Awaitable[None]], - ]: ... - - @overload - def on_message( - self, *, content_type: Literal["file"], **rest: Any - ) -> Callable[ - [Callable[[FileMessage], Awaitable[None]]], - Callable[[FileMessage], Awaitable[None]], - ]: ... - - @overload - def on_message( - self, *, content_type: Literal["report"], **rest: Any - ) -> Callable[ - [Callable[[ReportMessage], Awaitable[None]]], - Callable[[ReportMessage], Awaitable[None]], - ]: ... - - @overload - def on_message( - self, *, content_type: Literal["chat"], **rest: Any - ) -> Callable[ - [Callable[[ChatMessage], Awaitable[None]]], - Callable[[ChatMessage], Awaitable[None]], - ]: ... - - @overload - def on_message( - self, *, content_type: Literal["unknown"], **rest: Any - ) -> Callable[ - [Callable[[UnknownMessage], Awaitable[None]]], - Callable[[UnknownMessage], Awaitable[None]], - ]: ... - - @overload - def on_message(self, **rest: Any) -> Callable[[MessageHandler], MessageHandler]: ... - - def on_message(self, **filter_kw: Any) -> Callable[[MessageHandler], MessageHandler]: - predicate = compile_message_filter(filter_kw) - - def deco(fn: MessageHandler) -> MessageHandler: - self._message_handlers.append((predicate, fn)) - return fn - - return deco - - def on_command( - self, name: str | tuple[str, ...], **filter_kw: Any - ) -> Callable[[CommandHandler], CommandHandler]: - names = (name,) if isinstance(name, str) else tuple(name) - predicate = compile_message_filter(filter_kw) - - def deco(fn: CommandHandler) -> CommandHandler: - self._command_handlers.append((names, predicate, fn)) - return fn - - return deco - - def on_event(self, event: CEvt.ChatEvent_Tag, /) -> Callable[[EventHandler], EventHandler]: - def deco(fn: EventHandler) -> EventHandler: - self._event_handlers.setdefault(event, []).append(fn) - return fn - - return deco - - def use(self, middleware: Middleware) -> None: - self._middleware.append(middleware) - - # ------------------------------------------------------------------ # - # Lifecycle - # ------------------------------------------------------------------ # - - async def __aenter__(self) -> "Bot": - # Order matters: libsimplex `/_start` requires an active user, so - # ensure (or create) the user first, THEN start the chat, THEN - # do address + profile sync. Mirrors Node bot.ts:48-64. - self._api = await ChatApi.init(self._db, self._confirm_migrations) - user = await self._ensure_active_user() - await self._api.start_chat() - await self._sync_address_and_profile(user) - self._register_log_handlers() - return self - - async def __aexit__(self, *exc_info: object) -> None: - self.stop() - if self._api is not None: - try: - await self._api.stop_chat() - finally: - await self._api.close() - self._api = None - - def run(self) -> None: - """Blocking entry: runs serve_forever() with SIGINT/SIGTERM handlers installed. - - Configures `logging.basicConfig(level=INFO)` if the root logger has no - handlers yet, so the bot's startup messages and the announced address - are visible without callers having to set up logging. Embedders that - manage logging themselves are unaffected (basicConfig is a no-op when - handlers already exist). - """ - if not logging.getLogger().handlers: - logging.basicConfig( - level=logging.INFO, - format="%(asctime)s %(levelname)s %(name)s %(message)s", - ) - - async def _main() -> None: - async with self: - loop = asyncio.get_running_loop() - # First Ctrl+C → graceful stop (~500ms, bounded by the - # receive-loop poll interval). Second Ctrl+C → force-exit - # immediately (in case stop_chat / close hang on a wedged - # FFI call). Standard CLI UX (jupyter, ipython, …). - sigint_count = 0 - - def on_interrupt() -> None: - nonlocal sigint_count - sigint_count += 1 - if sigint_count == 1: - log.info("stopping bot... (press Ctrl+C again to force exit)") - self.stop() - else: - os._exit(130) # 128 + SIGINT - - if hasattr(_signal, "SIGINT"): - try: - loop.add_signal_handler(_signal.SIGINT, on_interrupt) - loop.add_signal_handler(_signal.SIGTERM, self.stop) - except NotImplementedError: # Windows - _signal.signal(_signal.SIGINT, lambda *_: on_interrupt()) - await self.serve_forever() - - asyncio.run(_main()) - - async def serve_forever(self) -> None: - if self._serving: - raise RuntimeError("already serving") - self._serving = True - self._stop_event.clear() - try: - await self._receive_loop() - finally: - self._serving = False - - def stop(self) -> None: - self._stop_event.set() - - async def _receive_loop(self) -> None: - # Catch broad Exception so a single malformed event or transient - # native error doesn't crash the whole bot. CancelledError must - # always re-raise so `bot.stop()` and asyncio cancellation work. - # `wait_us=500_000` (500ms) bounds the worst-case Ctrl+C latency: - # the C call blocks the worker thread until timeout, and the loop - # only checks `_stop_event` between polls. - while not self._stop_event.is_set(): - try: - event = await self.api.recv_chat_event(wait_us=500_000) - except asyncio.CancelledError: - raise - except ChatAPIError as e: - # Async chat errors emitted via the Haskell `eToView` path — - # routine soft errors (stale connections after a peer deletes - # a chat, file cleanup failures, etc.) intermixed with - # CRITICAL agent failures the operator must see. Mirror the - # desktop policy in SimpleXAPI.kt:3332-3340: escalate - # CRITICAL agent errors, keep everything else at debug. - chat_err: Any = e.chat_error or {} - agent_err: Any = ( - chat_err.get("agentError", {}) if chat_err.get("type") == "errorAgent" else {} - ) - if agent_err.get("type") == "CRITICAL": - log.error( - "chat agent CRITICAL: %s (offerRestart=%s)", - agent_err.get("criticalErr"), - agent_err.get("offerRestart"), - ) - else: - log.debug("chat event error: %s", chat_err.get("type")) - continue - except Exception: - log.exception("recv_chat_event failed") - # Bound the spin rate when the FFI is wedged on a persistent - # error (vs the timeout path, which already paces itself). - await asyncio.sleep(0.5) - continue - if event is None: - continue - try: - await self._dispatch_event(event) - except asyncio.CancelledError: - raise - except Exception: - log.exception("dispatch_event failed for tag=%s", event.get("type")) - - # ------------------------------------------------------------------ # - # Dispatch - # ------------------------------------------------------------------ # - - async def _dispatch_event(self, event: CEvt.ChatEvent) -> None: - tag = event["type"] - for h in self._event_handlers.get(tag, []): - try: - await h(event) - except Exception: - log.exception("on_event handler failed") - if tag == "newChatItems": - evt: CEvt.NewChatItems = event # type: ignore[assignment] - for ci in evt["chatItems"]: - content = ci["chatItem"]["content"] - if content["type"] != "rcvMsgContent": - continue - msg_content = content["msgContent"] # type: ignore[index] - msg: Message[T.MsgContent] = Message(chat_item=ci, content=msg_content, bot=self) - await self._dispatch_message(msg) - - async def _dispatch_message(self, msg: Message[Any]) -> None: - # First-match-wins. The squaring bot's `@on_message(text=NUMBER_RE)` - # and catch-all `@on_message(content_type="text")` both match a number - # like "1"; we want only the first to fire. Registration order is the - # priority order — register the most-specific filters first. - # - # Slash-commands are tried first against command handlers; if no - # command handler matches, fall through to message handlers (so - # `@on_message` can still catch unknown slash-commands). - cmd = self._parse_command(msg) - if cmd is not None: - for names, predicate, handler in self._command_handlers: - if cmd.keyword in names and predicate(msg): - await self._invoke_command_with_middleware(handler, msg, cmd) - return - for predicate, handler in self._message_handlers: - if predicate(msg): - await self._invoke_with_middleware(handler, msg) - return - - async def _invoke_with_middleware(self, handler: MessageHandler, message: Message[Any]) -> None: - # Fast path: most bots register no middleware. Skip the closure-chain - # construction and the empty-data dict on every dispatch. - if not self._middleware: - try: - await handler(message) - except Exception: - log.exception("message handler failed") - return - - async def call(m: Message[Any], _data: dict[str, object]) -> None: - await handler(m) - - chain: Callable[[Message[Any], dict[str, object]], Awaitable[None]] = call - for mw in reversed(self._middleware): - inner = chain - - async def _wrapped( - m: Message[Any], - d: dict[str, object], - mw: Middleware = mw, - inner: Callable[[Message[Any], dict[str, object]], Awaitable[None]] = inner, - ) -> None: - await mw(inner, m, d) - - chain = _wrapped - - try: - await chain(message, {}) - except Exception: - log.exception("message handler failed") - - async def _invoke_command_with_middleware( - self, handler: CommandHandler, message: Message[Any], cmd: ParsedCommand - ) -> None: - if not self._middleware: - try: - await handler(message, cmd) - except Exception: - log.exception("command handler failed") - return - - async def call(m: Message[Any], _data: dict[str, object]) -> None: - await handler(m, cmd) - - chain: Callable[[Message[Any], dict[str, object]], Awaitable[None]] = call - for mw in reversed(self._middleware): - inner = chain - - async def _wrapped( - m: Message[Any], - d: dict[str, object], - mw: Middleware = mw, - inner: Callable[[Message[Any], dict[str, object]], Awaitable[None]] = inner, - ) -> None: - await mw(inner, m, d) - - chain = _wrapped - - try: - await chain(message, {}) - except Exception: - log.exception("command handler failed") - - @staticmethod - def _parse_command(msg: Message[Any]) -> ParsedCommand | None: - parsed = util.ci_bot_command(msg.chat_item["chatItem"]) - if parsed is None: - return None - keyword, args = parsed - return ParsedCommand(keyword=keyword, args=args) - - # ------------------------------------------------------------------ # - # Profile + address sync - # ------------------------------------------------------------------ # - - async def _ensure_active_user(self) -> T.User: - """Get or create the active user. Must run before `start_chat`. - - Mirrors Node `createBotUser` (bot.ts:158-166). The chat controller - won't accept `/_start` without a user, so this phase has to land - before lifecycle proceeds. - """ - api = self.api - user = await api.api_get_active_user() - if user is None: - log.info("No active user in database, creating...") - user = await api.api_create_active_user(self._bot_profile_to_wire()) - log.info("Bot user: %s", user["profile"]["displayName"]) - return user - - async def _sync_address_and_profile(self, user: T.User) -> None: - """Address + profile sync. Runs after `start_chat` (mirrors bot.ts:57-63).""" + async def _sync_address(self, user: T.User) -> str | None: + """Address sync. Returns the public link if any, for embedding in the profile.""" api = self.api user_id = user["userId"] - # 2. Address (numbered to match bot.ts comments — phase 1 was user creation). address = await api.api_get_user_address(user_id) if address is None: - if self._opts["create_address"]: + if self._create_address: log.info("Bot has no address, creating...") await api.api_create_user_address(user_id) address = await api.api_get_user_address(user_id) @@ -549,17 +110,16 @@ class Bot: else: log.warning("Bot has no address") - # Always announce the address — matches Node bot.ts:60. link: str | None = None if address is not None: link = util.contact_address_str(address["connLinkContact"]) log.info("Bot address: %s", link) - # 3. Address settings (auto-accept + welcome message). Mirrors bot.ts:185-194. + # Address settings (auto-accept + welcome message). Mirrors bot.ts:185-194. # autoAccept present → accept; absent → no auto-accept (mirrors Node bot.ts). - if address is not None and self._opts["update_address"]: - desired: T.AddressSettings = {"businessAddress": self._opts["business_address"]} - if self._opts["auto_accept"]: + if address is not None and self._update_address: + desired: T.AddressSettings = {"businessAddress": self._business_address} + if self._auto_accept: desired["autoAccept"] = {"acceptIncognito": False} if self._welcome is not None: desired["autoReply"] = ( @@ -571,154 +131,45 @@ class Bot: log.info("Bot address settings changed, updating...") await api.api_set_address_settings(user_id, desired) - # 4. Profile update. Mirrors Node `updateBotUserProfile` (bot.ts:199-214). - # Field-by-field comparison: user["profile"] is LocalProfile (has extra - # fields profileId, localAlias, preferences, peerType) so a full-dict - # equality would always differ. - new_profile = self._bot_profile_to_wire() - if link is not None and self._opts["use_bot_profile"]: - # Mirrors bot.ts:62 — embed the connection link in the bot's profile - # so contacts that resolve the bot via stored profile data see the - # current address. - new_profile["contactLink"] = link - cur = user["profile"] - changed = ( - cur["displayName"] != new_profile["displayName"] - or cur.get("fullName", "") != new_profile.get("fullName", "") - or cur.get("shortDescr") != new_profile.get("shortDescr") - or cur.get("image") != new_profile.get("image") - or cur.get("preferences") != new_profile.get("preferences") - or cur.get("peerType") != new_profile.get("peerType") - or cur.get("contactLink") != new_profile.get("contactLink") - ) - if changed and self._opts["update_profile"]: - log.info("Bot profile changed, updating...") - await api.api_update_profile(user_id, new_profile) + return link - def _bot_profile_to_wire(self) -> T.Profile: - """Construct wire-format Profile, applying bot conventions when use_bot_profile=True. + def _profile_to_wire(self) -> T.Profile: + """Bot profile: base profile + peerType=bot, command list, calls/voice prefs disabled. - Mirrors Node mkBotProfile (bot.ts:88-102): bots get peerType="bot", - calls/voice prefs disabled, files gated on `allow_files`, and any - registered `commands` embedded in the profile preferences. + Mirrors Node `mkBotProfile` (bot.ts:88-102). """ - p: T.Profile = { - "displayName": self._profile.display_name, - "fullName": self._profile.full_name, + p = super()._profile_to_wire() + prefs: T.Preferences = { + "calls": {"allow": "no"}, + "voice": {"allow": "no"}, + "files": {"allow": "yes" if self._allow_files else "no"}, } - if self._profile.short_descr is not None: - p["shortDescr"] = self._profile.short_descr - if self._profile.image is not None: - p["image"] = self._profile.image - if self._opts["use_bot_profile"]: - prefs: T.Preferences = { - "calls": {"allow": "no"}, - "voice": {"allow": "no"}, - "files": {"allow": "yes" if self._opts["allow_files"] else "no"}, - } - if self._commands: - prefs["commands"] = [ - {"type": "command", "keyword": c.keyword, "label": c.label} - for c in self._commands - ] - p["preferences"] = prefs - p["peerType"] = "bot" - elif self._commands: - raise ValueError( - "use_bot_profile=False but commands were passed; commands are " - "only sent when use_bot_profile=True (they're embedded in the " - "user profile preferences)." - ) + if self._commands: + prefs["commands"] = [ + {"type": "command", "keyword": c.keyword, "label": c.label} + for c in self._commands + ] + p["preferences"] = prefs + p["peerType"] = "bot" return p - # ------------------------------------------------------------------ # - # Log subscriptions (mirror Node subscribeLogEvents bot.ts:142-156) - # ------------------------------------------------------------------ # - def _register_log_handlers(self) -> None: - # Idempotent: a Bot reused across multiple `__aenter__` cycles must - # not stack duplicate log handlers. Always-on error handlers run - # regardless of log_contacts/log_network so messageError/chatError/ - # chatErrors don't disappear into the void. - if self._defaults_registered: - return - self._defaults_registered = True - self._event_handlers.setdefault("messageError", []).append(self._log_message_error) - self._event_handlers.setdefault("chatError", []).append(self._log_chat_error) - self._event_handlers.setdefault("chatErrors", []).append(self._log_chat_errors) - if self._opts["log_contacts"]: - self._event_handlers.setdefault("contactConnected", []).append( - self._log_contact_connected - ) - self._event_handlers.setdefault("contactDeletedByContact", []).append( - self._log_contact_deleted - ) - if self._opts["log_network"]: - self._event_handlers.setdefault("hostConnected", []).append(self._log_host_connected) - self._event_handlers.setdefault("hostDisconnected", []).append( - self._log_host_disconnected - ) - self._event_handlers.setdefault("subscriptionStatus", []).append( - self._log_subscription_status - ) - - @staticmethod - async def _log_contact_connected(evt: CEvt.ChatEvent) -> None: - log.info("%s connected", evt["contact"]["profile"]["displayName"]) # type: ignore[index] - - @staticmethod - async def _log_contact_deleted(evt: CEvt.ChatEvent) -> None: - log.info( - "%s deleted connection with bot", - evt["contact"]["profile"]["displayName"], # type: ignore[index] - ) - - @staticmethod - async def _log_host_connected(evt: CEvt.ChatEvent) -> None: - log.info("connected server %s", evt["transportHost"]) # type: ignore[index] - - @staticmethod - async def _log_host_disconnected(evt: CEvt.ChatEvent) -> None: - log.info("disconnected server %s", evt["transportHost"]) # type: ignore[index] - - @staticmethod - async def _log_subscription_status(evt: CEvt.ChatEvent) -> None: - log.info( - "%d subscription(s) %s", - len(evt["connections"]), # type: ignore[index] - evt["subscriptionStatus"]["type"], # type: ignore[index] - ) - - @staticmethod - async def _log_message_error(evt: CEvt.ChatEvent) -> None: - log.warning("messageError: %s", evt.get("severity", "?")) # type: ignore[union-attr] - - @staticmethod - async def _log_chat_error(evt: CEvt.ChatEvent) -> None: - err = evt.get("chatError") # type: ignore[union-attr] - log.error("chatError: %s", err.get("type") if isinstance(err, dict) else err) - - @staticmethod - async def _log_chat_errors(evt: CEvt.ChatEvent) -> None: - errs = evt.get("chatErrors") or [] # type: ignore[union-attr] - log.error("chatErrors: %d errors", len(errs)) - - -# Suppress unused-import warnings for re-exported names used only at type-check time. __all__ = [ "Bot", "BotCommand", "BotProfile", "ChatMessage", + "Client", + "CommandHandler", + "EventHandler", "FileMessage", "ImageMessage", "LinkMessage", "Message", "MessageHandler", - "CommandHandler", - "EventHandler", "Middleware", "ParsedCommand", + "Profile", "ReportMessage", "TextMessage", "UnknownMessage", diff --git a/packages/simplex-chat-python/src/simplex_chat/client.py b/packages/simplex-chat-python/src/simplex_chat/client.py new file mode 100644 index 0000000000..b0d144b8b9 --- /dev/null +++ b/packages/simplex-chat-python/src/simplex_chat/client.py @@ -0,0 +1,955 @@ +"""Base `Client` API: lifecycle, dispatch, decorators, connect_to / send_and_wait / events. + +Bot extends Client to add server-side features (address, auto-accept, welcome, +commands). Client by itself is suitable for monitors, probes, automated +participants — anything that talks TO services rather than accepting incoming +connections. +""" + +from __future__ import annotations + +import asyncio +import logging +import os +import signal as _signal +from collections.abc import AsyncIterator, Awaitable, Callable +from dataclasses import dataclass +from typing import Any, Generic, Literal, TypeVar, overload + +from . import util +from .api import ChatApi, ChatCommandError, ContactAlreadyExistsError, Db +from .core import ChatAPIError, MigrationConfirmation +from .filters import compile_message_filter +from .types import CEvt, T + +log = logging.getLogger("simplex_chat") + +C = TypeVar("C", bound="T.MsgContent") + + +@dataclass(slots=True) +class Profile: + """SimpleX user profile fields: display name, optional full name, descr, avatar. + + Universal — used by both `Client` and `Bot`. The bot-specific extensions + (peerType=bot, command list, calls/voice preferences) are added at + wire-conversion time by `Bot`, not stored here. + """ + + display_name: str + full_name: str = "" + short_descr: str | None = None + image: str | None = None + + +# Backwards-compatibility alias — the dataclass was named `BotProfile` before +# the Client/Bot hierarchy was introduced. Keep the old name working so +# `from simplex_chat import BotProfile` doesn't break existing code. +BotProfile = Profile + + +@dataclass(slots=True, frozen=True) +class ParsedCommand: + keyword: str + args: str + + +@dataclass(slots=True, frozen=True) +class Message(Generic[C]): + chat_item: T.AChatItem + content: C + client: "Client" + + @property + def chat_info(self) -> T.ChatInfo: + return self.chat_item["chatInfo"] + + @property + def text(self) -> str | None: + c = self.content + if isinstance(c, dict): + return c.get("text") # type: ignore[return-value] + return None + + async def reply(self, text: str) -> "Message[T.MsgContent]": + items = await self.client.api.api_send_text_reply(self.chat_item, text) + ci = items[0] + content = ci["chatItem"]["content"] + # content is CIContent — snd variant has msgContent; cast for type safety. + msg_content: T.MsgContent = content["msgContent"] # type: ignore[index] + return Message(chat_item=ci, content=msg_content, client=self.client) + + async def reply_content(self, content: T.MsgContent) -> "Message[T.MsgContent]": + items = await self.client.api.api_send_messages( + self.chat_info, [{"msgContent": content, "mentions": {}}] + ) + ci = items[0] + ci_content = ci["chatItem"]["content"] + msg_content: T.MsgContent = ci_content["msgContent"] # type: ignore[index] + return Message(chat_item=ci, content=msg_content, client=self.client) + + +# Concrete narrowed aliases — one per MsgContent_ variant in _types.py. +TextMessage = Message[T.MsgContent_text] +LinkMessage = Message[T.MsgContent_link] +ImageMessage = Message[T.MsgContent_image] +VideoMessage = Message[T.MsgContent_video] +VoiceMessage = Message[T.MsgContent_voice] +FileMessage = Message[T.MsgContent_file] +ReportMessage = Message[T.MsgContent_report] +ChatMessage = Message[T.MsgContent_chat] +UnknownMessage = Message[T.MsgContent_unknown] + +MessageHandler = Callable[[Message[Any]], Awaitable[None]] +CommandHandler = Callable[[Message[Any], ParsedCommand], Awaitable[None]] +EventHandler = Callable[[CEvt.ChatEvent], Awaitable[None]] + + +class Middleware: + """Override `__call__` to wrap message handlers with cross-cutting logic. + + `handler` is the next stage in the chain — call it with `(message, data)` + to continue, or skip the call to short-circuit. `data` is a per-dispatch + dict that middleware can use to pass values down the chain. + """ + + async def __call__( + self, + handler: Callable[[Message[Any], dict[str, object]], Awaitable[None]], + message: Message[Any], + data: dict[str, object], + ) -> None: + await handler(message, data) + + +class Client: + """SimpleX participant — has an identity, sends and receives messages. + + No address, no auto-accept of incoming requests, no bot profile prefs. Use + this for monitors, probes, automated participants — anything that talks + TO services rather than accepting incoming connections. Use `Bot` for the + server-side flavour. + + Typical pattern: + + async with Client(profile=Profile(display_name="m"), db=...) as c: + serve = asyncio.create_task(c.serve_forever()) + contact = await c.connect_to(link) + reply = await c.send_and_wait(contact["contactId"], "/help") + c.stop() + await serve + + The decorator-style handlers (`@on_message`, `@on_command`, `@on_event`) + work too if you want callback-style dispatch instead of async-await. + """ + + def __init__( + self, + *, + profile: Profile, + db: Db, + confirm_migrations: MigrationConfirmation = MigrationConfirmation.YES_UP, + update_profile: bool = True, + log_contacts: bool = False, + log_network: bool = False, + ) -> None: + self._profile = profile + self._db = db + self._confirm_migrations = confirm_migrations + self._update_profile = update_profile + self._log_contacts = log_contacts + self._log_network = log_network + self._api: ChatApi | None = None + self._serving = False + self._stop_event = asyncio.Event() + self._message_handlers: list[tuple[Callable[[Message[Any]], bool], MessageHandler]] = [] + self._command_handlers: list[ + tuple[tuple[str, ...], Callable[[Message[Any]], bool], CommandHandler] + ] = [] + self._event_handlers: dict[str, list[EventHandler]] = {} + self._middleware: list[Middleware] = [] + # Track default-handler registration so __aenter__ on a re-used client + # doesn't accumulate duplicate log/error handlers. + self._defaults_registered = False + # Internal waiters used by `send_and_wait` (keyed by contact_id, FIFO + # within a contact) and `connect_to` (one-shot, resolved on the next + # contactConnected event). Populated by user-async-callers, drained + # in `_dispatch_event` before user handlers run. + self._reply_waiters: dict[int, list[asyncio.Future[Message[Any]]]] = {} + self._connect_waiters: list[asyncio.Future[T.Contact]] = [] + + @property + def api(self) -> ChatApi: + if self._api is None: + raise RuntimeError("Client not initialized — call run() or use `async with client:`") + return self._api + + # ------------------------------------------------------------------ # + # Decorators + # ------------------------------------------------------------------ # + + @overload + def on_message( + self, *, content_type: Literal["text"], **rest: Any + ) -> Callable[ + [Callable[[TextMessage], Awaitable[None]]], + Callable[[TextMessage], Awaitable[None]], + ]: ... + + @overload + def on_message( + self, *, content_type: Literal["link"], **rest: Any + ) -> Callable[ + [Callable[[LinkMessage], Awaitable[None]]], + Callable[[LinkMessage], Awaitable[None]], + ]: ... + + @overload + def on_message( + self, *, content_type: Literal["image"], **rest: Any + ) -> Callable[ + [Callable[[ImageMessage], Awaitable[None]]], + Callable[[ImageMessage], Awaitable[None]], + ]: ... + + @overload + def on_message( + self, *, content_type: Literal["video"], **rest: Any + ) -> Callable[ + [Callable[[VideoMessage], Awaitable[None]]], + Callable[[VideoMessage], Awaitable[None]], + ]: ... + + @overload + def on_message( + self, *, content_type: Literal["voice"], **rest: Any + ) -> Callable[ + [Callable[[VoiceMessage], Awaitable[None]]], + Callable[[VoiceMessage], Awaitable[None]], + ]: ... + + @overload + def on_message( + self, *, content_type: Literal["file"], **rest: Any + ) -> Callable[ + [Callable[[FileMessage], Awaitable[None]]], + Callable[[FileMessage], Awaitable[None]], + ]: ... + + @overload + def on_message( + self, *, content_type: Literal["report"], **rest: Any + ) -> Callable[ + [Callable[[ReportMessage], Awaitable[None]]], + Callable[[ReportMessage], Awaitable[None]], + ]: ... + + @overload + def on_message( + self, *, content_type: Literal["chat"], **rest: Any + ) -> Callable[ + [Callable[[ChatMessage], Awaitable[None]]], + Callable[[ChatMessage], Awaitable[None]], + ]: ... + + @overload + def on_message( + self, *, content_type: Literal["unknown"], **rest: Any + ) -> Callable[ + [Callable[[UnknownMessage], Awaitable[None]]], + Callable[[UnknownMessage], Awaitable[None]], + ]: ... + + @overload + def on_message(self, **rest: Any) -> Callable[[MessageHandler], MessageHandler]: ... + + def on_message(self, **filter_kw: Any) -> Callable[[MessageHandler], MessageHandler]: + predicate = compile_message_filter(filter_kw) + + def deco(fn: MessageHandler) -> MessageHandler: + self._message_handlers.append((predicate, fn)) + return fn + + return deco + + def on_command( + self, name: str | tuple[str, ...], **filter_kw: Any + ) -> Callable[[CommandHandler], CommandHandler]: + names = (name,) if isinstance(name, str) else tuple(name) + predicate = compile_message_filter(filter_kw) + + def deco(fn: CommandHandler) -> CommandHandler: + self._command_handlers.append((names, predicate, fn)) + return fn + + return deco + + # `on_event` is exposed as a property typed as the generated + # `OnEventDecorator` Protocol so per-tag narrowing applies — e.g. + # `@client.on_event("contactConnected")` types the handler's event + # parameter as `CEvt.ContactConnected`, not the unnarrowed + # `CEvt.ChatEvent` union. The Protocol's overload chain lives in + # generated code (one entry per event tag) so it stays in sync with + # the wire schema automatically. The runtime implementation is the + # plain handler-registration below. + @property + def on_event(self) -> CEvt.OnEventDecorator: + return self._register_event_handler # type: ignore[return-value] + + def _register_event_handler( + self, event: str, / + ) -> Callable[[EventHandler], EventHandler]: + def deco(fn: EventHandler) -> EventHandler: + self._event_handlers.setdefault(event, []).append(fn) + return fn + + return deco + + def use(self, middleware: Middleware) -> None: + self._middleware.append(middleware) + + # ------------------------------------------------------------------ # + # Lifecycle + # ------------------------------------------------------------------ # + + async def __aenter__(self) -> "Client": + # Order matters: libsimplex `/_start` requires an active user, so + # ensure (or create) the user first, THEN start the chat, THEN + # do post-start setup (profile sync; Bot adds address sync). + # Clear `_stop_event` here (not in `serve_forever`/`events`) so that + # a `stop()` call landing between `__aenter__` and the receive loop + # — e.g. a signal handler firing while signal handlers are being + # wired up — is preserved and causes the loop to exit immediately + # on entry. + self._stop_event.clear() + self._api = await ChatApi.init(self._db, self._confirm_migrations) + try: + user = await self._ensure_active_user() + await self._api.start_chat() + await self._post_start(user) + self._register_log_handlers() + return self + except BaseException: + # __aexit__ is only called when __aenter__ returns successfully — + # roll back the open chat controller here so a failure during + # init doesn't leak the FFI resource. + await self._shutdown_partial_init() + raise + + async def _shutdown_partial_init(self) -> None: + """Best-effort teardown for an `__aenter__` that didn't reach return.""" + api = self._api + if api is None: + return + if api.started: + try: + await api.stop_chat() + except Exception: + log.exception("stop_chat failed during init rollback") + try: + await api.close() + except Exception: + log.exception("close failed during init rollback") + self._api = None + + async def __aexit__(self, *exc_info: object) -> None: + self.stop() + api = self._api + if api is None: + return + # Null out the reference up-front so the Client appears closed even + # if stop_chat / close raise — otherwise `client.api` would still + # hand back a half-shutdown controller after `async with` exits. + self._api = None + try: + await api.stop_chat() + finally: + await api.close() + + async def _post_start(self, user: T.User) -> None: + """Hook for subclasses to add work between `start_chat` and serving. + + Default (Client): sync profile only. Bot overrides to also sync its + address and embed the connection link in the profile. + """ + await self._maybe_sync_profile(user, contact_link=None) + + def run(self) -> None: + """Blocking entry: runs serve_forever() with SIGINT/SIGTERM handlers installed. + + Configures `logging.basicConfig(level=INFO)` if the root logger has no + handlers yet, so startup messages and the announced address are + visible without callers having to set up logging. Embedders that + manage logging themselves are unaffected (basicConfig is a no-op when + handlers already exist). + """ + if not logging.getLogger().handlers: + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s %(message)s", + ) + + async def _main() -> None: + async with self: + loop = asyncio.get_running_loop() + # First Ctrl+C → graceful stop (~500ms, bounded by the + # receive-loop poll interval). Second Ctrl+C → force-exit + # immediately (in case stop_chat / close hang on a wedged + # FFI call). Standard CLI UX (jupyter, ipython, …). + sigint_count = 0 + + def on_interrupt() -> None: + nonlocal sigint_count + sigint_count += 1 + if sigint_count == 1: + log.info("stopping... (press Ctrl+C again to force exit)") + self.stop() + else: + os._exit(130) # 128 + SIGINT + + if hasattr(_signal, "SIGINT"): + try: + loop.add_signal_handler(_signal.SIGINT, on_interrupt) + loop.add_signal_handler(_signal.SIGTERM, self.stop) + except NotImplementedError: # Windows + _signal.signal(_signal.SIGINT, lambda *_: on_interrupt()) + await self.serve_forever() + + asyncio.run(_main()) + + async def serve_forever(self) -> None: + if self._serving: + raise RuntimeError("already serving") + self._serving = True + try: + await self._receive_loop() + finally: + self._serving = False + + def stop(self) -> None: + self._stop_event.set() + + async def events(self) -> AsyncIterator[CEvt.ChatEvent]: + """Yield chat events one at a time — alternative to `serve_forever`. + + Runs the full dispatch pipeline on each event (internal waiters, + user `@on_event`/`@on_message`/`@on_command` handlers), then yields + the raw event for inspection. Use this when you want direct control + over the receive loop, e.g. to surface errors that `serve_forever` + would otherwise swallow, or to compose with other async iterators. + + Mutually exclusive with `serve_forever`. Stops when `stop()` is + called or when the consumer exits the `async for` loop (which + triggers the generator's `aclose`). Async-generator GC alone is + not reliable for cleanup — exit the loop explicitly. + """ + if self._serving: + raise RuntimeError( + "already serving — events() and serve_forever() are mutually exclusive" + ) + self._serving = True + try: + while not self._stop_event.is_set(): + try: + event = await self.api.recv_chat_event(wait_us=500_000) + except asyncio.CancelledError: + raise + if event is None: + continue + try: + await self._dispatch_event(event) + except asyncio.CancelledError: + raise + except Exception: + log.exception("dispatch_event failed for tag=%s", event.get("type")) + yield event + finally: + self._serving = False + + async def connect_to(self, link: str, *, timeout: float = 120.0) -> T.Contact: + """Connect to a SimpleX contact link, returning the resulting Contact. + + Idempotent: if the link is already known (via `api_connect_plan`) + the existing Contact is returned without re-handshaking. Otherwise + initiates the handshake and waits for the `contactConnected` event. + + Requires the receive loop to be running (`serve_forever` or + `events()`), since the handshake completes asynchronously. + + Concurrency caveat: pending `connect_to` waiters are a single FIFO + with no link↔waiter correlation. If you call `connect_to` for two + different links concurrently, or if a third party connects to your + address (Bot subclass with `auto_accept=True`) while a `connect_to` + is in flight, the returned Contact may not be the one you asked + for. Sequence concurrent connects, or call them one at a time. + + Raises: + asyncio.TimeoutError: handshake didn't complete within `timeout` + ValueError: timeout is not positive + RuntimeError: no active user, or receive loop not running + """ + if timeout <= 0: + # Reject upfront — otherwise wait_for raises TimeoutError after + # the handshake side-effect (api_connect_active_user) has + # already gone over the wire, leaving the caller with no + # Contact reference and a half-initiated connection. + raise ValueError(f"timeout must be positive, got {timeout!r}") + if not self._serving: + raise RuntimeError( + "connect_to requires the receive loop to be running — " + "call serve_forever() (in a task) or iterate events() first" + ) + api = self.api + user = await api.api_get_active_user() + if user is None: + raise RuntimeError("no active user") + + existing = await self._lookup_known_contact(user["userId"], link) + if existing is not None: + return existing + + loop = asyncio.get_running_loop() + waiter: asyncio.Future[T.Contact] = loop.create_future() + self._connect_waiters.append(waiter) + try: + try: + await api.api_connect_active_user(link) + except ContactAlreadyExistsError: + # Handshake mid-flight, or a previous incomplete attempt + # left the connection in a known-but-not-connected state. + # Either way: wait for the contactConnected event. + pass + return await asyncio.wait_for(waiter, timeout=timeout) + finally: + if waiter in self._connect_waiters: + self._connect_waiters.remove(waiter) + + async def _lookup_known_contact(self, user_id: int, link: str) -> T.Contact | None: + """Resolve a link to an existing Contact via api_connect_plan, or None. + + Only ChatCommandError is swallowed (malformed link, etc.) — the + connect_to caller will fall back to the full handshake path. + Transport/FFI errors propagate so the caller sees the real cause. + """ + try: + plan, _ = await self.api.api_connect_plan(user_id, link) + except ChatCommandError: + return None + if plan["type"] == "contactAddress": + cap = plan["contactAddressPlan"] + if cap["type"] == "known": + return cap["contact"] + if plan["type"] == "invitationLink": + ilp = plan["invitationLinkPlan"] + if ilp["type"] == "known": + return ilp["contact"] + return None + + async def send_and_wait( + self, + contact_id: int, + text: str, + *, + timeout: float = 30.0, + ) -> "Message[T.MsgContent]": + """Send text to a direct contact and wait for the next reply from them. + + Waiters are FIFO per contact_id: two concurrent calls to the same + contact get two replies in send order. Concurrent calls to *different* + contacts run in parallel. Once a reply matches a waiter, user + message handlers do NOT fire for that message — the awaiter owns it. + + Correlation caveat: matching is by sender contact_id only — there + is no message-id correlation. ANY direct message from `contact_id` + arriving while a waiter is pending will resolve that waiter, even + if it was an unsolicited message (e.g. an auto-reply from a bot's + address settings, a delayed reply from a previous send, a push + notification). For strict request/response semantics, ensure the + peer is otherwise quiet, or use the `@on_message` callback model. + + Requires the receive loop to be running. Raises asyncio.TimeoutError + on timeout, ValueError if timeout is not positive. + """ + if timeout <= 0: + # Reject upfront — otherwise wait_for raises TimeoutError after + # api_send_text_message already went over the wire, surprising + # the caller with a sent message and no Future to await. + raise ValueError(f"timeout must be positive, got {timeout!r}") + if not self._serving: + raise RuntimeError( + "send_and_wait requires the receive loop to be running — " + "call serve_forever() (in a task) or iterate events() first" + ) + loop = asyncio.get_running_loop() + waiter: asyncio.Future[Message[Any]] = loop.create_future() + waiters = self._reply_waiters.setdefault(contact_id, []) + waiters.append(waiter) + try: + await self.api.api_send_text_message(["direct", contact_id], text) + return await asyncio.wait_for(waiter, timeout=timeout) + finally: + # Always clean up our slot, even on send error or timeout. Leaving + # an unresolved Future in the dict would make the next incoming + # message resolve a future no one is waiting on. + if waiter in waiters: + waiters.remove(waiter) + if not waiters: + self._reply_waiters.pop(contact_id, None) + + async def _receive_loop(self) -> None: + # Catch broad Exception so a single malformed event or transient + # native error doesn't crash the whole client. CancelledError must + # always re-raise so `stop()` and asyncio cancellation work. + # `wait_us=500_000` (500ms) bounds the worst-case Ctrl+C latency: + # the C call blocks the worker thread until timeout, and the loop + # only checks `_stop_event` between polls. + while not self._stop_event.is_set(): + try: + event = await self.api.recv_chat_event(wait_us=500_000) + except asyncio.CancelledError: + raise + except ChatAPIError as e: + # Async chat errors emitted via the Haskell `eToView` path — + # routine soft errors (stale connections after a peer deletes + # a chat, file cleanup failures, etc.) intermixed with + # CRITICAL agent failures the operator must see. Mirror the + # desktop policy in SimpleXAPI.kt:3332-3340: escalate + # CRITICAL agent errors, keep everything else at debug. + chat_err: Any = e.chat_error or {} + agent_err: Any = ( + chat_err.get("agentError", {}) if chat_err.get("type") == "errorAgent" else {} + ) + if agent_err.get("type") == "CRITICAL": + log.error( + "chat agent CRITICAL: %s (offerRestart=%s)", + agent_err.get("criticalErr"), + agent_err.get("offerRestart"), + ) + else: + log.debug("chat event error: %s", chat_err.get("type")) + continue + except Exception: + log.exception("recv_chat_event failed") + # Bound the spin rate when the FFI is wedged on a persistent + # error (vs the timeout path, which already paces itself). + await asyncio.sleep(0.5) + continue + if event is None: + continue + try: + await self._dispatch_event(event) + except asyncio.CancelledError: + raise + except Exception: + log.exception("dispatch_event failed for tag=%s", event.get("type")) + + # ------------------------------------------------------------------ # + # Dispatch + # ------------------------------------------------------------------ # + + async def _dispatch_event(self, event: CEvt.ChatEvent) -> None: + tag = event["type"] + # Resolve internal waiters BEFORE user handlers. A pending + # `connect_to` consumes the contactConnected; a pending + # `send_and_wait` consumes the matching incoming message — user + # handlers don't fire for that message. This matches the mental + # model: the awaiter explicitly asked for this event. + if tag == "contactConnected" and self._connect_waiters: + contact: T.Contact = event["contact"] # type: ignore[typeddict-item] + waiter = self._connect_waiters.pop(0) + if not waiter.done(): + waiter.set_result(contact) + for h in self._event_handlers.get(tag, []): + try: + await h(event) + except Exception: + log.exception("on_event handler failed") + if tag == "newChatItems": + evt: CEvt.NewChatItems = event # type: ignore[assignment] + for ci in evt["chatItems"]: + content = ci["chatItem"]["content"] + if content["type"] != "rcvMsgContent": + continue + msg_content = content["msgContent"] # type: ignore[index] + msg: Message[T.MsgContent] = Message(chat_item=ci, content=msg_content, client=self) + # If a send_and_wait is pending for this sender, fulfil it + # and skip the user dispatch chain — the awaiter "owns" this + # reply. FIFO within a contact_id. + if self._maybe_resolve_reply_waiter(msg): + continue + await self._dispatch_message(msg) + + def _maybe_resolve_reply_waiter(self, msg: Message[T.MsgContent]) -> bool: + chat_info = msg.chat_info + if chat_info.get("type") != "direct": + return False + contact_id = chat_info.get("contact", {}).get("contactId") # type: ignore[union-attr] + if contact_id is None: + return False + waiters = self._reply_waiters.get(contact_id) + if not waiters: + return False + # Skip waiters whose callers have already given up (cancelled by + # wait_for timing out at the same loop tick). Without this skip, + # a reply arriving in the narrow timeout-race window would be + # silently dropped because the FIFO would pop a done waiter and + # neither resolve it nor dispatch to user handlers. + while waiters: + waiter = waiters.pop(0) + if not waiter.done(): + if not waiters: + self._reply_waiters.pop(contact_id, None) + waiter.set_result(msg) + return True + self._reply_waiters.pop(contact_id, None) + return False + + async def _dispatch_message(self, msg: Message[Any]) -> None: + # First-match-wins. The squaring bot's `@on_message(text=NUMBER_RE)` + # and catch-all `@on_message(content_type="text")` both match a number + # like "1"; we want only the first to fire. Registration order is the + # priority order — register the most-specific filters first. + # + # Slash-commands are tried first against command handlers; if no + # command handler matches, fall through to message handlers (so + # `@on_message` can still catch unknown slash-commands). + cmd = self._parse_command(msg) + if cmd is not None: + for names, predicate, handler in self._command_handlers: + if cmd.keyword in names and predicate(msg): + await self._invoke_command_with_middleware(handler, msg, cmd) + return + for predicate, handler in self._message_handlers: + if predicate(msg): + await self._invoke_with_middleware(handler, msg) + return + + async def _invoke_with_middleware(self, handler: MessageHandler, message: Message[Any]) -> None: + # Fast path: most clients register no middleware. Skip the closure-chain + # construction and the empty-data dict on every dispatch. + if not self._middleware: + try: + await handler(message) + except Exception: + log.exception("message handler failed") + return + + async def call(m: Message[Any], _data: dict[str, object]) -> None: + await handler(m) + + chain: Callable[[Message[Any], dict[str, object]], Awaitable[None]] = call + for mw in reversed(self._middleware): + inner = chain + + async def _wrapped( + m: Message[Any], + d: dict[str, object], + mw: Middleware = mw, + inner: Callable[[Message[Any], dict[str, object]], Awaitable[None]] = inner, + ) -> None: + await mw(inner, m, d) + + chain = _wrapped + + try: + await chain(message, {}) + except Exception: + log.exception("message handler failed") + + async def _invoke_command_with_middleware( + self, handler: CommandHandler, message: Message[Any], cmd: ParsedCommand + ) -> None: + if not self._middleware: + try: + await handler(message, cmd) + except Exception: + log.exception("command handler failed") + return + + async def call(m: Message[Any], _data: dict[str, object]) -> None: + await handler(m, cmd) + + chain: Callable[[Message[Any], dict[str, object]], Awaitable[None]] = call + for mw in reversed(self._middleware): + inner = chain + + async def _wrapped( + m: Message[Any], + d: dict[str, object], + mw: Middleware = mw, + inner: Callable[[Message[Any], dict[str, object]], Awaitable[None]] = inner, + ) -> None: + await mw(inner, m, d) + + chain = _wrapped + + try: + await chain(message, {}) + except Exception: + log.exception("command handler failed") + + @staticmethod + def _parse_command(msg: Message[Any]) -> ParsedCommand | None: + parsed = util.ci_bot_command(msg.chat_item["chatItem"]) + if parsed is None: + return None + keyword, args = parsed + return ParsedCommand(keyword=keyword, args=args) + + # ------------------------------------------------------------------ # + # Profile sync + # ------------------------------------------------------------------ # + + async def _ensure_active_user(self) -> T.User: + """Get or create the active user. Must run before `start_chat`. + + Mirrors Node `createBotUser` (bot.ts:158-166). The chat controller + won't accept `/_start` without a user, so this phase has to land + before lifecycle proceeds. + """ + api = self.api + user = await api.api_get_active_user() + if user is None: + log.info("No active user in database, creating...") + user = await api.api_create_active_user(self._profile_to_wire()) + log.info("user: %s", user["profile"]["displayName"]) + return user + + async def _maybe_sync_profile(self, user: T.User, *, contact_link: str | None) -> None: + """Update the user profile on the wire if its fields changed. + + `contact_link` is only set by Bot (to embed its address). Mirrors + Node `updateBotUserProfile` (bot.ts:199-214). Field-by-field + comparison because user["profile"] is LocalProfile (has extra + fields profileId, localAlias, preferences, peerType) so a full + dict equality would always differ. + """ + if not self._update_profile: + return + new_profile = self._profile_to_wire() + if contact_link is not None: + new_profile["contactLink"] = contact_link + cur = user["profile"] + changed = ( + cur["displayName"] != new_profile["displayName"] + or cur.get("fullName", "") != new_profile.get("fullName", "") + or cur.get("shortDescr") != new_profile.get("shortDescr") + or cur.get("image") != new_profile.get("image") + or cur.get("preferences") != new_profile.get("preferences") + or cur.get("peerType") != new_profile.get("peerType") + or cur.get("contactLink") != new_profile.get("contactLink") + ) + if changed: + log.info("profile changed, updating...") + await self.api.api_update_profile(user["userId"], new_profile) + + def _profile_to_wire(self) -> T.Profile: + """Convert the user-facing Profile dataclass to wire format. + + Base version produces a plain user profile. Bot overrides this to + add the bot-specific extensions (peerType=bot, command list, + calls/voice/files prefs). + """ + p: T.Profile = { + "displayName": self._profile.display_name, + "fullName": self._profile.full_name, + } + if self._profile.short_descr is not None: + p["shortDescr"] = self._profile.short_descr + if self._profile.image is not None: + p["image"] = self._profile.image + return p + + # ------------------------------------------------------------------ # + # Log subscriptions (mirror Node subscribeLogEvents bot.ts:142-156) + # ------------------------------------------------------------------ # + + def _register_log_handlers(self) -> None: + # Idempotent: re-entering the async context must not stack duplicate + # log handlers. Always-on error handlers run regardless of + # log_contacts/log_network so messageError/chatError/chatErrors + # don't disappear into the void. + if self._defaults_registered: + return + self._defaults_registered = True + self._event_handlers.setdefault("messageError", []).append(self._log_message_error) + self._event_handlers.setdefault("chatError", []).append(self._log_chat_error) + self._event_handlers.setdefault("chatErrors", []).append(self._log_chat_errors) + if self._log_contacts: + self._event_handlers.setdefault("contactConnected", []).append( + self._log_contact_connected + ) + self._event_handlers.setdefault("contactDeletedByContact", []).append( + self._log_contact_deleted + ) + if self._log_network: + self._event_handlers.setdefault("hostConnected", []).append(self._log_host_connected) + self._event_handlers.setdefault("hostDisconnected", []).append( + self._log_host_disconnected + ) + self._event_handlers.setdefault("subscriptionStatus", []).append( + self._log_subscription_status + ) + + @staticmethod + async def _log_contact_connected(evt: CEvt.ChatEvent) -> None: + log.info("%s connected", evt["contact"]["profile"]["displayName"]) # type: ignore[index] + + @staticmethod + async def _log_contact_deleted(evt: CEvt.ChatEvent) -> None: + log.info( + "%s deleted connection", + evt["contact"]["profile"]["displayName"], # type: ignore[index] + ) + + @staticmethod + async def _log_host_connected(evt: CEvt.ChatEvent) -> None: + log.info("connected server %s", evt["transportHost"]) # type: ignore[index] + + @staticmethod + async def _log_host_disconnected(evt: CEvt.ChatEvent) -> None: + log.info("disconnected server %s", evt["transportHost"]) # type: ignore[index] + + @staticmethod + async def _log_subscription_status(evt: CEvt.ChatEvent) -> None: + log.info( + "%d subscription(s) %s", + len(evt["connections"]), # type: ignore[index] + evt["subscriptionStatus"]["type"], # type: ignore[index] + ) + + @staticmethod + async def _log_message_error(evt: CEvt.ChatEvent) -> None: + log.warning("messageError: %s", evt.get("severity", "?")) # type: ignore[union-attr] + + @staticmethod + async def _log_chat_error(evt: CEvt.ChatEvent) -> None: + err = evt.get("chatError") # type: ignore[union-attr] + log.error("chatError: %s", err.get("type") if isinstance(err, dict) else err) + + @staticmethod + async def _log_chat_errors(evt: CEvt.ChatEvent) -> None: + errs = evt.get("chatErrors") or [] # type: ignore[union-attr] + log.error("chatErrors: %d errors", len(errs)) + + +__all__ = [ + "BotProfile", # backwards-compat alias for Profile + "ChatMessage", + "Client", + "CommandHandler", + "EventHandler", + "FileMessage", + "ImageMessage", + "LinkMessage", + "Message", + "MessageHandler", + "Middleware", + "ParsedCommand", + "Profile", + "ReportMessage", + "TextMessage", + "UnknownMessage", + "VideoMessage", + "VoiceMessage", +] diff --git a/packages/simplex-chat-python/src/simplex_chat/filters.py b/packages/simplex-chat-python/src/simplex_chat/filters.py index cdce5b7bb6..8af15c1c66 100644 --- a/packages/simplex-chat-python/src/simplex_chat/filters.py +++ b/packages/simplex-chat-python/src/simplex_chat/filters.py @@ -37,6 +37,15 @@ def compile_message_filter(kw: dict[str, Any]) -> Callable[[Any], bool]: predicates.append(gid_match) + if (cid := kw.get("contact_id")) is not None: + cid_set: tuple[int, ...] = (cid,) if isinstance(cid, int) else tuple(cid) + + def cid_match(m: Any) -> bool: + ci = m.chat_item["chatInfo"] + return ci["type"] == "direct" and ci["contact"]["contactId"] in cid_set + + predicates.append(cid_match) + if (when := kw.get("when")) is not None: predicates.append(when) diff --git a/packages/simplex-chat-python/src/simplex_chat/types/_events.py b/packages/simplex-chat-python/src/simplex_chat/types/_events.py index 77484fbf3f..7b7c724c92 100644 --- a/packages/simplex-chat-python/src/simplex_chat/types/_events.py +++ b/packages/simplex-chat-python/src/simplex_chat/types/_events.py @@ -1,7 +1,8 @@ # API Events # This file is generated automatically. from __future__ import annotations -from typing import Literal, NotRequired, TypedDict +from collections.abc import Awaitable, Callable +from typing import Literal, NotRequired, Protocol, TypedDict, overload from . import _types as T class ContactConnected(TypedDict): @@ -377,3 +378,318 @@ ChatEvent = ( ) ChatEvent_Tag = Literal["contactConnected", "contactUpdated", "contactDeletedByContact", "receivedContactRequest", "newMemberContactReceivedInv", "contactSndReady", "newChatItems", "chatItemReaction", "chatItemsDeleted", "chatItemUpdated", "groupChatItemsDeleted", "chatItemsStatusesUpdated", "receivedGroupInvitation", "userJoinedGroup", "groupUpdated", "joinedGroupMember", "memberRole", "deletedMember", "leftMember", "deletedMemberUser", "groupDeleted", "connectedToGroupMember", "memberAcceptedByOther", "memberBlockedForAll", "groupMemberUpdated", "groupLinkDataUpdated", "groupRelayUpdated", "rcvFileDescrReady", "rcvFileComplete", "sndFileCompleteXFTP", "rcvFileStart", "rcvFileSndCancelled", "rcvFileAccepted", "rcvFileError", "rcvFileWarning", "sndFileError", "sndFileWarning", "acceptingContactRequest", "acceptingBusinessRequest", "contactConnecting", "businessLinkConnecting", "joinedGroupMemberConnecting", "sentGroupInvitation", "groupLinkConnecting", "hostConnected", "hostDisconnected", "subscriptionStatus", "messageError", "chatError", "chatErrors"] + + +class OnEventDecorator(Protocol): + """Per-tag narrowing protocol for ``Client.on_event``. + + ``@client.on_event("contactConnected")`` types the handler's + ``evt`` parameter as :class:`ContactConnected` rather than the + unnarrowed :data:`ChatEvent` union. + """ + + @overload + def __call__(self, event: Literal["contactConnected"], /) -> Callable[ + [Callable[["ContactConnected"], Awaitable[None]]], + Callable[["ContactConnected"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["contactUpdated"], /) -> Callable[ + [Callable[["ContactUpdated"], Awaitable[None]]], + Callable[["ContactUpdated"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["contactDeletedByContact"], /) -> Callable[ + [Callable[["ContactDeletedByContact"], Awaitable[None]]], + Callable[["ContactDeletedByContact"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["receivedContactRequest"], /) -> Callable[ + [Callable[["ReceivedContactRequest"], Awaitable[None]]], + Callable[["ReceivedContactRequest"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["newMemberContactReceivedInv"], /) -> Callable[ + [Callable[["NewMemberContactReceivedInv"], Awaitable[None]]], + Callable[["NewMemberContactReceivedInv"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["contactSndReady"], /) -> Callable[ + [Callable[["ContactSndReady"], Awaitable[None]]], + Callable[["ContactSndReady"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["newChatItems"], /) -> Callable[ + [Callable[["NewChatItems"], Awaitable[None]]], + Callable[["NewChatItems"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["chatItemReaction"], /) -> Callable[ + [Callable[["ChatItemReaction"], Awaitable[None]]], + Callable[["ChatItemReaction"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["chatItemsDeleted"], /) -> Callable[ + [Callable[["ChatItemsDeleted"], Awaitable[None]]], + Callable[["ChatItemsDeleted"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["chatItemUpdated"], /) -> Callable[ + [Callable[["ChatItemUpdated"], Awaitable[None]]], + Callable[["ChatItemUpdated"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["groupChatItemsDeleted"], /) -> Callable[ + [Callable[["GroupChatItemsDeleted"], Awaitable[None]]], + Callable[["GroupChatItemsDeleted"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["chatItemsStatusesUpdated"], /) -> Callable[ + [Callable[["ChatItemsStatusesUpdated"], Awaitable[None]]], + Callable[["ChatItemsStatusesUpdated"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["receivedGroupInvitation"], /) -> Callable[ + [Callable[["ReceivedGroupInvitation"], Awaitable[None]]], + Callable[["ReceivedGroupInvitation"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["userJoinedGroup"], /) -> Callable[ + [Callable[["UserJoinedGroup"], Awaitable[None]]], + Callable[["UserJoinedGroup"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["groupUpdated"], /) -> Callable[ + [Callable[["GroupUpdated"], Awaitable[None]]], + Callable[["GroupUpdated"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["joinedGroupMember"], /) -> Callable[ + [Callable[["JoinedGroupMember"], Awaitable[None]]], + Callable[["JoinedGroupMember"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["memberRole"], /) -> Callable[ + [Callable[["MemberRole"], Awaitable[None]]], + Callable[["MemberRole"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["deletedMember"], /) -> Callable[ + [Callable[["DeletedMember"], Awaitable[None]]], + Callable[["DeletedMember"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["leftMember"], /) -> Callable[ + [Callable[["LeftMember"], Awaitable[None]]], + Callable[["LeftMember"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["deletedMemberUser"], /) -> Callable[ + [Callable[["DeletedMemberUser"], Awaitable[None]]], + Callable[["DeletedMemberUser"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["groupDeleted"], /) -> Callable[ + [Callable[["GroupDeleted"], Awaitable[None]]], + Callable[["GroupDeleted"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["connectedToGroupMember"], /) -> Callable[ + [Callable[["ConnectedToGroupMember"], Awaitable[None]]], + Callable[["ConnectedToGroupMember"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["memberAcceptedByOther"], /) -> Callable[ + [Callable[["MemberAcceptedByOther"], Awaitable[None]]], + Callable[["MemberAcceptedByOther"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["memberBlockedForAll"], /) -> Callable[ + [Callable[["MemberBlockedForAll"], Awaitable[None]]], + Callable[["MemberBlockedForAll"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["groupMemberUpdated"], /) -> Callable[ + [Callable[["GroupMemberUpdated"], Awaitable[None]]], + Callable[["GroupMemberUpdated"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["groupLinkDataUpdated"], /) -> Callable[ + [Callable[["GroupLinkDataUpdated"], Awaitable[None]]], + Callable[["GroupLinkDataUpdated"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["groupRelayUpdated"], /) -> Callable[ + [Callable[["GroupRelayUpdated"], Awaitable[None]]], + Callable[["GroupRelayUpdated"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["rcvFileDescrReady"], /) -> Callable[ + [Callable[["RcvFileDescrReady"], Awaitable[None]]], + Callable[["RcvFileDescrReady"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["rcvFileComplete"], /) -> Callable[ + [Callable[["RcvFileComplete"], Awaitable[None]]], + Callable[["RcvFileComplete"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["sndFileCompleteXFTP"], /) -> Callable[ + [Callable[["SndFileCompleteXFTP"], Awaitable[None]]], + Callable[["SndFileCompleteXFTP"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["rcvFileStart"], /) -> Callable[ + [Callable[["RcvFileStart"], Awaitable[None]]], + Callable[["RcvFileStart"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["rcvFileSndCancelled"], /) -> Callable[ + [Callable[["RcvFileSndCancelled"], Awaitable[None]]], + Callable[["RcvFileSndCancelled"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["rcvFileAccepted"], /) -> Callable[ + [Callable[["RcvFileAccepted"], Awaitable[None]]], + Callable[["RcvFileAccepted"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["rcvFileError"], /) -> Callable[ + [Callable[["RcvFileError"], Awaitable[None]]], + Callable[["RcvFileError"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["rcvFileWarning"], /) -> Callable[ + [Callable[["RcvFileWarning"], Awaitable[None]]], + Callable[["RcvFileWarning"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["sndFileError"], /) -> Callable[ + [Callable[["SndFileError"], Awaitable[None]]], + Callable[["SndFileError"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["sndFileWarning"], /) -> Callable[ + [Callable[["SndFileWarning"], Awaitable[None]]], + Callable[["SndFileWarning"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["acceptingContactRequest"], /) -> Callable[ + [Callable[["AcceptingContactRequest"], Awaitable[None]]], + Callable[["AcceptingContactRequest"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["acceptingBusinessRequest"], /) -> Callable[ + [Callable[["AcceptingBusinessRequest"], Awaitable[None]]], + Callable[["AcceptingBusinessRequest"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["contactConnecting"], /) -> Callable[ + [Callable[["ContactConnecting"], Awaitable[None]]], + Callable[["ContactConnecting"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["businessLinkConnecting"], /) -> Callable[ + [Callable[["BusinessLinkConnecting"], Awaitable[None]]], + Callable[["BusinessLinkConnecting"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["joinedGroupMemberConnecting"], /) -> Callable[ + [Callable[["JoinedGroupMemberConnecting"], Awaitable[None]]], + Callable[["JoinedGroupMemberConnecting"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["sentGroupInvitation"], /) -> Callable[ + [Callable[["SentGroupInvitation"], Awaitable[None]]], + Callable[["SentGroupInvitation"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["groupLinkConnecting"], /) -> Callable[ + [Callable[["GroupLinkConnecting"], Awaitable[None]]], + Callable[["GroupLinkConnecting"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["hostConnected"], /) -> Callable[ + [Callable[["HostConnected"], Awaitable[None]]], + Callable[["HostConnected"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["hostDisconnected"], /) -> Callable[ + [Callable[["HostDisconnected"], Awaitable[None]]], + Callable[["HostDisconnected"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["subscriptionStatus"], /) -> Callable[ + [Callable[["SubscriptionStatus"], Awaitable[None]]], + Callable[["SubscriptionStatus"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["messageError"], /) -> Callable[ + [Callable[["MessageError"], Awaitable[None]]], + Callable[["MessageError"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["chatError"], /) -> Callable[ + [Callable[["ChatError"], Awaitable[None]]], + Callable[["ChatError"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["chatErrors"], /) -> Callable[ + [Callable[["ChatErrors"], Awaitable[None]]], + Callable[["ChatErrors"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: str, /) -> Callable[ + [Callable[["ChatEvent"], Awaitable[None]]], + Callable[["ChatEvent"], Awaitable[None]], + ]: ... diff --git a/packages/simplex-chat-python/tests/test_bot_registration.py b/packages/simplex-chat-python/tests/test_bot_registration.py index 7401d2ef5d..f6f245c344 100644 --- a/packages/simplex-chat-python/tests/test_bot_registration.py +++ b/packages/simplex-chat-python/tests/test_bot_registration.py @@ -1,6 +1,6 @@ import pytest -from simplex_chat import Bot, BotCommand, BotProfile, Middleware, SqliteDb +from simplex_chat import Bot, BotCommand, BotProfile, Client, Middleware, Profile, SqliteDb from simplex_chat.api import ChatApi @@ -57,9 +57,9 @@ def test_command_keyword_tuple(): def test_bot_profile_to_wire_default(): - """use_bot_profile=True (default) sets peerType=bot and disables calls/voice.""" + """Bot's profile wire-form sets peerType=bot and disables calls/voice.""" bot = _bot() - p = bot._bot_profile_to_wire() + p = bot._profile_to_wire() assert p["displayName"] == "x" assert p.get("peerType") == "bot" prefs = p.get("preferences") or {} @@ -74,7 +74,7 @@ def test_bot_profile_to_wire_allow_files(): db=SqliteDb(file_prefix="/tmp/test"), allow_files=True, ) - prefs = bot._bot_profile_to_wire().get("preferences") or {} + prefs = bot._profile_to_wire().get("preferences") or {} assert prefs.get("files", {}).get("allow") == "yes" @@ -84,32 +84,26 @@ def test_bot_profile_to_wire_with_commands(): db=SqliteDb(file_prefix="/tmp/test"), commands=[BotCommand(keyword="ping", label="Ping bot"), BotCommand("help", "Show help")], ) - cmds = bot._bot_profile_to_wire().get("preferences", {}).get("commands") or [] + cmds = bot._profile_to_wire().get("preferences", {}).get("commands") or [] assert len(cmds) == 2 assert cmds[0] == {"type": "command", "keyword": "ping", "label": "Ping bot"} assert cmds[1] == {"type": "command", "keyword": "help", "label": "Show help"} -def test_bot_profile_to_wire_no_bot_profile(): - bot = Bot( - profile=BotProfile(display_name="x"), - db=SqliteDb(file_prefix="/tmp/test"), - use_bot_profile=False, - ) - p = bot._bot_profile_to_wire() +def test_client_profile_to_wire_has_no_bot_extras(): + """Client's wire profile has no peerType=bot, no command list, no calls/voice prefs. + That's the whole point of having Client as a separate class.""" + c = Client(profile=Profile(display_name="x"), db=SqliteDb(file_prefix="/tmp/test")) + p = c._profile_to_wire() + assert p["displayName"] == "x" assert "peerType" not in p assert "preferences" not in p -def test_commands_without_bot_profile_raises(): - bot = Bot( - profile=BotProfile(display_name="x"), - db=SqliteDb(file_prefix="/tmp/test"), - use_bot_profile=False, - commands=[BotCommand("ping", "Ping bot")], - ) - with pytest.raises(ValueError, match="use_bot_profile=False"): - bot._bot_profile_to_wire() +def test_bot_profile_alias_is_profile(): + """`BotProfile` is kept as an alias for backwards compatibility.""" + assert BotProfile is Profile + assert BotProfile(display_name="x") == Profile(display_name="x") def test_dispatch_message_first_match_wins(): diff --git a/packages/simplex-chat-python/tests/test_client_and_waiters.py b/packages/simplex-chat-python/tests/test_client_and_waiters.py new file mode 100644 index 0000000000..7c01ae576a --- /dev/null +++ b/packages/simplex-chat-python/tests/test_client_and_waiters.py @@ -0,0 +1,616 @@ +"""Tests for Client class + connect_to / send_and_wait / events plumbing. + +Stubs out ChatApi so we exercise the dispatch and waiter logic without +spinning up the native libsimplex controller. +""" + +from __future__ import annotations + +import asyncio +from typing import Any + +import pytest + +from simplex_chat import ( + Bot, + BotProfile, + Client, + ContactAlreadyExistsError, + Profile, + SqliteDb, +) + + +class FakeApi: + """Drop-in replacement for ChatApi for tests that don't need the FFI. + + Records api_send_text_message calls; supports scripting api_connect_plan + and api_connect_active_user behaviour. + """ + + def __init__(self) -> None: + self.sent: list[tuple[Any, str]] = [] + self.connect_plan_result: Any = ("error", None) # default: no known contact + self.connect_should_raise: Exception | None = None + self.active_user: dict[str, Any] = {"userId": 1, "profile": {"displayName": "x"}} + + async def api_send_text_message(self, chat, text, in_reply_to=None): + self.sent.append((chat, text)) + return [] + + async def api_connect_plan(self, _user_id, _link): + kind = self.connect_plan_result[0] + if kind == "known_contact_address": + return ( + { + "type": "contactAddress", + "contactAddressPlan": {"type": "known", "contact": self.connect_plan_result[1]}, + }, + {}, + ) + if kind == "known_invitation": + return ( + { + "type": "invitationLink", + "invitationLinkPlan": {"type": "known", "contact": self.connect_plan_result[1]}, + }, + {}, + ) + if kind == "ok": + return ( + { + "type": "contactAddress", + "contactAddressPlan": {"type": "ok"}, + }, + {}, + ) + # default "error" + return ({"type": "error", "chatError": {}}, {}) + + async def api_connect_active_user(self, _link): + if self.connect_should_raise is not None: + raise self.connect_should_raise + return "contact" + + async def api_get_active_user(self): + return self.active_user + + +def _bot_with_fake_api() -> tuple[Bot, FakeApi]: + bot = Bot(profile=BotProfile(display_name="x"), db=SqliteDb(file_prefix="/tmp/test")) + api = FakeApi() + bot._api = api # type: ignore[assignment] + bot._serving = True # pretend receive loop is up + return bot, api + + +# --------------------------------------------------------------------------- +# Client class +# --------------------------------------------------------------------------- + + +def test_client_has_no_address_or_bot_profile_attributes(): + """Client should not carry bot-side state (address creation, auto-accept, + welcome, commands). That's the whole point of separating Client from Bot.""" + c = Client(profile=Profile(display_name="monitor"), db=SqliteDb(file_prefix="/tmp/test")) + for attr in ("_create_address", "_update_address", "_auto_accept", "_welcome", "_commands"): + assert not hasattr(c, attr), f"Client unexpectedly has Bot-only attribute {attr}" + # And the wire profile has no bot peerType + p = c._profile_to_wire() + assert "peerType" not in p + assert "preferences" not in p + + +def test_bot_is_a_client_subclass(): + """Bot should extend Client, so anywhere a Client is accepted, a Bot fits too.""" + assert issubclass(Bot, Client) + + +def test_client_exposes_messaging_methods(): + c = Client(profile=Profile(display_name="m"), db=SqliteDb(file_prefix="/tmp/test")) + assert hasattr(c, "connect_to") + assert hasattr(c, "send_and_wait") + assert hasattr(c, "events") + assert hasattr(c, "on_message") # decorators available on Client too + + +# --------------------------------------------------------------------------- +# send_and_wait +# --------------------------------------------------------------------------- + + +def test_send_and_wait_requires_serving(): + """Without the receive loop running, send_and_wait must raise — otherwise + callers would silently hang waiting for a reply that's never dispatched.""" + bot = Bot(profile=BotProfile(display_name="x"), db=SqliteDb(file_prefix="/tmp/test")) + bot._api = FakeApi() # type: ignore[assignment] + # _serving is False by default + with pytest.raises(RuntimeError, match="receive loop"): + asyncio.run(bot.send_and_wait(1, "hi")) + + +def test_send_and_wait_resolves_on_matching_reply(): + """A reply from the awaited contact should resolve the Future and skip + regular message dispatch.""" + bot, api = _bot_with_fake_api() + fallback_calls: list[str] = [] + + @bot.on_message(content_type="text") + async def fallback(_msg): + fallback_calls.append("fallback") + + async def go() -> str: + send_task = asyncio.create_task(bot.send_and_wait(42, "ping", timeout=2.0)) + # Yield so the task gets to register its waiter. + await asyncio.sleep(0) + evt = {"type": "newChatItems", "chatItems": [ + { + "chatInfo": {"type": "direct", "contact": {"contactId": 42}}, + "chatItem": { + "content": {"type": "rcvMsgContent", "msgContent": {"type": "text", "text": "pong"}}, + }, + } + ]} + await bot._dispatch_event(evt) # type: ignore[arg-type] + reply = await send_task + return reply.text or "" + + result = asyncio.run(go()) + assert result == "pong" + assert api.sent == [(["direct", 42], "ping")] + assert fallback_calls == [], "fallback handler should NOT fire when a waiter consumed the reply" + + +def test_send_and_wait_ignores_other_contacts(): + """Replies from a different contact must not resolve the waiter — that + would mis-correlate responses and is the bug send_and_wait exists to + prevent users from writing themselves.""" + bot, _api = _bot_with_fake_api() + + async def go(): + send_task = asyncio.create_task(bot.send_and_wait(42, "ping", timeout=0.5)) + await asyncio.sleep(0) + evt = {"type": "newChatItems", "chatItems": [ + { + "chatInfo": {"type": "direct", "contact": {"contactId": 99}}, + "chatItem": { + "content": {"type": "rcvMsgContent", "msgContent": {"type": "text", "text": "not for you"}}, + }, + } + ]} + await bot._dispatch_event(evt) # type: ignore[arg-type] + with pytest.raises(asyncio.TimeoutError): + await send_task + + asyncio.run(go()) + + +def test_send_and_wait_fifo_within_contact(): + """Two concurrent waiters on the same contact should resolve in send order.""" + bot, _api = _bot_with_fake_api() + + async def go() -> tuple[str, str]: + first = asyncio.create_task(bot.send_and_wait(42, "first", timeout=2.0)) + await asyncio.sleep(0) + second = asyncio.create_task(bot.send_and_wait(42, "second", timeout=2.0)) + await asyncio.sleep(0) + for text in ("reply1", "reply2"): + evt = {"type": "newChatItems", "chatItems": [ + { + "chatInfo": {"type": "direct", "contact": {"contactId": 42}}, + "chatItem": { + "content": {"type": "rcvMsgContent", "msgContent": {"type": "text", "text": text}}, + }, + } + ]} + await bot._dispatch_event(evt) # type: ignore[arg-type] + return (await first).text or "", (await second).text or "" + + a, b = asyncio.run(go()) + assert (a, b) == ("reply1", "reply2") + + +def test_send_and_wait_cleans_up_state_on_timeout(): + """Timed-out waiters must be removed so they don't accidentally consume + later replies.""" + bot, _api = _bot_with_fake_api() + + async def go(): + with pytest.raises(asyncio.TimeoutError): + await bot.send_and_wait(42, "ping", timeout=0.05) + assert 42 not in bot._reply_waiters, f"leaked waiters: {bot._reply_waiters}" + + asyncio.run(go()) + + +def test_dispatch_skips_cancelled_waiters_and_falls_through_to_handlers(): + """Race fix: if a waiter is cancelled (wait_for timed out) but still in + the FIFO when a reply arrives, the dispatcher must skip it and either + resolve a live waiter OR fall through to user message handlers — not + silently drop the message.""" + bot, _api = _bot_with_fake_api() + fallback_calls: list[str] = [] + + @bot.on_message(content_type="text") + async def fallback(msg): + fallback_calls.append(msg.text or "") + + async def go(): + # Manually inject a cancelled waiter (simulating wait_for timeout + # cleanup losing the race with the inbound message). + loop = asyncio.get_running_loop() + stale: asyncio.Future = loop.create_future() + stale.cancel() + bot._reply_waiters[42] = [stale] + + evt = {"type": "newChatItems", "chatItems": [ + { + "chatInfo": {"type": "direct", "contact": {"contactId": 42}}, + "chatItem": { + "content": {"type": "rcvMsgContent", "msgContent": {"type": "text", "text": "racing reply"}}, + }, + } + ]} + await bot._dispatch_event(evt) # type: ignore[arg-type] + + asyncio.run(go()) + assert fallback_calls == ["racing reply"], ( + "dispatcher dropped the message instead of falling through to user handlers; " + f"got {fallback_calls}" + ) + assert 42 not in bot._reply_waiters, "cancelled waiter wasn't cleaned up" + + +def test_send_and_wait_parallel_different_contacts(): + """Concurrent send_and_wait to different contacts must not block each other. + + The library docstring promises this; this test pins the behaviour so a + future refactor (e.g., adding a single lock) can't quietly break it.""" + bot, _api = _bot_with_fake_api() + + async def go() -> tuple[str, str]: + t_a = asyncio.create_task(bot.send_and_wait(10, "a", timeout=2.0)) + await asyncio.sleep(0) + t_b = asyncio.create_task(bot.send_and_wait(20, "b", timeout=2.0)) + await asyncio.sleep(0) + # Deliver reply for B first — order shouldn't matter. + await bot._dispatch_event({"type": "newChatItems", "chatItems": [ # type: ignore[arg-type] + { + "chatInfo": {"type": "direct", "contact": {"contactId": 20}}, + "chatItem": {"content": {"type": "rcvMsgContent", "msgContent": {"type": "text", "text": "B"}}}, + } + ]}) + await bot._dispatch_event({"type": "newChatItems", "chatItems": [ # type: ignore[arg-type] + { + "chatInfo": {"type": "direct", "contact": {"contactId": 10}}, + "chatItem": {"content": {"type": "rcvMsgContent", "msgContent": {"type": "text", "text": "A"}}}, + } + ]}) + return (await t_a).text or "", (await t_b).text or "" + + a, b = asyncio.run(go()) + assert (a, b) == ("A", "B") + + +# --------------------------------------------------------------------------- +# connect_to +# --------------------------------------------------------------------------- + + +def test_connect_to_returns_known_contact_without_handshake(): + """If the link is already known, connect_to skips api_connect entirely.""" + bot, api = _bot_with_fake_api() + existing = {"contactId": 7, "profile": {"displayName": "SimpleX Directory"}} + api.connect_plan_result = ("known_contact_address", existing) + + contact = asyncio.run(bot.connect_to("link", timeout=2.0)) + assert contact["contactId"] == 7 + # No connect issued: send buffer untouched. + assert api.sent == [] + + +def test_connect_to_waits_for_contactConnected(): + """For unknown links, connect_to issues the handshake and waits for the + contactConnected event before returning.""" + bot, api = _bot_with_fake_api() + api.connect_plan_result = ("ok", None) + new_contact = {"contactId": 11, "profile": {"displayName": "Friend"}} + + async def go(): + connect_task = asyncio.create_task(bot.connect_to("link", timeout=2.0)) + await asyncio.sleep(0) + await bot._dispatch_event({"type": "contactConnected", "contact": new_contact}) # type: ignore[arg-type] + return await connect_task + + contact = asyncio.run(go()) + assert contact["contactId"] == 11 + + +def test_connect_to_tolerates_contact_already_exists(): + """ContactAlreadyExistsError must NOT abort connect_to — a previous + incomplete attempt may have left the connection mid-handshake; the + contactConnected event will still arrive.""" + bot, api = _bot_with_fake_api() + api.connect_plan_result = ("ok", None) + api.connect_should_raise = ContactAlreadyExistsError( + "exists", {"type": "contactAlreadyExists"} # type: ignore[arg-type] + ) + + async def go(): + connect_task = asyncio.create_task(bot.connect_to("link", timeout=2.0)) + await asyncio.sleep(0) + await bot._dispatch_event({"type": "contactConnected", "contact": {"contactId": 5, "profile": {"displayName": "Friend"}}}) # type: ignore[arg-type] + return await connect_task + + contact = asyncio.run(go()) + assert contact["contactId"] == 5 + + +def test_connect_to_requires_serving(): + bot = Bot(profile=BotProfile(display_name="x"), db=SqliteDb(file_prefix="/tmp/test")) + bot._api = FakeApi() # type: ignore[assignment] + with pytest.raises(RuntimeError, match="receive loop"): + asyncio.run(bot.connect_to("link")) + + +def test_connect_to_timeout_cleans_up_waiter(): + bot, api = _bot_with_fake_api() + api.connect_plan_result = ("ok", None) + + async def go(): + with pytest.raises(asyncio.TimeoutError): + await bot.connect_to("link", timeout=0.05) + assert bot._connect_waiters == [], "leaked connect waiter" + + asyncio.run(go()) + + +def test_connect_to_rejects_non_positive_timeout(): + """timeout<=0 must fail upfront — otherwise wait_for raises after the + handshake side-effect has already gone over the wire.""" + bot, _api = _bot_with_fake_api() + + async def go(): + for bad in (0, -1, -0.001): + with pytest.raises(ValueError, match="timeout must be positive"): + await bot.connect_to("link", timeout=bad) + + asyncio.run(go()) + + +def test_send_and_wait_rejects_non_positive_timeout(): + """Same as connect_to: timeout<=0 would surprise the caller with a sent + message and no Future to await.""" + bot, api = _bot_with_fake_api() + + async def go(): + for bad in (0, -1, -0.5): + with pytest.raises(ValueError, match="timeout must be positive"): + await bot.send_and_wait(42, "ping", timeout=bad) + # And nothing was sent. + assert api.sent == [] + + asyncio.run(go()) + + +def test_stop_before_serve_forever_is_preserved(monkeypatch): + """If stop() is called between __aenter__ and serve_forever (e.g. a + signal handler fires during the window where run() wires SIGINT), the + pre-set _stop_event must NOT be cleared by serve_forever — otherwise + the signal is silently lost and the loop runs indefinitely.""" + import simplex_chat.client as client_mod + + class _FakeApi: + @classmethod + async def init(cls, *_a, **_kw): + return cls() + + @property + def started(self): + return False + + async def start_chat(self): + pass + + async def stop_chat(self): + pass + + async def close(self): + pass + + async def api_get_active_user(self): + return {"userId": 1, "profile": {"displayName": "x"}} + + async def recv_chat_event(self, wait_us=0): + # Should NOT be reached — the loop should exit on the pre-set + # stop event before it ever polls for an event. + raise AssertionError("receive loop should have exited immediately") + + # _ensure_active_user / _maybe_sync_profile pokes + async def send_chat_cmd(self, _cmd): + return {"type": "cmdOk"} + + monkeypatch.setattr(client_mod, "ChatApi", _FakeApi) + + c = Client(profile=Profile(display_name="x"), db=SqliteDb(file_prefix="/tmp/test")) + + async def go(): + async with c: + c.stop() # signal fires before serve_forever + await c.serve_forever() # must not block + + asyncio.run(go()) + + +def test_aexit_nulls_api_even_if_close_raises(monkeypatch): + """If `close()` raises inside __aexit__, the Client must still appear + closed — `client.api` should refuse to hand back the half-shutdown + controller, and re-entering the context manager should re-init cleanly.""" + import simplex_chat.client as client_mod + + init_count = [0] + + class _BoomCloseApi: + @classmethod + async def init(cls, *_a, **_kw): + init_count[0] += 1 + return cls() + + @property + def started(self): + return False + + async def start_chat(self): + pass + + async def stop_chat(self): + pass + + async def close(self): + raise RuntimeError("close failed") + + async def api_get_active_user(self): + return {"userId": 1, "profile": {"displayName": "x"}} + + async def send_chat_cmd(self, _cmd): + return {"type": "cmdOk"} + + monkeypatch.setattr(client_mod, "ChatApi", _BoomCloseApi) + + c = Client(profile=Profile(display_name="x"), db=SqliteDb(file_prefix="/tmp/test")) + + async def go(): + with pytest.raises(RuntimeError, match="close failed"): + async with c: + pass + # _api must be None despite close() raising + assert c._api is None, "Client._api leaked after __aexit__ close() raised" + with pytest.raises(RuntimeError, match="not initialized"): + _ = c.api + # Re-enter must work + try: + async with c: + pass + except RuntimeError: + pass # close raises again, fine + assert init_count[0] == 2, "re-entry didn't re-init the controller" + + asyncio.run(go()) + + +def test_aenter_rolls_back_partial_init_on_post_start_failure(monkeypatch): + """If anything in __aenter__ raises after ChatApi.init succeeded — including + _post_start — the controller must be closed. Otherwise the with-block isn't + entered, __aexit__ never runs, and the FFI handle leaks.""" + import simplex_chat.client as client_mod + + closed: list[str] = [] + started: list[bool] = [False] + + class FakeChatApi: + @classmethod + async def init(cls, *_args, **_kwargs): + return cls() + + @property + def started(self) -> bool: + return started[0] + + async def start_chat(self): + started[0] = True + + async def stop_chat(self): + started[0] = False + closed.append("stop") + + async def close(self): + closed.append("close") + + # Stub the bits _ensure_active_user / _maybe_sync_profile reach for. + async def api_get_active_user(self): + return {"userId": 1, "profile": {"displayName": "x"}} + + async def send_chat_cmd(self, _cmd): + return {"type": "cmdOk"} + + monkeypatch.setattr(client_mod, "ChatApi", FakeChatApi) + + class Boom(RuntimeError): + pass + + class BoomClient(Client): + async def _post_start(self, user): + raise Boom("kaboom") + + c = BoomClient(profile=Profile(display_name="x"), db=SqliteDb(file_prefix="/tmp/test")) + + async def go(): + with pytest.raises(Boom): + async with c: + pytest.fail("should not enter the with-block") + + asyncio.run(go()) + assert closed == ["stop", "close"], f"controller not cleaned up: {closed}" + assert c._api is None, "Client._api should be reset to None after rollback" + + +def test_lookup_known_contact_propagates_non_command_errors(): + """_lookup_known_contact must NOT mask transport / FFI errors as 'unknown + link' — only ChatCommandError (malformed link, etc.) should fall through + to the handshake path. Bare Exception catch would hide real bugs.""" + bot, api = _bot_with_fake_api() + + class BoomError(RuntimeError): + pass + + async def boom(_user_id, _link): + raise BoomError("FFI wedged") + + api.api_connect_plan = boom # type: ignore[assignment] + + async def go(): + with pytest.raises(BoomError): + await bot._lookup_known_contact(1, "link") + + asyncio.run(go()) + + +# --------------------------------------------------------------------------- +# Exception subclasses +# --------------------------------------------------------------------------- + + +def test_contact_already_exists_is_chat_command_error_subclass(): + """Callers should be able to catch the base class to handle all command + errors uniformly, and the specific subclass for targeted handling.""" + from simplex_chat import ChatCommandError, ContactAlreadyExistsError + + assert issubclass(ContactAlreadyExistsError, ChatCommandError) + + e = ContactAlreadyExistsError("x", {"type": "contactAlreadyExists"}) # type: ignore[arg-type] + assert isinstance(e, ChatCommandError) + assert e.response_type == "contactAlreadyExists" + + +def test_chat_command_error_response_type_property(): + from simplex_chat import ChatCommandError + + e = ChatCommandError("x", {"type": "someError"}) # type: ignore[arg-type] + assert e.response_type == "someError" + + +# --------------------------------------------------------------------------- +# events() mutual exclusion with serve_forever +# --------------------------------------------------------------------------- + + +def test_events_raises_if_already_serving(): + bot, _api = _bot_with_fake_api() + # _serving=True is set by _bot_with_fake_api + + async def go(): + with pytest.raises(RuntimeError, match="mutually exclusive"): + async for _ in bot.events(): + pass + + asyncio.run(go()) diff --git a/packages/simplex-chat-python/tests/test_filters.py b/packages/simplex-chat-python/tests/test_filters.py index 08fb66ed92..3c909df4df 100644 --- a/packages/simplex-chat-python/tests/test_filters.py +++ b/packages/simplex-chat-python/tests/test_filters.py @@ -3,7 +3,7 @@ import re from simplex_chat.filters import compile_message_filter -def _msg(content_type="text", text=None, chat_type="direct", group_id=None): +def _msg(content_type="text", text=None, chat_type="direct", group_id=None, contact_id=None): """Build a minimal mock Message-like object for filter testing.""" class M: @@ -11,12 +11,12 @@ def _msg(content_type="text", text=None, chat_type="direct", group_id=None): m = M() m.content = {"type": content_type, "text": text} if text is not None else {"type": content_type} - m.chat_item = { - "chatInfo": { - "type": chat_type, - **({"groupInfo": {"groupId": group_id}} if chat_type == "group" else {}), - } - } + chat_info: dict = {"type": chat_type} + if chat_type == "group": + chat_info["groupInfo"] = {"groupId": group_id} + elif chat_type == "direct" and contact_id is not None: + chat_info["contact"] = {"contactId": contact_id} + m.chat_item = {"chatInfo": chat_info} return m @@ -81,3 +81,23 @@ def test_group_id_tuple_or(): f = compile_message_filter({"group_id": (1, 2, 3)}) assert f(_msg(chat_type="group", group_id=2)) assert not f(_msg(chat_type="group", group_id=99)) + + +def test_contact_id_filter(): + f = compile_message_filter({"contact_id": 7}) + assert f(_msg(chat_type="direct", contact_id=7)) + assert not f(_msg(chat_type="direct", contact_id=99)) + assert not f(_msg(chat_type="group", group_id=7)) + + +def test_contact_id_tuple_or(): + f = compile_message_filter({"contact_id": (1, 2, 3)}) + assert f(_msg(chat_type="direct", contact_id=2)) + assert not f(_msg(chat_type="direct", contact_id=99)) + + +def test_contact_id_combined_with_content_type(): + f = compile_message_filter({"content_type": "text", "contact_id": 5}) + assert f(_msg(content_type="text", chat_type="direct", contact_id=5)) + assert not f(_msg(content_type="image", chat_type="direct", contact_id=5)) + assert not f(_msg(content_type="text", chat_type="direct", contact_id=99)) From 0097cc6e49add8e5d7d5c45073b3d09bb3cb1bc4 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Thu, 14 May 2026 09:45:01 +0100 Subject: [PATCH 05/14] core: 6.5.2.0 --- simplex-chat.cabal | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 1e463dfe48..3c260825b7 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplex-chat -version: 6.5.1.1 +version: 6.5.2.0 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat From 4bea161724261df64a60394eacb8161aa141fb2c Mon Sep 17 00:00:00 2001 From: Narasimha-sc <166327228+Narasimha-sc@users.noreply.github.com> Date: Thu, 14 May 2026 14:33:55 +0000 Subject: [PATCH 06/14] ios: hide private notes from share channel picker (#6980) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Companion to #6958 (Android/Desktop). Channel-link "Share via chat" picker showed Saved Messages, which produced `Failed reading: empty` on tap because `*` has no branch in `sendRefP`. Add `includeLocal` flag to `filterChatsToForwardTo` (default true) and to `ChatItemForwardingView`; `shareChannelPicker` passes false. Bug #2 from #6958 (button on plain groups) is not present on iOS — `GroupLinkView.swift:110` already gates by `publicGroup != nil`. --- .../Views/Chat/ChatItemForwardingView.swift | 3 +- .../Views/Chat/Group/GroupChatInfoView.swift | 3 +- apps/ios/SimpleXChat/ChatUtils.swift | 4 +- plans/2026-05-14-fix-group-link-share-ios.md | 141 ++++++++++++++++++ 4 files changed, 147 insertions(+), 4 deletions(-) create mode 100644 plans/2026-05-14-fix-group-link-share-ios.md diff --git a/apps/ios/Shared/Views/Chat/ChatItemForwardingView.swift b/apps/ios/Shared/Views/Chat/ChatItemForwardingView.swift index d83a5e8504..92bab973c9 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemForwardingView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemForwardingView.swift @@ -20,10 +20,11 @@ struct ChatItemForwardingView: View { var composeState: Binding? = nil var isProhibited: ((Chat) -> Bool)? = nil var onSelectChat: ((Chat) -> Void)? = nil + var includeLocal: Bool = true @State private var searchText: String = "" @State private var alert: SomeAlert? - private let chatsToForwardTo = filterChatsToForwardTo(chats: ChatModel.shared.chats) + private var chatsToForwardTo: [Chat] { filterChatsToForwardTo(chats: ChatModel.shared.chats, includeLocal: includeLocal) } var body: some View { NavigationView { diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index eee9500b3b..34479fc6cb 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -1104,7 +1104,8 @@ func shareChannelPicker(groupInfo: GroupInfo, composeState: Binding(chats: [C]) -> [C] { +public func filterChatsToForwardTo(chats: [C], includeLocal: Bool = true) -> [C] { var filteredChats = chats.filter { c in c.chatInfo.chatType != .local && canForwardToChat(c.chatInfo) } - if let privateNotes = chats.first(where: { $0.chatInfo.chatType == .local }) { + if includeLocal, let privateNotes = chats.first(where: { $0.chatInfo.chatType == .local }) { filteredChats.insert(privateNotes, at: 0) } return filteredChats diff --git a/plans/2026-05-14-fix-group-link-share-ios.md b/plans/2026-05-14-fix-group-link-share-ios.md new file mode 100644 index 0000000000..ba22b04dd6 --- /dev/null +++ b/plans/2026-05-14-fix-group-link-share-ios.md @@ -0,0 +1,141 @@ +# Share Channel Link — Filter Saved Messages (iOS) + +Companion to [#6958](https://github.com/simplex-chat/simplex-chat/pull/6958) (`nd/fix-group-link-share`, Android/Desktop). +Branch `nd/fix-group-link-share-ios`, base `master`. + +## 1. The bug + +On the iOS channel-link "Share via chat" picker, **Saved Messages** is offered as a destination. Tapping it produces `chat commandError Failed reading: empty` from the server. + +This is the iOS counterpart of bug #1 from PR #6958. Bug #2 from that PR (the "Share via chat" button rendering on plain groups) does **not** exist on iOS — `GroupLinkView.swift:110` already gates the button with `if groupInfo?.groupProfile.publicGroup != nil`, and plain groups have `publicGroup == nil`. + +## 2. Root cause + +`APIShareChatMsgContent` is parsed with `sendRefP` in `src/Simplex/Chat/Library/Commands.hs:5426`: + +```haskell +sendRefP = + (A.char '@' $> SRDirect <*> A.decimal) + <|> (A.char '#' $> SRGroup <*> A.decimal <*> optional gcScopeP <*> asGroupP) +``` + +The iOS client emits `*` for `ChatType.local` (Saved Messages) via the standard chat-type prefix. `sendRefP` has no `*` branch, attoparsec returns `Failed reading: empty`, the handler never runs. + +This is the correct server behaviour — sharing a channel link to one's own note folder is not a meaningful operation. The picker offered the destination by accident: `filterChatsToForwardTo` in `apps/ios/SimpleXChat/ChatUtils.swift:56` unconditionally inserts `ChatInfo.local` at index 0: + +```swift +public func filterChatsToForwardTo(chats: [C]) -> [C] { + var filteredChats = chats.filter { c in + c.chatInfo.chatType != .local && canForwardToChat(c.chatInfo) + } + if let privateNotes = chats.first(where: { $0.chatInfo.chatType == .local }) { + filteredChats.insert(privateNotes, at: 0) + } + return filteredChats +} +``` + +`shareChannelPicker` (`apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift:1103`) builds a `ChatItemForwardingView`, which calls `filterChatsToForwardTo`. So the channel-link picker inherits the Saved-Messages-at-index-0 behaviour that the forward picker wants. + +## 3. Approaches considered + +| # | Approach | Note | +|---|----------|------| +| A | **Final** — parameterize the filter: add `includeLocal: Bool = true` to `filterChatsToForwardTo` and to `ChatItemForwardingView`; pass `includeLocal: false` from `shareChannelPicker`. | Default keeps existing call-sites untouched. Mirrors PR #6958's pattern — the filter decides, callers express intent. | +| B | Post-filter `.local` inside `ChatItemForwardingView` after the call to `filterChatsToForwardTo`. | Same line count, but duplicates the `.local` predicate at the consumer instead of expressing it at the producer. | +| C | Pass a closure filter to `ChatItemForwardingView`. | A closure encodes one bit as a function — strictly more machinery for the same outcome. | +| D | Mirror Kotlin literally: read a global `SharedContent.ChatLink` discriminator inside the filter. | iOS's `SharedContent` lives in the Share Extension target, not the main app — the Kotlin-style predicate doesn't translate. | + +Approach A wins on minimality (5 lines, three files), preserves all default behaviour, and matches the architectural pattern of PR #6958 (decision lives where the data is produced). + +## 4. Final implementation + +### 4.1 `apps/ios/SimpleXChat/ChatUtils.swift` — add `includeLocal` parameter + +```diff +-public func filterChatsToForwardTo(chats: [C]) -> [C] { ++public func filterChatsToForwardTo(chats: [C], includeLocal: Bool = true) -> [C] { + var filteredChats = chats.filter { c in + c.chatInfo.chatType != .local && canForwardToChat(c.chatInfo) + } +- if let privateNotes = chats.first(where: { $0.chatInfo.chatType == .local }) { ++ if includeLocal, let privateNotes = chats.first(where: { $0.chatInfo.chatType == .local }) { + filteredChats.insert(privateNotes, at: 0) + } + return filteredChats + } +``` + +Default value preserves the contract for every existing caller (`ChatItemForwardingView.swift:26`, `SimpleX SE/ShareModel.swift:71-72`). The `if includeLocal, let ...` form Swift-natively short-circuits — no nested block needed. + +### 4.2 `apps/ios/Shared/Views/Chat/ChatItemForwardingView.swift` — thread the flag + +```diff + var isProhibited: ((Chat) -> Bool)? = nil + var onSelectChat: ((Chat) -> Void)? = nil ++ var includeLocal: Bool = true + + @State private var searchText: String = "" + @State private var alert: SomeAlert? +- private let chatsToForwardTo = filterChatsToForwardTo(chats: ChatModel.shared.chats) ++ private var chatsToForwardTo: [Chat] { filterChatsToForwardTo(chats: ChatModel.shared.chats, includeLocal: includeLocal) } +``` + +`private let → private var` (computed) is required because Swift property initializers cannot read sibling instance properties. The computed form re-evaluates when `body` runs — in this view that is twice per render (lines 49 and 52), against a list of size `chats.count`. No meaningful cost; if a profile ever flagged it, switching to a custom `init(...)` that captures `includeLocal` once is a trivial follow-up. + +### 4.3 `apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift` — opt out from the channel-link picker + +```diff + let v = ChatItemForwardingView( + title: "Share channel", + isProhibited: { $0.prohibitedByPref(hasSimplexLink: true, isMediaOrFileAttachment: false, isVoice: false) }, +- onSelectChat: { chat in shareChatLink(chat, sourceGroupInfo: groupInfo, composeState: composeState) } ++ onSelectChat: { chat in shareChatLink(chat, sourceGroupInfo: groupInfo, composeState: composeState) }, ++ includeLocal: false + ) +``` + +One-line opt-out from the only iOS site that uses the channel-link share flow. + +### 4.4 What is *not* changed + +- **`GroupLinkView.swift:110`** — already gates "Share via chat" with `groupInfo?.groupProfile.publicGroup != nil`. Bug #2 from PR #6958 has no iOS analog. +- **Forward picker** (`ChatView.swift:279, 282`) — uses `ChatItemForwardingView`'s default `includeLocal: true`. Saved Messages still appears at index 0. +- **Share extension** (`SimpleX SE/ShareModel.swift:71-72`) — calls `filterChatsToForwardTo` directly with the default. Unchanged. +- **Haskell.** `sendRefP` and `APIShareChatMsgContent` stay at master. The client just stops offering destinations the server refuses. +- **Android/Desktop, all other share/forward paths.** + +## 5. Why this works + +The server is the source of truth for which destinations are valid for `APIShareChatMsgContent`: + +- Destinations: `@` (direct), `#` (group / scope). Local (`*`) is rejected as a parse failure, by construction. +- Sources: groups with `publicGroup` and `groupLink`. iOS already gates the source side correctly. + +The client's job is to offer choices the server will accept. The picker offered Local in error; this PR narrows the offer to match the server's grammar. The default-`true` parameter means every other caller keeps its current behaviour without modification. + +## 6. Behaviour changes — full inventory + +1. **Picking Saved Messages in the iOS share-channel-link picker is no longer possible.** This is the bug fix. +2. **Forward picker — unchanged.** Default `includeLocal: true`. Forward-to-Saved-Messages still works. +3. **Share extension picker — unchanged.** Default `includeLocal: true`. +4. **`GroupLinkView` button gate — unchanged.** Already correct on iOS. + +Nothing else changes. Verified by reading the diff against master line-by-line. + +## 7. Verification + +1. **Diff is six insertions, four deletions across three files** (`git diff --stat`): + - `apps/ios/SimpleXChat/ChatUtils.swift | 4 ++--` + - `apps/ios/Shared/Views/Chat/ChatItemForwardingView.swift | 3 ++-` + - `apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift | 3 ++-` +2. **iOS build** requires Xcode on macOS — not run in this environment. To run by reviewer. +3. **Manual on iOS once built:** + - Open a public channel → profile → "Share via chat" → picker shows direct + group destinations only, **no "Saved Messages" row**. + - Long-press a message → Forward → picker still shows Saved Messages at the top (regression check). + - Open a plain group → group-link management → no "Share via chat" button (already correct, regression check). + +## 8. Trade-offs and follow-ups + +1. **Computed `chatsToForwardTo` re-evaluates on body refresh** rather than caching at struct init. In practice, twice per render against a small list, with `ChatModel.shared.chats` already SwiftUI-observed. Switching to a custom `init(...)` that captures `includeLocal` and assigns `chatsToForwardTo` once is a one-step refactor if ever needed. +2. **The flag is binary, not content-typed.** Kotlin discriminates on `SharedContent` variant; iOS uses an explicit caller intent. If a future iOS site needed to skip Local for a non-share-channel reason, the same flag applies — no further changes needed. From da05273293de08935d1eafae1cdf3e4075fb5b92 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Thu, 14 May 2026 16:00:10 +0100 Subject: [PATCH 07/14] ios: update core library --- apps/ios/SimpleX.xcodeproj/project.pbxproj | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index a43f84f153..4b93e66c05 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -183,8 +183,8 @@ 64C3B0212A0D359700E19930 /* CustomTimePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */; }; 64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C829982D54AEED006B9E89 /* libgmp.a */; }; 64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C829992D54AEEE006B9E89 /* libffi.a */; }; - 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.1.1-Fx4JRO2FuL8K4q8f3JAaMO-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.1.1-Fx4JRO2FuL8K4q8f3JAaMO-ghc9.6.3.a */; }; - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.5.1.1-Fx4JRO2FuL8K4q8f3JAaMO.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.1.1-Fx4JRO2FuL8K4q8f3JAaMO.a */; }; + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.2.0-8CNxlktzYRPIWqOtKFvRIa-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.2.0-8CNxlktzYRPIWqOtKFvRIa-ghc9.6.3.a */; }; + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.5.2.0-8CNxlktzYRPIWqOtKFvRIa.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.2.0-8CNxlktzYRPIWqOtKFvRIa.a */; }; 64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */; }; 64D0C2C029F9688300B38D5F /* UserAddressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */; }; 64D0C2C229FA57AB00B38D5F /* UserAddressLearnMore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */; }; @@ -561,8 +561,8 @@ 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomTimePicker.swift; sourceTree = ""; }; 64C829982D54AEED006B9E89 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; 64C829992D54AEEE006B9E89 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.1.1-Fx4JRO2FuL8K4q8f3JAaMO-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.5.1.1-Fx4JRO2FuL8K4q8f3JAaMO-ghc9.6.3.a"; sourceTree = ""; }; - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.1.1-Fx4JRO2FuL8K4q8f3JAaMO.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.5.1.1-Fx4JRO2FuL8K4q8f3JAaMO.a"; sourceTree = ""; }; + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.2.0-8CNxlktzYRPIWqOtKFvRIa-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.5.2.0-8CNxlktzYRPIWqOtKFvRIa-ghc9.6.3.a"; sourceTree = ""; }; + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.2.0-8CNxlktzYRPIWqOtKFvRIa.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.5.2.0-8CNxlktzYRPIWqOtKFvRIa.a"; sourceTree = ""; }; 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressView.swift; sourceTree = ""; }; 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressLearnMore.swift; sourceTree = ""; }; @@ -731,8 +731,8 @@ 64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */, 64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */, 64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */, - 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.1.1-Fx4JRO2FuL8K4q8f3JAaMO-ghc9.6.3.a in Frameworks */, - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.5.1.1-Fx4JRO2FuL8K4q8f3JAaMO.a in Frameworks */, + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.2.0-8CNxlktzYRPIWqOtKFvRIa-ghc9.6.3.a in Frameworks */, + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.5.2.0-8CNxlktzYRPIWqOtKFvRIa.a in Frameworks */, CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -818,8 +818,8 @@ 64C829992D54AEEE006B9E89 /* libffi.a */, 64C829982D54AEED006B9E89 /* libgmp.a */, 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */, - 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.1.1-Fx4JRO2FuL8K4q8f3JAaMO-ghc9.6.3.a */, - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.1.1-Fx4JRO2FuL8K4q8f3JAaMO.a */, + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.2.0-8CNxlktzYRPIWqOtKFvRIa-ghc9.6.3.a */, + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.2.0-8CNxlktzYRPIWqOtKFvRIa.a */, ); path = Libraries; sourceTree = ""; From 0c94f6bf10f5507b338412d8363b0d50c0e2b3e8 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Thu, 14 May 2026 16:52:53 +0100 Subject: [PATCH 08/14] 6.5.2: ios 332 --- apps/ios/SimpleX.xcodeproj/project.pbxproj | 40 +++++++++++----------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 4b93e66c05..f0bd6d9118 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -2073,7 +2073,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 331; + CURRENT_PROJECT_VERSION = 332; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2098,7 +2098,7 @@ "@executable_path/Frameworks", ); LLVM_LTO = YES_THIN; - MARKETING_VERSION = 6.5.1; + MARKETING_VERSION = 6.5.2; OTHER_LDFLAGS = "-Wl,-stack_size,0x1000000"; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; @@ -2123,7 +2123,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 331; + CURRENT_PROJECT_VERSION = 332; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2148,7 +2148,7 @@ "@executable_path/Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.5.1; + MARKETING_VERSION = 6.5.2; OTHER_LDFLAGS = "-Wl,-stack_size,0x1000000"; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; @@ -2165,11 +2165,11 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 331; + CURRENT_PROJECT_VERSION = 332; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 6.5.1; + MARKETING_VERSION = 6.5.2; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2185,11 +2185,11 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 331; + CURRENT_PROJECT_VERSION = 332; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 6.5.1; + MARKETING_VERSION = 6.5.2; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2210,7 +2210,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 331; + CURRENT_PROJECT_VERSION = 332; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GCC_OPTIMIZATION_LEVEL = s; @@ -2225,7 +2225,7 @@ "@executable_path/../../Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.5.1; + MARKETING_VERSION = 6.5.2; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2247,7 +2247,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 331; + CURRENT_PROJECT_VERSION = 332; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_CODE_COVERAGE = NO; @@ -2262,7 +2262,7 @@ "@executable_path/../../Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.5.1; + MARKETING_VERSION = 6.5.2; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2284,7 +2284,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 331; + CURRENT_PROJECT_VERSION = 332; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2310,7 +2310,7 @@ "$(PROJECT_DIR)/Libraries/sim", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.5.1; + MARKETING_VERSION = 6.5.2; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; @@ -2335,7 +2335,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 331; + CURRENT_PROJECT_VERSION = 332; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2362,7 +2362,7 @@ "$(PROJECT_DIR)/Libraries/sim", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.5.1; + MARKETING_VERSION = 6.5.2; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; @@ -2389,7 +2389,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 331; + CURRENT_PROJECT_VERSION = 332; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2404,7 +2404,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 6.5.1; + MARKETING_VERSION = 6.5.2; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2423,7 +2423,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 331; + CURRENT_PROJECT_VERSION = 332; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2438,7 +2438,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 6.5.1; + MARKETING_VERSION = 6.5.2; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; From 1491f68cd204c279db23caf951916c81941852d5 Mon Sep 17 00:00:00 2001 From: SimpleX Chat Date: Thu, 14 May 2026 15:22:46 +0000 Subject: [PATCH 09/14] 6.5.2: android 349, desktop 143 --- apps/multiplatform/gradle.properties | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/multiplatform/gradle.properties b/apps/multiplatform/gradle.properties index 09cf90553f..4d504e069e 100644 --- a/apps/multiplatform/gradle.properties +++ b/apps/multiplatform/gradle.properties @@ -24,13 +24,13 @@ android.nonTransitiveRClass=true kotlin.mpp.androidSourceSetLayoutVersion=2 kotlin.jvm.target=11 -android.version_name=6.5.1 -android.version_code=347 +android.version_name=6.5.2 +android.version_code=349 android.bundle=false -desktop.version_name=6.5.1 -desktop.version_code=142 +desktop.version_name=6.5.2 +desktop.version_code=143 kotlin.version=2.1.20 gradle.plugin.version=8.7.0 From 9011c9db2868a0db2d83d1109db62a8f026b96cf Mon Sep 17 00:00:00 2001 From: sh <37271604+shumvgolove@users.noreply.github.com> Date: Fri, 15 May 2026 10:59:18 +0000 Subject: [PATCH 10/14] flatpak: update metainfo (#6982) * flatpak: update metainfo * remove --------- Co-authored-by: Evgeny --- .../flatpak/chat.simplex.simplex.metainfo.xml | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/scripts/flatpak/chat.simplex.simplex.metainfo.xml b/scripts/flatpak/chat.simplex.simplex.metainfo.xml index a2bafe8a70..b55a08df26 100644 --- a/scripts/flatpak/chat.simplex.simplex.metainfo.xml +++ b/scripts/flatpak/chat.simplex.simplex.metainfo.xml @@ -38,6 +38,32 @@ + + https://simplex.chat/blog/20260430-simplex-channels-v6-5-consortium-crowdfunding-freedom-of-speech.html + +

New in v6.5.2:

+
    +
  • allow deleting messages from channel history without time limit.
  • +
+

New in v6.5:

+

Public channels - speak freely!

+
    +
  • Reliability: many relays per channel.
  • +
  • Ownership: you can run your own relays.
  • +
  • Security: owners hold channel keys.
  • +
  • Privacy: for owners and subscribers.
  • +
+

Easier to invite your friends: we made connecting simpler for new users.

+

Safe web links:

+
    +
  • opt-in to send link previews.
  • +
  • use SOCKS proxy for previews (if enabled).
  • +
  • prevent hyperlink phishing.
  • +
  • remove link tracking.
  • +
+

Non-profit governance: to make SimpleX Network last.

+
+
https://simplex.chat/blog/20260430-simplex-channels-v6-5-consortium-crowdfunding-freedom-of-speech.html From af24d030fa12be8542c8bac6869e440ff1de9c38 Mon Sep 17 00:00:00 2001 From: Narasimha-sc <166327228+Narasimha-sc@users.noreply.github.com> Date: Sat, 16 May 2026 09:22:57 +0000 Subject: [PATCH 11/14] core, ui: persist "Remove link tracking" setting on database import (#6977) * core, ui: persist "Remove link tracking" setting on database import The privacySanitizeLinks preference was stored locally only and absent from the AppSettings round-trip, so it was lost when migrating to another device or after a fresh install + DB import. Add the field to the Haskell, Kotlin, and Swift AppSettings payloads and wire it through iOS group defaults. * plans: justify privacySanitizeLinks AppSettings round-trip fix --- apps/ios/Shared/Model/AppAPITypes.swift | 3 + .../Views/UserSettings/AppSettings.swift | 2 + apps/ios/SimpleXChat/AppGroup.swift | 2 + .../chat/simplex/common/model/SimpleXAPI.kt | 5 + plans/2026-05-13-fix-privacy-links-import.md | 148 ++++++++++++++++++ src/Simplex/Chat/AppSettings.hs | 6 + 6 files changed, 166 insertions(+) create mode 100644 plans/2026-05-13-fix-privacy-links-import.md diff --git a/apps/ios/Shared/Model/AppAPITypes.swift b/apps/ios/Shared/Model/AppAPITypes.swift index 547c2b7000..b459f36c9d 100644 --- a/apps/ios/Shared/Model/AppAPITypes.swift +++ b/apps/ios/Shared/Model/AppAPITypes.swift @@ -2122,6 +2122,7 @@ struct AppSettings: Codable, Equatable { var privacyAskToApproveRelays: Bool? = nil var privacyAcceptImages: Bool? = nil var privacyLinkPreviews: Bool? = nil + var privacySanitizeLinks: Bool? = nil var privacyShowChatPreviews: Bool? = nil var privacySaveLastDraft: Bool? = nil var privacyProtectScreen: Bool? = nil @@ -2157,6 +2158,7 @@ struct AppSettings: Codable, Equatable { if privacyAskToApproveRelays != def.privacyAskToApproveRelays { empty.privacyAskToApproveRelays = privacyAskToApproveRelays } if privacyAcceptImages != def.privacyAcceptImages { empty.privacyAcceptImages = privacyAcceptImages } if privacyLinkPreviews != def.privacyLinkPreviews { empty.privacyLinkPreviews = privacyLinkPreviews } + if privacySanitizeLinks != def.privacySanitizeLinks { empty.privacySanitizeLinks = privacySanitizeLinks } if privacyShowChatPreviews != def.privacyShowChatPreviews { empty.privacyShowChatPreviews = privacyShowChatPreviews } if privacySaveLastDraft != def.privacySaveLastDraft { empty.privacySaveLastDraft = privacySaveLastDraft } if privacyProtectScreen != def.privacyProtectScreen { empty.privacyProtectScreen = privacyProtectScreen } @@ -2193,6 +2195,7 @@ struct AppSettings: Codable, Equatable { privacyAskToApproveRelays: true, privacyAcceptImages: true, privacyLinkPreviews: true, + privacySanitizeLinks: false, privacyShowChatPreviews: true, privacySaveLastDraft: true, privacyProtectScreen: false, diff --git a/apps/ios/Shared/Views/UserSettings/AppSettings.swift b/apps/ios/Shared/Views/UserSettings/AppSettings.swift index 8be0798fb1..3554ce720f 100644 --- a/apps/ios/Shared/Views/UserSettings/AppSettings.swift +++ b/apps/ios/Shared/Views/UserSettings/AppSettings.swift @@ -38,6 +38,7 @@ extension AppSettings { privacyLinkPreviewsGroupDefault.set(val) def.setValue(val, forKey: DEFAULT_PRIVACY_LINK_PREVIEWS) } + if let val = privacySanitizeLinks { privacySanitizeLinksGroupDefault.set(val) } if let val = privacyShowChatPreviews { def.setValue(val, forKey: DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS) } if let val = privacySaveLastDraft { def.setValue(val, forKey: DEFAULT_PRIVACY_SAVE_LAST_DRAFT) } if let val = privacyProtectScreen { def.setValue(val, forKey: DEFAULT_PRIVACY_PROTECT_SCREEN) } @@ -77,6 +78,7 @@ extension AppSettings { c.privacyAskToApproveRelays = privacyAskToApproveRelaysGroupDefault.get() c.privacyAcceptImages = privacyAcceptImagesGroupDefault.get() c.privacyLinkPreviews = privacyLinkPreviewsGroupDefault.get() + c.privacySanitizeLinks = privacySanitizeLinksGroupDefault.get() c.privacyShowChatPreviews = def.bool(forKey: DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS) c.privacySaveLastDraft = def.bool(forKey: DEFAULT_PRIVACY_SAVE_LAST_DRAFT) c.privacyProtectScreen = def.bool(forKey: DEFAULT_PRIVACY_PROTECT_SCREEN) diff --git a/apps/ios/SimpleXChat/AppGroup.swift b/apps/ios/SimpleXChat/AppGroup.swift index e77ad6cb82..d8543735b0 100644 --- a/apps/ios/SimpleXChat/AppGroup.swift +++ b/apps/ios/SimpleXChat/AppGroup.swift @@ -237,6 +237,8 @@ public let privacyEncryptLocalFilesGroupDefault = BoolDefault(defaults: groupDef public let privacyAskToApproveRelaysGroupDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_PRIVACY_ASK_TO_APPROVE_RELAYS) +public let privacySanitizeLinksGroupDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_PRIVACY_SANITIZE_LINKS) + public let profileImageCornerRadiusGroupDefault = Default(defaults: groupDefaults, forKey: GROUP_DEFAULT_PROFILE_IMAGE_CORNER_RADIUS) public let ntfBadgeCountGroupDefault = IntDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_NTF_BADGE_COUNT) 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 e23b76b025..a31dc145a3 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 @@ -8042,6 +8042,7 @@ data class AppSettings( var privacyAskToApproveRelays: Boolean? = null, var privacyAcceptImages: Boolean? = null, var privacyLinkPreviews: Boolean? = null, + var privacySanitizeLinks: Boolean? = null, var privacyChatListOpenLinks: PrivacyChatListOpenLinksMode? = null, var privacyShowChatPreviews: Boolean? = null, var privacySaveLastDraft: Boolean? = null, @@ -8078,6 +8079,7 @@ data class AppSettings( if (privacyAskToApproveRelays != def.privacyAskToApproveRelays) { empty.privacyAskToApproveRelays = privacyAskToApproveRelays } if (privacyAcceptImages != def.privacyAcceptImages) { empty.privacyAcceptImages = privacyAcceptImages } if (privacyLinkPreviews != def.privacyLinkPreviews) { empty.privacyLinkPreviews = privacyLinkPreviews } + if (privacySanitizeLinks != def.privacySanitizeLinks) { empty.privacySanitizeLinks = privacySanitizeLinks } if (privacyChatListOpenLinks != def.privacyChatListOpenLinks) { empty.privacyChatListOpenLinks = privacyChatListOpenLinks } if (privacyShowChatPreviews != def.privacyShowChatPreviews) { empty.privacyShowChatPreviews = privacyShowChatPreviews } if (privacySaveLastDraft != def.privacySaveLastDraft) { empty.privacySaveLastDraft = privacySaveLastDraft } @@ -8125,6 +8127,7 @@ data class AppSettings( privacyAskToApproveRelays?.let { def.privacyAskToApproveRelays.set(it) } privacyAcceptImages?.let { def.privacyAcceptImages.set(it) } privacyLinkPreviews?.let { def.privacyLinkPreviews.set(it) } + privacySanitizeLinks?.let { def.privacySanitizeLinks.set(it) } privacyChatListOpenLinks?.let { def.privacyChatListOpenLinks.set(it) } privacyShowChatPreviews?.let { def.privacyShowChatPreviews.set(it) } privacySaveLastDraft?.let { def.privacySaveLastDraft.set(it) } @@ -8162,6 +8165,7 @@ data class AppSettings( privacyAskToApproveRelays = true, privacyAcceptImages = true, privacyLinkPreviews = true, + privacySanitizeLinks = false, privacyChatListOpenLinks = PrivacyChatListOpenLinksMode.ASK, privacyShowChatPreviews = true, privacySaveLastDraft = true, @@ -8200,6 +8204,7 @@ data class AppSettings( privacyAskToApproveRelays = def.privacyAskToApproveRelays.get(), privacyAcceptImages = def.privacyAcceptImages.get(), privacyLinkPreviews = def.privacyLinkPreviews.get(), + privacySanitizeLinks = def.privacySanitizeLinks.get(), privacyChatListOpenLinks = def.privacyChatListOpenLinks.get(), privacyShowChatPreviews = def.privacyShowChatPreviews.get(), privacySaveLastDraft = def.privacySaveLastDraft.get(), diff --git a/plans/2026-05-13-fix-privacy-links-import.md b/plans/2026-05-13-fix-privacy-links-import.md new file mode 100644 index 0000000000..2596704c24 --- /dev/null +++ b/plans/2026-05-13-fix-privacy-links-import.md @@ -0,0 +1,148 @@ +# "Remove link tracking" setting does not persist across database import + +PR: [#6977](https://github.com/simplex-chat/simplex-chat/pull/6977) · branch `nd/fix-privacy-links-import` → `master` + +## 1. Problem statement + +The **Settings → Privacy & security → Remove link tracking** toggle (`privacySanitizeLinks`) is silently dropped when a user moves their chat database to another device or reinstalls. Reproduction: + +1. Device A: enable "Remove link tracking", export chat database. +2. Device B (fresh install) or same device after re-install: import the database. +3. Open Settings → Privacy & security on B: the toggle is **off**. + +All three platforms are affected (Android, desktop, iOS) and any combination of source/target. Every other v6.5 "Safe web links" privacy guarantee survives the import; only "Remove link tracking" reverts. + +## 2. Solution summary + +The preference is stored locally only (Android `SharedPreferences`, iOS `UserDefaults` group). The cross-device transport for app settings is the `AppSettings` JSON record that travels with the database via `apiGetAppSettings` / `apiSaveAppSettings`. `privacySanitizeLinks` was absent from this record in all three layers (Haskell core, Kotlin multiplatform, Swift iOS), so it had nothing to ride on. + +Fix: add `privacySanitizeLinks :: Maybe Bool` to the `AppSettings` record in each of the three layers, wired identically to the reference field `privacyAskToApproveRelays`. Default in all three layers is `false`, matching today's local default. The fix is strictly additive (`+18` lines, 5 files, no deletions); no schema change, no command/API change, no UI change. + +## 3. Detailed tech design + +### 3.1 The round-trip the fix plugs into + +``` +Device A Device B +───────── ───────── +local pref store local pref store + ↑ ↑ importIntoApp() + │ user toggles UI │ + │ AppSettings ← apiGetAppSettings(local prepareForExport) + │ ↑ + │ archive (.zip with │ +local pref → AppSettings.current chat.db) ─────┐ + → prepareForExport │ │ + → apiSaveAppSettings │ │ + → app_settings DB row ─────────┘ │ + │ + core: combineAppSettings + stored <|> platformDefaults <|> defaults +``` + +`AppSettings.current` reads every local pref; `prepareForExport` strips fields equal to their default (space optimisation); `apiSaveAppSettings` writes the JSON into the `app_settings` table of the chat DB, which travels inside the archive. On import, the receiving client runs `apiGetAppSettings(local.prepareForExport())`; the core merges stored ⟶ local-platform ⟶ hardcoded-defaults with `Alternative` (`<|>`) and returns the result; the client's `importIntoApp` applies any non-null fields to its local store. + +A field that is **absent from `AppSettings`** at any of the three layers never enters this pipeline and is therefore lost on import. `privacySanitizeLinks` was such a field. + +### 3.2 Three-layer parity + +The three `AppSettings` definitions must agree on every field name, default value, and the four operations: + +| Operation | Haskell | Kotlin | Swift | +|---|---|---|---| +| field declaration | `data AppSettings` (`src/Simplex/Chat/AppSettings.hs:28`) | `data class AppSettings` (`SimpleXAPI.kt:8038`) | `struct AppSettings` (`AppAPITypes.swift:2118`) | +| default | `defaultAppSettings` (`AppSettings.hs:79`) | `defaults` (`SimpleXAPI.kt:8157`) | `defaults` (`AppAPITypes.swift:2188`) | +| "missing key" parse default | `defaultParseAppSettings` (`AppSettings.hs:116`) | implicit `null` | implicit `nil` | +| merge fallback | `combineAppSettings` (`AppSettings.hs:153`) | n/a (only one source) | n/a | +| JSON parser | hand-written `parseJSON` (`AppSettings.hs:207`) | `@Serializable` derived | `Codable` derived | +| read-from-local | n/a (clients send it) | `AppSettings.current` (`SimpleXAPI.kt:8193`) | `AppSettings.current` (`AppSettings.swift:71`) | +| write-to-local | n/a (clients apply it) | `importIntoApp` (`SimpleXAPI.kt:8110`) | `updateIosGroupDefaults` / `init from cfg` (`AppSettings.swift:13`) | +| serialize-only-non-default | n/a | `prepareForExport` (`SimpleXAPI.kt:8072`) | `prepareForExport` (`AppAPITypes.swift:2151`) | + +The fix adds one line to every cell that exists for `privacyAskToApproveRelays`. Default value is `false` (matches `mkBoolPreference(..., false)` and the `registerGroupDefaults` entry). + +### 3.3 Round-trip correctness — case analysis + +The core's `combineAppSettings = stored <|> platformDefaults <|> defaultAppSettings` (with `Alternative` on `Maybe`) means: take the stored value if present, else what the client said its default is, else the hardcoded default. The client's `prepareForExport` only includes a field when it differs from the client's `defaults`. With both `defaults` set to `false`: + +| Case | Archive carries | Local pref before | platformDefaults sent | Merged | Result | +|---|---|---|---|---|---| +| New archive, source had on | `Just true` | false | `Nothing` (default) | `Just true` | **on** ✓ | +| New archive, source had off (default) | `Nothing` (stripped) | false | `Nothing` | `Just false` (from defaults) | **off** ✓ | +| New archive, source had off | `Nothing` | true (local toggled) | `Just true` | `Just true` | **on** (local wins, archive silent) ✓ | +| Old archive (pre-fix) | field unknown | false | `Nothing` | `Just false` | **off** (unchanged from before fix) | +| Old archive | field unknown | true | `Just true` | `Just true` | **on** (local preserved) ✓ | +| Cross-platform | `Just true` | false | `Nothing` | `Just true` | **on** ✓ | + +The only "interesting" semantic — *archive silent on the field while local has it on* — preserves local. This matches how every other field in `AppSettings` behaves and matches user intent ("I toggled it on this device, then imported some old archive — keep it on"). + +### 3.4 Edge cases verified + +- **Downgrade then upgrade.** New code → toggle on → export. Imported on *old* code: `parseJSON` ignores unknown keys, DB row is rewritten without the field. Re-upgrade: field absent, falls through to `Just false`. This is the standard "old client drops new fields" semantics for every prior AppSettings addition; not introduced by this PR. + +- **iOS `BoolDefault` before `set` is ever called.** `apps/ios/SimpleXChat/AppGroup.swift:100` already registers `GROUP_DEFAULT_PRIVACY_SANITIZE_LINKS: false` in `registerGroupDefaults`. So `privacySanitizeLinksGroupDefault.get()` returns `false` on first read — no NaN/nil sentinel risk. + +- **JSON field ordering.** `deriveToJSON defaultJSON` uses record-field order; new field is inserted between `privacyLinkPreviews` and `privacyShowChatPreviews`, shifting subsequent keys. No external consumer compares the JSON byte-for-byte; the existing `testAppSettings` test compares `J.encode defaultAppSettings` on both sides of the wire and so is self-consistent under the addition. + +- **`omitNothingFields = True`.** The Haskell `defaultJSON` config (`Simplex.Messaging.Parsers`) strips `Nothing` fields from JSON output, so `defaultParseAppSettings` (every field `Nothing`) does not pollute archives or wire payloads when used as a fallback. + +- **iOS NSE / SE extensions.** Neither references `privacySanitizeLinks`. No additional wiring required. + +### 3.5 What was deliberately not done + +- **Flipping the *user-facing* default to `true`.** Other privacy fields in `defaultAppSettings` are `Just True` (encrypt local files, ask to approve relays). "Remove link tracking" remains `Just False` because the local pref default (`mkBoolPreference(..., false)`, iOS `registerGroupDefaults: false`) is `false`. Aligning the `AppSettings` default with the local default keeps the `prepareForExport` "differs-from-default" comparison consistent — otherwise off-by-default users would suddenly serialise `false` everywhere and on-by-default users would serialise nothing, inverting the wire shape. Whether the *product* default should be flipped to on is a separate question for a separate change. + +- **Adding `apiSaveAppSettings` on toggle.** Toggling the pref in `PrivacySettings.kt` writes only to shared prefs; the DB's `app_settings` row stays stale until a separate trigger (theme change, export, migration) syncs. The export and migration paths already call `apiSaveAppSettings(AppSettings.current.prepareForExport())` immediately before producing the archive, so every UI-initiated export captures the current value. Plugging the sync into every toggle is a broader change affecting every AppSettings field equally — out of scope. + +- **Fixing `privacyChatListOpenLinks`.** The Kotlin `AppSettings` declares it (`SimpleXAPI.kt:8046`); the Haskell record and the Swift struct do not. Same failure mode as the bug being fixed here — almost certainly does not persist across Android-to-Android imports. Out of scope; should be tracked separately. + +- **Adding a targeted test.** The existing `testAppSettings` exercises a JSON round-trip with `defaultAppSettings`, so the new field rides through implicitly. A field-specific test (`defaultAppSettings { privacySanitizeLinks = Just True }`) would tighten coverage against a future client dropping the field; recommended as a small follow-up. + +## 4. Detailed implementation plan + +### 4.1 Files touched + +| File | Δ | Purpose | +|---|---|---| +| `src/Simplex/Chat/AppSettings.hs` | +6 / 0 | record field, `defaultAppSettings`, `defaultParseAppSettings`, `combineAppSettings`, JSON parser line, record reassembly | +| `apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt` | +5 / 0 | data class field, `prepareForExport`, `importIntoApp`, `defaults`, `current` | +| `apps/ios/Shared/Model/AppAPITypes.swift` | +3 / 0 | struct field, `prepareForExport`, `defaults` | +| `apps/ios/SimpleXChat/AppGroup.swift` | +2 / 0 | new `privacySanitizeLinksGroupDefault: BoolDefault` next to existing privacy defaults | +| `apps/ios/Shared/Views/UserSettings/AppSettings.swift` | +2 / 0 | import side (`set`), export side (`get`) | + +Total: 5 files, +18 / 0. No deletions. + +### 4.2 Step-by-step (commit `15457a903`) + +1. **`AppSettings.hs`** — add `privacySanitizeLinks :: Maybe Bool` to the record (between `privacyLinkPreviews` and `privacyShowChatPreviews`); set `Just False` in `defaultAppSettings`; `Nothing` in `defaultParseAppSettings`; `p privacySanitizeLinks` in `combineAppSettings`; `privacySanitizeLinks <- p "privacySanitizeLinks"` in `parseJSON`; add to record reassembly. Field position consistent with name groupings. + +2. **`SimpleXAPI.kt`** — same insertions in `data class AppSettings`, `prepareForExport`, `importIntoApp`, `defaults`, `current`. Local pref already exists (`SimpleXAPI.kt:126`). + +3. **`AppAPITypes.swift`** — same insertions in `struct AppSettings`, `prepareForExport`, `defaults`. + +4. **`AppGroup.swift`** — add `public let privacySanitizeLinksGroupDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_PRIVACY_SANITIZE_LINKS)`. The key constant (line 31) and registered default `false` (line 100) already exist; only the typed wrapper for non-`@AppStorage` access was missing. + +5. **`AppSettings.swift` (iOS view extension)** — import side: `if let val = privacySanitizeLinks { privacySanitizeLinksGroupDefault.set(val) }`. Export side: `c.privacySanitizeLinks = privacySanitizeLinksGroupDefault.get()`. + +### 4.3 Verification + +- Haskell `testAppSettings` (`tests/ChatTests/Direct.hs:2768`) covers the JSON round-trip through `defaultAppSettings`; the new field flows through both sides of the equality, so existing assertions hold. +- Manual test plan (in PR description): + 1. Enable on Android, export DB, import on a second Android device — toggle stays on. + 2. Enable on iOS, export, import on a second iOS device — toggle stays on. + 3. Enable on desktop, export, fresh-install + import — toggle stays on. + 4. Cross-platform: export from Android, import on iOS, and vice versa — toggle preserved. + 5. Fresh install with no archive — toggle defaults to off (unchanged). + +### 4.4 Risk and rollback + +- **Blast radius**: the `AppSettings` JSON payload. Every other field is untouched (positional inserts, no reordering of existing fields beyond the natural shift). +- **Backwards compatibility**: old clients (no field) parsing new JSON ignore the key. New clients (with field) parsing old JSON see `Nothing`, fall through to `defaultAppSettings` and the local pref is set to its default. Either direction is safe. +- **Rollback**: `git revert 15457a903`. Restores pre-fix behaviour (the field-loss bug returns). + +## 5. Why this specific shape + +- The bug has exactly one cause: a missing field in the round-trip payload. The smallest fix is to add the field. Anything larger (e.g. broadening `importIntoApp` to scan all shared prefs, or pinning the value in a side channel) would be a structural change that does not improve correctness. +- The `<|>` merge in `combineAppSettings` already gives the right behaviour for every edge case (archive-silent local-set, fresh install, downgrade) once the field exists. No new merge logic needed. +- The default `false` is forced: any other choice would either contradict the local pref default (`mkBoolPreference(..., false)`, iOS `registerGroupDefaults: false`) or invert the wire shape of `prepareForExport`. +- Final PR is 5 files, +18 / 0. Three of those files are the three `AppSettings` records; the other two are the iOS wiring the new field needs in order to read and write its group default. No other file in the codebase needed touching. diff --git a/src/Simplex/Chat/AppSettings.hs b/src/Simplex/Chat/AppSettings.hs index 1efa69fad4..22938dd48c 100644 --- a/src/Simplex/Chat/AppSettings.hs +++ b/src/Simplex/Chat/AppSettings.hs @@ -33,6 +33,7 @@ data AppSettings = AppSettings privacyAskToApproveRelays :: Maybe Bool, privacyAcceptImages :: Maybe Bool, privacyLinkPreviews :: Maybe Bool, + privacySanitizeLinks :: Maybe Bool, privacyShowChatPreviews :: Maybe Bool, privacySaveLastDraft :: Maybe Bool, privacyProtectScreen :: Maybe Bool, @@ -83,6 +84,7 @@ defaultAppSettings = privacyAskToApproveRelays = Just True, privacyAcceptImages = Just True, privacyLinkPreviews = Just True, + privacySanitizeLinks = Just False, privacyShowChatPreviews = Just True, privacySaveLastDraft = Just True, privacyProtectScreen = Just False, @@ -120,6 +122,7 @@ defaultParseAppSettings = privacyAskToApproveRelays = Nothing, privacyAcceptImages = Nothing, privacyLinkPreviews = Nothing, + privacySanitizeLinks = Nothing, privacyShowChatPreviews = Nothing, privacySaveLastDraft = Nothing, privacyProtectScreen = Nothing, @@ -157,6 +160,7 @@ combineAppSettings platformDefaults storedSettings = privacyAskToApproveRelays = p privacyAskToApproveRelays, privacyAcceptImages = p privacyAcceptImages, privacyLinkPreviews = p privacyLinkPreviews, + privacySanitizeLinks = p privacySanitizeLinks, privacyShowChatPreviews = p privacyShowChatPreviews, privacySaveLastDraft = p privacySaveLastDraft, privacyProtectScreen = p privacyProtectScreen, @@ -210,6 +214,7 @@ instance FromJSON AppSettings where privacyAskToApproveRelays <- p "privacyAskToApproveRelays" privacyAcceptImages <- p "privacyAcceptImages" privacyLinkPreviews <- p "privacyLinkPreviews" + privacySanitizeLinks <- p "privacySanitizeLinks" privacyShowChatPreviews <- p "privacyShowChatPreviews" privacySaveLastDraft <- p "privacySaveLastDraft" privacyProtectScreen <- p "privacyProtectScreen" @@ -244,6 +249,7 @@ instance FromJSON AppSettings where privacyAskToApproveRelays, privacyAcceptImages, privacyLinkPreviews, + privacySanitizeLinks, privacyShowChatPreviews, privacySaveLastDraft, privacyProtectScreen, From b0901106a9cee8229b074bb6f62f62918947f084 Mon Sep 17 00:00:00 2001 From: sh <37271604+shumvgolove@users.noreply.github.com> Date: Sat, 16 May 2026 15:05:02 +0000 Subject: [PATCH 12/14] nodejs, python: bump packages (#6984) * simplex-chat-nodejs: bump types and nodejs versions * support bot: bump simplex-chat and types deps * simplex-chat-python: bump version --- apps/simplex-support-bot/package.json | 4 ++-- packages/simplex-chat-client/types/typescript/package.json | 2 +- packages/simplex-chat-nodejs/package.json | 4 ++-- packages/simplex-chat-nodejs/src/download-libs.js | 2 +- packages/simplex-chat-python/src/simplex_chat/_version.py | 6 +++--- packages/simplex-chat-python/tests/test_native_cache.py | 4 ++-- packages/simplex-chat-python/tests/test_native_url.py | 4 ++-- 7 files changed, 13 insertions(+), 13 deletions(-) diff --git a/apps/simplex-support-bot/package.json b/apps/simplex-support-bot/package.json index 8541056aa5..97caee2278 100644 --- a/apps/simplex-support-bot/package.json +++ b/apps/simplex-support-bot/package.json @@ -8,10 +8,10 @@ "start": "node dist/index.js" }, "dependencies": { - "@simplex-chat/types": "^0.6.0", + "@simplex-chat/types": "^0.7.0", "async-mutex": "^0.5.0", "commander": "^14.0.3", - "simplex-chat": "^6.5.1", + "simplex-chat": "^6.5.2", "yaml": "^2.8.4" }, "devDependencies": { diff --git a/packages/simplex-chat-client/types/typescript/package.json b/packages/simplex-chat-client/types/typescript/package.json index 1d5eb5197c..c929125033 100644 --- a/packages/simplex-chat-client/types/typescript/package.json +++ b/packages/simplex-chat-client/types/typescript/package.json @@ -1,6 +1,6 @@ { "name": "@simplex-chat/types", - "version": "0.6.0", + "version": "0.7.0", "description": "TypeScript types for SimpleX Chat bot libraries", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/simplex-chat-nodejs/package.json b/packages/simplex-chat-nodejs/package.json index c5cc255722..5166283e75 100644 --- a/packages/simplex-chat-nodejs/package.json +++ b/packages/simplex-chat-nodejs/package.json @@ -1,6 +1,6 @@ { "name": "simplex-chat", - "version": "6.5.1", + "version": "6.5.2", "main": "dist/index.js", "types": "dist/index.d.ts", "files": [ @@ -24,7 +24,7 @@ "docs": "typedoc" }, "dependencies": { - "@simplex-chat/types": "^0.6.0", + "@simplex-chat/types": "^0.7.0", "extract-zip": "^2.0.1", "fast-deep-equal": "^3.1.3", "node-addon-api": "^8.5.0" diff --git a/packages/simplex-chat-nodejs/src/download-libs.js b/packages/simplex-chat-nodejs/src/download-libs.js index 5c1b70cda0..db042d48a2 100644 --- a/packages/simplex-chat-nodejs/src/download-libs.js +++ b/packages/simplex-chat-nodejs/src/download-libs.js @@ -4,7 +4,7 @@ const path = require('path'); const extract = require('extract-zip'); const GITHUB_REPO = 'simplex-chat/simplex-chat-libs'; -const RELEASE_TAG = 'v6.5.1'; +const RELEASE_TAG = 'v6.5.2'; const BACKEND = (process.env.SIMPLEX_BACKEND || process.env.npm_config_simplex_backend || 'sqlite').toLowerCase(); if (BACKEND !== 'sqlite' && BACKEND !== 'postgres') { diff --git a/packages/simplex-chat-python/src/simplex_chat/_version.py b/packages/simplex-chat-python/src/simplex_chat/_version.py index 0468b65dd9..bd182d0240 100644 --- a/packages/simplex-chat-python/src/simplex_chat/_version.py +++ b/packages/simplex-chat-python/src/simplex_chat/_version.py @@ -2,8 +2,8 @@ simplex-chat-libs release tag we depend on. Bump both together for normal releases. For wrapper-only fixes use a PEP 440 -post-release: __version__ = "6.5.1.post1", LIBS_VERSION unchanged. +post-release: __version__ = "6.5.2.post1", LIBS_VERSION unchanged. """ -__version__ = "6.5.1" # PEP 440 — read by hatchling for wheel metadata -LIBS_VERSION = "6.5.1" # simplex-chat-libs release tag (no 'v' prefix) +__version__ = "6.5.2" # PEP 440 — read by hatchling for wheel metadata +LIBS_VERSION = "6.5.2" # simplex-chat-libs release tag (no 'v' prefix) diff --git a/packages/simplex-chat-python/tests/test_native_cache.py b/packages/simplex-chat-python/tests/test_native_cache.py index bd3bc58da8..30a1f43e2a 100644 --- a/packages/simplex-chat-python/tests/test_native_cache.py +++ b/packages/simplex-chat-python/tests/test_native_cache.py @@ -41,7 +41,7 @@ def test_resolve_downloads_when_missing(tmp_path, monkeypatch): monkeypatch.setattr("simplex_chat._native._download", fake_download) libs_dir = _resolve_libs_dir("sqlite") - assert libs_dir == tmp_path / "simplex-chat" / "v6.5.1" / "sqlite" + assert libs_dir == tmp_path / "simplex-chat" / "v6.5.2" / "sqlite" assert called["backend"] == "sqlite" assert (libs_dir / "libsimplex.so").exists() @@ -49,7 +49,7 @@ def test_resolve_downloads_when_missing(tmp_path, monkeypatch): def test_resolve_uses_cache_on_second_call(tmp_path, monkeypatch): monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path)) monkeypatch.setattr("sys.platform", "linux") - cached = tmp_path / "simplex-chat" / "v6.5.1" / "sqlite" + cached = tmp_path / "simplex-chat" / "v6.5.2" / "sqlite" cached.mkdir(parents=True) (cached / "libsimplex.so").touch() # Should NOT call _download — use the cached file. diff --git a/packages/simplex-chat-python/tests/test_native_url.py b/packages/simplex-chat-python/tests/test_native_url.py index 7b53fa3ff7..b27c3e09cf 100644 --- a/packages/simplex-chat-python/tests/test_native_url.py +++ b/packages/simplex-chat-python/tests/test_native_url.py @@ -42,7 +42,7 @@ def test_url_sqlite(_): assert ( _libs_url("sqlite") == "https://github.com/simplex-chat/simplex-chat-libs/releases/download/" - "v6.5.1/simplex-chat-libs-linux-x86_64.zip" + "v6.5.2/simplex-chat-libs-linux-x86_64.zip" ) @@ -51,5 +51,5 @@ def test_url_postgres(_): assert ( _libs_url("postgres") == "https://github.com/simplex-chat/simplex-chat-libs/releases/download/" - "v6.5.1/simplex-chat-libs-linux-x86_64-postgres.zip" + "v6.5.2/simplex-chat-libs-linux-x86_64-postgres.zip" ) From c16566355597cb5c72e8fb396a58ae31dcba182a Mon Sep 17 00:00:00 2001 From: sh <37271604+shumvgolove@users.noreply.github.com> Date: Mon, 18 May 2026 08:15:20 +0000 Subject: [PATCH 13/14] desktop: prevent duplicate launches (#6979) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * desktop: prevent duplicate launches Acquires a file lock and listens on a loopback ServerSocket in dataDir. A second launch signals the running instance to restore its window and exits silently. See plans/2026-05-13-desktop-single-instance.md. * desktop: un-minimize window in showWindow toFront() does not un-minimize a JFrame on any AWT platform. Clear the ICONIFIED bit so a minimized window restores; preserves MAXIMIZED_BOTH. Also fixes the same case when restoring from the tray icon. * desktop: move showWindow from DesktopTray to DesktopApp It has callers outside the tray (single-instance signal) and belongs next to simplexWindowState, which it operates on. * simplify * refactor * desktop: start show-file watcher when choosing minimize from first-close dialog The handleCloseRequest path already starts the watcher when minimizing to tray; the Ask-dialog path did not, so the first-time user who picks "Minimize to tray" got a hidden window with no signal handling — a duplicate launch would not restore it. * desktop: always watch for duplicate-launch signal, drop hung-instance alert The watcher now runs for the JVM lifetime once the lock is acquired, not only when minimized to tray. Duplicate launches always restore the primary's window (un-minimize, un-tray-hide, toFront) instead of being silently dropped when the primary is not minimized. Drops the "may be hung, start anyway?" popup and the two strings — that fallback was needed only because the watcher could miss signals. With the always-on watcher there is no scenario where the primary fails to consume simplex.show, so the escape hatch becomes dead code. * desktop: alert when primary's watcher doesn't consume the show file Restores the "another instance may be running" alert. Every duplicate launch waits up to 1s for the primary's watcher to delete the show file it just created. If the file is consumed within the window, the duplicate exits silently. If still there after 1s the primary is hung and the alert fires. --------- Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> --- .../commonMain/resources/MR/base/strings.xml | 2 + .../kotlin/chat/simplex/common/DesktopApp.kt | 16 ++- .../kotlin/chat/simplex/common/DesktopTray.kt | 6 - .../chat/simplex/common/SingleInstance.kt | 133 ++++++++++++++++++ .../chat/simplex/app/SingleInstanceTest.kt | 56 ++++++++ .../kotlin/chat/simplex/desktop/Main.kt | 2 + plans/2026-05-13-desktop-single-instance.md | 30 ++++ 7 files changed, 237 insertions(+), 8 deletions(-) create mode 100644 apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/SingleInstance.kt create mode 100644 apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/SingleInstanceTest.kt create mode 100644 plans/2026-05-13-desktop-single-instance.md 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 7304625945..c7b48b4e5b 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -27,6 +27,8 @@ Invalid file path You shared an invalid file path. Report the issue to the app developers. View crashed + App is already running + Another app instance may be running or did not exit properly. Start anyway? connected diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt index 2ae4aed8e2..ba8901793f 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt @@ -23,6 +23,7 @@ import chat.simplex.res.MR import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.* +import java.awt.Frame import java.awt.event.WindowEvent import java.awt.event.WindowFocusListener import java.io.File @@ -241,10 +242,10 @@ private fun ApplicationScope.handleCloseRequest(closedByError: MutableState exitApplication() - CloseBehavior.MinimizeToTray -> if (trayIsAvailable) { + CloseBehavior.MinimizeToTray -> if (trayIsAvailable && singleInstanceLock) { simplexWindowState.windowVisible.value = false } else exitApplication() - CloseBehavior.Ask -> if (trayIsAvailable) { + CloseBehavior.Ask -> if (trayIsAvailable && singleInstanceLock) { requestCloseBehavior() } else { // Tray unavailable — Minimize is not a real option; remember Quit and exit. @@ -254,6 +255,17 @@ private fun ApplicationScope.handleCloseRequest(closedByError: MutableState Unit>() diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopTray.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopTray.kt index 3f35c10c9c..9f75e481f4 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopTray.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopTray.kt @@ -47,12 +47,6 @@ val trayIsAvailable: Boolean by lazy { } } -fun showWindow() { - simplexWindowState.windowVisible.value = true - simplexWindowState.window?.toFront() - simplexWindowState.window?.requestFocus() -} - @Composable fun ApplicationScope.SimplexTray() { if (!trayIsAvailable) return diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/SingleInstance.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/SingleInstance.kt new file mode 100644 index 0000000000..19cb7aea91 --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/SingleInstance.kt @@ -0,0 +1,133 @@ +package chat.simplex.common + +import chat.simplex.common.platform.Log +import chat.simplex.common.platform.TAG +import chat.simplex.common.platform.dataDir +import java.io.IOException +import java.nio.channels.FileChannel +import java.nio.channels.FileLock +import java.nio.channels.OverlappingFileLockException +import java.nio.file.* +import java.nio.file.StandardOpenOption.CREATE +import java.nio.file.StandardOpenOption.READ +import java.nio.file.StandardOpenOption.WRITE +import javax.swing.SwingUtilities +import kotlin.concurrent.thread + +private var lockHandle: FileLock? = null +private var watcher: WatchService? = null + +private val lockPath get() = dataDir.resolve("simplex.started").toPath() +private val showPath get() = dataDir.resolve("simplex.show").toPath() + +var singleInstanceLock = false + private set + +private sealed interface LockResult { + class Acquired(val lock: FileLock) : LockResult + object Taken : LockResult + object Failed : LockResult +} + +fun acquireSingleInstance(): Boolean { + dataDir.mkdirs() + when (val result = tryAcquireLock()) { + is LockResult.Acquired -> { + lockHandle = result.lock + singleInstanceLock = true + deleteShowFile() + startShowFileWatcher() + return true + } + LockResult.Failed -> { + return true + } + LockResult.Taken -> { + // Ensure the signal file exists (createShowFile is a no-op if it does) + // and wait up to 1s for the primary's watcher to consume it. If still + // there after the wait, the primary is hung — let the user decide. + createShowFile() + val deadline = System.currentTimeMillis() + 1000 + while (Files.exists(showPath) && System.currentTimeMillis() < deadline) { + try { Thread.sleep(50) } catch (_: InterruptedException) { break } + } + if (!Files.exists(showPath)) return false + val start = showSingleInstanceAlert() + if (start) deleteShowFile() + return start + } + } +} + +private fun tryAcquireLock(): LockResult { + val channel = try { + FileChannel.open(lockPath, READ, WRITE, CREATE) + } catch (e: IOException) { + Log.w(TAG, "single-instance: cannot open lock file: ${e.message}") + return LockResult.Failed + } + return try { + val lock = channel.tryLock(0L, 1L, false) + if (lock != null) { + LockResult.Acquired(lock) + } else { + channel.close() + LockResult.Taken + } + } catch (_: OverlappingFileLockException) { + Log.w(TAG, "single-instance: overlapping lock in same JVM") + LockResult.Failed + } catch (e: IOException) { + Log.w(TAG, "single-instance: tryLock failed: ${e.message}") + channel.close(); LockResult.Failed + } +} + +private fun deleteShowFile() { + try { Files.deleteIfExists(showPath) } catch (e: IOException) { + Log.w(TAG, "single-instance: cannot delete show file: ${e.message}") + } +} + +private fun createShowFile() { + try { Files.createFile(showPath) } catch (_: FileAlreadyExistsException) { + // Another duplicate already signalled; primary will pick it up. + } catch (e: IOException) { + Log.w(TAG, "single-instance: cannot create show file: ${e.message}") + } +} + +private fun showSingleInstanceAlert(): Boolean { + val title = chat.simplex.common.views.helpers.generalGetString(chat.simplex.res.MR.strings.another_instance_title) + val message = chat.simplex.common.views.helpers.generalGetString(chat.simplex.res.MR.strings.another_instance_not_responding) + val result = javax.swing.JOptionPane.showConfirmDialog( + null, message, title, + javax.swing.JOptionPane.YES_NO_OPTION, + javax.swing.JOptionPane.WARNING_MESSAGE + ) + return result == javax.swing.JOptionPane.YES_OPTION +} + +private fun startShowFileWatcher() { + if (watcher != null) return + val ws = try { + dataDir.toPath().fileSystem.newWatchService() + } catch (e: IOException) { + Log.w(TAG, "single-instance: WatchService failed: ${e.message}") + return + } + dataDir.toPath().register(ws, StandardWatchEventKinds.ENTRY_CREATE) + watcher = ws + thread(name = "simplex-single-instance", isDaemon = true) { + while (true) { + val key = try { ws.take() } catch (_: ClosedWatchServiceException) { return@thread } catch (_: InterruptedException) { return@thread } + for (event in key.pollEvents()) { + if ((event.context() as? Path)?.fileName?.toString() == "simplex.show") { + deleteShowFile() + SwingUtilities.invokeLater { showWindow() } + } + } + if (!key.reset()) return@thread + } + } +} diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/SingleInstanceTest.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/SingleInstanceTest.kt new file mode 100644 index 0000000000..1b495c1774 --- /dev/null +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/SingleInstanceTest.kt @@ -0,0 +1,56 @@ +package chat.simplex.app + +import java.nio.channels.FileChannel +import java.nio.channels.OverlappingFileLockException +import java.nio.file.Files +import java.nio.file.StandardOpenOption.CREATE +import java.nio.file.StandardOpenOption.READ +import java.nio.file.StandardOpenOption.WRITE +import kotlin.test.Test +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull + +class SingleInstanceTest { + @Test + fun overlappingLockOnSameRegionThrowsWithinOneJvm() = withTempDir { dir -> + val lockPath = dir.resolve("simplex.started") + val first = FileChannel.open(lockPath, READ, WRITE, CREATE) + val firstLock = first.tryLock(0L, 1L, false) + assertNotNull(firstLock, "first acquirer must get the lock") + + val second = FileChannel.open(lockPath, READ, WRITE, CREATE) + assertFailsWith { + second.tryLock(0L, 1L, false) + } + second.close() + firstLock.release() + first.close() + } + + @Test + fun releasedLockCanBeReacquired() = withTempDir { dir -> + val lockPath = dir.resolve("simplex.started") + val first = FileChannel.open(lockPath, READ, WRITE, CREATE) + val firstLock = first.tryLock(0L, 1L, false) + assertNotNull(firstLock) + firstLock.release() + first.close() + + val second = FileChannel.open(lockPath, READ, WRITE, CREATE) + val secondLock = second.tryLock(0L, 1L, false) + assertNotNull(secondLock, "after release, a fresh acquirer must succeed") + secondLock.release() + second.close() + } + + private fun withTempDir(block: (java.nio.file.Path) -> Unit) { + val tmp = Files.createTempDirectory("simplex-singleinstance-test") + try { + block(tmp) + } finally { + Files.walk(tmp).sorted(Comparator.reverseOrder()).forEach { + try { Files.delete(it) } catch (_: java.io.IOException) {} + } + } + } +} diff --git a/apps/multiplatform/desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt b/apps/multiplatform/desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt index 0e8a452e08..338660b746 100644 --- a/apps/multiplatform/desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt +++ b/apps/multiplatform/desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt @@ -8,6 +8,7 @@ import androidx.compose.runtime.* import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.input.pointer.* +import chat.simplex.common.acquireSingleInstance import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.model.size import chat.simplex.common.platform.* @@ -19,6 +20,7 @@ import kotlinx.coroutines.* import java.io.File fun main() { + if (!acquireSingleInstance()) return // Disable hardware acceleration //System.setProperty("skiko.renderApi", "SOFTWARE") initHaskell() diff --git a/plans/2026-05-13-desktop-single-instance.md b/plans/2026-05-13-desktop-single-instance.md new file mode 100644 index 0000000000..87c5f7c9bb --- /dev/null +++ b/plans/2026-05-13-desktop-single-instance.md @@ -0,0 +1,30 @@ +# Desktop single instance - restore on duplicate launch + +## Problem + +After tray support (#6970), the desktop app can minimize to tray. The process stays alive holding the database. When the user clicks the app launcher again (forgetting about the tray), a second process starts and either crashes on the SQLite lock or runs in a degraded state. + +## Design + +Two files in `dataDir`: `simplex.started` (lock file) and `simplex.show` (signal file). + +### Startup + +1. Try `FileChannel.tryLock(0, 1, false)` on `simplex.started`. +2. **Lock acquired**: delete stale `simplex.show` if present (leftover from crash), start a daemon `WatchService` on `dataDir` for `ENTRY_CREATE`, start the app normally. +3. **Lock taken** (another process holds it): create `simplex.show`, exit. The running instance detects it and shows its window. +4. **Lock fails** (IOException, filesystem doesn't support locks, etc.): start normally but disable minimize-to-tray. Close quits the app. No worse than before tray support existed. + +### Signal handling + +While the lock is held, the daemon watcher runs for the JVM lifetime. When `simplex.show` appears it deletes the file and posts `showWindow()` to the EDT. `showWindow()` sets `windowVisible = true`, clears `ICONIFIED`, and brings the window to front — restores from tray, from taskbar-minimize, or just raises if visible-but-behind. + +Minimize-to-tray is only available when `singleInstanceLock` is held. If the lock couldn't be acquired (case 4), close always quits - preventing the scenario where two tray'd instances fight over the database. + +### Crash recovery + +The OS releases the file lock when the process dies. `simplex.show` may be left behind but is harmless - the next startup (step 2) deletes it. + +## Scope + +Linux, Windows, macOS. Per-data-directory - separate installs with different `dataDir` run independently. From 92e9640e4fb6ef9e9864d141e71436f68dcbbfb8 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 18 May 2026 09:06:25 +0000 Subject: [PATCH 14/14] core, ui: relay reject rejoin (#6978) --- .../Chat/ComposeMessage/ComposeView.swift | 2 +- .../Views/Chat/Group/ChannelRelaysView.swift | 12 +- .../Chat/Group/GroupMemberInfoView.swift | 3 + .../Shared/Views/NewChat/AddChannelView.swift | 16 +- apps/ios/SimpleXChat/ChatTypes.swift | 22 +- apps/ios/product/concepts.md | 2 +- apps/ios/product/views/group-info.md | 2 + apps/ios/spec/api.md | 1 + apps/ios/spec/client/chat-view.md | 7 +- apps/ios/spec/impact.md | 2 +- apps/ios/spec/state.md | 4 +- .../chat/simplex/common/model/ChatModel.kt | 22 +- .../simplex/common/views/chat/ComposeView.kt | 2 +- .../views/chat/group/ChannelRelaysView.kt | 12 +- .../views/chat/group/GroupMemberInfoView.kt | 3 + .../common/views/newchat/AddChannelView.kt | 16 +- .../commonMain/resources/MR/base/strings.xml | 3 + apps/multiplatform/product/concepts.md | 1 + .../multiplatform/product/views/group-info.md | 27 ++ apps/multiplatform/spec/api.md | 1 + apps/multiplatform/spec/client/chat-view.md | 8 + apps/multiplatform/spec/impact.md | 58 +-- apps/multiplatform/spec/state.md | 15 + bots/api/COMMANDS.md | 38 ++ bots/api/TYPES.md | 1 + bots/src/API/Docs/Commands.hs | 2 + bots/src/API/Docs/Responses.hs | 1 + docs/protocol/channels-protocol.md | 14 + .../types/typescript/src/commands.ts | 14 + .../types/typescript/src/responses.ts | 8 + .../types/typescript/src/types.ts | 1 + .../src/simplex_chat/types/_commands.py | 12 + .../src/simplex_chat/types/_responses.py | 8 +- .../src/simplex_chat/types/_types.py | 2 +- plans/2026-05-13-relay-refuse-rejoin.md | 347 ++++++++++++++++++ simplex-chat.cabal | 2 + src/Simplex/Chat/Controller.hs | 4 + src/Simplex/Chat/Library/Commands.hs | 16 +- src/Simplex/Chat/Library/Internal.hs | 22 ++ src/Simplex/Chat/Library/Subscriber.hs | 49 ++- src/Simplex/Chat/Protocol.hs | 7 + src/Simplex/Chat/Store/Groups.hs | 47 ++- src/Simplex/Chat/Store/Postgres/Migrations.hs | 4 +- ...20260514_relay_request_group_link_index.hs | 21 ++ .../Store/Postgres/Migrations/chat_schema.sql | 4 + src/Simplex/Chat/Store/SQLite/Migrations.hs | 4 +- ...20260514_relay_request_group_link_index.hs | 20 + .../SQLite/Migrations/chat_query_plans.txt | 47 ++- .../Store/SQLite/Migrations/chat_schema.sql | 6 + src/Simplex/Chat/Types.hs | 26 ++ src/Simplex/Chat/Types/Shared.hs | 5 + src/Simplex/Chat/View.hs | 18 +- tests/ChatTests/Groups.hs | 290 ++++++++++++++- 53 files changed, 1169 insertions(+), 112 deletions(-) create mode 100644 plans/2026-05-13-relay-refuse-rejoin.md create mode 100644 src/Simplex/Chat/Store/Postgres/Migrations/M20260514_relay_request_group_link_index.hs create mode 100644 src/Simplex/Chat/Store/SQLite/Migrations/M20260514_relay_request_group_link_index.hs diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index 5c57a46129..5242923258 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -742,7 +742,7 @@ struct ComposeView: View { (relay, chatModel.groupMembers.first(where: { $0.wrapped.groupMemberId == relay.groupMemberId })?.wrapped) } let removedCount = relayMembers.filter { (_, m) in relayMemberRemoved(m?.memberStatus) }.count - let activeCount = relayMembers.filter { (relay, m) in !relayMemberRemoved(m?.memberStatus) && relay.relayStatus == .rsActive && m?.activeConn?.connFailedErr == nil }.count + let activeCount = relayMembers.filter { (relay, m) in !relayMemberRemoved(m?.memberStatus) && relay.relayStatus == .active && m?.activeConn?.connFailedErr == nil }.count let failedCount = relayMembers.filter { (_, m) in !relayMemberRemoved(m?.memberStatus) && m?.activeConn?.connFailedErr != nil }.count let noActiveRelays = activeCount == 0 && (failedCount + removedCount) == relays.count return (relays, activeCount, failedCount, removedCount, noActiveRelays) diff --git a/apps/ios/Shared/Views/Chat/Group/ChannelRelaysView.swift b/apps/ios/Shared/Views/Chat/Group/ChannelRelaysView.swift index 6600cec47b..27935768e3 100644 --- a/apps/ios/Shared/Views/Chat/Group/ChannelRelaysView.swift +++ b/apps/ios/Shared/Views/Chat/Group/ChannelRelaysView.swift @@ -37,7 +37,9 @@ struct ChannelRelaysView: View { } // TODO [relays] re-enable when relay management ships // .sheet(isPresented: $showAddRelay) { - // let existingRelayIds = Set(groupRelays.filter { $0.relayStatus != .rsInactive }.compactMap { $0.userChatRelay.chatRelayId }) + // // Backend gate (APIAddGroupRelays) rejects any chatRelayId already in group_relays + // // regardless of relayStatus, so all current rows must be excluded from the add list. + // let existingRelayIds = Set(groupRelays.compactMap { $0.userChatRelay.chatRelayId }) // AddGroupRelayView(groupInfo: groupInfo, existingRelayIds: existingRelayIds) { // Task { await chatModel.loadGroupMembers(groupInfo) } // } @@ -112,7 +114,10 @@ struct ChannelRelaysView: View { } private func ownerRelayStatusText(_ member: GroupMember) -> LocalizedStringKey { - if [.memLeft, .memRemoved, .memGroupDeleted].contains(member.memberStatus) { + let relayStatus = groupRelays.first(where: { $0.groupMemberId == member.groupMemberId })?.relayStatus + return if relayStatus == .rejected { + "rejected" + } else if [.memLeft, .memRemoved, .memGroupDeleted].contains(member.memberStatus) { relayConnStatus(member).text } else if case .failed = member.activeConn?.connStatus { "failed" @@ -121,8 +126,7 @@ struct ChannelRelaysView: View { } else if member.activeConn?.connInactive ?? false { "inactive" } else { - groupRelays.first(where: { $0.groupMemberId == member.groupMemberId })?.relayStatus.text - ?? relayConnStatus(member).text + relayStatus?.text ?? relayConnStatus(member).text } } diff --git a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift index 883a768d97..dc14c7520b 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift @@ -199,6 +199,9 @@ struct GroupMemberInfoView: View { Label("Share relay address", systemImage: "square.and.arrow.up") } } + if groupRelay?.relayStatus == .rejected { + infoRow("Status", "rejected by relay operator") + } } header: { Text(channelMemberSectionHeader).foregroundColor(theme.colors.secondary) } footer: { diff --git a/apps/ios/Shared/Views/NewChat/AddChannelView.swift b/apps/ios/Shared/Views/NewChat/AddChannelView.swift index 32d6e7fe2c..7d1e5ce827 100644 --- a/apps/ios/Shared/Views/NewChat/AddChannelView.swift +++ b/apps/ios/Shared/Views/NewChat/AddChannelView.swift @@ -281,7 +281,7 @@ struct AddChannelView: View { private func progressStepView(_ gInfo: GroupInfo) -> some View { let failedCount = groupRelays.filter { relayMemberConnFailed($0) != nil }.count - let activeCount = groupRelays.filter { $0.relayStatus == .rsActive && relayMemberConnFailed($0) == nil }.count + let activeCount = groupRelays.filter { $0.relayStatus == .active && relayMemberConnFailed($0) == nil }.count let total = groupRelays.count return List { Group { @@ -376,7 +376,7 @@ struct AddChannelView: View { .onChange(of: channelRelaysModel.groupRelays) { relays in guard channelRelaysModel.groupId == gInfo.groupId else { return } groupRelays = relays.sorted { relayDisplayName($0) < relayDisplayName($1) } - if relays.allSatisfy({ $0.relayStatus == .rsActive && relayMemberConnFailed($0) == nil }) { + if relays.allSatisfy({ $0.relayStatus == .active && relayMemberConnFailed($0) == nil }) { showLinkStep = true channelRelaysModel.reset() } @@ -433,7 +433,7 @@ struct AddChannelView: View { } private func showCancelChannelAlert(_ gInfo: GroupInfo) { - let activeCount = groupRelays.filter { $0.relayStatus == .rsActive && relayMemberConnFailed($0) == nil }.count + let activeCount = groupRelays.filter { $0.relayStatus == .active && relayMemberConnFailed($0) == nil }.count let total = groupRelays.count showAlert( NSLocalizedString("Cancel creating channel?", comment: "alert title"), @@ -486,8 +486,14 @@ func chatRelayDisplayName(_ relay: UserChatRelay) -> String { func relayStatusIndicator(_ status: RelayStatus, connFailed: Bool = false, memberStatus: GroupMemberStatus? = nil) -> some View { let removed = memberStatus.map { [.memLeft, .memRemoved, .memGroupDeleted].contains($0) } ?? false - let color: Color = connFailed || removed ? .red : (status == .rsActive ? .green : .yellow) - let text: LocalizedStringKey = connFailed ? "failed" : memberStatus == .memLeft ? "removed by operator" : removed ? "removed" : status.text + let isRejected = status == .rejected + let color: Color = connFailed || removed || isRejected ? .red : (status == .active ? .green : .yellow) + let text: LocalizedStringKey = + connFailed ? "failed" + : isRejected ? "rejected" + : memberStatus == .memLeft ? "removed by operator" + : removed ? "removed" + : status.text return HStack(spacing: 4) { Circle() .fill(color) diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index a5a35ba5c0..594f90c4e4 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -2635,11 +2635,12 @@ public struct GroupShortLinkData: Codable, Hashable { } public enum RelayStatus: String, Decodable, Equatable, Hashable { - case rsNew = "new" - case rsInvited = "invited" - case rsAccepted = "accepted" - case rsActive = "active" - case rsInactive = "inactive" + case new + case invited + case accepted + case active + case inactive + case rejected } public struct RelayProfile: Codable, Equatable, Hashable { @@ -2708,11 +2709,12 @@ public struct GroupRelay: Identifiable, Decodable, Equatable, Hashable { extension RelayStatus { public var text: LocalizedStringKey { switch self { - case .rsNew: "new" - case .rsInvited: "invited" - case .rsAccepted: "accepted" - case .rsActive: "active" - case .rsInactive: "inactive" + case .new: "new" + case .invited: "invited" + case .accepted: "accepted" + case .active: "active" + case .inactive: "inactive" + case .rejected: "rejected" } } } diff --git a/apps/ios/product/concepts.md b/apps/ios/product/concepts.md index 3fa722d47a..6d63ee2faf 100644 --- a/apps/ios/product/concepts.md +++ b/apps/ios/product/concepts.md @@ -49,7 +49,7 @@ This document provides a structured mapping between product-level concepts, thei | 28 | Chat Tags | [views/chat-list.md](views/chat-list.md) | [spec/state.md](../spec/state.md) | `ChatList/TagListView.swift`, `ChatListView.swift` | `Types.hs` (`ChatTag`), `Controller.hs` | | 29 | User Address | [views/settings.md](views/settings.md) | [spec/api.md](../spec/api.md) | `UserSettings/UserAddressView.swift`, `Onboarding/AddressCreationCard.swift` | `Controller.hs` (`APICreateMyAddress`) | | 30 | Member Support Chat | [views/group-info.md](views/group-info.md) | [spec/api.md](../spec/api.md) | `Group/MemberSupportView.swift`, `MemberAdmissionView.swift` | `Messages.hs` (`GroupChatScope`), `Controller.hs` | -| 31 | Channels (Relays) | [glossary.md](glossary.md), [views/chat.md](views/chat.md) | [spec/api.md](../spec/api.md), [spec/state.md](../spec/state.md), [spec/client/chat-view.md](../spec/client/chat-view.md), [spec/client/compose.md](../spec/client/compose.md) | `SimpleXChat/ChatTypes.swift` (`RelayStatus`, `RelayStatus.text`, `GroupRelay`, `GroupMemberRole.relay`, `CIDirection.channelRcv`, `GroupInfo.chatIconName`, `userCantSendReason`), `Shared/Views/Chat/ChatView.swift` (channel message rendering), `Shared/Views/Chat/ComposeMessage/ComposeView.swift` (`sendAsGroup`, Broadcast placeholder), `Shared/Views/Chat/Group/GroupChatInfoView.swift` (channel info adaptations), `Shared/Views/Chat/Group/ChannelMembersView.swift`, `Shared/Views/Chat/Group/ChannelRelaysView.swift`, `Shared/Model/AppAPITypes.swift` (`GroupShortLinkInfo`, `UserChatRelay`), `Shared/Model/SimpleXAPI.swift` (`apiNewPublicGroup`), `SimpleX SE/ShareAPI.swift` (channel `sendAsGroup`) | `Controller.hs` (`APINewPublicGroup`) | +| 31 | Channels (Relays) | [glossary.md](glossary.md), [views/chat.md](views/chat.md), [views/group-info.md](views/group-info.md) | [spec/api.md](../spec/api.md), [spec/state.md](../spec/state.md), [spec/client/chat-view.md](../spec/client/chat-view.md), [spec/client/compose.md](../spec/client/compose.md) | `SimpleXChat/ChatTypes.swift` (`RelayStatus` incl. `.rsRejected`, `RelayStatus.text`, `GroupRelay`, `GroupMemberRole.relay`, `GroupMemberStatus.memRejected`, `CIDirection.channelRcv`, `GroupInfo.chatIconName`, `userCantSendReason`), `Shared/Views/Chat/ChatView.swift` (channel message rendering), `Shared/Views/Chat/ComposeMessage/ComposeView.swift` (`sendAsGroup`, Broadcast placeholder), `Shared/Views/Chat/Group/GroupChatInfoView.swift` (channel info adaptations), `Shared/Views/Chat/Group/GroupMemberInfoView.swift` (rejected-status row), `Shared/Views/Chat/Group/ChannelMembersView.swift`, `Shared/Views/Chat/Group/ChannelRelaysView.swift`, `Shared/Views/NewChat/AddChannelView.swift` (`relayStatusIndicator` rejected branch), `Shared/Model/AppAPITypes.swift` (`GroupShortLinkInfo`, `UserChatRelay`), `Shared/Model/SimpleXAPI.swift` (`apiNewPublicGroup`), `SimpleX SE/ShareAPI.swift` (channel `sendAsGroup`) | `Controller.hs` (`APINewPublicGroup`, `APIAllowRelayGroup`, `XGrpRelayReject` CONF handler) | --- diff --git a/apps/ios/product/views/group-info.md b/apps/ios/product/views/group-info.md index ee0c449c68..bfc9acfa71 100644 --- a/apps/ios/product/views/group-info.md +++ b/apps/ios/product/views/group-info.md @@ -188,6 +188,7 @@ New view accessible from channel info, showing relay members (role == `.relay`): | Relay list | Filtered from `chatModel.groupMembers` by `.relay` role | | Relay row | Profile image, relay display name, status text (`RelayStatus` or connection status) | | Relay tap | NavigationLink to `GroupMemberInfoView` with `groupRelay:` parameter | +| Add relay sheet | Owner-only "Add relay" button opens `AddGroupRelayView`; the available-to-add list excludes any `chatRelayId` already present in `groupRelays` (regardless of `relayStatus`), so inactive or rejected relays cannot be re-added without first removing them via the row's swipe action | | Empty state | "No chat relays" | | Footer | "Chat relays forward messages to channel subscribers." | @@ -221,6 +222,7 @@ Owner sees relay status from `apiGetGroupRelays`; non-owner sees connection stat | "Unblock for all?" alert | "Unblock subscriber for all?" | | Relay link info row | Shown when `member.relayLink` exists, displays `hostFromRelayLink(link)` | | Relay address info row | Shown when `groupRelay?.userChatRelay.address` exists, with "Share relay address" button | +| Status row (rejected) | Shown when `groupRelay?.relayStatus == .rsRejected`: "Status: rejected by relay operator". The relay rejected the invitation to rejoin this channel after a prior `/leave`; the owner-side `GroupMember.memberStatus` is also set to `.memLeft` so the relay renders identically to one that explicitly left. Clearable only by the relay operator running `/group allow #`. | | Relay footer | Owner: "Subscribers use relay link to connect to the channel. Relay address was used to set up this relay for the channel." Non-owner: "You connected to the channel via this relay link." | ## Related Specs diff --git a/apps/ios/spec/api.md b/apps/ios/spec/api.md index 45a06c371f..f9a3c35917 100644 --- a/apps/ios/spec/api.md +++ b/apps/ios/spec/api.md @@ -415,6 +415,7 @@ Event processing entry point: [`processReceivedMsg`](../Shared/Model/SimpleXAPI. | `groupUpdated` | `user, toGroup: GroupInfo` | Group profile changed | [L1106](../Shared/Model/AppAPITypes.swift#L1106) | | `groupLinkRelaysUpdated` | `user, groupInfo, groupLink, groupRelays: [GroupRelay]` | Channel relay configuration changed | [L1107](../Shared/Model/AppAPITypes.swift#L1107) | | `groupMemberUpdated` | `user, groupInfo, fromMember, toMember` | Member info updated | [L1081](../Shared/Model/AppAPITypes.swift#L1081) | +| `groupRelayUpdated` | `user, groupInfo, member, groupRelay` | Owner-side: a relay's `relayStatus` and/or the member's status changed. Fires on `XGrpRelayReject` with `relayStatus = .rsRejected` and `member.memberStatus = .memRejected` — final until cleared by the relay operator's `/group allow ` (no event emitted to the owner for that clear). | Controller.hs (`CEvtGroupRelayUpdated`) | ### File Transfer Events diff --git a/apps/ios/spec/client/chat-view.md b/apps/ios/spec/client/chat-view.md index 182e7b7ce9..afe656ed04 100644 --- a/apps/ios/spec/client/chat-view.md +++ b/apps/ios/spec/client/chat-view.md @@ -350,8 +350,13 @@ Groups use separate [`groupLinkButton()`](../../Shared/Views/Chat/Group/GroupCha ### [`channelRelaysButton()`](../../Shared/Views/Chat/Group/GroupChatInfoView.swift#L639) → [`ChannelRelaysView`](../../Shared/Views/Chat/Group/ChannelRelaysView.swift) Navigates to relay list view with role-based branches: -- **Owner**: loads `[GroupRelay]` via [`apiGetGroupRelays`](../../Shared/Model/SimpleXAPI.swift#L1839) (owner-only API, guarded by `assertUserGroupRole GROwner` on backend). Joins with `chatModel.groupMembers` by `groupMemberId` for display names. Shows status indicators (colored circle + `RelayStatus.text`). +- **Owner**: loads `[GroupRelay]` via [`apiGetGroupRelays`](../../Shared/Model/SimpleXAPI.swift#L1839) (owner-only API, guarded by `assertUserGroupRole GROwner` on backend). Joins with `chatModel.groupMembers` by `groupMemberId` for display names. Shows status indicators (colored circle + `RelayStatus.text`). When `relayStatus == .rsRejected` the indicator dot is red and the text reads "rejected", matching the `connFailed`/`removed` rendering. - **Member**: filters `chatModel.groupMembers` by `.memberRole == .relay`. Shows relay member display names only (no status data). +- **Add relay sheet**: `existingRelayIds` excludes every `chatRelayId` present in `groupRelays` regardless of `relayStatus`, so an already-listed relay (including `.rsInactive` and `.rsRejected`) cannot be re-added from the sheet. This mirrors the backend gate at `APIAddGroupRelays` (`existingRelayIds`), which rejects duplicate `chatRelayId`s; operator must remove the relay first via the swipe action. + +### Relay Rejection Surface + +When a relay operator runs `/leave #channel`, the relay sends `x.grp.relay.reject` over the owner-relay direct contact channel. Owner-side handling: the corresponding `GroupRelay.relayStatus` transitions `RSInvited → RSRejected`; the relay's `GroupMember.memberStatus` is set to `.memLeft` so the owner UI renders the rejected relay identically to one that explicitly ran `/leave` (`.memRejected` is reserved for the knocking-admission flow). The transition surfaces through `CEvtGroupRelayUpdated`. In `GroupMemberInfoView`, an additional `Status: rejected by relay operator` info row appears when `groupRelay?.relayStatus == .rsRejected`. The status is final on the owner side — clearable only by the relay operator running `/group allow #`, which has no owner-facing event. ### Leave Button Logic diff --git a/apps/ios/spec/impact.md b/apps/ios/spec/impact.md index eaf646e7f4..74acec789e 100644 --- a/apps/ios/spec/impact.md +++ b/apps/ios/spec/impact.md @@ -61,7 +61,7 @@ | Shared/Views/Chat/Group/ChannelRelaysView.swift | PC31 | Medium | Channel relay status list | | Shared/Views/Chat/Group/AddGroupMembersView.swift | PC14, PC16 | Medium | Member invitation flow | | Shared/Views/Chat/Group/GroupLinkView.swift | PC15 | Low | Group link creation/sharing | -| Shared/Views/Chat/Group/GroupMemberInfoView.swift | PC3, PC14, PC16, PC30 | Medium | Member details and role management | +| Shared/Views/Chat/Group/GroupMemberInfoView.swift | PC3, PC14, PC16, PC30, PC31 | Medium | Member details and role management; rejected-by-operator status row for relay members | | Shared/Views/NewChat/NewChatView.swift | PC12, PC31 | High | New connection creation — onramp for all contacts and channels | | Shared/Views/NewChat/QRCode.swift | PC12 | Low | QR code display/scanning utility | | Shared/Views/Call/ActiveCallView.swift | PC17 | Medium | Call UI rendering | diff --git a/apps/ios/spec/state.md b/apps/ios/spec/state.md index 6dda4ba275..db16aa2936 100644 --- a/apps/ios/spec/state.md +++ b/apps/ios/spec/state.md @@ -390,8 +390,8 @@ A **channel** is a group with `groupInfo.useRelays == true`. These types support | Type | Kind | Description | Line | |------|------|-------------|------| -| `RelayStatus` | `enum` | Relay lifecycle: `.rsNew`, `.rsInvited`, `.rsAccepted`, `.rsActive` | [L2506](../SimpleXChat/ChatTypes.swift#L2506) | -| `RelayStatus.text` | `extension` | Localized display text: New/Invited/Accepted/Active | [L2565](../SimpleXChat/ChatTypes.swift#L2565) | +| `RelayStatus` | `enum` | Relay lifecycle: `.rsNew`, `.rsInvited`, `.rsAccepted`, `.rsActive`, `.rsInactive`, `.rsRejected` | [L2506](../SimpleXChat/ChatTypes.swift#L2506) | +| `RelayStatus.text` | `extension` | Localized display text: New/Invited/Accepted/Active/Inactive/Rejected | [L2565](../SimpleXChat/ChatTypes.swift#L2565) | | `GroupRelay` | `struct` | Relay instance for a group (ID, member ID, relay status). Fetched at runtime via `apiGetGroupRelays` (owner only) | [L2555](../SimpleXChat/ChatTypes.swift#L2555) | | `UserChatRelay` | `struct` | User's chat relay configuration (ID, SMP address, name, domains, preset/tested/enabled/deleted flags) | [L2513](../SimpleXChat/ChatTypes.swift#L2513) | 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 1b1d403521..3c9ece9dce 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 @@ -2286,18 +2286,20 @@ data class GroupShortLinkData ( @Serializable enum class RelayStatus { - @SerialName("new") RsNew, - @SerialName("invited") RsInvited, - @SerialName("accepted") RsAccepted, - @SerialName("active") RsActive, - @SerialName("inactive") RsInactive; + @SerialName("new") New, + @SerialName("invited") Invited, + @SerialName("accepted") Accepted, + @SerialName("active") Active, + @SerialName("inactive") Inactive, + @SerialName("rejected") Rejected; val text: String get() = when (this) { - RsNew -> generalGetString(MR.strings.relay_status_new) - RsInvited -> generalGetString(MR.strings.relay_status_invited) - RsAccepted -> generalGetString(MR.strings.relay_status_accepted) - RsActive -> generalGetString(MR.strings.relay_status_active) - RsInactive -> generalGetString(MR.strings.relay_status_inactive) + New -> generalGetString(MR.strings.relay_status_new) + Invited -> generalGetString(MR.strings.relay_status_invited) + Accepted -> generalGetString(MR.strings.relay_status_accepted) + Active -> generalGetString(MR.strings.relay_status_active) + Inactive -> generalGetString(MR.strings.relay_status_inactive) + Rejected -> generalGetString(MR.strings.relay_status_rejected) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt index d0782f6bb4..d874079238 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt @@ -2011,7 +2011,7 @@ private fun ownerRelayState(chat: Chat, chatModel: ChatModel): OwnerRelayState? relay to chatModel.groupMembers.value.firstOrNull { it.groupMemberId == relay.groupMemberId } } val removedCount = relayMembers.count { (_, m) -> relayMemberRemoved(m?.memberStatus) } - val activeCount = relayMembers.count { (relay, m) -> !relayMemberRemoved(m?.memberStatus) && relay.relayStatus == RelayStatus.RsActive && m?.activeConn?.connFailedErr == null } + val activeCount = relayMembers.count { (relay, m) -> !relayMemberRemoved(m?.memberStatus) && relay.relayStatus == RelayStatus.Active && m?.activeConn?.connFailedErr == null } val failedCount = relayMembers.count { (_, m) -> !relayMemberRemoved(m?.memberStatus) && m?.activeConn?.connFailedErr != null } val noActiveRelays = activeCount == 0 && (failedCount + removedCount) == relays.size return OwnerRelayState(relays, activeCount, failedCount, removedCount, noActiveRelays) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelRelaysView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelRelaysView.kt index 891753aed8..cfe9f0472d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelRelaysView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelRelaysView.kt @@ -114,7 +114,9 @@ private fun ChannelRelaysLayout( if (groupInfo.isOwner) { SectionView { SectionItemView(click = { - val existingRelayIds = groupRelays.filter { it.relayStatus != RelayStatus.RsInactive }.mapNotNull { it.userChatRelay.chatRelayId }.toSet() + // Backend gate (APIAddGroupRelays) rejects any chatRelayId already in group_relays + // regardless of relayStatus, so all current rows must be excluded from the add list. + val existingRelayIds = groupRelays.mapNotNull { it.userChatRelay.chatRelayId }.toSet() ModalManager.end.showModalCloseable(true) { close -> AddGroupRelayView( groupInfo = groupInfo, @@ -179,7 +181,10 @@ private fun subscriberRelayStatusText(member: GroupMember): String { } private fun ownerRelayStatusText(member: GroupMember, groupRelays: List): String { - return if (member.memberStatus in listOf(GroupMemberStatus.MemLeft, GroupMemberStatus.MemRemoved, GroupMemberStatus.MemGroupDeleted)) { + val relayStatus = groupRelays.firstOrNull { it.groupMemberId == member.groupMemberId }?.relayStatus + return if (relayStatus == RelayStatus.Rejected) { + generalGetString(MR.strings.relay_status_rejected) + } else if (member.memberStatus in listOf(GroupMemberStatus.MemLeft, GroupMemberStatus.MemRemoved, GroupMemberStatus.MemGroupDeleted)) { relayConnStatus(member).first } else if (member.activeConn?.connStatus is ConnStatus.Failed) { generalGetString(MR.strings.relay_conn_status_failed) @@ -188,8 +193,7 @@ private fun ownerRelayStatusText(member: GroupMember, groupRelays: List Unit ) { val failedCount = groupRelays.value.count { relayMemberConnFailed(chatModel, it) != null } - val activeCount = groupRelays.value.count { it.relayStatus == RelayStatus.RsActive && relayMemberConnFailed(chatModel, it) == null } + val activeCount = groupRelays.value.count { it.relayStatus == RelayStatus.Active && relayMemberConnFailed(chatModel, it) == null } val total = groupRelays.value.size fun showCancelAlert() { - val active = groupRelays.value.count { it.relayStatus == RelayStatus.RsActive && relayMemberConnFailed(chatModel, it) == null } + val active = groupRelays.value.count { it.relayStatus == RelayStatus.Active && relayMemberConnFailed(chatModel, it) == null } val tot = groupRelays.value.size AlertManager.shared.showAlertDialog( title = generalGetString(MR.strings.cancel_creating_channel_question), @@ -394,7 +394,7 @@ private fun ProgressStepView( .collect { relays -> if (ChannelRelaysModel.groupId.value != gInfo.groupId) return@collect groupRelays.value = relays.sortedBy { relayDisplayName(it) } - if (relays.all { it.relayStatus == RelayStatus.RsActive && relayMemberConnFailed(chatModel, it) == null }) { + if (relays.all { it.relayStatus == RelayStatus.Active && relayMemberConnFailed(chatModel, it) == null }) { onLinkReady() ChannelRelaysModel.reset() } @@ -596,8 +596,14 @@ fun chatRelayDisplayName(relay: UserChatRelay): String { @Composable fun RelayStatusIndicator(status: RelayStatus, connFailed: Boolean = false, memberStatus: GroupMemberStatus? = null) { val removed = memberStatus in listOf(GroupMemberStatus.MemLeft, GroupMemberStatus.MemRemoved, GroupMemberStatus.MemGroupDeleted) - val color = if (connFailed || removed) Color.Red else if (status == RelayStatus.RsActive) Color.Green else WarningYellow - val text = if (connFailed) generalGetString(MR.strings.relay_status_failed) else if (memberStatus == GroupMemberStatus.MemLeft) generalGetString(MR.strings.relay_conn_status_removed_by_operator) else if (removed) generalGetString(MR.strings.relay_conn_status_removed) else status.text + val isRejected = status == RelayStatus.Rejected + val color = if (connFailed || removed || isRejected) Color.Red else if (status == RelayStatus.Active) Color.Green else WarningYellow + val text = + if (connFailed) generalGetString(MR.strings.relay_status_failed) + else if (isRejected) generalGetString(MR.strings.relay_status_rejected) + else if (memberStatus == GroupMemberStatus.MemLeft) generalGetString(MR.strings.relay_conn_status_removed_by_operator) + else if (removed) generalGetString(MR.strings.relay_conn_status_removed) + else status.text Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp) 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 c7b48b4e5b..375edecd44 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -2996,6 +2996,9 @@ accepted active inactive + rejected + Status + rejected by relay operator All relays removed diff --git a/apps/multiplatform/product/concepts.md b/apps/multiplatform/product/concepts.md index da33bf11d7..5d707cf832 100644 --- a/apps/multiplatform/product/concepts.md +++ b/apps/multiplatform/product/concepts.md @@ -49,6 +49,7 @@ This document provides a structured mapping between product-level concepts, thei | PC28 | Chat Tags | [README.md](README.md) (Navigation Map) | [spec/state.md](../spec/state.md) | `common/.../views/chatlist/TagListView.kt`, `ChatListView.kt` | `Types.hs` (`ChatTag`), `Controller.hs` | | PC29 | User Address | [README.md](README.md) (Contacts, User Management) | [spec/api.md](../spec/api.md) | `common/.../views/usersettings/UserAddressView.kt`, `UserAddressLearnMore.kt` | `Controller.hs` (`APICreateMyAddress`) | | PC30 | Member Support Chat | [README.md](README.md) (Groups) | [spec/api.md](../spec/api.md) | `common/.../views/chat/group/MemberSupportView.kt`, `MemberSupportChatView.kt`, `MemberAdmission.kt` | `Messages.hs` (`GroupChatScope`), `Controller.hs` | +| PC31 | Channels (Relays) | [views/group-info.md](views/group-info.md) | [spec/client/chat-view.md](../spec/client/chat-view.md), [spec/state.md](../spec/state.md) | `common/.../model/ChatModel.kt` (`RelayStatus` incl. `RsRejected`, `GroupRelay`, `GroupMemberRole.Relay`, `GroupMemberStatus.MemRejected`), `common/.../views/chat/group/ChannelRelaysView.kt`, `GroupMemberInfoView.kt` (rejected-status row), `common/.../views/newchat/AddChannelView.kt` (`RelayStatusIndicator` rejected branch), `common/.../views/chat/group/AddGroupRelayView.kt` | `Controller.hs` (`APIAddGroupRelays`, `APIAllowRelayGroup`, `XGrpRelayReject` CONF handler) | **Legend for abbreviated paths:** - `common/.../` expands to `common/src/commonMain/kotlin/chat/simplex/common/` diff --git a/apps/multiplatform/product/views/group-info.md b/apps/multiplatform/product/views/group-info.md index 65b068adc8..2335de7178 100644 --- a/apps/multiplatform/product/views/group-info.md +++ b/apps/multiplatform/product/views/group-info.md @@ -130,6 +130,30 @@ Shown when `developerTools` preference is enabled: Business chats use alternative labels: "Delete chat" instead of "Delete group". +### Channel Relays View (`ChannelRelaysView`) + +Accessible from channel info; shows relay members (role == `Relay`): + +| Element | Description | +|---|---| +| Relay list | Filtered from `chatModel.groupMembers` by `Relay` role; excludes `MemRemoved` and `MemGroupDeleted` | +| Relay row | Profile image, relay display name, status text (`RelayStatus.text` or connection status via `relayConnStatus`) | +| Relay tap | Navigates to `GroupMemberInfoView` with `groupRelay:` parameter | +| Add relay entry | Owner-only "Add relay" action opens `AddGroupRelayView`; the available-to-add list excludes any `chatRelayId` already present in `groupRelays` (regardless of `relayStatus`), so inactive or rejected relays cannot be re-added without first removing them via the row's long-press menu | +| Long-press menu | Owner-only "Remove relay" action for relays that can be removed | +| Empty state | "No chat relays" | +| Footer | "Chat relays forward messages to channel subscribers." | + +Owner sees relay status from `apiGetGroupRelays`; non-owner sees connection status only. + +#### Channel Member Info — relay surface (in `GroupMemberInfoView`) + +| Element | Description | +|---|---| +| Relay link info row | Shown when `member.relayLink` exists, displays `hostFromRelayLink(link)` | +| Relay address info row | Shown when `groupRelay?.userChatRelay.address` exists, with "Share relay address" button | +| Status row (rejected) | Shown when `groupRelay?.relayStatus == RelayStatus.RsRejected`: "Status: rejected by relay operator". The relay rejected the invitation to rejoin this channel after a prior `/leave`; the owner-side `GroupMember.memberStatus` is also set to `MemLeft` so the relay renders identically to one that explicitly left. Clearable only by the relay operator running `/group allow #`. | + ## Source Files | File | Path | @@ -143,3 +167,6 @@ Business chats use alternative labels: "Delete chat" instead of "Delete group". | `WelcomeMessageView.kt` | `views/chat/group/WelcomeMessageView.kt` | | `MemberAdmission.kt` | `views/chat/group/MemberAdmission.kt` | | `MemberSupportView.kt` | `views/chat/group/MemberSupportView.kt` | +| `ChannelRelaysView.kt` | `views/chat/group/ChannelRelaysView.kt` | +| `AddGroupRelayView.kt` | `views/chat/group/AddGroupRelayView.kt` | +| `AddChannelView.kt` (`RelayStatusIndicator`) | `views/newchat/AddChannelView.kt` | diff --git a/apps/multiplatform/spec/api.md b/apps/multiplatform/spec/api.md index 15d5e141a0..4114e9de4f 100644 --- a/apps/multiplatform/spec/api.md +++ b/apps/multiplatform/spec/api.md @@ -352,6 +352,7 @@ Events handled in `processReceivedMsg` include: | `DeletedMember` / `DeletedMemberUser` | A member was removed | | `LeftMember` | A member left voluntarily | | `GroupUpdated` | Group profile changed | +| `GroupRelayUpdated` | Owner-side: a relay's `relayStatus` and/or the member's status changed. Fires on `XGrpRelayReject` with `relayStatus = RsRejected` and `GroupMember.memberStatus = MemLeft` — final on owner side until cleared by the relay operator's `/group allow #` (no event emitted to the owner for that clear). | | `MemberRole` | A member's role changed | | `MemberBlockedForAll` | A member was blocked for all | | `RcvFileStart` / `RcvFileComplete` / `RcvFileError` | File receive progress | diff --git a/apps/multiplatform/spec/client/chat-view.md b/apps/multiplatform/spec/client/chat-view.md index 2819b1e751..728ace4936 100644 --- a/apps/multiplatform/spec/client/chat-view.md +++ b/apps/multiplatform/spec/client/chat-view.md @@ -322,3 +322,11 @@ Key sections: group profile, group link, member list with roles, group preferenc | `MemberSupportChatView.kt` | Member support chat (scoped context) | | `MemberSupportView.kt` | Support chat list for moderators | | `WelcomeMessageView.kt` | Group welcome message editor | +| `ChannelRelaysView.kt` | Channel relay list. Owner-only Add relay entry opens `AddGroupRelayView` with `existingRelayIds = groupRelays.mapNotNull { it.userChatRelay.chatRelayId }.toSet()` — every relay currently in `groupRelays` is excluded regardless of `relayStatus`, mirroring the backend `APIAddGroupRelays` gate. Long-press menu offers Remove relay for relays that can be removed. | +| `AddGroupRelayView.kt` | Sheet to pick relays to add to a channel | + +### Relay Rejection Surface + +When a relay operator runs `/leave #channel`, the relay sends `x.grp.relay.reject` over the owner-relay direct contact channel. Owner-side handling: the corresponding `GroupRelay.relayStatus` transitions `RSInvited → RSRejected`; the relay's `GroupMember.memberStatus` is set to `MemLeft` so the owner UI renders the rejected relay identically to one that explicitly ran `/leave` (`MemRejected` is reserved for the knocking-admission flow). In `GroupMemberInfoView`, an additional "Status: rejected by relay operator" `InfoRow` appears when `groupRelay?.relayStatus == RelayStatus.RsRejected`. The status is final on the owner side — clearable only by the relay operator running `/group allow #`, which has no owner-facing event. + +The `RelayStatusIndicator` composable in `AddChannelView.kt` renders `RsRejected` with a red dot and "rejected" text, matching the `connFailed`/`removed` rendering. diff --git a/apps/multiplatform/spec/impact.md b/apps/multiplatform/spec/impact.md index cd0f836585..f808cf31ba 100644 --- a/apps/multiplatform/spec/impact.md +++ b/apps/multiplatform/spec/impact.md @@ -40,6 +40,7 @@ | PC28 | Chat Tags | | PC29 | User Address | | PC30 | Member Support Chat | +| PC31 | Channels (Relays) | --- @@ -51,13 +52,13 @@ Path prefix: `common/src/commonMain/kotlin/chat/simplex/common/` | Source File | Product Concepts Affected | Risk Level | Notes | |-------------|--------------------------|------------|-------| -| `App.kt` | PC1 through PC30 | High | Root composable — navigation scaffold for all features | +| `App.kt` | PC1 through PC31 | High | Root composable — navigation scaffold for all features | | `AppLock.kt` | PC22 | Medium | App lock state and authorization lifecycle | -| `model/ChatModel.kt` | PC1 through PC30 | High | Central state object — every feature reads or writes here | -| `model/SimpleXAPI.kt` | PC1 through PC30 | High | FFI bridge to Haskell core — all commands and responses | +| `model/ChatModel.kt` | PC1 through PC31 | High | Central state object — every feature reads or writes here | +| `model/SimpleXAPI.kt` | PC1 through PC31 | High | FFI bridge to Haskell core — all commands and responses | | `model/CryptoFile.kt` | PC10, PC23 | Medium | Encrypted file read/write helpers | -| `platform/Core.kt` | PC1 through PC30 | High | Native FFI declarations (`chatMigrateInit`, `chatSendCmd`, etc.) — all API traffic | -| `platform/AppCommon.kt` | PC1 through PC30 | Medium | Shared app initialization logic | +| `platform/Core.kt` | PC1 through PC31 | High | Native FFI declarations (`chatMigrateInit`, `chatSendCmd`, etc.) — all API traffic | +| `platform/AppCommon.kt` | PC1 through PC31 | Medium | Shared app initialization logic | | `platform/Files.kt` | PC10, PC23, PC26 | Medium | File path resolution, temp dirs, encryption utilities | | `platform/NtfManager.kt` | PC18 | High | Notification manager expect declarations | | `platform/Notifications.kt` | PC18 | Medium | Notification channel and permission abstractions | @@ -67,7 +68,7 @@ Path prefix: `common/src/commonMain/kotlin/chat/simplex/common/` | `platform/Cryptor.kt` | PC23 | Medium | Keystore encryption expect declarations | | `platform/Share.kt` | PC10, PC12 | Low | Share sheet abstractions | | `platform/Images.kt` | PC10, PC19 | Low | Image processing utilities | -| `platform/Platform.kt` | PC1 through PC30 | Low | Platform detection and capability flags | +| `platform/Platform.kt` | PC1 through PC31 | Low | Platform detection and capability flags | | `platform/PlatformTextField.kt` | PC4 | Low | Native text input expect declarations | | `platform/Back.kt` | PC1 | Low | Back navigation handling | | `platform/UI.kt` | PC24 | Low | UI density and locale helpers | @@ -160,7 +161,9 @@ Path prefix: `common/src/commonMain/kotlin/chat/simplex/common/` |-------------|--------------------------|------------|-------| | `views/chat/group/GroupChatInfoView.kt` | PC3, PC14, PC15, PC16, PC30 | High | Group management hub | | `views/chat/group/AddGroupMembersView.kt` | PC14, PC16 | Medium | Member invitation flow | -| `views/chat/group/GroupMemberInfoView.kt` | PC3, PC14, PC16, PC30 | Medium | Member details and role management | +| `views/chat/group/GroupMemberInfoView.kt` | PC3, PC14, PC16, PC30, PC31 | Medium | Member details and role management; relay-address + rejected-status info rows | +| `views/chat/group/ChannelRelaysView.kt` | PC31 | Medium | Channel relay list, add/remove entries | +| `views/chat/group/AddGroupRelayView.kt` | PC31 | Low | Add relay sheet | | `views/chat/group/GroupProfileView.kt` | PC3, PC14 | Medium | Group profile editing | | `views/chat/group/GroupLinkView.kt` | PC15 | Low | Group link creation and sharing | | `views/chat/group/GroupPreferences.kt` | PC3, PC8, PC14 | Medium | Group feature toggles | @@ -189,6 +192,7 @@ Path prefix: `common/src/commonMain/kotlin/chat/simplex/common/` | `views/newchat/NewChatSheet.kt` | PC12 | Medium | Bottom sheet with connection options | | `views/newchat/ConnectPlan.kt` | PC12, PC15 | Medium | Link parsing and connection plan resolution | | `views/newchat/AddGroupView.kt` | PC3, PC14 | Medium | New group creation flow | +| `views/newchat/AddChannelView.kt` | PC31 | Medium | Public channel creation, channel link card, `RelayStatusIndicator` | | `views/newchat/ContactConnectionInfoView.kt` | PC12 | Low | Pending connection details | | `views/newchat/AddContactLearnMore.kt` | PC12 | Low | Educational content | | `views/newchat/QRCode.kt` | PC12 | Low | QR code display | @@ -264,9 +268,9 @@ Path prefix: `common/src/commonMain/kotlin/chat/simplex/common/` | Source File | Product Concepts Affected | Risk Level | Notes | |-------------|--------------------------|------------|-------| -| `views/helpers/AlertManager.kt` | PC1 through PC30 | Medium | Modal alert system used across all features | -| `views/helpers/ModalView.kt` | PC1 through PC30 | Medium | Modal navigation stack | -| `views/helpers/Utils.kt` | PC1 through PC30 | Low | Shared formatting, clipboard, and utility functions | +| `views/helpers/AlertManager.kt` | PC1 through PC31 | Medium | Modal alert system used across all features | +| `views/helpers/ModalView.kt` | PC1 through PC31 | Medium | Modal navigation stack | +| `views/helpers/Utils.kt` | PC1 through PC31 | Low | Shared formatting, clipboard, and utility functions | | `views/helpers/DatabaseUtils.kt` | PC23 | Medium | Keystore passphrase and database helpers | | `views/helpers/LinkPreviews.kt` | PC11 | Medium | Link preview fetching and rendering | | `views/helpers/LocalAuthentication.kt` | PC22 | Medium | Biometric/passcode authentication expect | @@ -319,8 +323,8 @@ Path prefix: `android/src/main/java/chat/simplex/app/` | Source File | Product Concepts Affected | Risk Level | Notes | |-------------|--------------------------|------------|-------| -| `SimplexApp.kt` | PC1 through PC30 | High | Application class — initializes core, preferences, and notification channels | -| `MainActivity.kt` | PC1 through PC30 | High | Single-activity host — intent handling, lifecycle, deep links | +| `SimplexApp.kt` | PC1 through PC31 | High | Application class — initializes core, preferences, and notification channels | +| `MainActivity.kt` | PC1 through PC31 | High | Single-activity host — intent handling, lifecycle, deep links | | `SimplexService.kt` | PC18 | High | Foreground service — keeps message receiver alive | | `CallService.kt` | PC17 | Medium | Foreground service for active calls | | `MessagesFetcherWorker.kt` | PC18 | Medium | WorkManager periodic message fetch | @@ -334,7 +338,7 @@ Path prefix: `common/src/androidMain/kotlin/chat/simplex/common/` | Source File | Product Concepts Affected | Risk Level | Notes | |-------------|--------------------------|------------|-------| -| `platform/AppCommon.android.kt` | PC1 through PC30 | Medium | Android app initialization actual declarations | +| `platform/AppCommon.android.kt` | PC1 through PC31 | Medium | Android app initialization actual declarations | | `platform/SimplexService.android.kt` | PC18 | Medium | Android foreground service actual implementation | | `platform/Files.android.kt` | PC10, PC23, PC26 | Medium | Android file paths and content-URI resolution | | `platform/Cryptor.android.kt` | PC23 | Medium | Android Keystore encryption actual implementation | @@ -400,7 +404,7 @@ Path prefix: `desktop/src/jvmMain/kotlin/chat/simplex/desktop/` | Source File | Product Concepts Affected | Risk Level | Notes | |-------------|--------------------------|------------|-------| -| `Main.kt` | PC1 through PC30 | High | JVM entry point — Haskell init, migrations, app launch | +| `Main.kt` | PC1 through PC31 | High | JVM entry point — Haskell init, migrations, app launch | ### 3.2 Desktop Platform Implementations (desktopMain) @@ -411,7 +415,7 @@ Path prefix: `common/src/desktopMain/kotlin/chat/simplex/common/` | `DesktopApp.kt` | PC1, PC2, PC3 | High | Desktop Compose window — window lifecycle, crash recovery | | `StoreWindowState.kt` | — | Low | Window position/size persistence | | `model/NtfManager.desktop.kt` | PC18 | Medium | Desktop system tray notification display | -| `platform/AppCommon.desktop.kt` | PC1 through PC30 | Medium | Desktop app initialization actual declarations | +| `platform/AppCommon.desktop.kt` | PC1 through PC31 | Medium | Desktop app initialization actual declarations | | `platform/SimplexService.desktop.kt` | PC18 | Low | Desktop background receiver (no foreground service) | | `platform/Files.desktop.kt` | PC10, PC23, PC26 | Medium | Desktop file path resolution | | `platform/Cryptor.desktop.kt` | PC23 | Medium | Desktop keystore encryption actual implementation | @@ -473,13 +477,13 @@ The Haskell core is compiled as a shared native library (`libsimplex.so` / `libs | Source File | Product Concepts Affected | Risk Level | Notes | |-------------|--------------------------|------------|-------| -| `src/Simplex/Chat.hs` | PC1 through PC30 | High | Main chat module — top-level orchestration | -| `src/Simplex/Chat/Controller.hs` | PC1 through PC30 | High | Command processor — all API commands dispatched here | -| `src/Simplex/Chat/Types.hs` | PC1 through PC30 | High | Core data types shared across all features | -| `src/Simplex/Chat/Core.hs` | PC1 through PC30 | High | Chat engine lifecycle (start, stop, subscribe) | -| `src/Simplex/Chat/Library/Commands.hs` | PC1 through PC30 | High | API command handler implementations | -| `src/Simplex/Chat/Library/Internal.hs` | PC1 through PC30 | High | Internal helpers for command processing | -| `src/Simplex/Chat/Library/Subscriber.hs` | PC1 through PC30 | High | Event subscriber — incoming message routing | +| `src/Simplex/Chat.hs` | PC1 through PC31 | High | Main chat module — top-level orchestration | +| `src/Simplex/Chat/Controller.hs` | PC1 through PC31 | High | Command processor — all API commands dispatched here | +| `src/Simplex/Chat/Types.hs` | PC1 through PC31 | High | Core data types shared across all features | +| `src/Simplex/Chat/Core.hs` | PC1 through PC31 | High | Chat engine lifecycle (start, stop, subscribe) | +| `src/Simplex/Chat/Library/Commands.hs` | PC1 through PC31 | High | API command handler implementations | +| `src/Simplex/Chat/Library/Internal.hs` | PC1 through PC31 | High | Internal helpers for command processing | +| `src/Simplex/Chat/Library/Subscriber.hs` | PC1 through PC31 | High | Event subscriber — incoming message routing | | `src/Simplex/Chat/Protocol.hs` | PC2, PC3, PC4, PC5, PC6, PC7 | High | Chat-level message protocol (x-events) | | `src/Simplex/Chat/Messages.hs` | PC2, PC3, PC4, PC5, PC6, PC7, PC8, PC9 | High | Message types and content | | `src/Simplex/Chat/Messages/CIContent.hs` | PC4, PC5, PC6, PC7, PC8, PC9, PC11 | Medium | Chat item content variants | @@ -489,8 +493,8 @@ The Haskell core is compiled as a shared native library (`libsimplex.so` / `libs | `src/Simplex/Chat/Files.hs` | PC10 | Medium | File transfer orchestration | | `src/Simplex/Chat/Delivery.hs` | PC2, PC3 | Medium | Message delivery engine | | `src/Simplex/Chat/Markdown.hs` | PC4 | Low | Markdown parsing for message formatting | -| `src/Simplex/Chat/Store.hs` | PC1 through PC30 | High | Database store interface | -| `src/Simplex/Chat/Store/Shared.hs` | PC1 through PC30 | Medium | Shared store utilities | +| `src/Simplex/Chat/Store.hs` | PC1 through PC31 | High | Database store interface | +| `src/Simplex/Chat/Store/Shared.hs` | PC1 through PC31 | Medium | Shared store utilities | | `src/Simplex/Chat/Store/Messages.hs` | PC4, PC5, PC6, PC7, PC8 | High | Message persistence | | `src/Simplex/Chat/Store/Groups.hs` | PC3, PC14, PC15, PC16, PC30 | High | Group persistence | | `src/Simplex/Chat/Store/Direct.hs` | PC2, PC12, PC13 | High | Contact persistence | @@ -519,11 +523,11 @@ The Haskell core is compiled as a shared native library (`libsimplex.so` / `libs | `src/Simplex/Chat/Operators/Presets.hs` | PC25 | Low | Preset server operators | | `src/Simplex/Chat/Operators/Conditions.hs` | PC25 | Low | Operator usage conditions | | `src/Simplex/Chat/AppSettings.hs` | PC25 | Low | App settings sync types | -| `src/Simplex/Chat/Mobile.hs` | PC1 through PC30 | High | C FFI exports — JNI bridge target | +| `src/Simplex/Chat/Mobile.hs` | PC1 through PC31 | High | C FFI exports — JNI bridge target | | `src/Simplex/Chat/Mobile/File.hs` | PC10 | Medium | Mobile file read/write FFI | -| `src/Simplex/Chat/Mobile/Shared.hs` | PC1 through PC30 | Medium | Shared FFI helpers | +| `src/Simplex/Chat/Mobile/Shared.hs` | PC1 through PC31 | Medium | Shared FFI helpers | | `src/Simplex/Chat/Mobile/WebRTC.hs` | PC17 | Low | WebRTC FFI helpers | -| `src/Simplex/Chat/View.hs` | PC1 through PC30 | Low | Terminal view rendering (not used by mobile/desktop UI) | +| `src/Simplex/Chat/View.hs` | PC1 through PC31 | Low | Terminal view rendering (not used by mobile/desktop UI) | | `src/Simplex/Chat/Stats.hs` | PC25 | Low | Server statistics tracking | | `src/Simplex/Chat/Util.hs` | — | Low | General Haskell utilities | | `src/Simplex/Chat/Styled.hs` | — | Low | Terminal styled text (not used by mobile/desktop UI) | diff --git a/apps/multiplatform/spec/state.md b/apps/multiplatform/spec/state.md index 900d6593ab..09457c4dd3 100644 --- a/apps/multiplatform/spec/state.md +++ b/apps/multiplatform/spec/state.md @@ -300,6 +300,21 @@ data class ChatStats( | `ChatInfo.ContactConnection` | `"contactConnection"` | `contactConnection: PendingContactConnection` | | `ChatInfo.InvalidJSON` | `"invalidJSON"` | `json: String` | +### RelayStatus (Channels) + +`RelayStatus` is an `enum class` at [`ChatModel.kt line 2288`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L2288) modelling a relay's lifecycle for a channel on the owner's side. Serialized as a lowercase string via `@SerialName`. + +| Case | SerialName | Meaning | +|---|---|---| +| `RsNew` | `"new"` | Allocated locally; not yet sent | +| `RsInvited` | `"invited"` | `XGrpRelayInv` sent, awaiting `XGrpRelayAcpt` | +| `RsAccepted` | `"accepted"` | Accepted, link-data update pending | +| `RsActive` | `"active"` | Listed in channel link data; forwarding | +| `RsInactive` | `"inactive"` | No longer in link data or backend reports it removed | +| `RsRejected` | `"rejected"` | Relay sent `XGrpRelayReject` for the channel link; final on the owner side. Clearable only by the relay operator running `/group allow #`. The owner-side `GroupMember.memberStatus` is also set to `MemLeft` so the relay renders identically to one that explicitly left (`MemRejected` is reserved for the knocking-admission flow). | + +The `text` extension on the enum returns the localized status string (resource key `relay_status_*`, with `relay_status_rejected` = "rejected"). + --- diff --git a/bots/api/COMMANDS.md b/bots/api/COMMANDS.md index 2d804ccaa9..d14435cabd 100644 --- a/bots/api/COMMANDS.md +++ b/bots/api/COMMANDS.md @@ -33,6 +33,7 @@ This file is generated automatically. - [APINewPublicGroup](#apinewpublicgroup) - [APIGetGroupRelays](#apigetgrouprelays) - [APIAddGroupRelays](#apiaddgrouprelays) +- [APIAllowRelayGroup](#apiallowrelaygroup) - [APIUpdateGroupProfile](#apiupdategroupprofile) [Group link commands](#group-link-commands) @@ -1080,6 +1081,43 @@ ChatCmdError: Command error (only used in WebSockets API). --- +### APIAllowRelayGroup + +Clear relay rejection for a channel (relay operator). + +*Network usage*: background. + +**Parameters**: +- groupId: int64 + +**Syntax**: + +``` +/_relay allow # +``` + +```javascript +'/_relay allow #' + groupId // JavaScript +``` + +```python +'/_relay allow #' + str(groupId) # Python +``` + +**Responses**: + +RelayGroupAllowed: Relay rejection cleared for a channel. +- type: "relayGroupAllowed" +- user: [User](./TYPES.md#user) +- groupInfo: [GroupInfo](./TYPES.md#groupinfo) + +ChatCmdError: Command error (only used in WebSockets API). +- type: "chatCmdError" +- chatError: [ChatError](./TYPES.md#chaterror) + +--- + + ### APIUpdateGroupProfile Update group profile. diff --git a/bots/api/TYPES.md b/bots/api/TYPES.md index 4bd924dc31..b4edb9bd22 100644 --- a/bots/api/TYPES.md +++ b/bots/api/TYPES.md @@ -3350,6 +3350,7 @@ ParseError: - "accepted" - "active" - "inactive" +- "rejected" --- diff --git a/bots/src/API/Docs/Commands.hs b/bots/src/API/Docs/Commands.hs index b3eaf96837..8894609758 100644 --- a/bots/src/API/Docs/Commands.hs +++ b/bots/src/API/Docs/Commands.hs @@ -120,6 +120,7 @@ chatCommandsDocsData = ("APINewPublicGroup", [], "Create public group.", ["CRPublicGroupCreated", "CRPublicGroupCreationFailed", "CRChatCmdError"], [], Just UNInteractive, "/_public group " <> Param "userId" <> OnOffParam "incognito" "incognito" (Just False) <> " " <> Join ',' "relayIds" <> " " <> Json "groupProfile"), ("APIGetGroupRelays", [], "Get group relays.", ["CRGroupRelays", "CRChatCmdError"], [], Nothing, "/_get relays #" <> Param "groupId"), ("APIAddGroupRelays", [], "Add relays to group.", ["CRGroupRelaysAdded", "CRGroupRelaysAddFailed", "CRChatCmdError"], [], Just UNInteractive, "/_add relays #" <> Param "groupId" <> " " <> Join ',' "relayIds"), + ("APIAllowRelayGroup", [], "Clear relay rejection for a channel (relay operator).", ["CRRelayGroupAllowed", "CRChatCmdError"], [], Just UNBackground, "/_relay allow #" <> Param "groupId"), ("APIUpdateGroupProfile", [], "Update group profile.", ["CRGroupUpdated", "CRChatCmdError"], [], Just UNBackground, "/_group_profile #" <> Param "groupId" <> " " <> Json "groupProfile") ] ), @@ -203,6 +204,7 @@ cliCommands = "AcceptMember", "AddContact", "AddMember", + "AllowRelayGroup", "BlockForAll", "ChatHelp", "ClearContact", diff --git a/bots/src/API/Docs/Responses.hs b/bots/src/API/Docs/Responses.hs index 55f12f0a0a..ddd127241b 100644 --- a/bots/src/API/Docs/Responses.hs +++ b/bots/src/API/Docs/Responses.hs @@ -73,6 +73,7 @@ chatResponsesDocsData = ("CRGroupRelays", ""), ("CRGroupRelaysAdded", ""), ("CRGroupRelaysAddFailed", ""), + ("CRRelayGroupAllowed", "Relay rejection cleared for a channel"), ("CRGroupMembers", ""), ("CRGroupUpdated", ""), ("CRGroupsList", "Groups"), diff --git a/docs/protocol/channels-protocol.md b/docs/protocol/channels-protocol.md index b6b9b3ee5b..6a232ea2ff 100644 --- a/docs/protocol/channels-protocol.md +++ b/docs/protocol/channels-protocol.md @@ -72,6 +72,20 @@ When the owner adds a relay to an existing channel: The announce is an optimisation. When it does not reach a subscriber — because the channel had no subscribers at announce time, because an older client or relay sits in the path, or because of a transient network failure — the subscriber reaches the same end state on the next channel open via its relay sync against the channel's link data. +### Relay rejection + +When a relay operator removes the relay from a channel, the relay marks the channel as rejected and refuses future invitations from the same channel link: + +1. **Leave.** The relay operator runs `/leave #channel`. The relay marks the channel as rejected locally, keyed by the channel's short link. + +2. **Refuse.** When the owner later sends `x.grp.relay.inv` for the same channel link — typically from a re-invitation — the relay does not accept the invitation as a relay. Instead it replies with `x.grp.relay.reject` over the owner-relay direct contact channel, carrying a rejection reason. The current reason is `rejoin_rejected`; older relays or future reasons fall through to an unknown reason for forward compatibility. + +3. **Owner handling.** The owner marks the corresponding relay as rejected and notifies the operator UI. The owner also sets the relay member's status to `GSMemLeft` so the UI treats the rejected relay identically to one that ran `/leave`. The owner's next user-initiated relay addition for the same channel creates a fresh invitation, which the relay rejects again unless the rejection has been cleared. + +4. **Clear.** The relay operator runs `/group allow ` to clear the rejection for the channel. After the next user-initiated relay addition, the relay accepts the invitation and rejoins as a relay. + +An older owner client that does not recognise `x.grp.relay.reject` ignores the message and leaves the relay invitation in an invited state indefinitely — the same end state as a relay that does not respond. An older relay binary does not enforce rejection; in mixed-version deployments the operator can re-run `/leave` under the new binary to re-establish rejection. + ### Subscriber connection A subscriber joins a channel through the following flow: diff --git a/packages/simplex-chat-client/types/typescript/src/commands.ts b/packages/simplex-chat-client/types/typescript/src/commands.ts index f8aa6e445d..d1b89ffe27 100644 --- a/packages/simplex-chat-client/types/typescript/src/commands.ts +++ b/packages/simplex-chat-client/types/typescript/src/commands.ts @@ -387,6 +387,20 @@ export namespace APIAddGroupRelays { } } +// Clear relay rejection for a channel (relay operator). +// Network usage: background. +export interface APIAllowRelayGroup { + groupId: number // int64 +} + +export namespace APIAllowRelayGroup { + export type Response = CR.RelayGroupAllowed | CR.ChatCmdError + + export function cmdString(self: APIAllowRelayGroup): string { + return '/_relay allow #' + self.groupId + } +} + // Update group profile. // Network usage: background. export interface APIUpdateGroupProfile { diff --git a/packages/simplex-chat-client/types/typescript/src/responses.ts b/packages/simplex-chat-client/types/typescript/src/responses.ts index e4284bf87e..0fcf0e6eca 100644 --- a/packages/simplex-chat-client/types/typescript/src/responses.ts +++ b/packages/simplex-chat-client/types/typescript/src/responses.ts @@ -32,6 +32,7 @@ export type ChatResponse = | CR.GroupRelays | CR.GroupRelaysAdded | CR.GroupRelaysAddFailed + | CR.RelayGroupAllowed | CR.GroupMembers | CR.GroupUpdated | CR.GroupsList @@ -89,6 +90,7 @@ export namespace CR { | "groupRelays" | "groupRelaysAdded" | "groupRelaysAddFailed" + | "relayGroupAllowed" | "groupMembers" | "groupUpdated" | "groupsList" @@ -293,6 +295,12 @@ export namespace CR { addRelayResults: T.AddRelayResult[] } + export interface RelayGroupAllowed extends Interface { + type: "relayGroupAllowed" + user: T.User + groupInfo: T.GroupInfo + } + export interface GroupMembers extends Interface { type: "groupMembers" user: T.User diff --git a/packages/simplex-chat-client/types/typescript/src/types.ts b/packages/simplex-chat-client/types/typescript/src/types.ts index 64a8b49502..7e618e05c8 100644 --- a/packages/simplex-chat-client/types/typescript/src/types.ts +++ b/packages/simplex-chat-client/types/typescript/src/types.ts @@ -3751,6 +3751,7 @@ export enum RelayStatus { Accepted = "accepted", Active = "active", Inactive = "inactive", + Rejected = "rejected", } export enum ReportReason { diff --git a/packages/simplex-chat-python/src/simplex_chat/types/_commands.py b/packages/simplex-chat-python/src/simplex_chat/types/_commands.py index 9806388835..3847f44811 100644 --- a/packages/simplex-chat-python/src/simplex_chat/types/_commands.py +++ b/packages/simplex-chat-python/src/simplex_chat/types/_commands.py @@ -340,6 +340,18 @@ def APIAddGroupRelays_cmd_string(self: APIAddGroupRelays) -> str: APIAddGroupRelays_Response = CR.GroupRelaysAdded | CR.GroupRelaysAddFailed | CR.ChatCmdError +# Clear relay rejection for a channel (relay operator). +# Network usage: background. +class APIAllowRelayGroup(TypedDict): + groupId: int # int64 + + +def APIAllowRelayGroup_cmd_string(self: APIAllowRelayGroup) -> str: + return '/_relay allow #' + str(self['groupId']) + +APIAllowRelayGroup_Response = CR.RelayGroupAllowed | CR.ChatCmdError + + # Update group profile. # Network usage: background. class APIUpdateGroupProfile(TypedDict): diff --git a/packages/simplex-chat-python/src/simplex_chat/types/_responses.py b/packages/simplex-chat-python/src/simplex_chat/types/_responses.py index 84d0f1c79f..e85de02c78 100644 --- a/packages/simplex-chat-python/src/simplex_chat/types/_responses.py +++ b/packages/simplex-chat-python/src/simplex_chat/types/_responses.py @@ -149,6 +149,11 @@ class GroupRelaysAddFailed(TypedDict): user: "T.User" addRelayResults: list["T.AddRelayResult"] +class RelayGroupAllowed(TypedDict): + type: Literal["relayGroupAllowed"] + user: "T.User" + groupInfo: "T.GroupInfo" + class GroupMembers(TypedDict): type: Literal["groupMembers"] user: "T.User" @@ -329,6 +334,7 @@ ChatResponse = ( | GroupRelays | GroupRelaysAdded | GroupRelaysAddFailed + | RelayGroupAllowed | GroupMembers | GroupUpdated | GroupsList @@ -357,4 +363,4 @@ ChatResponse = ( | ApiChats ) -ChatResponse_Tag = Literal["acceptingContactRequest", "activeUser", "chatItemNotChanged", "chatItemReaction", "chatItemUpdated", "chatItemsDeleted", "chatRunning", "chatStarted", "chatStopped", "cmdOk", "chatCmdError", "connectionPlan", "contactAlreadyExists", "contactConnectionDeleted", "contactDeleted", "contactPrefsUpdated", "contactRequestRejected", "contactsList", "groupDeletedUser", "groupLink", "groupLinkCreated", "groupLinkDeleted", "groupCreated", "publicGroupCreated", "publicGroupCreationFailed", "groupRelays", "groupRelaysAdded", "groupRelaysAddFailed", "groupMembers", "groupUpdated", "groupsList", "invitation", "leftMemberUser", "memberAccepted", "membersBlockedForAllUser", "membersRoleUser", "newChatItems", "rcvFileAccepted", "rcvFileAcceptedSndCancelled", "rcvFileCancelled", "sentConfirmation", "sentGroupInvitation", "sentInvitation", "sndFileCancelled", "userAcceptedGroupSent", "userContactLink", "userContactLinkCreated", "userContactLinkDeleted", "userContactLinkUpdated", "userDeletedMembers", "userProfileUpdated", "userProfileNoChange", "usersList", "apiChats"] +ChatResponse_Tag = Literal["acceptingContactRequest", "activeUser", "chatItemNotChanged", "chatItemReaction", "chatItemUpdated", "chatItemsDeleted", "chatRunning", "chatStarted", "chatStopped", "cmdOk", "chatCmdError", "connectionPlan", "contactAlreadyExists", "contactConnectionDeleted", "contactDeleted", "contactPrefsUpdated", "contactRequestRejected", "contactsList", "groupDeletedUser", "groupLink", "groupLinkCreated", "groupLinkDeleted", "groupCreated", "publicGroupCreated", "publicGroupCreationFailed", "groupRelays", "groupRelaysAdded", "groupRelaysAddFailed", "relayGroupAllowed", "groupMembers", "groupUpdated", "groupsList", "invitation", "leftMemberUser", "memberAccepted", "membersBlockedForAllUser", "membersRoleUser", "newChatItems", "rcvFileAccepted", "rcvFileAcceptedSndCancelled", "rcvFileCancelled", "sentConfirmation", "sentGroupInvitation", "sentInvitation", "sndFileCancelled", "userAcceptedGroupSent", "userContactLink", "userContactLinkCreated", "userContactLinkDeleted", "userContactLinkUpdated", "userDeletedMembers", "userProfileUpdated", "userProfileNoChange", "usersList", "apiChats"] 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 8fd700a8a2..b2fc00a44c 100644 --- a/packages/simplex-chat-python/src/simplex_chat/types/_types.py +++ b/packages/simplex-chat-python/src/simplex_chat/types/_types.py @@ -2627,7 +2627,7 @@ class RelayProfile(TypedDict): shortDescr: NotRequired[str] image: NotRequired[str] -RelayStatus = Literal["new", "invited", "accepted", "active", "inactive"] +RelayStatus = Literal["new", "invited", "accepted", "active", "inactive", "rejected"] ReportReason = Literal["spam", "content", "community", "profile", "other"] diff --git a/plans/2026-05-13-relay-refuse-rejoin.md b/plans/2026-05-13-relay-refuse-rejoin.md new file mode 100644 index 0000000000..e33a525c03 --- /dev/null +++ b/plans/2026-05-13-relay-refuse-rejoin.md @@ -0,0 +1,347 @@ +Plan rewritten for conciseness with fresh-context re-evaluation; supersedes earlier revisions. + +# Plan: relay refuses to rejoin a channel it left + +## 1. Identifier + +Gating key: `GroupRelayInvitation.groupLink :: ShortLinkContact` (Types.hs:884-889). Available at `xGrpRelayInv` (Subscriber.hs:1524-1528) before any DB write or network call. The relay already stores this value on every `groups` row it processes (column `relay_request_group_link`, M20260222:38), and the existing `relay_own_status` column already carries the relay's lifecycle for the channel — refusal slots into that state machine as a new `RSRejected` variant. Lookup is a single SELECT against `groups`. Link rotation by the owner bypasses refusal; `publicGroupId` (Types.hs:790) would resist that but is only known after `getShortLinkConnReq'` — defer that gating to a follow-up. + +## 2. Storage + +No new column, no new type, no new field on `GroupInfo`. The existing `relay_own_status TEXT` (M20260222:37) is the carrier. + +`RelayStatus` (`src/Simplex/Chat/Types/Shared.hs:81-114`) gains an `RSRejected` constructor (encoded as `"rejected"`). It is reused on both sides: on the relay it is the row's own state after `APILeaveGroup`; on the owner it is the `GroupRelay.relayStatus` after `XGrpRelayReject` arrives in §5. + +State-machine slot for `RSRejected` on the relay: + +- `updateRelayOwnStatus_` (Store/Groups.hs:1593-1597) writes `relay_inactive_at = Just currentTs` only when the new status is `RSInactive`. `RSRejected` therefore correctly leaves `relay_inactive_at = NULL`, so the row is NOT eligible for `checkRelayInactiveGroups` cleanup (Commands.hs:4812-4817). +- `checkRelayServedGroups` (Commands.hs:4795-4810) iterates only `getRelayServedGroups` rows — `relay_own_status IN (RSAccepted, RSActive)` (Store/Groups.hs:1607). RSRejected rows are not iterated. +- `xGrpMemDel` writer at Subscriber.hs:3132 currently flips any non-NULL `relay_own_status` to `RSInactive` when the owner removes the relay member. That would silently regress `RSRejected → RSInactive` and let a subsequent `XGrpRelayInv` slip through (the lookup checks `'rejected'`). The write at line 3132 is tightened to skip when the row is already `RSRejected`: + +```haskell +when (maybe False (/= RSRejected) (relayOwnStatus gInfo)) $ + updateRelayOwnStatus_ db gInfo RSInactive +``` + +New migration `M20260514_relay_request_group_link_index` adds a partial index — the column is unindexed today and the new gate SELECTs on it. SQLite: + +```sql +CREATE INDEX idx_groups_relay_request_group_link + ON groups(user_id, relay_request_group_link) + WHERE relay_request_group_link IS NOT NULL; +``` + +Postgres mirror. Partial-on-`IS NOT NULL` because most rows on owner-only or p2p installs leave the column NULL. Both engines support partial indexes. Down: `DROP INDEX idx_groups_relay_request_group_link`. + +One helper, added next to the existing `relay_*` helpers in `src/Simplex/Chat/Store/Groups.hs`: + +```haskell +isRelayGroupRefused :: DB.Connection -> User -> ShortLinkContact -> IO Bool +isRelayGroupRefused db User {userId} groupLink = + fromOnly . head <$> DB.query db + [sql| + SELECT EXISTS ( + SELECT 1 FROM groups + WHERE user_id = ? + AND relay_request_group_link = ? + AND relay_own_status = ? + LIMIT 1 + ) + |] + (userId, groupLink, RSRejected) +``` + +`EXISTS … LIMIT 1` because more than one `groups` row may share `relay_request_group_link` (`createRelayRequestGroup` at Store/Groups.hs:1526 INSERTs unconditionally). If any matching row has `relay_own_status = 'rejected'`, the channel is refused. The equality check naturally excludes other states (NULL, RSInvited, RSAccepted, RSActive, RSInactive). + +All other operator-allow and leave writes reuse existing helpers `updateRelayOwnStatus_` and `updateRelayOwnStatusFromTo` (Store/Groups.hs:1587-1597). No new write helpers. + +## 3. Rejection point — `xGrpRelayInv` (Subscriber.hs:1524) + +```haskell +xGrpRelayInv :: InvitationId -> VersionRangeChat -> GroupRelayInvitation -> CM () +xGrpRelayInv invId chatVRange groupRelayInv@GroupRelayInvitation {groupLink} = do + refused <- withStore' $ \db -> isRelayGroupRefused db user groupLink + if refused + then sendRelayRejection `catchAllErrors` eToView + else do + initialDelay <- asks $ initialInterval . relayRequestRetryInterval . config + (_gInfo, _ownerMember) <- withStore $ \db -> + createRelayRequestGroup db vr user groupRelayInv invId chatVRange initialDelay + lift $ void $ getRelayRequestWorker True + where + sendRelayRejection = do + let pqSup = PQSupportOff + subMode <- chatReadVar subscriptionMode + chatVR <- chatVersionRange + let chatV = chatVR `peerConnChatVersion` chatVRange + connId <- withAgent $ \a -> prepareConnectionToAccept a (aUserId user) False invId pqSup + dm <- encodeConnInfoPQ pqSup chatV XGrpRelayReject + void $ withAgent $ \a -> + acceptContact a NRMBackground (aUserId user) connId False invId dm pqSup subMode + deleteAgentConnectionAsync' connId False +``` + +**Why synchronous `acceptContact` (not `acceptContactAsync`).** `acceptContactAsync` enqueues a JOIN agent command; the CONF send and the snd-queue creation happen later inside the agent's command worker (Agent.hs:1826-1830). If we immediately call `deleteAgentConnectionAsync' acId True`, `setConnDeleted` runs, `prepareDeleteConnections_` finds zero rcv queues (no JOIN yet), `deleteConn db (Just timeout) connId` finds zero `snd_message_deliveries` and calls `deleteConnRecord`. The connection record is gone before the JOIN worker can send the CONF — the rejection signal is silently dropped. + +`acceptContact` (Internal.hs:881-912 precedent; Agent.hs:1437-1442 → `joinConn` 1263 → `joinConnSrv` 1358-1369 for CRContactUri → `sendInvitation` Agent/Client.hs:1796-1799 → `sendOrProxySMPMessage` 1084-1094 → `sendSMPMessage`/`proxySMPMessage`) hands the CONF to the SMP server via a direct SMP client call. The CONF does NOT go through `snd_message_deliveries` — it is transmitted inline. Subsequent `deleteAgentConnectionAsync' connId False` is therefore safe. The cost is one SMP round-trip blocking the receive loop, which the refusal path can absorb. + +No chat-layer `Connection` row is persisted for the refused contact — the agent owns the connection state, and `deleteAgentConnectionAsync'` cleans it up. + +If `sendInvitation` throws (SMP server unreachable), `acceptContact` throws before reaching its internal `acceptInvitation` step and the agent-allocated rcv queue from `newRcvConnSrv` is left for the agent's eventual cleanup. The owner receives no rejection and falls back to the silent-degradation path (GroupRelay stuck at `RSInvited`). The outer `catchAllErrors eToView` prevents the receive loop from being held by the bubbled-up exception. + +## 4. Wire format — `XGrpRelayReject` + +Empty-payload event, owner-relay direct contact channel only. Not group-signed. Naming matches the existing `XGrpLinkReject` precedent (Protocol.hs:440, tag:985, string:1043). + +`src/Simplex/Chat/Protocol.hs`: + +- GADT constructor (after `XGrpRelayNew`, line 446): `XGrpRelayReject :: ChatMsgEvent 'Json` +- Tag GADT (after `XGrpRelayNew_`, line 991): `XGrpRelayReject_ :: CMEventTag 'Json` +- `strEncode` (line 1049): `XGrpRelayReject_ -> "x.grp.relay.reject"` +- `strDecode` (line 1108): `"x.grp.relay.reject" -> XGrpRelayReject_` +- `toCMEventTag` (line 1163): `XGrpRelayReject -> XGrpRelayReject_` +- JSON parse (line 1321): `XGrpRelayReject_ -> pure XGrpRelayReject` +- JSON encode (line 1391): `XGrpRelayReject -> JM.empty` — matches `XGrpLeave -> JM.empty` (1402) and `XDirectDel -> JM.empty` (1379). +- **No** entry in `isForwardedGroupMsg` (485-505) or `requiresSignature` (1227-1238). + +Older owner clients parse the unknown tag as `XUnknown` (default branch at 1134) and hit the CONF handler's catch-all `_ -> messageError "CONF from invited member must have x.grp.acpt"`. No state change, no crash; the GroupRelay stays at `RSInvited` — the same end state as today's "relay never responds" mode. The owner UI shows the relay as permanently "invited" with no progress; documented degradation. + +`docs/protocol/channels-protocol.md`: insert a `### Relay refusal` subsection between `### Relay addition` (61-73) and `### Subscriber connection` (75). Paragraphs: + +1. **Trigger** — relay's `APILeaveGroup` sets `relay_own_status = 'rejected'` on the relay's local `groups` row for the channel. +2. **Signal** — empty-payload `x.grp.relay.reject` over the owner-relay direct contact channel. +3. **Owner handling** — `GroupRelay` transitions `RSInvited → RSRejected`; final. Cleared only by the relay operator running `/group allow `. +4. **Limitations** — (a) older owner clients log a CONF parse error and leave their `GroupRelay` at `RSInvited` indefinitely (same UX as a relay that doesn't respond); (b) older relay binaries do not enforce refusal — mixed-version deployments where some relays are old behave asymmetrically. + +## 5. Owner-side state + +`RelayStatus` gains `RSRejected` (§2). Add to `relayStatusText`, `textEncode`, `textDecode`. + +CONF handler arm in `src/Simplex/Chat/Library/Subscriber.hs:760-773` (immediately after the existing `XGrpRelayAcpt` clause): + +```haskell +XGrpRelayReject + | memberRole' membership == GROwner && isRelay m -> do + relay <- withStore $ \db -> do + liftIO $ updateGroupMemberStatus db userId m GSMemRejected + relay <- getGroupRelayByGMId db (groupMemberId' m) + liftIO $ updateRelayStatusFromTo db relay RSInvited RSRejected + let m' = m {memberStatus = GSMemRejected} + deleteMemberConnection m' + toView $ CEvtGroupRelayUpdated user gInfo m' relay + | otherwise -> messageError "x.grp.relay.reject: only owner can receive relay rejection" +``` + +`getGroupRelayByGMId` (Store/Groups.hs:1307) and `updateRelayStatusFromTo` (1438-1442) are already exported. `updateRelayStatusFromTo` is conditional on the current status equalling `RSInvited` — racing CONFs cannot regress an already-rejected or already-active row. `deleteMemberConnection` (Internal.hs:1807-1808) safely no-ops when `activeConn` is `Nothing`. `CEvtGroupRelayUpdated` (Controller.hs:900) carries exactly the iOS payload. + +`addRelays` (Commands.hs:3942-3976) persists `GroupRelay` with `RSNew → RSInvited` before sending `XGrpRelayInv`, so the row exists when the CONF arrives. A second user-initiated `addRelays` after rejection creates a fresh row, independent of the rejected one — no automatic retry. + +## 6. Refusal write — `APILeaveGroup` (Commands.hs:2919-2935) + +Currently `leaveChannelRelay` does NOT touch `relay_own_status` — verified at Commands.hs:2938-2947. The new write is added to the existing leave path, unconditionally on the relay-leave branch: + +```haskell +APILeaveGroup groupId -> withUser $ \user@User {userId} -> do + gInfo@GroupInfo {membership} <- withFastStore $ \db -> getGroupInfo db vr user groupId + filesInfo <- withFastStore' $ \db -> getGroupFileInfo db user gInfo + withGroupLock "leaveGroup" groupId $ do + cancelFilesInProgress user filesInfo + msg <- if useRelays' gInfo && isRelay membership + then leaveChannelRelay gInfo + else leaveGroupSendMsg user gInfo + (gInfo', scopeInfo) <- mkLocalGroupChatScope gInfo + ci <- saveSndChatItem user (CDGroupSnd gInfo' scopeInfo) msg (CISndGroupEvent SGEUserLeft) + toView $ CEvtNewChatItems user [AChatItem SCTGroup SMDSnd (GroupChat gInfo' scopeInfo) ci] + deleteGroupLinkIfExists user gInfo' + withFastStore' $ \db -> updateGroupMemberStatus db userId membership GSMemLeft + -- NEW: mark the relay's local groups row as refused + when (useRelays' gInfo && isRelay membership) $ + withFastStore' $ \db -> updateRelayOwnStatus_ db gInfo RSRejected + pure $ CRLeftMemberUser user gInfo' {membership = membership {memberStatus = GSMemLeft}} +``` + +`updateRelayOwnStatus_` (Store/Groups.hs:1593) writes unconditionally. The prior status can legitimately be any of `RSInvited` (operator leaves mid-request, placeholder profile still in place — verified at Store/Groups.hs:1531-1541), `RSAccepted` (waiting for health-check), `RSActive` (steady state), or `RSInactive` (already inactive — re-leaving). The earlier rev's `publicGroup == Nothing` throw was wrong: `RSInvited` is a real lifecycle state with `publicGroup = Nothing` (`createRelayRequestGroup` at Store/Groups.hs:1531 uses a placeholder profile until `updateGroupProfile` at Subscriber.hs:3847 runs inside the relay-request worker). Writing `RSRejected` unconditionally on the relay-leave path correctly cancels an in-progress invitation: `getNextPendingRelayRequest` (Store/RelayRequests.hs:60-72) selects only rows where `relay_own_status = 'invited'`, so the flip to `RSRejected` removes the row from the worker queue. + +## 7. Operator command — relay side + +One API command. Operator discovers rejected channels through `/gs` (see §7.2). + +`src/Simplex/Chat/Controller.hs` (after `APITestChatRelay` at ~408): + +```haskell +| APIAllowRelayGroup {groupId :: GroupId} +-- response (after CRGroupRelays at ~737): +| CRRelayGroupAllowed {user :: User, groupInfo :: GroupInfo} +``` + +Parser entries (`src/Simplex/Chat/Library/Commands.hs:5033+`). `GroupId = Int64` is a type alias (Types.hs:449), so `A.decimal` decodes directly — matches `APILeaveGroup <$> A.decimal` at 5021: + +```haskell +"/_relay allow " *> (APIAllowRelayGroup <$> A.decimal), +"/group allow " *> (APIAllowRelayGroup <$> A.decimal), +``` + +Handler: + +```haskell +APIAllowRelayGroup groupId -> withUser $ \user -> do + gInfo <- withFastStore $ \db -> getGroupInfo db vr user groupId + gInfo' <- withStore' $ \db -> updateRelayOwnStatusFromTo db gInfo RSRejected RSInactive + pure $ CRRelayGroupAllowed user gInfo' +``` + +`updateRelayOwnStatusFromTo` (Store/Groups.hs:1587-1591) atomically transitions only if the current status equals the from-state — a non-rejected row stays unchanged and the response reports the unchanged `gInfo`. The transition to `RSInactive` writes `relay_inactive_at = currentTs` via `updateRelayOwnStatus_` (1593-1597), so the row becomes eligible for `checkRelayInactiveGroups` connection cleanup on TTL — the correct hygiene state for a previously-rejected, now-cleared row. + +No event to the owner. The owner's next user-initiated `addRelays` succeeds normally (the relay's `xGrpRelayInv` finds no `'rejected'` row for the link). Operator authorization is the chat-relay binary's process-level access. + +### 7.1 Guard against deleting a rejected group + +`APIDeleteChat CTGroup` at Commands.hs:1242-1246 lets the operator delete the group once `memberCurrent membership` is false (post-leave). That path would silently clear the refusal — an accidental `/d` should not undo a moderation decision. Add a guard immediately after the existing `unless canDelete` check: + +```haskell +when (relayOwnStatus gInfo == Just RSRejected) $ + throwChatError $ CECommandError "cannot delete a rejected channel; run /_relay allow first" +``` + +`checkRelayInactiveGroups` (Commands.hs:4812-4817) only deletes connections via `deleteGroupConnections`, not group rows, so no guard is needed there. + +### 7.2 Surface `[rejected]` in `/gs` + +`viewGroupsList` in `src/Simplex/Chat/View.hs:1432-1459`. Extend `groupSS`'s destructure to pull `relayOwnStatus` while keeping the existing `GroupSummary {currentMembers}` pattern (used at line 1456 by `memberCount`), and append `[rejected]` between status and alias: + +```haskell +groupSS g@GroupInfo { membership + , chatSettings = ChatSettings {enableNtfs} + , groupSummary = GroupSummary {currentMembers} + , relayOwnStatus + } = + case memberStatus membership of + GSMemInvited -> groupInvitation' g + s -> membershipIncognito g <> ttyFullGroup g <> viewMemberStatus s <> rejectionSuffix <> alias g + where + rejectionSuffix = case relayOwnStatus of + Just RSRejected -> " [rejected]" + _ -> "" + … +``` + +## 8. iOS + +No iOS storage-side change. The owner-side `RSRejected` rendering is the same as the rev-4 plan. + +`apps/ios/SimpleXChat/ChatTypes.swift:2637-2643 + 2708-2718`: + +```swift +public enum RelayStatus: String, Decodable, Equatable, Hashable { + … + case rsRejected = "rejected" +} +extension RelayStatus { public var text: LocalizedStringKey { + switch self { … case .rsRejected: "rejected" } +}} +``` + +`apps/ios/Shared/Views/NewChat/AddChannelView.swift:487-504` (`relayStatusIndicator`): + +```swift +let isRejected = status == .rsRejected +let color: Color = + connFailed || removed || isRejected ? .red + : (status == .rsActive ? .green : .yellow) +let text: LocalizedStringKey = + connFailed ? "failed" + : memberStatus == .memLeft ? "removed by operator" + : isRejected ? "rejected" + : removed ? "removed" + : status.text +``` + +`apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift`, inside the existing `Section` after the `Relay address` block at line 195: + +```swift +if groupRelay?.relayStatus == .rsRejected { + infoRow("Status", "rejected by relay operator") +} +``` + +`ChannelRelaysView.swift` requires no change — the existing fall-through in `ownerRelayStatusText` (line 114-127) to `groupRelays.first(…)?.relayStatus.text` already renders `"rejected"`. + +`GroupMemberStatus.memRejected` already exists at ChatTypes.swift:3002. No iOS enum change; cited here so an iOS-only reviewer doesn't drop the case. + +Per `apps/ios/CODE.md` Change Protocol, the implementer updates `apps/ios/spec/state.md`, `apps/ios/spec/api.md`, `apps/ios/spec/client/chat-view.md`, `apps/ios/product/views/group-info.md`, `apps/ios/spec/impact.md`, and `apps/ios/product/concepts.md`. + +Kotlin/Android/desktop port is a separate PR. + +## 9. Tests — `tests/ChatTests/RelayRefused.hs` + +All tests use the existing channel harness and block on chat events, not `threadDelay`. + +- **`testRelayRefuseAfterLeave`** — relay1 leaves; owner re-adds; owner blocks on `CEvtGroupRelayUpdated`; assert owner `relayStatus == RSRejected`, member `GSMemRejected`, channel link data excludes relay1. Also assert relay's `groups.relay_own_status = 'rejected'`. Deterministic delivery check for the sync-accept-then-delete path. +- **`testRelayAllowAcceptsAgain`** — operator runs `/group allow `; relay's `groups.relay_own_status` becomes `'inactive'`; owner re-adds; relay reaches `RSActive` on a fresh `GroupRelay` row. +- **`testRelayDoesNotRefuseUnrelatedChannel`** — relay1 leaves channel A; owner of unrelated channel B issues `XGrpRelayInv`; relay1 accepts B; only A's `groups` row has `relay_own_status = 'rejected'`. +- **`testRelayRefuseRaceConcurrentInvitations`** — owner sends two `XGrpRelayInv` for the same channel concurrently after the relay has left; both refuse; relay's `groups` table acquires no placeholder row for the second invitation (both lookups match the same rejected row). +- **`testRelayForwardCompatOldOwner`** — owner's `chatVersionRange` excludes `x.grp.relay.reject`; relay refuses; owner emits `messageError` and the GroupRelay row stays at `RSInvited`; no crash. +- **`testRelayDeleteRejectedBlocked`** — relay1 leaves channel A; operator runs `/d #A`; deletion fails with the guard error from §7.1; channel still exists; operator runs `/group allow ` then `/d #A`; deletion succeeds. +- **`testRelayRejectSurvivesOwnerRemoveRelayMember`** — relay1 leaves channel A (sets `RSRejected`); owner sends `XGrpMemDel` removing relay1; assert relay's `groups.relay_own_status` is still `'rejected'`, not flipped to `'inactive'`. Covers the §2 tightening of `xGrpMemDel` at Subscriber.hs:3132. +- **`testNonOwnerXGrpRelayRejectIgnored`** — owner-side negative case: deliver an `XGrpRelayReject` CONF on a connection where either `memberRole' membership /= GROwner` or the sender member is not `isRelay`; assert the owner emits `messageError` and neither the GroupRelay row nor the member status changes. + +## 10. Adversarial review + +- **Existing `RSInactive` consumers.** Three call sites filter on `Just RSInactive` to mean "relay not serving — drop normal delivery": + - Subscriber.hs:936 (`MSG` handler filters delivery tasks). + - Subscriber.hs:3571 (delivery-task worker rejects `DJDeliveryJob`). + - Subscriber.hs:3641 (delivery-job worker errors `DJDeliveryJob`). + All three must broaden to also match `Just RSRejected` — both states share the "not serving" semantic. `DJRelayRemoved` is handled in a separate branch and remains status-independent. Add a small predicate (e.g., `relayNotServing :: Maybe RelayStatus -> Bool`) near the existing `relayOwnStatus` accessors. +- **`xGrpMemDel` writer at Subscriber.hs:3132** — this is also a writer of `relay_own_status`, not a filter. It flips any non-NULL status to `RSInactive` when the owner removes the relay member. Tightened in §2 to skip when the row is already `RSRejected`; otherwise the refusal would be silently undone by a normal protocol event. +- **Health-check loop never touches RSRejected.** `getRelayServedGroups` filters `relay_own_status IN (RSAccepted, RSActive)` (Store/Groups.hs:1607); RSRejected rows are not iterated. `updateRelayOwnStatusFromTo` calls in Commands.hs:4808-4809 only transition RSAccepted↔RSActive↔RSInactive. With the §2 tightening of `xGrpMemDel` line 3132, no path can silently undo a refusal. +- **Operator deletes a rejected group** — blocked at `APIDeleteChat CTGroup` per §7.1. +- **Timing side channel** — refusal path is one synchronous SMP round-trip; accepted path is much longer. Passive SMP-server observation can distinguish, though relay load adds variance to both paths. SMP server already infers relay-channel relationships from connection patterns; marginal additional leak. +- **Information leakage in `XGrpRelayReject`** — empty payload. +- **Concurrent leave-then-rejoin** — operator-facing contract: invitations arriving before the leave commits locally are processed normally; invitations after are refused. Note that `xGrpRelayInv` does NOT take `withGroupLock "leaveGroup" groupId` (no group ID is known at REQ time); the bound is the SQL commit of the `relay_own_status = 'rejected'` write, not a lock. Sibling rows already at `RSInvited` from before the leave are not retroactively rejected — they are processed normally by the worker. See §12 for follow-up scope. +- **Two concurrent `XGrpRelayInv` for the same rejected channel** — both lookups hit the same indexed row, both refuse. No race. +- **Duplicate `groups` rows for the same `relay_request_group_link`** — pre-existing (`createRelayRequestGroup` INSERTs unconditionally; no uniqueness on `relay_request_inv_id` or `relay_request_group_link`). Any `RSRejected` row blocks *future invitations from creating new rows that progress to acceptance* (the lookup uses `EXISTS … LIMIT 1`). Sibling rows already in `RSInvited` continue to be processed by the worker — see §12. +- **Operator-allow vs. concurrent invitation** — UPDATE-SELECT race resolves to either "still refused" or "slipped through with accept"; both match operator intent. +- **`getGroupRelayByGMId` failure on owner side** — propagates as `ChatErrorStore`; cannot happen in normal operation. +- **Multi-user relay binary** — `groups.user_id` scopes both lookup and write. `withUser` for the CLI. No cross-user pollution. +- **`sendRelayRejection` SMP failure** — wrapped in `catchAllErrors eToView` per §3 so a single SMP failure during refusal does not propagate to the agent receive loop. The owner falls back to silent-degradation (GroupRelay stuck at RSInvited), matching today's "relay unresponsive" mode. +- **Forward compat — mixed-version relays.** An old relay binary leaves a channel by writing `RSInactive`, not `RSRejected`, and does not enforce refusal at `xGrpRelayInv`. Mixed-version deployments (some relays new, some old) have asymmetric behavior: new relays refuse, old relays accept. Acceptable v1 limitation; document in `docs/protocol/channels-protocol.md`. Operator on an upgraded relay can `/leave` again under the new binary to re-establish refusal. +- **Forward compat (old owner)** — old owner's CONF handler lands in the `_ -> messageError "CONF from invited member must have x.grp.acpt"` catch-all (Subscriber.hs:773). GroupRelay stays at `RSInvited`; same end state as today's "relay never responds" mode. Documented in the protocol doc. + +## 11. Files changed + +| File | Change | +|---|---| +| `src/Simplex/Chat/Types/Shared.hs` | `RSRejected` variant + text encodings | +| `src/Simplex/Chat/Protocol.hs` | `XGrpRelayReject` constructor, tag, str enc/dec, JSON enc/dec | +| `src/Simplex/Chat/Store/Groups.hs` | `isRelayGroupRefused` helper | +| `src/Simplex/Chat/Store/SQLite/Migrations/M20260514_relay_request_group_link_index.hs` | NEW. Partial index | +| `src/Simplex/Chat/Store/SQLite/Migrations.hs` | Register migration | +| `src/Simplex/Chat/Store/Postgres/Migrations/M20260514_relay_request_group_link_index.hs` | NEW | +| `src/Simplex/Chat/Store/Postgres/Migrations.hs` | Register migration | +| `src/Simplex/Chat/Controller.hs` | `APIAllowRelayGroup` command; `CRRelayGroupAllowed` response | +| `src/Simplex/Chat/Library/Commands.hs` | Parser; handler; refusal write in `APILeaveGroup`; delete guard in `APIDeleteChat CTGroup` | +| `src/Simplex/Chat/Library/Subscriber.hs` | Gate in `xGrpRelayInv`; `XGrpRelayReject` arm in CONF handler; broaden three RSInactive filters to also match RSRejected (lines 936, 3571, 3641); tighten `xGrpMemDel` writer at 3132 to skip when row is `RSRejected` | +| `src/Simplex/Chat/View.hs` | `[rejected]` suffix in `viewGroupsList` | +| `simplex-chat.cabal` | Register new migration modules | +| `docs/protocol/channels-protocol.md` | Insert "Relay refusal" subsection | +| `apps/ios/SimpleXChat/ChatTypes.swift` | `rsRejected` case + text | +| `apps/ios/Shared/Views/NewChat/AddChannelView.swift` | Red dot + "rejected" in `relayStatusIndicator` | +| `apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift` | "Status: rejected by relay operator" row | +| `tests/ChatTests/RelayRefused.hs` | NEW. Eight tests | +| Test list registration | Add the new module | + +`chat_schema.sql` is auto-regenerated by tests. + +## 12. Out of scope + +- Kotlin/Android/desktop UI port. +- New alerts, modals, banners, compose-bar changes. +- Refusal triggered by `xGrpMemDel` (owner removing relay). +- Pre-emptive blocking of unseen channels. +- Owner-side independent clear of `RSRejected`. +- `publicGroupId`-keyed refusal. +- Timing-uniform refusal. +- **Sibling-row worker race.** When a relay leaves a channel for which it has a sibling `groups` row in `RSInvited` (e.g., the owner re-sent `XGrpRelayInv` and `createRelayRequestGroup` created a second row), only the row whose ID `APILeaveGroup` targets is flipped to `RSRejected`; sibling `RSInvited` rows continue through the worker. Pre-existing behavior — `leaveChannelRelay` doesn't touch sibling rows today either. Cheapest future closure: in §6, also `UPDATE groups SET relay_request_failed = 1 WHERE user_id = ? AND relay_request_group_link = ? AND relay_own_status = 'invited'` in the same transaction (the worker filters on `relay_request_failed = 0` at Store/RelayRequests.hs:67). Deferred to a follow-up. +- **`XGrpRelayInv` re-delivery duplicates.** `createRelayRequestGroup` has no uniqueness on `relay_request_inv_id` or `relay_request_group_link`; an owner retry of `XGrpRelayInv` creates duplicate rows. Pre-existing; closure ties to the sibling-row item above. + +The mixed-version-relay asymmetry and the old-owner stuck-RSInvited UI degradation are documented in `docs/protocol/channels-protocol.md` alongside the new `### Relay refusal` subsection. diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 3c260825b7..2a837feaba 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -132,6 +132,7 @@ library Simplex.Chat.Store.Postgres.Migrations.M20260403_item_viewed Simplex.Chat.Store.Postgres.Migrations.M20260429_relay_request_retries Simplex.Chat.Store.Postgres.Migrations.M20260507_relay_inactive_at + Simplex.Chat.Store.Postgres.Migrations.M20260514_relay_request_group_link_index else exposed-modules: Simplex.Chat.Archive @@ -286,6 +287,7 @@ library Simplex.Chat.Store.SQLite.Migrations.M20260403_item_viewed Simplex.Chat.Store.SQLite.Migrations.M20260429_relay_request_retries Simplex.Chat.Store.SQLite.Migrations.M20260507_relay_inactive_at + Simplex.Chat.Store.SQLite.Migrations.M20260514_relay_request_group_link_index other-modules: Paths_simplex_chat hs-source-dirs: diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 84bebb3de6..fa2d0af009 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -407,6 +407,7 @@ data ChatCommand | SetUserChatRelays [CLINewRelay] | APITestChatRelay UserId ShortLinkContact | TestChatRelay ShortLinkContact + | APIAllowRelayGroup {groupId :: GroupId} | APIGetServerOperators | APISetServerOperators (NonEmpty ServerOperator) | SetServerOperators (NonEmpty ServerOperatorRoles) @@ -532,6 +533,7 @@ data ChatCommand | BlockForAll GroupName ContactName Bool | RemoveMembers {groupName :: GroupName, members :: NonEmpty ContactName, withMessages :: Bool} | LeaveGroup GroupName + | AllowRelayGroup GroupName | DeleteGroup GroupName | ClearGroup GroupName | ListMembers GroupName @@ -735,6 +737,7 @@ data ChatResponse | CRPublicGroupCreated {user :: User, groupInfo :: GroupInfo, groupLink :: GroupLink, groupRelays :: [GroupRelay]} | CRPublicGroupCreationFailed {user :: User, addRelayResults :: [AddRelayResult]} | CRGroupRelays {user :: User, groupInfo :: GroupInfo, groupRelays :: [GroupRelay]} + | CRRelayGroupAllowed {user :: User, groupInfo :: GroupInfo} | CRGroupRelaysAdded {user :: User, groupInfo :: GroupInfo, groupLink :: GroupLink, groupRelays :: [GroupRelay]} | CRGroupRelaysAddFailed {user :: User, addRelayResults :: [AddRelayResult]} | CRGroupMembers {user :: User, group :: Group} @@ -945,6 +948,7 @@ data ChatEvent data TerminalEvent = TEGroupLinkRejected {user :: User, groupInfo :: GroupInfo, groupRejectionReason :: GroupRejectionReason} + | TERelayRejected {user :: User, groupInfo :: GroupInfo, relayRejectionReason :: RelayRejectionReason} | TERejectingGroupJoinRequestMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember, groupRejectionReason :: GroupRejectionReason} | TENewMemberContact {user :: User, contact :: Contact, groupInfo :: GroupInfo, member :: GroupMember} | TEContactVerificationReset {user :: User, contact :: Contact} diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 3f66579969..bb31ee26a5 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -1587,6 +1587,9 @@ processChatCommand vr nm = \case Right (Just (Just failure)) -> pure $ CRChatRelayTestResult user (Just relayProfile) (Just failure) TestChatRelay address -> withUser $ \User {userId} -> processChatCommand vr nm $ APITestChatRelay userId address + APIAllowRelayGroup groupId -> withUser $ \user -> do + gInfo' <- withStore $ \db -> allowRelayGroup db vr user groupId + pure $ CRRelayGroupAllowed user gInfo' GetUserChatRelays -> withUser $ \user -> do srvs <- withFastStore (`getUserServers` user) liftIO $ CRUserServers user <$> groupByOperator (onlyRelays srvs) @@ -2939,9 +2942,13 @@ processChatCommand vr nm = \case toView $ CEvtNewChatItems user [AChatItem SCTGroup SMDSnd (GroupChat gInfo' scopeInfo) ci] -- TODO delete direct connections that were unused deleteGroupLinkIfExists user gInfo' + let relayRejected = useRelays' gInfo && isRelay membership -- member records are not deleted to keep history - withFastStore' $ \db -> updateGroupMemberStatus db userId membership GSMemLeft - pure $ CRLeftMemberUser user gInfo' {membership = membership {memberStatus = GSMemLeft}} + withFastStore' $ \db -> do + updateGroupMemberStatus db userId membership GSMemLeft + when relayRejected $ updateRelayOwnStatus_ db gInfo RSRejected + let relayOwnStatus' = if relayRejected then Just RSRejected else relayOwnStatus gInfo + pure $ CRLeftMemberUser user gInfo' {membership = membership {memberStatus = GSMemLeft}, relayOwnStatus = relayOwnStatus'} where -- Relay leaving channel: create delivery job for cursor-based sending and async connection cleanup. leaveChannelRelay gInfo = do @@ -2993,6 +3000,9 @@ processChatCommand vr nm = \case LeaveGroup gName -> withUser $ \user -> do groupId <- withFastStore $ \db -> getGroupIdByName db user gName processChatCommand vr nm $ APILeaveGroup groupId + AllowRelayGroup gName -> withUser $ \user -> do + groupId <- withFastStore $ \db -> getGroupIdByName db user gName + processChatCommand vr nm $ APIAllowRelayGroup groupId DeleteGroup gName -> withUser $ \user -> do groupId <- withFastStore $ \db -> getGroupIdByName db user gName processChatCommand vr nm $ APIDeleteChat (ChatRef CTGroup groupId Nothing) (CDMFull True) @@ -5041,6 +5051,8 @@ chatCommandP = "/xftp" $> GetUserProtoServers (AProtocolType SPXFTP), "/_relay test " *> (APITestChatRelay <$> A.decimal <* A.space <*> strP), "/relay test " *> (TestChatRelay <$> strP), + "/_relay allow #" *> (APIAllowRelayGroup <$> A.decimal), + "/group allow #" *> (AllowRelayGroup <$> displayNameP), "/relays " *> (SetUserChatRelays <$> chatRelaysP), "/relays" $> GetUserChatRelays, "/_operators" $> APIGetServerOperators, diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 8324107a11..c6c3f92752 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -1059,6 +1059,28 @@ acceptRelayJoinRequestAsync ownerMember' <- getGroupMemberById db vr user groupMemberId pure (gInfo', ownerMember') +rejectRelayInvitationAsync + :: User + -> Int64 + -> VersionRangeChat + -> GroupRelayInvitation + -> InvitationId + -> VersionRangeChat + -> Int64 + -> RelayRejectionReason + -> CM () +rejectRelayInvitationAsync user uclId vr groupRelayInv invId reqChatVRange initialDelay reason = do + (_gInfo, ownerMember) <- withStore $ \db -> + createRelayRequestGroup db vr user groupRelayInv invId reqChatVRange initialDelay GSMemInvited RSRejected + let GroupMember {groupMemberId} = ownerMember + msg = XGrpRelayReject reason + subMode <- chatReadVar subscriptionMode + chatVR <- chatVersionRange + let chatV = chatVR `peerConnChatVersion` reqChatVRange + connIds <- agentAcceptContactAsync user False invId msg subMode PQSupportOff chatV + withStore' $ \db -> + createJoiningMemberConnection db user uclId connIds chatV reqChatVRange groupMemberId subMode + businessGroupProfile :: Profile -> GroupPreferences -> GroupProfile businessGroupProfile Profile {displayName, fullName, shortDescr, image} groupPreferences = GroupProfile {displayName, fullName, description = Nothing, shortDescr, image, publicGroup = Nothing, groupPreferences = Just groupPreferences, memberAdmission = Nothing} diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 6c960c3ce8..08ca90f2a6 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -770,6 +770,21 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = withStore' $ \db -> setRelayLinkConfId db m confId relayLink void $ getAgentConnShortLinkAsync user CFGetRelayDataAccept (Just conn') relayLink | otherwise -> messageError "x.grp.relay.acpt: only owner can add relay" + XGrpRelayReject reason + | memberRole' membership == GROwner && isRelay m -> do + -- GSMemLeft (not GSMemRejected): owner UI treats this identically to an explicit /leave from the relay; GSMemRejected has knocking-admission semantics. + (relay', m') <- withStore $ \db -> do + relay <- getGroupRelayByGMId db (groupMemberId' m) + relay' <- if relayStatus relay == RSInvited + then liftIO $ updateRelayStatusFromTo db relay RSInvited RSRejected + else pure relay + liftIO $ updateGroupMemberStatus db userId m GSMemLeft + pure (relay', m {memberStatus = GSMemLeft}) + -- complete the contact handshake so the relay receives INFO and cleans up its transient bookkeeping + allowAgentConnectionAsync user conn' confId XOk + toView $ CEvtGroupRelayUpdated user gInfo m' relay' + toViewTE $ TERelayRejected user gInfo reason + | otherwise -> messageError "x.grp.relay.reject: only owner should receive relay rejection" _ -> messageError "CONF from invited member must have x.grp.acpt" GCHostMember -> case chatMsgEvent of @@ -817,10 +832,16 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = when (memberStatus m == GSMemRejected) $ do deleteMemberConnection' m True withStore' $ \db -> deleteGroupMember db user m - XOk -> pure () + XOk -> + -- transient relay-reject row cleanup after the rejection handshake completes + when (memberCategory m == GCHostMember && not (relayServesGroup gInfo)) $ do + deleteMemberConnection' m True + withStore' $ \db -> do + deleteGroupMember db user m + deleteGroup db user gInfo _ -> messageError "INFO from member must have x.grp.mem.info, x.info or x.ok" pure () - CON _pqEnc -> unless (memberStatus m == GSMemRejected || memberStatus membership == GSMemRejected) $ do + CON _pqEnc -> unless rejected $ do -- TODO [knocking] send pending messages after accepting? -- possible improvement: check for each pending message, requires keeping track of connection state unless (connDisabled conn) $ sendPendingGroupMessages user gInfo m conn @@ -922,6 +943,11 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = forM_ (memberConn im) $ \imConn -> void $ sendDirectMemberMessage imConn (XGrpMemCon memberId) groupId _ -> messageWarning "sendXGrpMemCon: member category GCPreMember or GCPostMember is expected" + where + rejected = + memberStatus m `elem` ([GSMemRejected, GSMemLeft, GSMemRemoved, GSMemGroupDeleted] :: [GroupMemberStatus]) + || memberStatus membership == GSMemRejected + || not (relayServesGroup gInfo) MSG msgMeta _msgFlags msgBody -> do tags <- newTVarIO [] withAckMessage "group msg" agentConnId msgMeta True (Just tags) $ \eInfo -> do @@ -933,7 +959,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = if isUserGrpFwdRelay gInfo' && not (blockedByAdmin m) then let tasks - | relayOwnStatus gInfo' == Just RSInactive = filter relayRemovedNewTask newDeliveryTasks + | not (relayServesGroup gInfo') = filter relayRemovedNewTask newDeliveryTasks | otherwise = newDeliveryTasks in createDeliveryTasks gInfo' m' tasks else pure False @@ -1523,10 +1549,15 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = mem <- acceptGroupJoinSendRejectAsync user uclId gInfo invId chatVRange p xContactId_ rjctReason toViewTE $ TERejectingGroupJoinRequestMember user gInfo mem rjctReason xGrpRelayInv :: InvitationId -> VersionRangeChat -> GroupRelayInvitation -> CM () - xGrpRelayInv invId chatVRange groupRelayInv = do + xGrpRelayInv invId chatVRange groupRelayInv@GroupRelayInvitation {groupLink} = do + rejected <- withStore' $ \db -> isRelayGroupRejected db user groupLink initialDelay <- asks $ initialInterval . relayRequestRetryInterval . config - (_gInfo, _ownerMember) <- withStore $ \db -> createRelayRequestGroup db vr user groupRelayInv invId chatVRange initialDelay - lift $ void $ getRelayRequestWorker True + if rejected + then rejectRelayInvitationAsync user uclId vr groupRelayInv invId chatVRange initialDelay RRRRejoinRejected + else do + (_gInfo, _ownerMember) <- withStore $ \db -> + createRelayRequestGroup db vr user groupRelayInv invId chatVRange initialDelay GSMemAccepted RSInvited + lift $ void $ getRelayRequestWorker True xGrpRelayTest :: InvitationId -> VersionRangeChat -> ByteString -> CM () xGrpRelayTest invId chatVRange challenge = do privKey_ <- withAgent $ \a -> getConnLinkPrivKey a (aConnId conn) @@ -3133,7 +3164,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = unless (isUserGrpFwdRelay gInfo) $ deleteGroupConnections user gInfo False withStore' $ \db -> do updateGroupMemberStatus db userId membership GSMemRemoved - when (isJust $ relayOwnStatus gInfo) $ updateRelayOwnStatus_ db gInfo RSInactive + when (maybe False (/= RSRejected) (relayOwnStatus gInfo)) $ updateRelayOwnStatus_ db gInfo RSInactive let membership' = membership {memberStatus = GSMemRemoved} when withMessages $ deleteMessages gInfo membership' SMDSnd deleteMemberItem msg gInfo RGEUserDeleted @@ -3572,7 +3603,7 @@ runDeliveryTaskWorker a deliveryKey Worker {doWork} = do processDeliveryTask task@MessageDeliveryTask {jobScope} = case jobScopeImpliedSpec jobScope of DJDeliveryJob _includePending - | relayOwnStatus gInfo == Just RSInactive -> do + | not (relayServesGroup gInfo) -> do logWarn "delivery task worker: relay inactive" withStore' $ \db -> setDeliveryTaskErrStatus db (deliveryTaskId task) "relay inactive" | otherwise -> @@ -3642,7 +3673,7 @@ runDeliveryJobWorker a deliveryKey Worker {doWork} = do processDeliveryJob job = case jobScopeImpliedSpec jobScope of DJDeliveryJob _includePending - | relayOwnStatus gInfo == Just RSInactive -> do + | not (relayServesGroup gInfo) -> do logWarn "delivery job worker: relay inactive" withStore' $ \db -> setDeliveryJobErrStatus db (deliveryJobId job) "relay inactive" | otherwise -> do diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 3436a64132..f9c29e3552 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -444,6 +444,7 @@ data ChatMsgEvent (e :: MsgEncoding) where XGrpRelayAcpt :: ShortLinkContact -> ChatMsgEvent 'Json XGrpRelayTest :: ByteString -> Maybe ByteString -> ChatMsgEvent 'Json XGrpRelayNew :: ShortLinkContact -> ChatMsgEvent 'Json + XGrpRelayReject :: RelayRejectionReason -> ChatMsgEvent 'Json XGrpMemNew :: MemberInfo -> Maybe MsgScope -> ChatMsgEvent 'Json XGrpMemIntro :: MemberInfo -> Maybe MemberRestrictions -> ChatMsgEvent 'Json XGrpMemInv :: MemberId -> IntroInvitation -> ChatMsgEvent 'Json @@ -989,6 +990,7 @@ data CMEventTag (e :: MsgEncoding) where XGrpRelayAcpt_ :: CMEventTag 'Json XGrpRelayTest_ :: CMEventTag 'Json XGrpRelayNew_ :: CMEventTag 'Json + XGrpRelayReject_ :: CMEventTag 'Json XGrpMemNew_ :: CMEventTag 'Json XGrpMemIntro_ :: CMEventTag 'Json XGrpMemInv_ :: CMEventTag 'Json @@ -1047,6 +1049,7 @@ instance MsgEncodingI e => StrEncoding (CMEventTag e) where XGrpRelayAcpt_ -> "x.grp.relay.acpt" XGrpRelayTest_ -> "x.grp.relay.test" XGrpRelayNew_ -> "x.grp.relay.new" + XGrpRelayReject_ -> "x.grp.relay.reject" XGrpMemNew_ -> "x.grp.mem.new" XGrpMemIntro_ -> "x.grp.mem.intro" XGrpMemInv_ -> "x.grp.mem.inv" @@ -1106,6 +1109,7 @@ instance StrEncoding ACMEventTag where "x.grp.relay.acpt" -> XGrpRelayAcpt_ "x.grp.relay.test" -> XGrpRelayTest_ "x.grp.relay.new" -> XGrpRelayNew_ + "x.grp.relay.reject" -> XGrpRelayReject_ "x.grp.mem.new" -> XGrpMemNew_ "x.grp.mem.intro" -> XGrpMemIntro_ "x.grp.mem.inv" -> XGrpMemInv_ @@ -1161,6 +1165,7 @@ toCMEventTag msg = case msg of XGrpRelayAcpt _ -> XGrpRelayAcpt_ XGrpRelayTest {} -> XGrpRelayTest_ XGrpRelayNew _ -> XGrpRelayNew_ + XGrpRelayReject _ -> XGrpRelayReject_ XGrpMemNew {} -> XGrpMemNew_ XGrpMemIntro _ _ -> XGrpMemIntro_ XGrpMemInv _ _ -> XGrpMemInv_ @@ -1319,6 +1324,7 @@ appJsonToCM AppMessageJson {v, msgId, event, params} = do sig_ <- fmap (\(B64UrlByteString s) -> s) <$> opt "signature" pure $ XGrpRelayTest challenge sig_ XGrpRelayNew_ -> XGrpRelayNew <$> p "relayLink" + XGrpRelayReject_ -> XGrpRelayReject <$> p "reason" XGrpMemNew_ -> XGrpMemNew <$> p "memberInfo" <*> opt "scope" XGrpMemIntro_ -> XGrpMemIntro <$> p "memberInfo" <*> opt "memberRestrictions" XGrpMemInv_ -> XGrpMemInv <$> p "memberId" <*> p "memberIntro" @@ -1389,6 +1395,7 @@ chatToAppMessage chatMsg@ChatMessage {chatVRange, msgId, chatMsgEvent} = case en ("signature" .=? (B64UrlByteString <$> sig_)) ["challenge" .= B64UrlByteString challenge] XGrpRelayNew relayLink -> o ["relayLink" .= relayLink] + XGrpRelayReject reason -> o ["reason" .= reason] XGrpMemNew memInfo scope -> o $ ("scope" .=? scope) ["memberInfo" .= memInfo] XGrpMemIntro memInfo memRestrictions -> o $ ("memberRestrictions" .=? memRestrictions) ["memberInfo" .= memInfo] XGrpMemInv memId memIntro -> o ["memberId" .= memId, "memberIntro" .= memIntro] diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 4bb94ba2a8..9b21f0697b 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -95,6 +95,8 @@ module Simplex.Chat.Store.Groups createRelayRequestGroup, updateRelayOwnStatusFromTo, updateRelayOwnStatus_, + isRelayGroupRejected, + allowRelayGroup, getRelayServedGroups, getRelayInactiveGroups, createNewContactMemberAsync, @@ -1523,8 +1525,8 @@ setGroupInProgressDone db GroupInfo {groupId} = do "UPDATE groups SET creating_in_progress = 0, updated_at = ? WHERE group_id = ?" (currentTs, groupId) -createRelayRequestGroup :: DB.Connection -> VersionRangeChat -> User -> GroupRelayInvitation -> InvitationId -> VersionRangeChat -> Int64 -> ExceptT StoreError IO (GroupInfo, GroupMember) -createRelayRequestGroup db vr user@User {userId} GroupRelayInvitation {fromMember, fromMemberProfile, relayMemberId, groupLink} invId reqChatVRange initialDelay = do +createRelayRequestGroup :: DB.Connection -> VersionRangeChat -> User -> GroupRelayInvitation -> InvitationId -> VersionRangeChat -> Int64 -> GroupMemberStatus -> RelayStatus -> ExceptT StoreError IO (GroupInfo, GroupMember) +createRelayRequestGroup db vr user@User {userId} GroupRelayInvitation {fromMember, fromMemberProfile, relayMemberId, groupLink} invId reqChatVRange initialDelay memberStatus relayStatus = do currentTs <- liftIO getCurrentTime -- Create group with placeholder profile let Profile {displayName = fromMemberLDN} = fromMemberProfile @@ -1538,13 +1540,13 @@ createRelayRequestGroup db vr user@User {userId} GroupRelayInvitation {fromMembe groupPreferences = Nothing, memberAdmission = Nothing } - (groupId, _groupLDN) <- createGroup_ db userId placeholderProfile Nothing Nothing True (Just RSInvited) Nothing currentTs + (groupId, _groupLDN) <- createGroup_ db userId placeholderProfile Nothing Nothing True (Just relayStatus) Nothing currentTs -- Store relay request data for recovery liftIO $ setRelayRequestData_ groupId currentTs ownerMemberId <- insertOwner_ currentTs groupId let relayMember = MemberIdRole relayMemberId GRRelay -- TODO [member keys] should relays use member keys? - _membership <- createContactMemberInv_ db user groupId (Just ownerMemberId) user relayMember GCUserMember GSMemAccepted IBUnknown Nothing Nothing currentTs vr + _membership <- createContactMemberInv_ db user groupId (Just ownerMemberId) user relayMember GCUserMember memberStatus IBUnknown Nothing Nothing currentTs vr ownerMember <- getGroupMember db vr user groupId ownerMemberId g <- getGroupInfo db vr user groupId pure (g, ownerMember) @@ -1578,7 +1580,7 @@ createRelayRequestGroup db vr user@User {userId} GroupRelayInvitation {fromMembe peer_chat_min_version, peer_chat_max_version) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ( (groupId, indexInGroup, memberId, memberRole, GCHostMember, GSMemAccepted) + ( (groupId, indexInGroup, memberId, memberRole, GCHostMember, memberStatus) :. (userId, localDisplayName, Nothing :: (Maybe Int64), profileId, currentTs, currentTs) :. (minV, maxV) ) @@ -1596,6 +1598,41 @@ updateRelayOwnStatus_ db GroupInfo {groupId} relayStatus = do let inactiveAt_ = if relayStatus == RSInactive then Just currentTs else Nothing DB.execute db "UPDATE groups SET relay_own_status = ?, relay_inactive_at = ?, updated_at = ? WHERE group_id = ?" (relayStatus, inactiveAt_, currentTs, groupId) +-- Flip every RSRejected row sharing the targeted group's relay_request_group_link +-- to RSInactive in one statement; returns the refreshed GroupInfo for the targeted groupId. +allowRelayGroup :: DB.Connection -> VersionRangeChat -> User -> GroupId -> ExceptT StoreError IO GroupInfo +allowRelayGroup db vr user@User {userId} groupId = do + currentTs <- liftIO getCurrentTime + liftIO $ + DB.execute + db + [sql| + UPDATE groups + SET relay_own_status = ?, relay_inactive_at = ?, updated_at = ? + WHERE user_id = ? + AND relay_request_group_link = (SELECT relay_request_group_link FROM groups WHERE group_id = ?) + AND relay_own_status = ? + |] + (RSInactive, currentTs, currentTs, userId, groupId, RSRejected) + getGroupInfo db vr user groupId + +isRelayGroupRejected :: DB.Connection -> User -> ShortLinkContact -> IO Bool +isRelayGroupRejected db User {userId} groupLink = + fromMaybe False <$> maybeFirstRow fromOnly ( + DB.query + db + [sql| + SELECT EXISTS ( + SELECT 1 FROM groups + WHERE user_id = ? + AND relay_request_group_link = ? + AND relay_own_status = ? + LIMIT 1 + ) + |] + (userId, groupLink, RSRejected) + ) + getRelayServedGroups :: DB.Connection -> VersionRangeChat -> User -> IO [GroupInfo] getRelayServedGroups db vr User {userId, userContactId} = do map (toGroupInfo vr userContactId []) diff --git a/src/Simplex/Chat/Store/Postgres/Migrations.hs b/src/Simplex/Chat/Store/Postgres/Migrations.hs index 822068a771..437f16a43c 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations.hs @@ -30,6 +30,7 @@ import Simplex.Chat.Store.Postgres.Migrations.M20260222_chat_relays import Simplex.Chat.Store.Postgres.Migrations.M20260403_item_viewed import Simplex.Chat.Store.Postgres.Migrations.M20260429_relay_request_retries import Simplex.Chat.Store.Postgres.Migrations.M20260507_relay_inactive_at +import Simplex.Chat.Store.Postgres.Migrations.M20260514_relay_request_group_link_index import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Text, Maybe Text)] @@ -59,7 +60,8 @@ schemaMigrations = ("20260222_chat_relays", m20260222_chat_relays, Just down_m20260222_chat_relays), ("20260403_item_viewed", m20260403_item_viewed, Just down_m20260403_item_viewed), ("20260429_relay_request_retries", m20260429_relay_request_retries, Just down_m20260429_relay_request_retries), - ("20260507_relay_inactive_at", m20260507_relay_inactive_at, Just down_m20260507_relay_inactive_at) + ("20260507_relay_inactive_at", m20260507_relay_inactive_at, Just down_m20260507_relay_inactive_at), + ("20260514_relay_request_group_link_index", m20260514_relay_request_group_link_index, Just down_m20260514_relay_request_group_link_index) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20260514_relay_request_group_link_index.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20260514_relay_request_group_link_index.hs new file mode 100644 index 0000000000..217b56d2fa --- /dev/null +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20260514_relay_request_group_link_index.hs @@ -0,0 +1,21 @@ +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.Postgres.Migrations.M20260514_relay_request_group_link_index where + +import Data.Text (Text) +import Text.RawString.QQ (r) + +m20260514_relay_request_group_link_index :: Text +m20260514_relay_request_group_link_index = + [r| +CREATE INDEX idx_groups_relay_request_group_link + ON groups(user_id, relay_request_group_link) + WHERE relay_request_group_link IS NOT NULL; +|] + +down_m20260514_relay_request_group_link_index :: Text +down_m20260514_relay_request_group_link_index = + [r| +DROP INDEX idx_groups_relay_request_group_link; +|] diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql index 495a6bb752..6026049313 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql @@ -2359,6 +2359,10 @@ CREATE INDEX idx_groups_inv_queue_info ON test_chat_schema.groups USING btree (i +CREATE INDEX idx_groups_relay_request_group_link ON test_chat_schema.groups USING btree (user_id, relay_request_group_link) WHERE (relay_request_group_link IS NOT NULL); + + + CREATE INDEX idx_groups_summary_current_members_count ON test_chat_schema.groups USING btree (summary_current_members_count); diff --git a/src/Simplex/Chat/Store/SQLite/Migrations.hs b/src/Simplex/Chat/Store/SQLite/Migrations.hs index 4ee3f44b07..9990ed74fd 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations.hs @@ -153,6 +153,7 @@ import Simplex.Chat.Store.SQLite.Migrations.M20260222_chat_relays import Simplex.Chat.Store.SQLite.Migrations.M20260403_item_viewed import Simplex.Chat.Store.SQLite.Migrations.M20260429_relay_request_retries import Simplex.Chat.Store.SQLite.Migrations.M20260507_relay_inactive_at +import Simplex.Chat.Store.SQLite.Migrations.M20260514_relay_request_group_link_index import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -305,7 +306,8 @@ schemaMigrations = ("20260222_chat_relays", m20260222_chat_relays, Just down_m20260222_chat_relays), ("20260403_item_viewed", m20260403_item_viewed, Just down_m20260403_item_viewed), ("20260429_relay_request_retries", m20260429_relay_request_retries, Just down_m20260429_relay_request_retries), - ("20260507_relay_inactive_at", m20260507_relay_inactive_at, Just down_m20260507_relay_inactive_at) + ("20260507_relay_inactive_at", m20260507_relay_inactive_at, Just down_m20260507_relay_inactive_at), + ("20260514_relay_request_group_link_index", m20260514_relay_request_group_link_index, Just down_m20260514_relay_request_group_link_index) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20260514_relay_request_group_link_index.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20260514_relay_request_group_link_index.hs new file mode 100644 index 0000000000..ef2bc8ccd0 --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20260514_relay_request_group_link_index.hs @@ -0,0 +1,20 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20260514_relay_request_group_link_index where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20260514_relay_request_group_link_index :: Query +m20260514_relay_request_group_link_index = + [sql| +CREATE INDEX idx_groups_relay_request_group_link + ON groups(user_id, relay_request_group_link) + WHERE relay_request_group_link IS NOT NULL; +|] + +down_m20260514_relay_request_group_link_index :: Query +down_m20260514_relay_request_group_link_index = + [sql| +DROP INDEX idx_groups_relay_request_group_link; +|] 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 f4590f48c9..127fce8e45 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -3338,6 +3338,20 @@ SCAN CONSTANT ROW SCALAR SUBQUERY 1 SCAN groups +Query: + SELECT EXISTS ( + SELECT 1 FROM groups + WHERE user_id = ? + AND relay_request_group_link = ? + AND relay_own_status = ? + LIMIT 1 + ) + +Plan: +SCAN CONSTANT ROW +SCALAR SUBQUERY 1 +SEARCH groups USING INDEX idx_groups_relay_request_group_link (user_id=? AND relay_request_group_link=?) + Query: SELECT agent_conn_id FROM connections @@ -3955,15 +3969,6 @@ Query: Plan: SEARCH chat_items USING INDEX idx_chat_items_group_shared_msg_id (user_id=? AND group_id=? AND group_member_id=?) -Query: - UPDATE chat_items - SET item_deleted = ?, item_deleted_ts = ?, item_deleted_by_group_member_id = ?, updated_at = ? - WHERE user_id = ? AND group_id = ? AND msg_content_tag = ? AND item_deleted = ? AND item_sent = 0 - RETURNING chat_item_id - -Plan: -SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_msg_content_tag_deleted (user_id=? AND group_id=? AND msg_content_tag=? AND item_deleted=? AND item_sent=?) - Query: UPDATE chat_items SET item_deleted = ?, item_deleted_ts = ?, item_deleted_by_group_member_id = ?, updated_at = ? @@ -4044,6 +4049,18 @@ Query: Plan: SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) +Query: + UPDATE groups + SET relay_own_status = ?, relay_inactive_at = ?, updated_at = ? + WHERE user_id = ? + AND relay_request_group_link = (SELECT relay_request_group_link FROM groups WHERE group_id = ?) + AND relay_own_status = ? + +Plan: +SEARCH groups USING INDEX idx_groups_relay_request_group_link (user_id=? AND relay_request_group_link=?) +SCALAR SUBQUERY 1 +SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE groups SET via_group_link_uri = ?, via_group_link_uri_hash = ? @@ -6586,6 +6603,10 @@ Query: SELECT 1 FROM settings WHERE user_id = ? LIMIT 1 Plan: SEARCH settings USING COVERING INDEX idx_settings_user_id (user_id=?) +Query: SELECT COUNT(*) FROM groups WHERE relay_own_status IS NOT NULL +Plan: +SCAN groups + Query: SELECT COUNT(1) FROM chat_item_versions WHERE chat_item_id = ? Plan: SEARCH chat_item_versions USING COVERING INDEX idx_chat_item_versions_chat_item_id (chat_item_id=?) @@ -6801,6 +6822,10 @@ Query: SELECT group_id, conn_full_link_to_connect FROM groups WHERE user_id = ? Plan: SEARCH groups USING INDEX sqlite_autoindex_groups_2 (user_id=?) +Query: SELECT group_id, relay_own_status FROM groups WHERE relay_own_status IS NOT NULL ORDER BY group_id +Plan: +SCAN groups + Query: SELECT group_member_id FROM group_members WHERE user_id = ? AND contact_profile_id = ? AND group_member_id != ? LIMIT 1 Plan: SEARCH group_members USING INDEX idx_group_members_user_id (user_id=?) @@ -6865,6 +6890,10 @@ Query: SELECT relay_own_status FROM groups WHERE group_id = ? Plan: SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) +Query: SELECT relay_status FROM group_relays +Plan: +SCAN group_relays + Query: SELECT relay_status FROM group_relays WHERE group_relay_id = ? Plan: SEARCH group_relays USING INTEGER PRIMARY KEY (rowid=?) diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index b7a6db437b..86c198670c 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -1295,6 +1295,12 @@ CREATE INDEX idx_chat_items_groups_item_viewed ON chat_items( item_viewed, item_ts ); +CREATE INDEX idx_groups_relay_request_group_link +ON groups( + user_id, + relay_request_group_link +) +WHERE relay_request_group_link IS NOT NULL; CREATE TRIGGER on_group_members_insert_update_summary AFTER INSERT ON group_members FOR EACH ROW diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index e2efcdf6d6..f2892898c4 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -494,6 +494,12 @@ data GroupInfo = GroupInfo useRelays' :: GroupInfo -> Bool useRelays' GroupInfo {useRelays} = isTrue useRelays +relayServesGroup :: GroupInfo -> Bool +relayServesGroup GroupInfo {relayOwnStatus} = case relayOwnStatus of + Just RSInactive -> False + Just RSRejected -> False + _ -> True + publicGroupEditor :: GroupInfo -> GroupMember -> Bool publicGroupEditor gInfo mem = useRelays' gInfo && memberRole' mem >= GRModerator @@ -919,6 +925,26 @@ instance ToJSON GroupRejectionReason where toJSON = strToJSON toEncoding = strToJEncoding +data RelayRejectionReason + = RRRRejoinRejected + | RRRUnknown {text :: Text} + deriving (Eq, Show) + +instance StrEncoding RelayRejectionReason where + strEncode = \case + RRRRejoinRejected -> "rejoin_rejected" + RRRUnknown text -> encodeUtf8 text + strP = + "rejoin_rejected" $> RRRRejoinRejected + <|> RRRUnknown . safeDecodeUtf8 <$> A.takeByteString + +instance FromJSON RelayRejectionReason where + parseJSON = strParseJSON "RelayRejectionReason" + +instance ToJSON RelayRejectionReason where + toJSON = strToJSON + toEncoding = strToJEncoding + data MemberIdRole = MemberIdRole { memberId :: MemberId, memberRole :: GroupMemberRole diff --git a/src/Simplex/Chat/Types/Shared.hs b/src/Simplex/Chat/Types/Shared.hs index e0630e2e42..c71f7ce37a 100644 --- a/src/Simplex/Chat/Types/Shared.hs +++ b/src/Simplex/Chat/Types/Shared.hs @@ -84,6 +84,7 @@ data RelayStatus | RSAccepted | RSActive | RSInactive + | RSRejected deriving (Eq, Show) relayStatusText :: RelayStatus -> Text @@ -93,6 +94,7 @@ relayStatusText = \case RSAccepted -> "accepted" RSActive -> "active" RSInactive -> "inactive" + RSRejected -> "rejected" instance TextEncoding RelayStatus where textEncode = \case @@ -101,12 +103,14 @@ instance TextEncoding RelayStatus where RSAccepted -> "accepted" RSActive -> "active" RSInactive -> "inactive" + RSRejected -> "rejected" textDecode = \case "new" -> Just RSNew "invited" -> Just RSInvited "accepted" -> Just RSAccepted "active" -> Just RSActive "inactive" -> Just RSInactive + "rejected" -> Just RSRejected _ -> Nothing instance FromField RelayStatus where fromField = fromTextField_ textDecode @@ -115,6 +119,7 @@ instance ToField RelayStatus where toField = toField . textEncode $(JQ.deriveJSON (enumJSON $ dropPrefix "RS") ''RelayStatus) + data MsgSigStatus = MSSVerified | MSSSignedNoKey deriving (Eq, Show) diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 1211dc55a9..477850d4b0 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -184,6 +184,7 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, testView} liveIte CRGroupRelays u g relays -> ttyUser u $ viewGroupRelays g relays CRGroupRelaysAdded u g _groupLink relays -> ttyUser u $ viewGroupRelays g relays CRGroupRelaysAddFailed u results -> ttyUser u $ viewGroupRelaysAddFailed results + CRRelayGroupAllowed u g -> ttyUser u [ttyFullGroup g <> ": relay rejection cleared"] CRGroupMembers u g -> ttyUser u $ viewGroupMembers g CRMemberSupportChats u g ms -> ttyUser u $ viewMemberSupportChats g ms -- CRGroupConversationsArchived u _g _conversations -> ttyUser u [] @@ -222,7 +223,14 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, testView} liveIte CRUserDeletedMembers u g members wm signed -> case members of [m] -> ttyUser u [ttyGroup' g <> ": you removed " <> ttyMember m <> " from the group" <> withMessages wm <> signedStr signed] mems' -> ttyUser u [ttyGroup' g <> ": you removed " <> sShow (length mems') <> " members from the group" <> withMessages wm <> signedStr signed] - CRLeftMemberUser u g -> ttyUser u $ [ttyGroup' g <> ": you left the group"] <> groupPreserved g + CRLeftMemberUser u g + | relayOwnStatus g == Just RSRejected -> + ttyUser u + [ ttyGroup' g <> ": you left the group (future invitations will be rejected)", + "use " <> highlight ("/group allow #" <> viewGroupName g) <> " to allow future invitations", + "use " <> highlight ("/d #" <> viewGroupName g) <> " to delete the group (also clears the rejection)" + ] + | otherwise -> ttyUser u $ [ttyGroup' g <> ": you left the group"] <> groupPreserved g CRGroupDeletedUser u g signed -> ttyUser u [ttyGroup' g <> ": you deleted the group" <> signedStr signed] CRForwardPlan u count itemIds fc -> ttyUser u $ viewForwardPlan count itemIds fc CRChatMsgContent u mc -> ttyUser u $ ttyMsgContent mc <> viewMsgTestInfo testView mc @@ -541,6 +549,7 @@ chatEventToView hu ChatConfig {logLevel, showReactions, showReceipts, testView} CEvtTerminalEvent te -> case te of TERejectingGroupJoinRequestMember _ g m reason -> [ttyFullMember m <> ": rejecting request to join group " <> ttyGroup' g <> ", reason: " <> sShow reason] TEGroupLinkRejected u g reason -> ttyUser u [ttyGroup' g <> ": join rejected, reason: " <> sShow reason] + TERelayRejected u g reason -> ttyUser u [ttyGroup' g <> ": relay rejected, reason: " <> sShow reason] TENewMemberContact u _ g m -> ttyUser u ["contact for member " <> ttyGroup' g <> " " <> ttyMember m <> " is created"] TEContactVerificationReset u ct -> ttyUser u $ viewContactVerificationReset ct TEGroupMemberVerificationReset u g m -> ttyUser u $ viewGroupMemberVerificationReset g m @@ -1435,11 +1444,14 @@ viewGroupsList gs = map groupSS $ sortOn ldn_ gs where ldn_ :: GroupInfo -> Text ldn_ GroupInfo {localDisplayName} = T.toLower localDisplayName - groupSS g@GroupInfo {membership, chatSettings = ChatSettings {enableNtfs}, groupSummary = GroupSummary {currentMembers}} = + groupSS g@GroupInfo {membership, chatSettings = ChatSettings {enableNtfs}, groupSummary = GroupSummary {currentMembers}, relayOwnStatus} = case memberStatus membership of GSMemInvited -> groupInvitation' g - s -> membershipIncognito g <> ttyFullGroup g <> viewMemberStatus s <> alias g + s -> membershipIncognito g <> ttyFullGroup g <> viewMemberStatus s <> rejectionSuffix <> alias g where + rejectionSuffix = case relayOwnStatus of + Just RSRejected -> " [rejected]" + _ -> "" viewMemberStatus = \case GSMemRejected -> delete "you are rejected" GSMemRemoved -> delete "you are removed" diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 6ceb3c2cbe..e0ff178db4 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -272,6 +272,11 @@ chatGroupTests = do it "should add relay to existing channel" testChannelAddRelay it "should remove relay from channel" testChannelRemoveRelay it "should remove left relay from channel" testChannelRemoveLeftRelay + describe "relay rejection" $ do + it "relay rejects fresh invitation after leaving the same channel" testRelayRejectAfterLeave + it "operator allow clears rejection and relay accepts again" testRelayAllowAcceptsAgain + it "rejection on channel A does not affect unrelated channel B" testRelayDoesNotRejectUnrelatedChannel + it "concurrent fresh invitations both rejected" testRelayRejectRaceConcurrentInvitations describe "channel message operations" $ do it "should update channel message" testChannelMessageUpdate it "should delete channel message" testChannelMessageDelete @@ -9474,8 +9479,9 @@ testChannelRelayLeave ps = -- relay1 (bob) leaves threadDelay 100000 bob ##> "/leave #team" - bob <## "#team: you left the group" - bob <## "use /d #team to delete the group" + bob <## "#team: you left the group (future invitations will be rejected)" + bob <## "use /group allow #team to allow future invitations" + bob <## "use /d #team to delete the group (also clears the rejection)" concurrentlyN_ [ alice <## "#team: bob left the group (signed)", -- cath: not notified (relays not connected, owner doesn't forward) @@ -9497,8 +9503,9 @@ testChannelRelayLeave ps = -- relay2 (cath) leaves threadDelay 100000 cath ##> "/leave #team" - cath <## "#team: you left the group" - cath <## "use /d #team to delete the group" + cath <## "#team: you left the group (future invitations will be rejected)" + cath <## "use /group allow #team to allow future invitations" + cath <## "use /d #team to delete the group (also clears the rejection)" concurrentlyN_ [ alice <## "#team: cath left the group (signed)", dan <## "#team: cath left the group (signed)", @@ -9869,8 +9876,9 @@ testChannelRemoveLeftRelay ps = bob ##> "/l team" concurrentlyN_ [ do - bob <## "#team: you left the group" - bob <## "use /d #team to delete the group", + bob <## "#team: you left the group (future invitations will be rejected)" + bob <## "use /group allow #team to allow future invitations" + bob <## "use /d #team to delete the group (also clears the rejection)", alice <## "#team: bob left the group (signed)", dan <## "#team: bob left the group (signed)" ] @@ -9898,8 +9906,9 @@ testChannelRemoveLeftRelay ps = cath ##> "/l team" concurrentlyN_ [ do - cath <## "#team: you left the group" - cath <## "use /d #team to delete the group", + cath <## "#team: you left the group (future invitations will be rejected)" + cath <## "use /group allow #team to allow future invitations" + cath <## "use /d #team to delete the group (also clears the rejection)", alice <## "#team: cath left the group (signed)", dan <## "#team: cath left the group (signed)" ] @@ -9921,6 +9930,271 @@ testChannelRemoveLeftRelay ps = DB.query_ db "SELECT local_display_name FROM group_members" :: IO [Only T.Text] danMembers2 `shouldMatchList` [Only "dan", Only "alice"] +queryRelayOwnStatus :: TestCC -> Int64 -> IO (Maybe T.Text) +queryRelayOwnStatus cc gId = do + rows <- withCCTransaction cc $ \db -> + DB.query db "SELECT relay_own_status FROM groups WHERE group_id = ?" (Only gId) + :: IO [Only (Maybe T.Text)] + pure $ case rows of + [Only s] -> s + _ -> Nothing + +listRelayOwnStatuses :: TestCC -> IO [(Int64, T.Text)] +listRelayOwnStatuses cc = + withCCTransaction cc $ \db -> + DB.query_ + db + "SELECT group_id, relay_own_status FROM groups WHERE relay_own_status IS NOT NULL ORDER BY group_id" + :: IO [(Int64, T.Text)] + +checkRelayGroupCount :: TestCC -> Int -> IO () +checkRelayGroupCount cc expected = do + rows <- withCCTransaction cc $ \db -> + DB.query_ db "SELECT COUNT(*) FROM groups WHERE relay_own_status IS NOT NULL" :: IO [Only Int] + let n = case rows of + [Only c] -> c + _ -> 0 + n `shouldBe` expected + +testRelayRejectAfterLeave :: HasCallStack => TestParams -> IO () +testRelayRejectAfterLeave ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do + (shortLink, fullLink) <- prepareChannel1Relay "team" alice bob + memberJoinChannel "team" [bob] [alice] shortLink fullLink cath + threadDelay 100000 + + -- baseline: subscriber receives forwarded messages via the active relay + alice #> "#team hello" + bob <# "#team> hello" + cath <# "#team> hello [>>]" + + -- relay leaves the channel: subscriber gets the signed leave notice via bob's + -- DJRelayRemoved job, then has no relay to forward subsequent messages. + bob ##> "/leave #team" + bob <## "#team: you left the group (future invitations will be rejected)" + bob <## "use /group allow #team to allow future invitations" + bob <## "use /d #team to delete the group (also clears the rejection)" + concurrentlyN_ + [ alice <## "#team: bob left the group (signed)", + cath <## "#team: bob left the group (signed)" + ] + threadDelay 100000 + + bobLeaveStatus <- queryRelayOwnStatus bob 1 + bobLeaveStatus `shouldBe` Just "rejected" + + -- with no active relay, owner's messages don't reach the subscriber + alice #> "#team after leave" + (cath "/rm #team bob" + alice <## "#team: you removed bob from the group (signed)" + threadDelay 100000 + + -- owner re-adds bob as relay + alice ##> "/_add relays #1 1" + alice <## "#team: group relays:" + alice .<##. (" - relay id", ": invited") + + -- bob's xGrpRelayInv finds the 'rejected' row for this link and sends XGrpRelayReject. + -- alice's CONF handler emits TERelayRejected; the relay row flips to 'rejected'. + alice <## "#team: relay rejected, reason: RRRRejoinRejected" + + -- assert alice's fresh GroupRelay row is marked 'rejected' and the relay + -- GroupMember is GSMemLeft so the owner UI treats it as gone + aliceRelayStatuses <- withCCTransaction alice $ \db -> + DB.query_ db "SELECT relay_status FROM group_relays" :: IO [Only T.Text] + map (\(Only s) -> s) aliceRelayStatuses `shouldBe` ["rejected"] + aliceRelayMemStatuses <- withCCTransaction alice $ \db -> + DB.query_ db "SELECT member_status FROM group_members WHERE member_role = 'relay'" + :: IO [Only T.Text] + map (\(Only s) -> s) aliceRelayMemStatuses `shouldBe` ["left"] + + -- subscriber still doesn't receive after the failed re-invitation + alice #> "#team after rejection" + (cath TestParams -> IO () +testRelayAllowAcceptsAgain ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do + (shortLink, fullLink) <- prepareChannel1Relay "team" alice bob + memberJoinChannel "team" [bob] [alice] shortLink fullLink cath + threadDelay 100000 + + -- baseline: subscriber receives forwarded messages + alice #> "#team hello" + bob <# "#team> hello" + cath <# "#team> hello [>>]" + + bob ##> "/leave #team" + bob <## "#team: you left the group (future invitations will be rejected)" + bob <## "use /group allow #team to allow future invitations" + bob <## "use /d #team to delete the group (also clears the rejection)" + concurrentlyN_ + [ alice <## "#team: bob left the group (signed)", + cath <## "#team: bob left the group (signed)" + ] + threadDelay 100000 + + -- with no relay, subscriber doesn't receive + alice #> "#team during downtime" + (cath "/group allow #team" + bob <## "#team: relay rejection cleared" + bobClearStatus <- queryRelayOwnStatus bob 1 + bobClearStatus `shouldBe` Just "inactive" + + -- owner can now re-add and bob accepts as relay (the rejection has been cleared) + alice ##> "/rm #team bob" + alice <## "#team: you removed bob from the group (signed)" + threadDelay 100000 + + alice ##> "/_add relays #1 1" + concurrentlyN_ + [ do + alice <## "#team: group relays:" + alice .<##. (" - relay id", ": invited") + alice <## "#team: group link relays updated, current relays:" + alice .<##. (" - relay id", ": active") + alice <## "group link:" + void $ getTermLine alice, + bob <## "#team_1: you joined the group as relay" + ] + threadDelay 100000 + + -- subscriber syncs against link data and reconnects to the new relay + cath ##> "/_get group link data #1" + cath <## "group ID: 1" + void $ getTermLine cath + concurrentlyN_ + [ do + cath <## "#team: joining the group (connecting to relay bob)..." + cath <## "#team: you joined the group (connected to relay bob)", + do + bob <## "cath_1 (Catherine): accepting request to join group #team_1..." + bob <## "#team_1: cath_1 joined the group" + ] + threadDelay 100000 + + -- delivery resumes through the freshly accepted relay + alice #> "#team after allow" + bob <# "#team_1> after allow" + cath <# "#team> after allow [>>]" + + -- after re-acceptance, the relay GroupMember is not in the rejected/left state + aliceRelayMemStatuses <- withCCTransaction alice $ \db -> + DB.query_ db "SELECT member_status FROM group_members WHERE member_role = 'relay'" + :: IO [Only T.Text] + map (\(Only s) -> s) aliceRelayMemStatuses `shouldBe` ["connected"] + +testRelayDoesNotRejectUnrelatedChannel :: HasCallStack => TestParams -> IO () +testRelayDoesNotRejectUnrelatedChannel ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do + _ <- prepareChannel1Relay "teama" alice bob + threadDelay 100000 + + bob ##> "/leave #teama" + bob <## "#teama: you left the group (future invitations will be rejected)" + bob <## "use /group allow #teama to allow future invitations" + bob <## "use /d #teama to delete the group (also clears the rejection)" + alice <## "#teama: bob left the group (signed)" + threadDelay 100000 + + bobAStatus <- queryRelayOwnStatus bob 1 + bobAStatus `shouldBe` Just "rejected" + + -- alice creates a second channel reusing the same bob relay config. + -- bob's xGrpRelayInv for teamb's link finds no rejection and accepts normally. + (shortLinkB, fullLinkB) <- prepareChannel' 2 "teamb" alice bob + memberJoinChannel "teamb" [bob] [alice] shortLinkB fullLinkB cath + threadDelay 100000 + + -- subscriber on teamb receives forwarded messages, proving bob accepts teamb + -- even though teama remains rejected on bob's side. + alice #> "#teamb hello" + bob <# "#teamb> hello" + cath <# "#teamb> hello [>>]" + + bobBStatus <- queryRelayOwnStatus bob 2 + bobBStatus `shouldNotBe` Just "rejected" + bobBStatus `shouldNotBe` Nothing + +testRelayRejectRaceConcurrentInvitations :: HasCallStack => TestParams -> IO () +testRelayRejectRaceConcurrentInvitations ps = + -- After rejection, multiple sequential re-invitations must all reject with + -- consistent state (each transient row created with RSRejected and cleaned + -- up by its own INFO). + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do + (shortLink, fullLink) <- prepareChannel1Relay "team" alice bob + memberJoinChannel "team" [bob] [alice] shortLink fullLink cath + threadDelay 100000 + + -- baseline: subscriber receives forwarded messages + alice #> "#team hello" + bob <# "#team> hello" + cath <# "#team> hello [>>]" + + bob ##> "/leave #team" + bob <## "#team: you left the group (future invitations will be rejected)" + bob <## "use /group allow #team to allow future invitations" + bob <## "use /d #team to delete the group (also clears the rejection)" + concurrentlyN_ + [ alice <## "#team: bob left the group (signed)", + cath <## "#team: bob left the group (signed)" + ] + threadDelay 100000 + + -- first rejection + alice ##> "/rm #team bob" + alice .<##. ("#team: you removed bob from the group", "") + threadDelay 100000 + alice ##> "/_add relays #1 1" + alice <## "#team: group relays:" + alice .<##. (" - relay id", ": invited") + alice <## "#team: relay rejected, reason: RRRRejoinRejected" + threadDelay 1000000 + checkRelayGroupCount bob 1 + + -- subscriber doesn't receive between rejections (no active relay) + alice #> "#team between rejections" + (cath "/rm #team bob" + alice .<##. ("#team: you removed bob from the group", "") + threadDelay 100000 + alice ##> "/_add relays #1 1" + alice <## "#team: group relays:" + alice .<##. (" - relay id", ": invited") + alice <## "#team: relay rejected, reason: RRRRejoinRejected" + + -- subscriber still doesn't receive after the second rejection + alice #> "#team after second rejection" + (cath TestParams -> IO () testChannelCreateDeletedRelay ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do