mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-09-01 17:58:37 +00:00
core, ui: relay reject rejoin (#6978)
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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) |
|
||||
|
||||
|
||||
+12
-10
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
+8
-4
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+3
@@ -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(
|
||||
|
||||
+11
-5
@@ -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>
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -3350,6 +3350,7 @@ ParseError:
|
||||
- "accepted"
|
||||
- "active"
|
||||
- "inactive"
|
||||
- "rejected"
|
||||
|
||||
|
||||
---
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -73,6 +73,7 @@ chatResponsesDocsData =
|
||||
("CRGroupRelays", ""),
|
||||
("CRGroupRelaysAdded", ""),
|
||||
("CRGroupRelaysAddFailed", ""),
|
||||
("CRRelayGroupAllowed", "Relay rejection cleared for a channel"),
|
||||
("CRGroupMembers", ""),
|
||||
("CRGroupUpdated", ""),
|
||||
("CRGroupsList", "Groups"),
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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:
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
+21
@@ -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);
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user