This commit is contained in:
spaced4ndy
2026-05-15 18:08:45 +04:00
parent e1497615ad
commit 0ae4d4ddb9
13 changed files with 108 additions and 9 deletions
@@ -35,7 +35,9 @@ struct ChannelRelaysView: View {
}
}
.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) }
}
+2 -1
View File
@@ -188,6 +188,7 @@ New view accessible from channel info, showing relay members (role == `.relay`):
| Relay list | Filtered from `chatModel.groupMembers` by `.relay` role |
| Relay row | Profile image, relay display name, status text (`RelayStatus` or connection status) |
| Relay tap | NavigationLink to `GroupMemberInfoView` with `groupRelay:` parameter |
| Add relay sheet | Owner-only "Add relay" button opens `AddGroupRelayView`; the available-to-add list excludes any `chatRelayId` already present in `groupRelays` (regardless of `relayStatus`), so inactive or rejected relays cannot be re-added without first removing them via the row's swipe action |
| Empty state | "No chat relays" |
| Footer | "Chat relays forward messages to channel subscribers." |
@@ -221,7 +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". Indicates the relay rejected the invitation to rejoin this channel after a prior `/leave` — clearable only by the relay operator via `/relay allow <groupId>`. |
| 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 `/relay 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
+2 -1
View File
@@ -352,10 +352,11 @@ Groups use separate [`groupLinkButton()`](../../Shared/Views/Chat/Group/GroupCha
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`). 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` 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.
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 `/relay allow #<channel>`, which has no owner-facing event.
### Leave Button Logic
+38
View File
@@ -33,6 +33,7 @@ This file is generated automatically.
- [APINewPublicGroup](#apinewpublicgroup)
- [APIGetGroupRelays](#apigetgrouprelays)
- [APIAddGroupRelays](#apiaddgrouprelays)
- [APIAllowRelayGroup](#apiallowrelaygroup)
- [APIUpdateGroupProfile](#apiupdategroupprofile)
[Group link commands](#group-link-commands)
@@ -1080,6 +1081,43 @@ ChatCmdError: Command error (only used in WebSockets API).
---
### APIAllowRelayGroup
Clear relay rejection for a channel (relay operator).
*Network usage*: background.
**Parameters**:
- groupId: int64
**Syntax**:
```
/_relay allow #<groupId>
```
```javascript
'/_relay allow #' + groupId // JavaScript
```
```python
'/_relay allow #' + str(groupId) # Python
```
**Responses**:
RelayGroupAllowed: Relay rejection cleared for a channel.
- type: "relayGroupAllowed"
- user: [User](./TYPES.md#user)
- groupInfo: [GroupInfo](./TYPES.md#groupinfo)
ChatCmdError: Command error (only used in WebSockets API).
- type: "chatCmdError"
- chatError: [ChatError](./TYPES.md#chaterror)
---
### APIUpdateGroupProfile
Update group profile.
+2
View File
@@ -120,6 +120,7 @@ chatCommandsDocsData =
("APINewPublicGroup", [], "Create public group.", ["CRPublicGroupCreated", "CRPublicGroupCreationFailed", "CRChatCmdError"], [], Just UNInteractive, "/_public group " <> Param "userId" <> OnOffParam "incognito" "incognito" (Just False) <> " " <> Join ',' "relayIds" <> " " <> Json "groupProfile"),
("APIGetGroupRelays", [], "Get group relays.", ["CRGroupRelays", "CRChatCmdError"], [], Nothing, "/_get relays #" <> Param "groupId"),
("APIAddGroupRelays", [], "Add relays to group.", ["CRGroupRelaysAdded", "CRGroupRelaysAddFailed", "CRChatCmdError"], [], Just UNInteractive, "/_add relays #" <> Param "groupId" <> " " <> Join ',' "relayIds"),
("APIAllowRelayGroup", [], "Clear relay rejection for a channel (relay operator).", ["CRRelayGroupAllowed", "CRChatCmdError"], [], Just UNBackground, "/_relay allow #" <> Param "groupId"),
("APIUpdateGroupProfile", [], "Update group profile.", ["CRGroupUpdated", "CRChatCmdError"], [], Just UNBackground, "/_group_profile #" <> Param "groupId" <> " " <> Json "groupProfile")
]
),
@@ -203,6 +204,7 @@ cliCommands =
"AcceptMember",
"AddContact",
"AddMember",
"AllowRelayGroup",
"BlockForAll",
"ChatHelp",
"ClearContact",
+1
View File
@@ -73,6 +73,7 @@ chatResponsesDocsData =
("CRGroupRelays", ""),
("CRGroupRelaysAdded", ""),
("CRGroupRelaysAddFailed", ""),
("CRRelayGroupAllowed", "Relay rejection cleared for a channel"),
("CRGroupMembers", ""),
("CRGroupUpdated", ""),
("CRGroupsList", "Groups"),
+1 -1
View File
@@ -80,7 +80,7 @@ When a relay operator removes the relay from a channel, the relay marks the chan
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'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.
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 `/relay 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.
@@ -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
@@ -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"]
+6 -3
View File
@@ -772,12 +772,15 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
| otherwise -> messageError "x.grp.relay.acpt: only owner can add relay"
XGrpRelayReject reason
| memberRole' membership == GROwner && isRelay m -> do
relay <- withStore $ \db -> 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)
liftIO $ updateRelayStatusFromTo db relay RSInvited RSRejected
relay' <- liftIO $ updateRelayStatusFromTo db relay RSInvited RSRejected
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
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"
+12 -1
View File
@@ -9998,10 +9998,15 @@ testRelayRejectAfterLeave ps =
-- 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'
-- 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"
@@ -10084,6 +10089,12 @@ testRelayAllowAcceptsAgain ps =
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 ->