core, ui: relay reject rejoin (#6978)

This commit is contained in:
spaced4ndy
2026-05-18 09:06:25 +00:00
committed by GitHub
parent c165663555
commit 92e9640e4f
53 changed files with 1169 additions and 112 deletions
@@ -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)
@@ -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
}
}
@@ -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: {
@@ -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)
+12 -10
View File
@@ -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"
}
}
}
+1 -1
View File
@@ -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) |
---
+2
View File
@@ -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 #<channel>`. |
| 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
+1
View File
@@ -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 <groupId>` (no event emitted to the owner for that clear). | Controller.hs (`CEvtGroupRelayUpdated`) |
### File Transfer Events
+6 -1
View File
@@ -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 #<channel>`, which has no owner-facing event.
### Leave Button Logic
+1 -1
View File
@@ -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 |
+2 -2
View File
@@ -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) |
@@ -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)
}
}
@@ -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)
@@ -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<GroupRelay>): 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<GroupRel
} else if (member.activeConn?.connInactive == true) {
generalGetString(MR.strings.member_info_member_inactive)
} else {
groupRelays.firstOrNull { it.groupMemberId == member.groupMemberId }?.relayStatus?.text
?: relayConnStatus(member).first
relayStatus?.text ?: relayConnStatus(member).first
}
}
@@ -616,6 +616,9 @@ fun GroupMemberInfoLayout(
val clipboard = LocalClipboardManager.current
ShareRelayAddressButton { clipboard.shareText(simplexChatLink(relayAddress)) }
}
if (groupRelay?.relayStatus == RelayStatus.Rejected) {
InfoRow(stringResource(MR.strings.member_info_status), stringResource(MR.strings.member_info_relay_status_rejected_by_operator))
}
}
if (groupInfo.useRelays && member.memberRole == GroupMemberRole.Relay) {
SectionTextFooter(
@@ -361,11 +361,11 @@ private fun ProgressStepView(
cancelChannelCreation: () -> 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)
@@ -2996,6 +2996,9 @@
<string name="relay_status_accepted">accepted</string>
<string name="relay_status_active">active</string>
<string name="relay_status_inactive">inactive</string>
<string name="relay_status_rejected">rejected</string>
<string name="member_info_status">Status</string>
<string name="member_info_relay_status_rejected_by_operator">rejected by relay operator</string>
<!-- ComposeView.kt channel relay bars -->
<string name="relay_bar_all_relays_removed">All relays removed</string>
+1
View File
@@ -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/`
@@ -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 #<channel>`. |
## 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` |
+1
View File
@@ -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 #<channel>` (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 |
@@ -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 #<channel>`, 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.
+31 -27
View File
@@ -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) |
+15
View File
@@ -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 #<channel>`. 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").
---
<a id="AppPreferences"></a>
+38
View File
@@ -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 #<groupId>
```
```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.
+1
View File
@@ -3350,6 +3350,7 @@ ParseError:
- "accepted"
- "active"
- "inactive"
- "rejected"
---
+2
View File
@@ -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",
+1
View File
@@ -73,6 +73,7 @@ chatResponsesDocsData =
("CRGroupRelays", ""),
("CRGroupRelaysAdded", ""),
("CRGroupRelaysAddFailed", ""),
("CRRelayGroupAllowed", "Relay rejection cleared for a channel"),
("CRGroupMembers", ""),
("CRGroupUpdated", ""),
("CRGroupsList", "Groups"),
+14
View File
@@ -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 <groupId>` 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:
@@ -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 {
@@ -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
@@ -3751,6 +3751,7 @@ export enum RelayStatus {
Accepted = "accepted",
Active = "active",
Inactive = "inactive",
Rejected = "rejected",
}
export enum ReportReason {
@@ -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):
@@ -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"]
@@ -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"]
+347
View File
@@ -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 <groupId>`.
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 <groupId> 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 <groupId>`; 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 <groupId>` 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.
+2
View File
@@ -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:
+4
View File
@@ -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}
+14 -2
View File
@@ -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,
+22
View File
@@ -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}
+40 -9
View File
@@ -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
+7
View File
@@ -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]
+42 -5
View File
@@ -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 [])
@@ -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
@@ -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;
|]
@@ -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);
+3 -1
View File
@@ -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
@@ -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;
|]
@@ -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=?)
@@ -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
+26
View File
@@ -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
+5
View File
@@ -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)
+15 -3
View File
@@ -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"
+282 -8
View File
@@ -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 </)
-- owner removes the (now-left) relay member; cascade clears alice's group_relays row
alice ##> "/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 </)
-- bob's transient row was created with relay_own_status='rejected';
-- after INFO arrives the cleanup arm deletes it. Original row 1 remains rejected.
threadDelay 1000000
checkRelayGroupCount bob 1
finalStatuses <- listRelayOwnStatuses bob
finalStatuses `shouldBe` [(1, "rejected")]
testRelayAllowAcceptsAgain :: HasCallStack => 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 </)
-- /_relay allow flips bob's row from 'rejected' to 'inactive'
bob ##> "/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 </)
-- second 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"
-- subscriber still doesn't receive after the second rejection
alice #> "#team after second rejection"
(cath </)
threadDelay 1000000
checkRelayGroupCount bob 1
finalStatuses <- listRelayOwnStatuses bob
finalStatuses `shouldBe` [(1, "rejected")]
testChannelCreateDeletedRelay :: HasCallStack => TestParams -> IO ()
testChannelCreateDeletedRelay ps =
withNewTestChat ps "alice" aliceProfile $ \alice -> do