mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-05-24 23:55:50 +00:00
wip
This commit is contained in:
@@ -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) }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
Reference in New Issue
Block a user