mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-05-15 05:56:04 +00:00
docs: groups via relays plan
This commit is contained in:
@@ -0,0 +1,783 @@
|
||||
# Plan: Public groups via relays
|
||||
|
||||
Date: 2026-05-08
|
||||
|
||||
## 1. Overview
|
||||
|
||||
The previous release shipped **Channels** — relay-mediated groups where owners
|
||||
publish and subscribers consume. The relay-forwarding pipeline is general:
|
||||
`Subscriber.hs` already dispatches `XMsgNew`, edits, deletes, reactions, file
|
||||
descriptors, profile updates, and leaves from any sender, and gates posting by
|
||||
`memberRole > GRObserver` (`memberCanSend`, `Subscriber.hs:1551`). Subscribers
|
||||
in channels are pinned to `GRObserver`, so they cannot post — but the machinery
|
||||
to forward content from non-owners is present and exercised today
|
||||
(`testChannels1RelayDeliver`, `tests/ChatTests/Groups.hs:8523`, where a
|
||||
non-owner reaction reaches every member through the relay).
|
||||
|
||||
**Public groups** are the second value of the existing two-axis design: same
|
||||
relay transport as channels, but every member can post like a regular "secret"
|
||||
group. They give us scale (relay topology) without the broadcast governance.
|
||||
The actual code blocker is narrow: there is no path that produces
|
||||
`groupType = GTGroup`, `Commands.hs:2514` always writes `GTChannel`, and the
|
||||
relay's joiner-role default is read from a global config (`channelSubscriberRole`)
|
||||
instead of being derived from the channel's `groupType`. Plumb the type through
|
||||
the create command, branch the joiner-role derivation on `groupType`, integrate
|
||||
with the already-approved member-profile dissemination plan, and the feature
|
||||
works.
|
||||
|
||||
## 2. Concept summary: the `useRelays × groupType` matrix
|
||||
|
||||
| `useRelays` | `groupType` | Name | Wire shape | UX |
|
||||
|-------------|-------------------------|-------------------|-------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------|
|
||||
| `false` | (no `publicGroup`) | **Secret group** | P2P `x.grp.inv` invitations; full mesh between members; JSON array batch. | Today's group: all members can post; profiles known eagerly; admins moderate. |
|
||||
| `true` | `GTChannel` | **Channel** | Relay-mediated; subscribers join via channel link; binary signed-batch format; `GRObserver`. | Today's channel: only owners post; subscribers anonymous to each other. |
|
||||
| `true` | `GTGroup` | **Public group** | Same wire as channel; subscribers join with `GRMember`; profile dissemination on demand. | New: every member can post; member-to-member DMs allowed; member roster grown lazily via on-demand profile send. |
|
||||
| `true` | `GTUnknown _` (decode) | (refuse to join) | Channel link from a newer client; older client sees unknown discriminator. | New clients reject with a clear "needs newer version" message; pre-existing channels unaffected. |
|
||||
|
||||
Two derivations follow from this matrix:
|
||||
|
||||
- **Transport** is `useRelays` — affects connection topology, binary batch
|
||||
format, signatures, delivery pipeline, identity binding.
|
||||
- **Governance** is `groupType` (when `useRelays = true`) — affects the joiner
|
||||
default role, profile dissemination, and member-to-member affordances.
|
||||
|
||||
The existing iOS/Kotlin `isChannel = publicGroup?.groupType == channel`
|
||||
distinguishes governance (channel only). The existing `useRelays` boolean
|
||||
distinguishes transport (channel or public group). Many call sites today use
|
||||
`useRelays` as a proxy for `isChannel` because `GTGroup` doesn't exist yet —
|
||||
those sites are the audit work.
|
||||
|
||||
## 3. Backend changes (Haskell, in `src/Simplex/Chat`)
|
||||
|
||||
The order below is the order the changes touch the wire/types/commands/runtime
|
||||
pipeline. Within each subsection, files are listed in approximate touch order.
|
||||
|
||||
### 3.1 Wire format / protocol
|
||||
|
||||
The wire format does not change — `groupType = "group"` is already a valid
|
||||
encoded value for the existing `GroupType` field on `PublicGroupProfile`
|
||||
(`Types.hs:767-781`); today's encode/decode round-trips it (`textEncode GTGroup
|
||||
= "group"`). No new fields, no new messages.
|
||||
|
||||
What does change:
|
||||
|
||||
- **Chat protocol version bump.** Add `publicGroupsVersion :: VersionChat =
|
||||
VersionChat 18` in `src/Simplex/Chat/Protocol.hs` (currently
|
||||
`currentChatVersion = VersionChat 17`, line 90). The new version signals
|
||||
that the peer understands `groupType = "group"` semantically. `Protocol.hs`
|
||||
already has the version-bump idiom (`shortLinkDataVersion = 16`,
|
||||
`memberSupportVoiceVersion = 17`).
|
||||
- **Older-client behaviour.** Older clients decode the channel link's
|
||||
`groupType` field as `GTUnknown "group"` (lossless tag preservation in
|
||||
`textDecode`, `Types.hs:778-781`). They must refuse to join with a clear
|
||||
message; see §3.3.
|
||||
|
||||
No protocol doc changes are wire-mandatory, but `docs/protocol/simplex-chat.md`
|
||||
("Channels: relay-mediated groups", line 269-273) and `channels-overview.md` /
|
||||
`channels-protocol.md` should grow a brief paragraph explaining the
|
||||
`groupType` discriminator and that "Public groups" is the second value. This
|
||||
is a docs task that pairs with the implementation, not a separate gating step.
|
||||
|
||||
### 3.2 Type changes
|
||||
|
||||
Mostly inert. The `GroupType` ADT (`Types.hs:767-771`) already has `GTGroup` —
|
||||
no change. The `PublicGroupProfile`/`GroupProfile` definitions
|
||||
(`Types.hs:787-804`) are unchanged.
|
||||
|
||||
Two small additions:
|
||||
|
||||
- `Types.hs` — derive `groupType' :: GroupInfo -> Maybe GroupType` helper
|
||||
alongside `useRelays' :: GroupInfo -> Bool` (line 494). Lets call sites read
|
||||
governance without inlining the `groupProfile.publicGroup.groupType` walk.
|
||||
Optional but reduces site-by-site verbosity in `Subscriber.hs`/`Commands.hs`.
|
||||
- `Types.hs` — derive `isPublicGroup' :: GroupInfo -> Bool` (true iff
|
||||
`useRelays' && groupType' == Just GTGroup`). One helper, used at the
|
||||
joiner-role derivation site and the DM-invite gate.
|
||||
|
||||
`Types/Shared.hs` (the `GroupMemberRole` ADT, lines 18-51) is unchanged.
|
||||
`Protocol.hs` `requiresSignature` (line 1221) is unchanged for the MVP — see
|
||||
§3.4 for `unverifiedAllowed` tightening, which is a follow-up.
|
||||
|
||||
### 3.3 API/command changes
|
||||
|
||||
Goal: extend the existing `/public group` and `APINewPublicGroup` command with
|
||||
a `groupType` parameter (option (a) in the brief). This is the smallest diff:
|
||||
two parsers, one constructor field, one downstream substitution.
|
||||
|
||||
**`src/Simplex/Chat/Controller.hs`**
|
||||
|
||||
- Line 525, `APINewPublicGroup` constructor — add `groupType :: GroupType`
|
||||
field. Owner specifies the kind at creation time.
|
||||
- Line 528, `NewPublicGroup` command — same field, parsed from the CLI form.
|
||||
- Update `View.hs` and `Library/Commands.hs` to consume the new field.
|
||||
- Remove or rename the `channelSubscriberRole :: GroupMemberRole` config field
|
||||
at line 161. See §3.4 — the config-driven default is replaced by a
|
||||
`groupType`-driven derivation, so the config has no callers after this
|
||||
change. Removing is cleaner than leaving dead config; if removal feels
|
||||
scary, mark it deprecated and stop reading it (no consumer remains).
|
||||
|
||||
**`src/Simplex/Chat/Library/Commands.hs`**
|
||||
|
||||
- Line 2471-2527, `APINewPublicGroup` handler. Two substitutions:
|
||||
- Line 2514: `groupType = GTChannel` becomes `groupType = gType` (the new
|
||||
parameter).
|
||||
- Line 2522: `subRole <- asks $ channelSubscriberRole . config` becomes
|
||||
`let subRole = subscriberRoleFor gType` where `subscriberRoleFor GTChannel
|
||||
= GRObserver; subscriberRoleFor GTGroup = GRMember; subscriberRoleFor
|
||||
(GTUnknown _) = GRObserver` (defensive: unknown means do not let posting
|
||||
happen). Place `subscriberRoleFor` next to the helpers in `Types.hs` or in
|
||||
a small `Subscriber/Roles.hs` if you want a single canonical site.
|
||||
- Line 2024-2040, `APIPrepareGroup` handler. Line 2029:
|
||||
`subRole <- if useRelays then asks $ channelSubscriberRole . config else
|
||||
pure GRMember` becomes `subRole = if useRelays then subscriberRoleFor (case
|
||||
groupSLinkData of GroupShortLinkData {groupProfile = GroupProfile
|
||||
{publicGroup = Just PublicGroupProfile {groupType = t}}} -> t; _ ->
|
||||
GTChannel) else GRMember`. The subscriber sees the `groupType` directly
|
||||
from the channel link's resolved `GroupShortLinkData`.
|
||||
- Line 5111-5112, parser. Extend `/public group` and `/_public group`
|
||||
parsers to accept an optional `type=channel|group` token; default
|
||||
`channel` to keep all current scripts and tests working without edits.
|
||||
- Line 4111-4116, `groupShortLinkPlan`. The `entityId == publicGroupId`
|
||||
check is unchanged; we already have the resolved `groupType` from
|
||||
`groupSLinkData_` if needed for downstream UX.
|
||||
|
||||
**`src/Simplex/Chat.hs`**
|
||||
|
||||
- Line 119: `channelSubscriberRole = GRObserver` — remove with the field
|
||||
(or stop passing it).
|
||||
|
||||
**`tests/ChatClient.hs`**
|
||||
|
||||
- Line 214: `channelSubscriberRole = GRMember, -- starting role is GRMember
|
||||
to test members sending messages` — remove. Tests that need members to
|
||||
post will create Public groups (`GTGroup`) explicitly; tests that exercise
|
||||
channels keep `GTChannel` defaults.
|
||||
|
||||
### 3.4 Message processing changes
|
||||
|
||||
**`src/Simplex/Chat/Library/Subscriber.hs`**
|
||||
|
||||
- Line 3181-3184, `unknownMemberRole`. Currently:
|
||||
```haskell
|
||||
unknownMemberRole gInfo
|
||||
| useRelays' gInfo = asks $ channelSubscriberRole . config
|
||||
| otherwise = pure GRAuthor
|
||||
```
|
||||
becomes:
|
||||
```haskell
|
||||
unknownMemberRole gInfo
|
||||
| useRelays' gInfo = pure $ subscriberRoleFor (groupType' gInfo)
|
||||
| otherwise = pure GRAuthor
|
||||
```
|
||||
The relay derives the joiner default from the channel's immutable
|
||||
`groupType` rather than its global config. Each relay arrives at the same
|
||||
default for the same group, eliminating the cross-relay disparity that
|
||||
motivated the existing TODO at line 3183.
|
||||
|
||||
- Line 3850-3852, `createRelayLink`. Same substitution:
|
||||
`subRole <- asks $ channelSubscriberRole . config` becomes
|
||||
`let subRole = subscriberRoleFor (groupType' gi)`. The relay knows the
|
||||
group's `groupType` because `getLinkDataCreateRelayLink` (line 3814+)
|
||||
has just resolved and validated the channel profile's
|
||||
`PublicGroupProfile`.
|
||||
|
||||
- Line 2429, `processGroupInvitation`. The current check
|
||||
`isJust publicGroup = messageError "x.grp.inv: can't invite to channel"`
|
||||
is correct for channels and public groups alike — `x.grp.inv` is the
|
||||
legacy P2P group invitation; it has no place in relay-mediated groups
|
||||
regardless of `groupType`. **No change.**
|
||||
|
||||
- §3.5 Member-to-member DM forwarding. `XGrpDirectInv` is currently NOT in
|
||||
`isForwardedGroupMsg` (`Protocol.hs:484-503`), and the forwarded-message
|
||||
dispatch in `processForwardedMsg` (`Subscriber.hs:3357-3378`) does not
|
||||
handle it. For Public groups, member A's DM invitation must reach
|
||||
member B via the relay, since A and B have no direct connection. The
|
||||
resulting direct contact (after B accepts) uses an ordinary SMP queue
|
||||
pair — peer-to-peer, relay not in the data path.
|
||||
|
||||
Approach: forward `XGrpDirectInv` through the existing pipeline,
|
||||
scoped to a single recipient. Steps:
|
||||
|
||||
1. Add `XGrpDirectInv {} -> True` to `isForwardedGroupMsg`
|
||||
(`Protocol.hs:484-503`).
|
||||
2. Add a dispatch arm in `processForwardedMsg` (`Subscriber.hs:3357+`):
|
||||
`XGrpDirectInv connReq mContent_ msgScope -> withAuthor
|
||||
XGrpDirectInv_ $ \author -> void $ memberCanSend (Just author)
|
||||
msgScope $ Nothing <$ xGrpDirectInv gInfo author conn connReq
|
||||
mContent_ rcvMsg msgTs`. Note that `xGrpDirectInv` (line 3249+)
|
||||
already gates on `groupFeatureMemberAllowed SGFDirectMessages`, so
|
||||
the DM preference is honored.
|
||||
3. The sender-side currently produces `XGrpDirectInv` with
|
||||
`msgScope = Just (MSMember recipientMemberId)`. Verify that the
|
||||
relay's delivery-task creation (`infoToDeliveryContext`,
|
||||
`Subscriber.hs:1811`/`2394`/`2115`) yields a `DJSMemberSupport`
|
||||
scope so the relay routes only to the target member, not to all
|
||||
members. If the existing support-scope path is the right substrate,
|
||||
reuse it; if support-scope semantics conflict, introduce a sibling
|
||||
`DJSDirectInv` scope. Decide during implementation by tracing one
|
||||
end-to-end DM flow with logging.
|
||||
4. Relay-side gate: only forward `XGrpDirectInv` when
|
||||
`groupFeatureMemberAllowed SGFDirectMessages senderMember gInfo`
|
||||
holds. The DM preference is already in `groupPreferences`; the
|
||||
relay reads it from its cached `groupProfile`. Add this check
|
||||
before creating the delivery task (in the dispatch at
|
||||
`Subscriber.hs:990-1027`, `XGrpDirectInv` arm).
|
||||
|
||||
Edge case: `xGrpDirectInv` (line 3249+) currently writes a `Connection`
|
||||
record and creates a contact. When the message is forwarded and
|
||||
arrives via `processForwardedMsg`, the `conn'` argument the existing
|
||||
handler expects is the relay's connection — wrong. Either pass a
|
||||
marker/None and adapt `xGrpDirectInv` to handle the forwarded case,
|
||||
or introduce `xGrpDirectInvForwarded` that diverges only where it
|
||||
needs to. Prefer the latter — keeps the existing direct path
|
||||
unchanged.
|
||||
|
||||
- Line 1240-1249, `unverifiedAllowed`. Current behaviour: subscribers may
|
||||
pass unsigned `XGrpLeave` and `XInfo` between each other when the
|
||||
sender's key is not yet known. Once member-key dissemination ships
|
||||
(the sidecar `XGrpMemNew` carries `memberKey`, see
|
||||
`2026-04-29-member-profile-sending-channels.md` step 6), every
|
||||
subscriber-to-subscriber message has a known sender key, and the
|
||||
unverified path can be tightened. **For the Public groups MVP, leave
|
||||
`unverifiedAllowed` as-is** — tightening it is gated on the
|
||||
dissemination plan landing first. Track it as a follow-up.
|
||||
|
||||
- Line 985-989 / 2087-2089, `checkSendAsGroup`. Already restricts
|
||||
`asGroup = True` to `GROwner`. Public group members are `GRMember`, so
|
||||
the existing gate naturally blocks them from sending as the group.
|
||||
**No change**, but add a test asserting that a Public-group member
|
||||
attempting `asGroup = True` sees the existing error message.
|
||||
|
||||
- Line 103-104 / 1042, `smallGroupsRcptsMemLimit`. The receipt gate is
|
||||
membership-count based, not transport-based — it already disables
|
||||
receipts above 20 members regardless of `useRelays`. Public groups
|
||||
inherit this. **No change.** Add a test that creates a Public group
|
||||
with > 20 members and asserts receipts are not requested.
|
||||
|
||||
- Line 887-897, channel join introduction. The existing path
|
||||
(`introduceInChannel` -> `sendHistory`) is independent of `groupType`
|
||||
— it runs whenever `useRelays' gInfo`. For Public groups, history
|
||||
default is ON (matching channels) and the same code path runs. No
|
||||
change required, but verify with a test that joins a Public group
|
||||
with two existing messages and asserts the joiner sees both.
|
||||
|
||||
**`src/Simplex/Chat/Library/Internal.hs`**
|
||||
|
||||
- Line 949, `acceptanceToStatus (memberAdmission groupProfile) gAccepted`
|
||||
is already evaluated for any group — channel or otherwise. The relay's
|
||||
`memberJoinRequestViaRelay` (`Subscriber.hs:1530-1541`) currently calls
|
||||
`acceptGroupJoinRequestAsync` with a hardcoded `GAAccepted`, bypassing
|
||||
any review/captcha admission. **For the Public groups MVP, do not
|
||||
honor `memberAdmission` on the relay side.** Reasons:
|
||||
(1) admission needs an owner-side decision in the current design, and
|
||||
the protocol path for a relay-routed pending-review handoff is
|
||||
unspecified;
|
||||
(2) it is not on the critical path for shipping;
|
||||
(3) it applies to channels equally — it is a generic "admission in
|
||||
relay-mediated groups" gap, not a Public-groups gap.
|
||||
Document this as a known limitation in the release notes and surface
|
||||
in §7 Open questions. The first review-sensitive Public group
|
||||
deployment will force the design conversation.
|
||||
|
||||
### 3.5 Database migrations
|
||||
|
||||
**None for the type plumbing.** `groupType` is already a column of
|
||||
`group_profile.public_group` JSON (or wherever `PublicGroupProfile` is
|
||||
serialised); existing rows have `"channel"` and new rows can have `"group"`.
|
||||
|
||||
**One migration arrives via the prior plan** (`sent_profile_vector BLOB`
|
||||
column on `group_members`,
|
||||
`2026-04-29-member-profile-sending-channels.md` §1). That migration is
|
||||
required for Public groups (the "unknown member" UX is unacceptable when
|
||||
every member is expected to post). It is not duplicated here — adopt the
|
||||
prior plan as a hard prerequisite of this one.
|
||||
|
||||
### 3.6 Test additions
|
||||
|
||||
In `tests/ChatTests/Groups.hs`, mirror the channel helpers
|
||||
(`prepareChannel'`, line 8583; `memberJoinChannel'`, line 8654) with
|
||||
`preparePublicGroup'` and `memberJoinPublicGroup'`. The bodies are
|
||||
near-identical, differing in (i) the create command's `type=group`
|
||||
parameter, (ii) the joiner's role assertion (`GRMember` not
|
||||
`GRObserver`), and (iii) the connect-plan message ("ok to join via
|
||||
relays" vs "ok to connect via relays" — see §4 for the wording
|
||||
decision).
|
||||
|
||||
Test cases (each new, all in the existing `describe "channels"` block or
|
||||
a new sibling `describe "public groups"`):
|
||||
|
||||
1. **Member sends content; all members receive it.** Mirror
|
||||
`testChannels1RelayDeliver` (line 8523), but with cath as a Public
|
||||
group member, not a channel subscriber. Verify dan and eve receive
|
||||
cath's message attributed to cath (no "unknown member record"
|
||||
line — assumes the dissemination plan has landed).
|
||||
2. **Profile dissemination integration.** With dissemination on, assert
|
||||
no "unknown member" lines appear.
|
||||
3. **Member edit / delete / react.** Each forwarded by the relay,
|
||||
each visible to all members.
|
||||
4. **Member-to-member DM creation.** Member A sends `/_create direct
|
||||
contact with @bob` (the existing `XGrpDirectInv` flow). Verify
|
||||
the resulting Contact, exchange a direct message, and assert the
|
||||
relay is not in the data path (no relay forwarding line for the
|
||||
direct message).
|
||||
5. **Role changes on members.** Owner promotes a member to moderator;
|
||||
a moderator-only event verifies role propagation through the
|
||||
relay's signed forwarding.
|
||||
6. **Blocking a member.** Owner blocks; verify the blocked member's
|
||||
subsequent messages are not forwarded (`blockedByAdmin m` gate at
|
||||
`Subscriber.hs:933`).
|
||||
7. **Multi-relay delivery.** Mirror `testChannels2RelaysDeliver` for
|
||||
Public groups; verify cross-relay deduplication.
|
||||
8. **History on join.** Send messages, then join a new member; verify
|
||||
history events arrive.
|
||||
9. **`asGroup=true` rejection from member.** Member sends a message
|
||||
with `asGroup=true`; verify the existing "member is not allowed to
|
||||
send as group" error, and that no message item is created.
|
||||
10. **Receipts disabled above limit.** Create a Public group with 21
|
||||
members; verify `aChatMsgHasReceipt` does not produce a receipt
|
||||
request.
|
||||
11. **Older-client refusal.** Channel link with `groupType = "group"`;
|
||||
older client (chat version 17) sees `GTUnknown "group"` and
|
||||
refuses to join with a clear message.
|
||||
|
||||
## 4. iOS changes (Swift, in `apps/ios/Shared`)
|
||||
|
||||
Order: model → audit → create flow → views.
|
||||
|
||||
### 4.1 Model
|
||||
|
||||
`apps/ios/SimpleXChat/ChatTypes.swift`:
|
||||
|
||||
- Line 2512-2532, `GroupType` enum. Add `case group`. Update
|
||||
`init(from:)` and `encode(to:)`. (`GroupInfo.isChannel` at line 2447
|
||||
and `GroupProfile.isChannel` at line 2576 keep their current
|
||||
semantics — channel only.)
|
||||
- Add `var isPublicGroup: Bool { publicGroup?.groupType == .group }`
|
||||
on `GroupProfile` and `GroupInfo`.
|
||||
- Add `var groupType: GroupType?` accessor on `GroupInfo` reading
|
||||
through `groupProfile.publicGroup?.groupType`.
|
||||
|
||||
### 4.2 Audit `useRelays` vs `isChannel`
|
||||
|
||||
73 sites. The audit is mechanical but per-site:
|
||||
|
||||
For each `useRelays` site, ask: "is this branch about transport (uses
|
||||
relays for delivery) or governance (channel-only — no member posts, no
|
||||
member DMs, etc.)?" Choose `useRelays` for transport, `isChannel` for
|
||||
governance. The list below names the tricky sites and the right
|
||||
answer. Mostly the heuristic is: titles, subscriber/member labels,
|
||||
and "channel preferences"-style strings are governance (use
|
||||
`isChannel`); link-management, relay-management, "delete the
|
||||
group/channel" prompts on the host side are transport-or-both — keep
|
||||
`useRelays`.
|
||||
|
||||
Concrete picks (file names + line numbers verified at the time of
|
||||
writing; verify with grep before editing):
|
||||
|
||||
- `apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift`
|
||||
- Line 110, 172, 232, 240, 287, 376, 378 — these branch on
|
||||
"channel-style member display" vs "group-style member display".
|
||||
**Use `isChannel`.**
|
||||
- Line 95 (`useRelays && membership.memberIncognito`) — incognito
|
||||
flag display only matters for transport; **keep `useRelays`**.
|
||||
- Line 658, 662 — `GroupLinkView(isChannel:)` and "Channel link"
|
||||
title. The link is shared the same way for channels and Public
|
||||
groups (both are relay links), but the title and helper text
|
||||
differ. **Use `isChannel`** for the title; the underlying view
|
||||
already takes the boolean.
|
||||
- Line 784, 806, 825, 836, 873, 875 — "Edit channel profile",
|
||||
"Delete channel?", "Leave channel?". Wording follows governance,
|
||||
**use `isChannel`**.
|
||||
- Line 928, 944, 1004, 1026 — "subscribers" framing,
|
||||
"Channel preferences". Governance, **use `isChannel`**.
|
||||
- `apps/ios/Shared/Views/ChatList/ChatListNavLink.swift` — lines 247,
|
||||
272, 568, 623, 625. The "owner can't leave own relayed group"
|
||||
rule applies to both channels and Public groups (transport
|
||||
constraint — only the owner can rotate relays). **Keep
|
||||
`useRelays`** for the leave-suppression branch (247, 272). For
|
||||
the title strings (568, 623, 625), **use `isChannel`** — Public
|
||||
group owners see "Leave group?" / "Delete group?".
|
||||
- `apps/ios/Shared/Views/Chat/ChatView.swift` — line 263, 596, 739,
|
||||
1087, 1090, 1091, 1092, 1120, 1446. All UX strings —
|
||||
**use `isChannel`** except line 596 (`incognito || useRelays`,
|
||||
which is about whether typing-indicator-style state can leak
|
||||
identity — that is transport, **keep `useRelays`**).
|
||||
- `apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift` —
|
||||
line 30, 33. Wording, **use `isChannel`**.
|
||||
- `apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift` — line 20.
|
||||
This is a parameter; rename to `isChannel: Bool = false` and pass
|
||||
governance from callers. (Already named correctly — verify call
|
||||
sites pass `isChannel` not `useRelays`.)
|
||||
- `apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift` — line 86. The
|
||||
toolbar shows the relay-status indicator, which is transport,
|
||||
**keep `useRelays`**.
|
||||
|
||||
The audit is straightforward in aggregate: ~70% of sites should switch
|
||||
to `isChannel`; the rest stay on `useRelays`. Rebuild after the audit
|
||||
and visually compare a Public-group chat against a channel and a
|
||||
secret group on a small test corpus.
|
||||
|
||||
### 4.3 Create flow
|
||||
|
||||
`apps/ios/Shared/Views/NewChat/AddChannelView.swift`. Two options:
|
||||
|
||||
- (A) Unified create flow with a "Channel / Public group" segmented
|
||||
control at the top. The two paths differ only in the `groupType`
|
||||
parameter passed to `apiNewPublicGroup`.
|
||||
- (B) Add `AddPublicGroupView` as a sibling, navigated to from the
|
||||
same "+" menu.
|
||||
|
||||
**Pick (A).** The two flows are 95% identical (relay selection,
|
||||
profile entry, link generation). Duplicating the view doubles the
|
||||
surface area for relay-management bugs. The toggle is a 3-line
|
||||
addition. Place the toggle above the display-name field; default to
|
||||
"Channel" so users who don't change anything keep current behavior.
|
||||
Reflect the choice in the title ("Create channel" / "Create public
|
||||
group"), the link-step screen ("Channel link" / "Public group link"),
|
||||
and the success-screen wording.
|
||||
|
||||
`apps/ios/Shared/Model/AppAPITypes.swift` — `apiNewPublicGroup` (the
|
||||
existing call, around `Model/SimpleXAPI.swift:1880-1882`) gains a
|
||||
`groupType: GroupType` parameter; default `.channel` for a one-line
|
||||
diff at unaffected call sites.
|
||||
|
||||
### 4.4 Strings
|
||||
|
||||
`apps/ios/Shared/en.lproj/Localizable.strings` — for each existing
|
||||
`_channel` string that has a logical `_group` analog, audit whether
|
||||
the existing `_group` form is correct for Public groups (usually it
|
||||
is). For relay-specific strings ("relay", "subscribers" framed
|
||||
neutrally), no Public-group variant is needed. Net: add ~5-10 new
|
||||
strings, not 50. Strings to add (illustrative, names only):
|
||||
|
||||
- `add_public_group`, `create_public_group`, `your_public_group`
|
||||
- `delete_public_group`, `leave_public_group`
|
||||
- `public_group_link`
|
||||
- `public_group_no_active_relays_try_later`
|
||||
(parallel to `channel_no_active_relays_try_later`)
|
||||
- `public_group_temporarily_unavailable`
|
||||
|
||||
The connect-plan-resolved message ("ok to connect via relays") needs
|
||||
a Public-group form. See §4.6.
|
||||
|
||||
### 4.5 Compose / post permissions
|
||||
|
||||
Public group members are `GRMember`. Existing client-side gates check
|
||||
`memberRole > .observer` (or equivalent) — these naturally let
|
||||
members post. Audit `apps/ios/Shared/Views/Chat/ComposeView.swift`
|
||||
for any `useRelays && !isOwner` branch that suppresses composition;
|
||||
swap to `isChannel && !isOwner` if it gates governance, leave alone
|
||||
if it gates transport (e.g., owner-only relay-management hooks).
|
||||
|
||||
### 4.6 Group info / link views / icons
|
||||
|
||||
- `GroupChatInfoView.swift` — three-way branching at the sites flagged
|
||||
in §4.2 (channel / public group / secret).
|
||||
- `GroupProfileView.swift` — the profile-edit form already does not
|
||||
branch on `isChannel`; verify nothing in it assumes "no member
|
||||
posts".
|
||||
- `GroupLinkView.swift` — title + helper text now needs three
|
||||
variants. Two changes: (i) extend `isChannel: Bool` parameter to
|
||||
an enum `LinkVariant { secret, publicGroup, channel }`, or (ii)
|
||||
pass `groupInfo` and read variant inside. Pick (ii) — fewer call
|
||||
sites to update.
|
||||
- `GroupPreferencesView.swift` — preferences UI is unchanged for
|
||||
Public groups; the directMessages preference, which is
|
||||
channel-default-off, channel-Public-group-default-on, is already
|
||||
controlled by `groupPreferences`. Verify that creating a Public
|
||||
group sets `directMessages` to its default-on value (the existing
|
||||
default for non-channel groups). Pick this default at the
|
||||
`Commands.hs` create site (§3.3).
|
||||
- `chatIconName` (`ChatTypes.swift:2472-2482`):
|
||||
- `useRelays && isChannel` → existing antenna icon
|
||||
(`antenna.radiowaves.left.and.right.circle.fill`).
|
||||
- `useRelays && !isChannel` (Public group) → distinct icon. SF
|
||||
Symbols candidate: `person.3.sequence.fill` or
|
||||
`person.2.wave.2.fill`. Pick one in design review; the constraint
|
||||
is "visually distinct from both channel-antenna and
|
||||
secret-group-people". Mirror choice on Kotlin.
|
||||
- `!useRelays && businessChat == nil` → existing person.2.
|
||||
- business cases unchanged.
|
||||
- Members view in `GroupChatInfoView`. Channels show the relay-known
|
||||
list which is thin (post-dissemination it grows). Public groups
|
||||
use the same list — once dissemination ships, the list is
|
||||
populated for any member who has interacted in the group. **MVP
|
||||
decision**: show all members the relay has announced (the same
|
||||
list as channels post-dissemination). Show "subscribers" for
|
||||
channels, "members" for Public groups in the section header. No
|
||||
separate "owner+moderators only" filter for the MVP; defer
|
||||
filtered-views to a follow-up.
|
||||
|
||||
### 4.7 Connect-plan messaging
|
||||
|
||||
`View.hs:2103-2105` produces "ok to connect via relays" for any
|
||||
relay-mediated link. iOS reads this into the connect-plan flow
|
||||
(`apps/ios/Shared/Views/NewChat/NewChatView.swift:1316`-area). For
|
||||
Public groups, change wording to "ok to join via relays" (channel
|
||||
phrasing is "ok to subscribe via relays"). The backend sends the
|
||||
resolved `GroupShortLinkData`; the client decides wording from
|
||||
`groupSLinkData.groupProfile.publicGroup?.groupType`.
|
||||
|
||||
## 5. Kotlin changes (Compose, in `apps/multiplatform/common`)
|
||||
|
||||
Mirrors §4. Same order: model → audit → create flow → views.
|
||||
|
||||
### 5.1 Model
|
||||
|
||||
`apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt`:
|
||||
|
||||
- Line 2186-2210, `GroupType` sealed class. Add
|
||||
`@Serializable @SerialName("group") object Group: GroupType()`. Add
|
||||
`"group" -> GroupType.Group` arm to the deserializer. Add the
|
||||
reverse arm to the serializer.
|
||||
- Line 2231 — `isChannel` reads `groupType == GroupType.Channel`.
|
||||
Add `val isPublicGroup: Boolean get() = publicGroup?.groupType ==
|
||||
GroupType.Group` next to it.
|
||||
- Line 2110 — `GroupInfo.isChannel` reads through to
|
||||
`GroupProfile.isChannel`. Add `val isPublicGroup` analogously.
|
||||
|
||||
### 5.2 Audit `useRelays` vs `isChannel`
|
||||
|
||||
74 sites. Same per-site rule as iOS. Notable Kotlin-specific sites:
|
||||
|
||||
- `views/chatlist/ChatListNavLinkView.kt` — line 319, 341. Same
|
||||
leave-suppression as iOS. **Keep `useRelays`** (transport).
|
||||
- `views/chatlist/ChatListView.kt` — line 1363, 1367. The
|
||||
classification of "group / channel / business" for the chat-list
|
||||
filter chips already uses `isChannel`. Add `isPublicGroup` to
|
||||
whichever bucket Public groups belong in (recommend: include in
|
||||
the "groups" bucket alongside secret groups, not in the "channels"
|
||||
bucket — the user mental model is "things I can post in").
|
||||
- `views/chat/ComposeView.kt` — line 1444, 1541, 1621, 1623, 1696,
|
||||
2005. Mostly governance — switch to `isChannel`. Line 1696
|
||||
(`useRelays && memberRole >= GRMember.Owner`) gates the broadcast
|
||||
button; that's owner-channel-only. Switch to `isChannel && isOwner`.
|
||||
- `views/chat/ChatView.kt` — line 206, 867, 1283, 1293, 1294, 1578,
|
||||
2242, 2244, 2247, 2248, 2249, 3207. Each is wording or icon.
|
||||
**Use `isChannel`** except line 1578 which is "subscriber count
|
||||
for relay-mediated groups" — that should also display for Public
|
||||
groups (member count from relay-side dissemination), so generalise
|
||||
to `useRelays` and rename the field `publicMemberCount` → display
|
||||
it as "subscribers" for channels and "members" for Public groups.
|
||||
- `model/ChatModel.kt` line 4617, 4624, 4631 — group icons in
|
||||
`chatIconName`/`chatLinkText`. Add a third arm for Public groups
|
||||
with the chosen distinct icon (mirror iOS).
|
||||
- `model/ChatModel.kt` lines 1621, 1628, 1693 — relay-broken /
|
||||
no-relays alerting. **Keep `useRelays`**, transport-level.
|
||||
- `views/chat/group/GroupPreferences.kt` — lines 47, 60, 179, 183,
|
||||
229. Wording, **use `isChannel`**. The "save and notify
|
||||
subscribers" string should switch to "save and notify members" for
|
||||
Public groups.
|
||||
- `views/chat/group/GroupLinkView.kt` — line 35, 175, 194, 196,
|
||||
217, 231, 236, 250, 274. Same pattern as iOS — pass `groupInfo`
|
||||
and derive wording/branches inside.
|
||||
|
||||
### 5.3 Create flow
|
||||
|
||||
`apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddChannelView.kt`:
|
||||
|
||||
- Add a `groupType` state variable (default `GroupType.Channel`).
|
||||
- Add a segmented toggle at the top of `ProfileStepView` (between
|
||||
the title and the name field).
|
||||
- Pass `groupType` to `apiNewPublicGroup`. The Haskell command parser
|
||||
defaults to channel if absent (§3.3), so the Kotlin call site
|
||||
passes the chosen value directly.
|
||||
- `groupPreferences` defaults: for Public groups,
|
||||
`directMessages = ON`, `history = ON`, `support = OFF`. Today's
|
||||
channel defaults are at line 115-117 — add a `when (groupType)`.
|
||||
- Title string and progress messages: thread through the choice.
|
||||
|
||||
Either rename `AddChannelView` to `AddRelayedGroupView` or keep the
|
||||
name and let it cover both kinds. Recommend keep the name to
|
||||
minimise churn; the title varies inside.
|
||||
|
||||
### 5.4 ConnectPlan
|
||||
|
||||
`apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt`:
|
||||
|
||||
- Line 634, `showPrepareGroupAlert`: `val isChannel =
|
||||
!(groupShortLinkInfo?.direct ?: true)` is **wrong for Public
|
||||
groups** — it equates "uses relays" with "is a channel". Fix:
|
||||
`val groupType = groupShortLinkData.groupProfile.publicGroup?.groupType`,
|
||||
branch:
|
||||
- `GroupType.Channel` → channel icon, "subscribers" subtitle,
|
||||
"Open new channel" confirm.
|
||||
- `GroupType.Group` → public-group icon, "members" subtitle,
|
||||
"Open new public group" confirm.
|
||||
- `null` (direct link, no `publicGroup` field) → secret-group icon
|
||||
and wording.
|
||||
- `GroupType.Unknown` → reject the link with a "needs newer
|
||||
version" alert and `cleanup()` (do not proceed to prepare).
|
||||
- Line 472, `ownGroupLinkConfirmConnect` — same three-way branch on
|
||||
`groupInfo.groupType` (or `isChannel` / `isPublicGroup`).
|
||||
- Line 295-318, `GroupLinkPlan.NoRelays` — wording is currently
|
||||
"channel temporarily unavailable". Generalise based on
|
||||
`groupSLinkData.groupProfile.publicGroup?.groupType`.
|
||||
|
||||
### 5.5 Strings
|
||||
|
||||
`apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml`. Same
|
||||
strategy as iOS: ~5-10 new keys, mostly mirroring the channel ones with a
|
||||
`public_group` suffix:
|
||||
|
||||
- `chat_link_public_group`, `add_public_group`, `error_creating_public_group`
|
||||
- `connect_plan_open_new_public_group`, `connect_plan_open_public_group`
|
||||
- `you_will_join_public_group`
|
||||
- `public_group_temporarily_unavailable`,
|
||||
`public_group_no_active_relays_try_later`
|
||||
- `public_group_link`,
|
||||
`you_can_share_public_group_link_anybody_will_be_able_to_connect`
|
||||
|
||||
For "subscribers" → "members" framing, prefer reusing the existing
|
||||
`group_members_*` strings rather than introducing new public-group
|
||||
variants. Channels keep their `_subscriber*` strings.
|
||||
|
||||
### 5.6 Icons
|
||||
|
||||
`ChatModel.kt:4617, 4624, 2128`. Add a third arm:
|
||||
|
||||
- `useRelays && isChannel` → existing `ic_bigtop_updates_circle_filled`.
|
||||
- `useRelays && !isChannel` → new icon. Candidate:
|
||||
`ic_groups_2_filled` or a new asset that visually combines a
|
||||
"people" silhouette with a "broadcast" wedge. Pick in design
|
||||
review; mirror to iOS SF Symbol choice.
|
||||
- `!useRelays && businessChat == null` → existing
|
||||
`ic_supervised_user_circle_filled`.
|
||||
|
||||
## 6. Migration / compatibility
|
||||
|
||||
- **Existing channels are unaffected.** Channel profiles continue to
|
||||
carry `groupType = "channel"`; the new code path produces
|
||||
`GTGroup` only when explicitly requested.
|
||||
- **Older clients** (chat version ≤ 17) decode `groupType = "group"`
|
||||
as `GTUnknown "group"`. They should not silently treat it as a
|
||||
channel — that would let owners post but block members and break
|
||||
the UX. Required client behavior: when about to join a link
|
||||
whose `publicGroup.groupType` is not recognised, show a clear
|
||||
"this group requires a newer version of SimpleX Chat" alert and
|
||||
block the join. Add this alert in `ConnectPlan.kt` /
|
||||
`NewChatView.swift` as part of §4.7/§5.4. The Haskell side does
|
||||
not need to refuse — the client decides.
|
||||
- **Minimum versions.** Chat protocol version 18 (`publicGroupsVersion`)
|
||||
is the new floor. Owner client must be at least 18 to *create* a
|
||||
Public group. Member clients must be at least 18 to *join*. Older
|
||||
clients that are already members of a channel are unaffected.
|
||||
Older relays — currently relays accept any `groupType` and forward
|
||||
by `useRelays`, so they will forward Public-group traffic
|
||||
correctly without an upgrade. The relay-side type-driven
|
||||
joiner-role derivation (§3.4) does require a relay upgrade for
|
||||
Public groups to function (a relay running old code would assign
|
||||
`GRObserver` from its config, blocking member posts). State this
|
||||
explicitly in release notes.
|
||||
- **Relay upgrade ordering.** Owner upgrades first, then relays,
|
||||
then members. If an owner creates a Public group while one of
|
||||
their relays is still on old code, that relay assigns `GRObserver`
|
||||
to joiners — members joining via that relay cannot post, but
|
||||
members joining via an upgraded relay can. The owner sees a
|
||||
partial-functionality state. Mitigation: warn at create time if
|
||||
any selected relay's chat version is < 18 (`Commands.hs` already
|
||||
has access to relay versions via the relay request flow). The
|
||||
warning is not a hard block — the owner may proceed knowing that
|
||||
some relays will reject member posts.
|
||||
|
||||
## 7. Open questions
|
||||
|
||||
1. **Forwarding scope for `XGrpDirectInv`.** The relay needs to deliver a
|
||||
single-target message. Reuse `MSMember` / `DJSMemberSupport`, or
|
||||
introduce a sibling scope for direct-invite delivery? The two
|
||||
semantics overlap (deliver to a specific member) but support-scope
|
||||
is also delivered to all moderators, which would leak a DM
|
||||
intention. Likely answer: introduce a new `DJSDirectInv` scope
|
||||
that delivers only to the target member. **Decide before
|
||||
implementing §3.4 step 3.**
|
||||
2. **Member-DM consent.** P2P groups gate `XGrpDirectInv` by the
|
||||
`directMessages` group preference. Public groups inherit the
|
||||
same gate. Should owners get a per-channel additional toggle ("DMs
|
||||
between members allowed") or should the existing preference
|
||||
suffice? Recommend: existing preference is enough for MVP.
|
||||
3. **`memberAdmission` (review/captcha) on relay-mediated join.**
|
||||
Today, relay-side join short-circuits `GAAccepted` regardless of
|
||||
the channel's `memberAdmission` setting. This is a generic
|
||||
relay-side gap (channels and Public groups equally), out of scope
|
||||
for this feature. Surface in release notes; defer until a
|
||||
separate plan.
|
||||
4. **Distinct icon for Public groups.** Pending design review on
|
||||
both platforms. The set must distinguish Public groups from
|
||||
channels (which use the broadcast/antenna metaphor) and from
|
||||
secret groups (which use a plain people metaphor). A "people +
|
||||
wedge" composite is the obvious candidate.
|
||||
5. **Removing `channelSubscriberRole` from config.** The field has no
|
||||
callers after §3.3. Tests at `tests/ChatClient.hs:214` already
|
||||
override it for member-posting scenarios; those tests should
|
||||
become Public-group tests. Confirm that no out-of-tree consumer
|
||||
(CLI scripts, embedded clients) reads this config.
|
||||
6. **Subscribed/unsubscribed roster filter in members view.** With
|
||||
100K+ Public-group members the relay-known list grows large.
|
||||
Should the client paginate / filter (e.g., "recently active
|
||||
only")? Out of scope for the MVP — the existing channel members
|
||||
view already handles this case for subscribers.
|
||||
7. **Wording for connect plan**: "ok to join via relays" (Public group)
|
||||
vs "ok to subscribe via relays" (channel) vs "ok to connect via
|
||||
relays" (current, ambiguous). The CLI string in `View.hs:2105`
|
||||
is read by tests — update test expectations alongside the new
|
||||
wording. Mobile clients can derive their own.
|
||||
8. **Adopt-the-prior-plan timing.** `2026-04-29-member-profile-sending-channels.md`
|
||||
is approved but unmerged at the time of writing. Public groups
|
||||
are *usable* without it but feel broken ("unknown member" lines
|
||||
on every member-authored message). Two ship orders:
|
||||
- (A) Land the dissemination plan first, then ship Public groups
|
||||
when it is in.
|
||||
- (B) Ship Public groups behind a feature flag while dissemination
|
||||
is in flight; flip the flag once dissemination lands.
|
||||
Recommend (A) — fewer states to support, less user confusion.
|
||||
|
||||
## 8. Sequencing
|
||||
|
||||
1. **Prerequisite: member-profile dissemination plan**
|
||||
(`2026-04-29-member-profile-sending-channels.md`). Lands first,
|
||||
independently. Public groups are a soft dependency: usable without
|
||||
it but UX-poor.
|
||||
2. **Backend types + command + role derivation** (§3.2, §3.3, §3.4
|
||||
except DM forwarding). Single PR. Adds `GTGroup`-producing path,
|
||||
replaces config with `groupType`-based derivation, removes
|
||||
`channelSubscriberRole` config. Tests 1, 5, 8, 9, 10, 11 pass at
|
||||
this stage. Member DMs (test 4) and dissemination (test 2) are
|
||||
not yet in.
|
||||
3. **Relay-forwarded `XGrpDirectInv`** (§3.4 sub-section 3.5, the
|
||||
member-to-member DM path). Single PR. Test 4 passes.
|
||||
4. **iOS plumbing + audit + create flow** (§4.1-§4.4). Single PR.
|
||||
No backend coupling — the `groupType` parameter at the API level
|
||||
is already optional.
|
||||
5. **iOS views, icons, ConnectPlan messaging** (§4.5-§4.7).
|
||||
Independent of Kotlin.
|
||||
6. **Kotlin plumbing + audit + create flow** (§5.1-§5.3). Mirror
|
||||
iOS.
|
||||
7. **Kotlin views, icons, ConnectPlan** (§5.4-§5.6).
|
||||
8. **Older-client refusal, version bump release notes, docs
|
||||
updates** (§3.1, §6).
|
||||
|
||||
Steps 4-5 and 6-7 ship independently per platform — iOS can ship
|
||||
Public groups without waiting on Kotlin and vice versa, as long as
|
||||
the create-side defaults to channel for older clients (§3.3).
|
||||
|
||||
Steps 2 and 3 ship in either order; step 3 has no dependency on
|
||||
step 2 other than the existence of `GTGroup` rows in the wild,
|
||||
which step 2 enables.
|
||||
|
||||
## 9. Adjacent work (one paragraph, not planned here)
|
||||
|
||||
Two pre-existing channel-protocol disparities are deliberately
|
||||
untouched. (1) **Owner→relay communication of joiner role and
|
||||
rejection rules** (`Controller.hs:161, 524`; `Commands.hs:2521`;
|
||||
`Subscriber.hs:1528-1529, 3850`). The cleaner long-term fix is to
|
||||
carry the joiner role on the channel link analogously to
|
||||
`GroupLink.acceptMemberRole` (`Types.hs:554`, default `GRMember` at
|
||||
`Store/Groups.hs:316`) for regular groups, or include it in the
|
||||
`x.grp.relay.inv` owner→relay message. Both are protocol extensions;
|
||||
both benefit channels just as much as Public groups. (2) **Owner
|
||||
signature verification on the channel profile by relays**
|
||||
(`Subscriber.hs:3829`). Both are real and worth doing, but neither
|
||||
gates Public groups: the `groupType`-derived joiner role makes the
|
||||
config disparity moot for the only two values that matter
|
||||
(`channel → GRObserver`, `group → GRMember`), and signature
|
||||
verification of the profile is independent of `groupType`.
|
||||
Reference in New Issue
Block a user