This commit is contained in:
spaced4ndy
2026-05-15 13:55:29 +04:00
parent d4a586a496
commit 3ccd5de96c
26 changed files with 499 additions and 30 deletions
@@ -199,6 +199,9 @@ struct GroupMemberInfoView: View {
Label("Share relay address", systemImage: "square.and.arrow.up")
}
}
if groupRelay?.relayStatus == .rsRejected {
infoRow("Status", "rejected by relay operator")
}
} header: {
Text(channelMemberSectionHeader).foregroundColor(theme.colors.secondary)
} footer: {
@@ -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 == .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
return HStack(spacing: 4) {
Circle()
.fill(color)
+2
View File
@@ -2640,6 +2640,7 @@ public enum RelayStatus: String, Decodable, Equatable, Hashable {
case rsAccepted = "accepted"
case rsActive = "active"
case rsInactive = "inactive"
case rsRejected = "rejected"
}
public struct RelayProfile: Codable, Equatable, Hashable {
@@ -2713,6 +2714,7 @@ extension RelayStatus {
case .rsAccepted: "accepted"
case .rsActive: "active"
case .rsInactive: "inactive"
case .rsRejected: "rejected"
}
}
}
+1 -1
View File
@@ -49,7 +49,7 @@ This document provides a structured mapping between product-level concepts, thei
| 28 | Chat Tags | [views/chat-list.md](views/chat-list.md) | [spec/state.md](../spec/state.md) | `ChatList/TagListView.swift`, `ChatListView.swift` | `Types.hs` (`ChatTag`), `Controller.hs` |
| 29 | User Address | [views/settings.md](views/settings.md) | [spec/api.md](../spec/api.md) | `UserSettings/UserAddressView.swift`, `Onboarding/AddressCreationCard.swift` | `Controller.hs` (`APICreateMyAddress`) |
| 30 | Member Support Chat | [views/group-info.md](views/group-info.md) | [spec/api.md](../spec/api.md) | `Group/MemberSupportView.swift`, `MemberAdmissionView.swift` | `Messages.hs` (`GroupChatScope`), `Controller.hs` |
| 31 | Channels (Relays) | [glossary.md](glossary.md), [views/chat.md](views/chat.md) | [spec/api.md](../spec/api.md), [spec/state.md](../spec/state.md), [spec/client/chat-view.md](../spec/client/chat-view.md), [spec/client/compose.md](../spec/client/compose.md) | `SimpleXChat/ChatTypes.swift` (`RelayStatus`, `RelayStatus.text`, `GroupRelay`, `GroupMemberRole.relay`, `CIDirection.channelRcv`, `GroupInfo.chatIconName`, `userCantSendReason`), `Shared/Views/Chat/ChatView.swift` (channel message rendering), `Shared/Views/Chat/ComposeMessage/ComposeView.swift` (`sendAsGroup`, Broadcast placeholder), `Shared/Views/Chat/Group/GroupChatInfoView.swift` (channel info adaptations), `Shared/Views/Chat/Group/ChannelMembersView.swift`, `Shared/Views/Chat/Group/ChannelRelaysView.swift`, `Shared/Model/AppAPITypes.swift` (`GroupShortLinkInfo`, `UserChatRelay`), `Shared/Model/SimpleXAPI.swift` (`apiNewPublicGroup`), `SimpleX SE/ShareAPI.swift` (channel `sendAsGroup`) | `Controller.hs` (`APINewPublicGroup`) |
| 31 | Channels (Relays) | [glossary.md](glossary.md), [views/chat.md](views/chat.md), [views/group-info.md](views/group-info.md) | [spec/api.md](../spec/api.md), [spec/state.md](../spec/state.md), [spec/client/chat-view.md](../spec/client/chat-view.md), [spec/client/compose.md](../spec/client/compose.md) | `SimpleXChat/ChatTypes.swift` (`RelayStatus` incl. `.rsRejected`, `RelayStatus.text`, `GroupRelay`, `GroupMemberRole.relay`, `GroupMemberStatus.memRejected`, `CIDirection.channelRcv`, `GroupInfo.chatIconName`, `userCantSendReason`), `Shared/Views/Chat/ChatView.swift` (channel message rendering), `Shared/Views/Chat/ComposeMessage/ComposeView.swift` (`sendAsGroup`, Broadcast placeholder), `Shared/Views/Chat/Group/GroupChatInfoView.swift` (channel info adaptations), `Shared/Views/Chat/Group/GroupMemberInfoView.swift` (rejected-status row), `Shared/Views/Chat/Group/ChannelMembersView.swift`, `Shared/Views/Chat/Group/ChannelRelaysView.swift`, `Shared/Views/NewChat/AddChannelView.swift` (`relayStatusIndicator` rejected branch), `Shared/Model/AppAPITypes.swift` (`GroupShortLinkInfo`, `UserChatRelay`), `Shared/Model/SimpleXAPI.swift` (`apiNewPublicGroup`), `SimpleX SE/ShareAPI.swift` (channel `sendAsGroup`) | `Controller.hs` (`APINewPublicGroup`, `APIAllowRelayGroup`, `XGrpRelayReject` CONF handler) |
---
+1
View File
@@ -221,6 +221,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". Indicates the relay refused to rejoin this channel after a prior `/leave` — clearable only by the relay operator via `/relay allow <groupId>`. |
| Relay footer | Owner: "Subscribers use relay link to connect to the channel. Relay address was used to set up this relay for the channel." Non-owner: "You connected to the channel via this relay link." |
## Related Specs
+1
View File
@@ -415,6 +415,7 @@ Event processing entry point: [`processReceivedMsg`](../Shared/Model/SimpleXAPI.
| `groupUpdated` | `user, toGroup: GroupInfo` | Group profile changed | [L1106](../Shared/Model/AppAPITypes.swift#L1106) |
| `groupLinkRelaysUpdated` | `user, groupInfo, groupLink, groupRelays: [GroupRelay]` | Channel relay configuration changed | [L1107](../Shared/Model/AppAPITypes.swift#L1107) |
| `groupMemberUpdated` | `user, groupInfo, fromMember, toMember` | Member info updated | [L1081](../Shared/Model/AppAPITypes.swift#L1081) |
| `groupRelayUpdated` | `user, groupInfo, member, groupRelay` | Owner-side: a relay's `relayStatus` and/or the member's status changed. Fires on `XGrpRelayReject` with `relayStatus = .rsRejected` and `member.memberStatus = .memRejected` — final until cleared by the relay operator's `/relay allow <groupId>` (no event emitted to the owner for that clear). | Controller.hs (`CEvtGroupRelayUpdated`) |
### File Transfer Events
+5 -1
View File
@@ -350,9 +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).
### Relay Refusal 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` becomes `.memRejected`. 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 `/relay allow <groupId>`, which has no owner-facing event.
### Leave Button Logic
Sole channel owner cannot leave (only delete). Guard: `members.filter({ $0.wrapped.memberRole == .owner && $0.wrapped.groupMemberId != groupInfo.membership.groupMemberId }).count > 0`.
+1 -1
View File
@@ -61,7 +61,7 @@
| Shared/Views/Chat/Group/ChannelRelaysView.swift | PC31 | Medium | Channel relay status list |
| Shared/Views/Chat/Group/AddGroupMembersView.swift | PC14, PC16 | Medium | Member invitation flow |
| Shared/Views/Chat/Group/GroupLinkView.swift | PC15 | Low | Group link creation/sharing |
| Shared/Views/Chat/Group/GroupMemberInfoView.swift | PC3, PC14, PC16, PC30 | Medium | Member details and role management |
| Shared/Views/Chat/Group/GroupMemberInfoView.swift | PC3, PC14, PC16, PC30, PC31 | Medium | Member details and role management; rejected-by-operator status row for relay members |
| Shared/Views/NewChat/NewChatView.swift | PC12, PC31 | High | New connection creation — onramp for all contacts and channels |
| Shared/Views/NewChat/QRCode.swift | PC12 | Low | QR code display/scanning utility |
| Shared/Views/Call/ActiveCallView.swift | PC17 | Medium | Call UI rendering |
+2 -2
View File
@@ -390,8 +390,8 @@ A **channel** is a group with `groupInfo.useRelays == true`. These types support
| Type | Kind | Description | Line |
|------|------|-------------|------|
| `RelayStatus` | `enum` | Relay lifecycle: `.rsNew`, `.rsInvited`, `.rsAccepted`, `.rsActive` | [L2506](../SimpleXChat/ChatTypes.swift#L2506) |
| `RelayStatus.text` | `extension` | Localized display text: New/Invited/Accepted/Active | [L2565](../SimpleXChat/ChatTypes.swift#L2565) |
| `RelayStatus` | `enum` | Relay lifecycle: `.rsNew`, `.rsInvited`, `.rsAccepted`, `.rsActive`, `.rsInactive`, `.rsRejected` | [L2506](../SimpleXChat/ChatTypes.swift#L2506) |
| `RelayStatus.text` | `extension` | Localized display text: New/Invited/Accepted/Active/Inactive/Rejected | [L2565](../SimpleXChat/ChatTypes.swift#L2565) |
| `GroupRelay` | `struct` | Relay instance for a group (ID, member ID, relay status). Fetched at runtime via `apiGetGroupRelays` (owner only) | [L2555](../SimpleXChat/ChatTypes.swift#L2555) |
| `UserChatRelay` | `struct` | User's chat relay configuration (ID, SMP address, name, domains, preset/tested/enabled/deleted flags) | [L2513](../SimpleXChat/ChatTypes.swift#L2513) |