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] 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