core, ui: auto-accept group invitations per user profile

Add a per-profile toggle for auto-accepting group invitations, and regroup it
with the existing contact-requests setting under a single Auto-accept section
in Privacy & Security, relabelled "Contact requests in groups".

The join is fully async. processGroupInvitation already had an async accept
path, used when the invitation matches a group link the user opened:
prepareAgentJoin + createMemberConnectionAsync + joinAgentConnectionAsync,
with the outcome reported later against the CFJoinConn command id. Auto-accept
takes that same path instead of going through APIJoinGroup, so it works while
the app is closed and never blocks message processing.

An auto-accepted invitation still records a CIRcvGroupInvitation item in the
chat with the inviting contact, so there is a record of who added the user to
which group.

Two details worth noting for review:

hostContact is reported to clients only for group links. Clients respond to it
by replacing the transient host connection view with the group and removing
that chat - correct for a group link, where the contact is a placeholder, but
wrong for a plain invitation, where it is a real contact.

A resent invitation returns the existing group, because createGroupInvitation
is idempotent on inv_queue_info. The join therefore only runs while the
membership is still GSMemInvited, so a resend cannot open a second connection.
This commit is contained in:
Narasimha-sc
2026-08-15 14:22:07 +00:00
parent 6fcfbd54c5
commit 0bec70821f
32 changed files with 480 additions and 58 deletions
+4
View File
@@ -21,6 +21,7 @@ enum ChatCommand: ChatCmdProtocol {
case apiSetUserContactReceipts(userId: Int64, userMsgReceiptSettings: UserMsgReceiptSettings)
case apiSetUserGroupReceipts(userId: Int64, userMsgReceiptSettings: UserMsgReceiptSettings)
case apiSetUserAutoAcceptMemberContacts(userId: Int64, enable: Bool)
case apiSetUserAutoAcceptGroupInvitations(userId: Int64, enable: Bool)
case apiHideUser(userId: Int64, viewPwd: String)
case apiUnhideUser(userId: Int64, viewPwd: String)
case apiMuteUser(userId: Int64)
@@ -215,6 +216,8 @@ enum ChatCommand: ChatCmdProtocol {
return "/_set receipts groups \(userId) \(onOff(umrs.enable)) clear_overrides=\(onOff(umrs.clearOverrides))"
case let .apiSetUserAutoAcceptMemberContacts(userId, enable):
return "/_set accept member contacts \(userId) \(onOff(enable))"
case let .apiSetUserAutoAcceptGroupInvitations(userId, enable):
return "/_set accept group invitations \(userId) \(onOff(enable))"
case let .apiHideUser(userId, viewPwd): return "/_hide user \(userId) \(encodeJSON(viewPwd))"
case let .apiUnhideUser(userId, viewPwd): return "/_unhide user \(userId) \(encodeJSON(viewPwd))"
case let .apiMuteUser(userId): return "/_mute user \(userId)"
@@ -430,6 +433,7 @@ enum ChatCommand: ChatCmdProtocol {
case .apiSetUserContactReceipts: return "apiSetUserContactReceipts"
case .apiSetUserGroupReceipts: return "apiSetUserGroupReceipts"
case .apiSetUserAutoAcceptMemberContacts: return "apiSetUserAutoAcceptMemberContacts"
case .apiSetUserAutoAcceptGroupInvitations: return "apiSetUserAutoAcceptGroupInvitations"
case .apiHideUser: return "apiHideUser"
case .apiUnhideUser: return "apiUnhideUser"
case .apiMuteUser: return "apiMuteUser"
+4
View File
@@ -302,6 +302,10 @@ func apiSetUserAutoAcceptMemberContacts(_ userId: Int64, enable: Bool) async thr
try await sendCommandOkResp(.apiSetUserAutoAcceptMemberContacts(userId: userId, enable: enable))
}
func apiSetUserAutoAcceptGroupInvitations(_ userId: Int64, enable: Bool) async throws {
try await sendCommandOkResp(.apiSetUserAutoAcceptGroupInvitations(userId: userId, enable: enable))
}
func apiHideUser(_ userId: Int64, viewPwd: String) async throws -> User {
try await setUserPrivacy_(.apiHideUser(userId: userId, viewPwd: viewPwd))
}
@@ -37,6 +37,8 @@ struct PrivacySettings: View {
@State private var groupReceiptsDialogue = false
@State private var autoAcceptMemberContacts = false
@State private var autoAcceptMemberContactsReset = false
@State private var autoAcceptGroupInvitations = false
@State private var autoAcceptGroupInvitationsReset = false
@State private var alert: PrivacySettingsViewAlert?
enum PrivacySettingsViewAlert: Identifiable {
@@ -117,14 +119,17 @@ struct PrivacySettings: View {
}
Section {
settingsRow("checkmark", color: theme.colors.secondary) {
Toggle("Auto-accept", isOn: $autoAcceptMemberContacts)
settingsRow("person", color: theme.colors.secondary) {
Toggle("Contact requests in groups", isOn: $autoAcceptMemberContacts)
}
settingsRow("person.2", color: theme.colors.secondary) {
Toggle("Group invitations", isOn: $autoAcceptGroupInvitations)
}
} header: {
Text("Contact requests from groups")
Text("Auto-accept")
.foregroundColor(theme.colors.secondary)
} footer: {
Text("This setting is for your current profile **\(m.currentUser?.displayName ?? "")**.")
Text("These settings are for your current profile **\(m.currentUser?.displayName ?? "")**.")
.foregroundColor(theme.colors.secondary)
}
@@ -139,7 +144,14 @@ struct PrivacySettings: View {
if autoAcceptMemberContactsReset {
autoAcceptMemberContactsReset = false
} else {
setAutoAcceptGrpDirectInvs(autoAcceptMemberContacts)
setAutoAcceptMemberContacts(autoAcceptMemberContacts)
}
}
.onChange(of: autoAcceptGroupInvitations) { _ in
if autoAcceptGroupInvitationsReset {
autoAcceptGroupInvitationsReset = false
} else {
setAutoAcceptGroupInvitations(autoAcceptGroupInvitations)
}
}
.onAppear {
@@ -148,6 +160,10 @@ struct PrivacySettings: View {
autoAcceptMemberContactsReset = true
autoAcceptMemberContacts = u.autoAcceptMemberContacts
}
if autoAcceptGroupInvitations != u.autoAcceptGroupInvitations {
autoAcceptGroupInvitationsReset = true
autoAcceptGroupInvitations = u.autoAcceptGroupInvitations
}
}
}
.alert(item: $alert) { alert in
@@ -426,7 +442,7 @@ struct PrivacySettings: View {
}
}
private func setAutoAcceptGrpDirectInvs(_ enable: Bool) {
private func setAutoAcceptMemberContacts(_ enable: Bool) {
Task {
do {
if let currentUser = m.currentUser {
@@ -443,6 +459,23 @@ struct PrivacySettings: View {
}
}
private func setAutoAcceptGroupInvitations(_ enable: Bool) {
Task {
do {
if let currentUser = m.currentUser {
try await apiSetUserAutoAcceptGroupInvitations(currentUser.userId, enable: enable)
await MainActor.run {
var updatedUser = currentUser
updatedUser.autoAcceptGroupInvitations = enable
m.updateUser(updatedUser)
}
}
} catch let error {
alert = .error(title: "Error setting auto-accept", error: "Error: \(responseError(error))")
}
}
}
private func simplexLockRow(_ value: LocalizedStringKey) -> some View {
HStack {
Text("SimpleX Lock")
+2
View File
@@ -42,6 +42,7 @@ public struct User: Identifiable, Decodable, UserLike, NamedChat, Hashable {
public var sendRcptsContacts: Bool
public var sendRcptsSmallGroups: Bool
public var autoAcceptMemberContacts: Bool
public var autoAcceptGroupInvitations: Bool
public var viewPwdHash: UserPwdHash?
public var uiThemes: ThemeModeOverrides?
public var userChatRelay: Bool
@@ -71,6 +72,7 @@ public struct User: Identifiable, Decodable, UserLike, NamedChat, Hashable {
sendRcptsContacts: true,
sendRcptsSmallGroups: false,
autoAcceptMemberContacts: false,
autoAcceptGroupInvitations: false,
userChatRelay: false
)
}
@@ -1293,6 +1293,7 @@ data class User(
val sendRcptsContacts: Boolean,
val sendRcptsSmallGroups: Boolean,
val autoAcceptMemberContacts: Boolean,
val autoAcceptGroupInvitations: Boolean,
val viewPwdHash: UserPwdHash?,
val uiThemes: ThemeModeOverrides? = null,
val userChatRelay: Boolean,
@@ -1325,6 +1326,7 @@ data class User(
sendRcptsContacts = true,
sendRcptsSmallGroups = false,
autoAcceptMemberContacts = false,
autoAcceptGroupInvitations = false,
viewPwdHash = null,
uiThemes = null,
userChatRelay = false,
@@ -941,6 +941,12 @@ object ChatController {
throw Exception("failed to set auto-accept ${r.responseType} ${r.details}")
}
suspend fun apiSetUserAutoAcceptGroupInvitations(u: User, enable: Boolean) {
val r = sendCmd(u.remoteHostId, CC.ApiSetUserAutoAcceptGroupInvitations(u.userId, enable))
if (r.result is CR.CmdOk) return
throw Exception("failed to set auto-accept group invitations ${r.responseType} ${r.details}")
}
suspend fun apiHideUser(u: User, viewPwd: String): User =
setUserPrivacy(u.remoteHostId, CC.ApiHideUser(u.userId, viewPwd))
@@ -3775,6 +3781,7 @@ sealed class CC {
class ApiSetUserContactReceipts(val userId: Long, val userMsgReceiptSettings: UserMsgReceiptSettings): CC()
class ApiSetUserGroupReceipts(val userId: Long, val userMsgReceiptSettings: UserMsgReceiptSettings): CC()
class ApiSetUserAutoAcceptMemberContacts(val userId: Long, val enable: Boolean): CC()
class ApiSetUserAutoAcceptGroupInvitations(val userId: Long, val enable: Boolean): CC()
class ApiHideUser(val userId: Long, val viewPwd: String): CC()
class ApiUnhideUser(val userId: Long, val viewPwd: String): CC()
class ApiMuteUser(val userId: Long): CC()
@@ -3965,6 +3972,7 @@ sealed class CC {
"/_set receipts groups $userId ${onOff(mrs.enable)} clear_overrides=${onOff(mrs.clearOverrides)}"
}
is ApiSetUserAutoAcceptMemberContacts -> "/_set accept member contacts $userId ${onOff(enable)}"
is ApiSetUserAutoAcceptGroupInvitations -> "/_set accept group invitations $userId ${onOff(enable)}"
is ApiHideUser -> "/_hide user $userId ${json.encodeToString(viewPwd)}"
is ApiUnhideUser -> "/_unhide user $userId ${json.encodeToString(viewPwd)}"
is ApiMuteUser -> "/_mute user $userId"
@@ -4176,6 +4184,7 @@ sealed class CC {
is ApiSetUserContactReceipts -> "apiSetUserContactReceipts"
is ApiSetUserGroupReceipts -> "apiSetUserGroupReceipts"
is ApiSetUserAutoAcceptMemberContacts -> "apiSetUserAutoAcceptMemberContacts"
is ApiSetUserAutoAcceptGroupInvitations -> "apiSetUserAutoAcceptGroupInvitations"
is ApiHideUser -> "apiHideUser"
is ApiUnhideUser -> "apiUnhideUser"
is ApiMuteUser -> "apiMuteUser"
@@ -87,13 +87,19 @@ fun PrivacySettingsView(
val currentUser = chatModel.currentUser.value
if (currentUser != null && !chatModel.desktopNoUserNoRemote) {
SectionDividerSpaced()
ContacRequestsFromGroupsSection(
AutoAcceptSection(
currentUser = currentUser,
setAutoAcceptGrpDirectInvs = { enable ->
setAutoAcceptMemberContacts = { enable ->
withApi {
chatModel.controller.apiSetUserAutoAcceptMemberContacts(currentUser, enable)
chatModel.currentUser.value = currentUser.copy(autoAcceptMemberContacts = enable)
}
},
setAutoAcceptGroupInvitations = { enable ->
withApi {
chatModel.controller.apiSetUserAutoAcceptGroupInvitations(currentUser, enable)
chatModel.currentUser.value = currentUser.copy(autoAcceptGroupInvitations = enable)
}
}
)
}
@@ -333,16 +339,26 @@ expect fun PrivacyDeviceSection(
)
@Composable
private fun ContacRequestsFromGroupsSection(
private fun AutoAcceptSection(
currentUser: User,
setAutoAcceptGrpDirectInvs: (Boolean) -> Unit
setAutoAcceptMemberContacts: (Boolean) -> Unit,
setAutoAcceptGroupInvitations: (Boolean) -> Unit
) {
SectionView(stringResource(MR.strings.settings_section_title_contact_requests_from_groups)) {
SettingsActionItemWithContent(painterResource(MR.images.ic_check), stringResource(MR.strings.auto_accept_contact)) {
// legacy string key names, reused for their values so this section stays translated
SectionView(stringResource(MR.strings.auto_accept_contact)) {
SettingsActionItemWithContent(painterResource(MR.images.ic_person), stringResource(MR.strings.settings_section_title_contact_requests_from_groups)) {
DefaultSwitch(
checked = currentUser.autoAcceptMemberContacts,
onCheckedChange = { enable ->
setAutoAcceptGrpDirectInvs(enable)
setAutoAcceptMemberContacts(enable)
}
)
}
SettingsActionItemWithContent(painterResource(MR.images.ic_group), stringResource(MR.strings.group_invitations)) {
DefaultSwitch(
checked = currentUser.autoAcceptGroupInvitations,
onCheckedChange = { enable ->
setAutoAcceptGroupInvitations(enable)
}
)
}
@@ -350,7 +366,7 @@ private fun ContacRequestsFromGroupsSection(
SectionTextFooter(
remember(currentUser.displayName) {
buildAnnotatedString {
append(generalGetString(MR.strings.this_setting_is_for_your_current_profile) + " ")
append(generalGetString(MR.strings.receipts_section_description) + " ")
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
append(currentUser.displayName)
}
@@ -1206,6 +1206,7 @@
<string name="stop_sharing_address">Stop sharing address?</string>
<string name="stop_sharing">Stop sharing</string>
<string name="auto_accept_contact">Auto-accept</string>
<string name="group_invitations">Group invitations</string>
<string name="sent_to_your_contact_after_connection">Sent to your contact after connection.</string>
<string name="address_welcome_message">Welcome message</string>
<string name="enter_welcome_message_optional">Enter welcome message… (optional)</string>
@@ -1602,7 +1603,7 @@
<string name="settings_section_title_chats">Chats</string>
<string name="settings_section_title_files">Files</string>
<string name="settings_section_title_delivery_receipts">Send delivery receipts to</string>
<string name="settings_section_title_contact_requests_from_groups">Contact requests from groups</string>
<string name="settings_section_title_contact_requests_from_groups">Contact requests in groups</string>
<string name="settings_section_title_about">About</string>
<string name="settings_section_title_contact">Contact</string>
<string name="settings_section_title_support_project">Support the project</string>
+38
View File
@@ -61,6 +61,7 @@ This file is generated automatically.
- [APISetGroupCustomData](#apisetgroupcustomdata)
- [APISetContactCustomData](#apisetcontactcustomdata)
- [APISetUserAutoAcceptMemberContacts](#apisetuserautoacceptmembercontacts)
- [APISetUserAutoAcceptGroupInvitations](#apisetuserautoacceptgroupinvitations)
[User profile commands](#user-profile-commands)
- [ShowActiveUser](#showactiveuser)
@@ -1972,6 +1973,43 @@ ChatCmdError: Command error (only used in WebSockets API).
---
### APISetUserAutoAcceptGroupInvitations
Set auto-accept group invitations.
*Network usage*: no.
**Parameters**:
- userId: int64
- onOff: bool
**Syntax**:
```
/_set accept group invitations <userId> on|off
```
```javascript
'/_set accept group invitations ' + userId + ' ' + (onOff ? 'on' : 'off') // JavaScript
```
```python
'/_set accept group invitations ' + str(userId) + ' ' + ('on' if onOff else 'off') # Python
```
**Responses**:
CmdOk: Ok.
- type: "cmdOk"
- user_: [User](./TYPES.md#user)?
ChatCmdError: Command error (only used in WebSockets API).
- type: "chatCmdError"
- chatError: [ChatError](./TYPES.md#chaterror)
---
## User profile commands
Most bots don't need to use these commands, as bot profile can be configured manually via CLI or desktop client. These commands can be used by bots that need to manage multiple user profiles (e.g., the profiles of support agents).
+1
View File
@@ -4366,6 +4366,7 @@ Handshake:
- sendRcptsContacts: bool
- sendRcptsSmallGroups: bool
- autoAcceptMemberContacts: bool
- autoAcceptGroupInvitations: bool
- userMemberProfileUpdatedAt: UTCTime?
- userChatRelay: bool
- clientService: bool
+3 -1
View File
@@ -154,7 +154,8 @@ chatCommandsDocsData =
("APIDeleteChat", [], "Delete chat.", ["CRContactDeleted", "CRContactConnectionDeleted", "CRGroupDeletedUser", "CRChatCmdError"], [], Just UNBackground, "/_delete " <> Param "chatRef" <> " " <> Param "chatDeleteMode"),
("APISetGroupCustomData", [], "Set group custom data.", ["CRCmdOk", "CRChatCmdError"], [], Nothing, "/_set custom #" <> Param "groupId" <> Optional "" (" " <> Json "$0") "customData"),
("APISetContactCustomData", [], "Set contact custom data.", ["CRCmdOk", "CRChatCmdError"], [], Nothing, "/_set custom @" <> Param "contactId" <> Optional "" (" " <> Json "$0") "customData"),
("APISetUserAutoAcceptMemberContacts", [], "Set auto-accept member contacts.", ["CRCmdOk", "CRChatCmdError"], [], Nothing, "/_set accept member contacts " <> Param "userId" <> " " <> OnOff "onOff")
("APISetUserAutoAcceptMemberContacts", [], "Set auto-accept member contacts.", ["CRCmdOk", "CRChatCmdError"], [], Nothing, "/_set accept member contacts " <> Param "userId" <> " " <> OnOff "onOff"),
("APISetUserAutoAcceptGroupInvitations", [], "Set auto-accept group invitations.", ["CRCmdOk", "CRChatCmdError"], [], Nothing, "/_set accept group invitations " <> Param "userId" <> " " <> OnOff "onOff")
-- ("APIChatItemsRead", [], "Mark items as read.", ["CRItemsReadForChat"], [], Nothing, ""),
-- ("APIChatRead", [], "Mark chat as read.", ["CRCmdOk"], [], Nothing, ""),
-- ("APIChatUnread", [], "Mark chat as unread.", ["CRCmdOk"], [], Nothing, ""),
@@ -297,6 +298,7 @@ cliCommands =
"SetUserFeature",
"SetUserGroupReceipts",
"SetUserAutoAcceptMemberContacts",
"SetUserAutoAcceptGroupInvitations",
"SetUserTimedMessages",
"ShareMyAddress",
"SharePublicGroup",
@@ -728,6 +728,21 @@ export namespace APISetUserAutoAcceptMemberContacts {
}
}
// Set auto-accept group invitations.
// Network usage: no.
export interface APISetUserAutoAcceptGroupInvitations {
userId: number // int64
onOff: boolean
}
export namespace APISetUserAutoAcceptGroupInvitations {
export type Response = CR.CmdOk | CR.ChatCmdError
export function cmdString(self: APISetUserAutoAcceptGroupInvitations): string {
return '/_set accept group invitations ' + self.userId + ' ' + (self.onOff ? 'on' : 'off')
}
}
// User profile commands
// Most bots don't need to use these commands, as bot profile can be configured manually via CLI or desktop client. These commands can be used by bots that need to manage multiple user profiles (e.g., the profiles of support agents).
@@ -5042,6 +5042,7 @@ export interface User {
sendRcptsContacts: boolean
sendRcptsSmallGroups: boolean
autoAcceptMemberContacts: boolean
autoAcceptGroupInvitations: boolean
userMemberProfileUpdatedAt?: string // ISO-8601 timestamp
userChatRelay: boolean
clientService: boolean
@@ -637,6 +637,19 @@ def APISetUserAutoAcceptMemberContacts_cmd_string(self: APISetUserAutoAcceptMemb
APISetUserAutoAcceptMemberContacts_Response = CR.CmdOk | CR.ChatCmdError
# Set auto-accept group invitations.
# Network usage: no.
class APISetUserAutoAcceptGroupInvitations(TypedDict):
userId: int # int64
onOff: bool
def APISetUserAutoAcceptGroupInvitations_cmd_string(self: APISetUserAutoAcceptGroupInvitations) -> str:
return '/_set accept group invitations ' + str(self['userId']) + ' ' + ('on' if self['onOff'] else 'off')
APISetUserAutoAcceptGroupInvitations_Response = CR.CmdOk | CR.ChatCmdError
# User profile commands
# Most bots don't need to use these commands, as bot profile can be configured manually via CLI or desktop client. These commands can be used by bots that need to manage multiple user profiles (e.g., the profiles of support agents).
@@ -3526,6 +3526,7 @@ class User(TypedDict):
sendRcptsContacts: bool
sendRcptsSmallGroups: bool
autoAcceptMemberContacts: bool
autoAcceptGroupInvitations: bool
userMemberProfileUpdatedAt: NotRequired[str] # ISO-8601 timestamp
userChatRelay: bool
clientService: bool
+149
View File
@@ -0,0 +1,149 @@
# Auto-accept Group Invitations — Plan
## Table of Contents
1. [Context](#1-context)
2. [Why This Belongs in the Core](#2-why-this-belongs-in-the-core)
3. [Design](#3-design)
4. [Decisions and Justification](#4-decisions-and-justification)
5. [Scope](#5-scope)
6. [Verification](#6-verification)
---
## 1. Context
**Problem**: Every group invitation requires the user to tap accept, even when they have
already decided they want to join groups from their contacts. Users in active communities
accumulate invitations that are pure friction — the decision was made when they added the
contact, not when the invitation arrived.
**Precedent**: Privacy & security already carries a per-profile "Contact requests from
groups / Auto-accept" toggle (`users.auto_accept_member_contacts`, added in
`M20250729_member_contact_requests`). This change adds the equivalent for group
invitations — per-profile flag, same command pair — and regroups both under a single
**Auto-accept** section, so the two rows name what is being accepted rather than repeating
"Auto-accept" as two adjacent section headers:
```
Auto-accept
Contact requests in groups [ ]
Group invitations [ ]
These settings are for your current profile <name>.
```
---
## 2. Why This Belongs in the Core
The naive implementation is client-side: observe `CEvtReceivedGroupInvitation`, then call
`APIJoinGroup`. That is wrong here for three reasons.
1. **It does not work when the app is closed.** Invitations arrive through the notification
extension and background message processing. A client-side rule cannot run there.
2. **`APIJoinGroup` blocks.** It takes `withGroupLock`, calls the agent's `joinConnection`
synchronously, and rolls member status back to `GSMemInvited` via `catchAllErrors` on
failure. Driving that from an event handler couples message processing to a network
round trip.
3. **It would be reimplemented per client.** Android, desktop and iOS would each carry the
decision, and they would drift.
The flag therefore lives on `users`, and the decision is made where the invitation is
received.
---
## 3. Design
`processGroupInvitation` already contained an async accept path, used when an invitation
matches a group link the user opened:
```
prepareAgentJoin -> createMemberConnectionAsync -> joinAgentConnectionAsync
```
The outcome is reported later against the `CFJoinConn` command id, so nothing blocks. This
change does not add a second mechanism — it turns the existing two-way branch into three,
and auto-accept takes the path that already exists:
| Condition | Behaviour |
|---|---|
| invitation matches an opened group link | join async, no chat item (unchanged) |
| profile auto-accepts, membership still `GSMemInvited` | join async, record accepted item |
| otherwise | create pending invitation item, notify (unchanged) |
The shared sequence is extracted as `joinGroupAsync`; the invitation item as
`createInvitationItem`, parameterised by `CIGroupInvitationStatus` rather than a boolean.
---
## 4. Decisions and Justification
**Per-profile, not global.** Matches the sibling toggle and the user's mental model: a
profile is an identity, and willingness to auto-join groups is a property of that identity.
An incognito or work profile should not inherit a personal profile's setting.
**A chat item is still recorded.** The group-link branch creates no item because the user
initiated the join. Auto-accept is not user-initiated, so a `CIRcvGroupInvitation` with
status `CIGISAccepted` is written to the chat with the inviting contact — a durable record
of who added the user to what. It is deliberately left counting as unread
(`ciRequiresAttention`), so an auto-join is noticed rather than silent.
**`hostContact` is reported only for group links.** Clients respond to `hostContact` on
`CEvtUserAcceptedGroupSent` by replacing the transient host connection view with the group
and removing that chat. That is right for a group link, where the contact is a placeholder
created to join. For a plain invitation the contact is a real one, and removing their chat
would be destructive — so auto-accept passes `Nothing`, matching `APIJoinGroup`.
**The join only runs while membership is `GSMemInvited`.** `createGroupInvitation` is
idempotent on `inv_queue_info`: a resent invitation returns the existing group rather than
failing. Without the guard, every resend would open another agent connection, which a
hostile or buggy host could drive indefinitely. A resend after joining is ignored.
**UI strings reuse legacy keys deliberately.** The section header, the contact-requests row
and the footer use `auto_accept_contact`, `settings_section_title_contact_requests_from_groups`
and `receipts_section_description` — key names that no longer describe where they are used.
This is intentional: those keys are already translated in 35, 20 and 28 of 41 locales
respectively, while any newly added key ships English everywhere until translators catch up,
which would have put an English header and footer around a translated row. Only
`group_invitations` is genuinely new, so the feature adds exactly one string. Renaming these
keys to match their new use would discard the existing translations. All 28 translations of
`receipts_section_description` were checked and none mention receipts, so the reuse is safe.
**Security note.** This converts a user-gated action into an automatic one: a contact can
cause the client to open group connections and fetch history without a prompt. It is
opt-in and per-profile, and channels cannot be used for it — `processGroupInvitation`
rejects `publicGroup` invitations outright. No rate cap is applied; the setting is
explicit.
**Known behaviour.** Clients drop events for non-active profiles (`active(user)` guard), so
a group auto-accepted on a background profile becomes visible when switching to that
profile. The join itself happens on arrival: `subscribeUsers` passes the agent the active
user's id rather than the user list, and the agent enumerates every user's servers from its
own store — the active id orders subscriptions, it does not filter them.
---
## 5. Scope
| Layer | Change |
|---|---|
| Schema | `users.auto_accept_group_invitations` (`M20260813`, SQLite + Postgres) |
| Core | `User` field; `APISetUserAutoAcceptGroupInvitations` / `SetUserAutoAcceptGroupInvitations`; third branch in `processGroupInvitation` |
| Bot API | documented command; regenerated TypeScript/Python bindings and markdown |
| Android/desktop | Privacy & security: two per-profile toggles regrouped under one **Auto-accept** section; the contact-requests row relabelled "Contact requests in groups" |
| iOS | same section in `PrivacySettings.swift` |
---
## 6. Verification
- Schema dump, `.lint`, strict tables, and **down-migration round-trip** pass; the down
migration restores the original DDL byte-for-byte, so no skip-list entry is needed.
- JSON fixtures and all Bot API doc/codegen specs pass with no regenerated drift.
- `testGroupCheckMessages` and `testGroupLink` pass — the manual and group-link branches
are behaviour-preserving through the refactor.
- Three new tests: auto-accept on the active profile, on a second profile, and on an
inactive profile while another is active.
**Not verified**: iOS is not compiled (no toolchain available); the Postgres schema test is
gated behind `#if defined(dbPostgres)` and was not run.
+2
View File
@@ -152,6 +152,7 @@ library
Simplex.Chat.Store.Postgres.Migrations.M20260715_profile_description
Simplex.Chat.Store.Postgres.Migrations.M20260716_signed_history
Simplex.Chat.Store.Postgres.Migrations.M20260720_server_roles
Simplex.Chat.Store.Postgres.Migrations.M20260813_auto_accept_group_invitations
Simplex.Chat.Store.Postgres.Migrations.M20260723_contact_request_rejection
else
exposed-modules:
@@ -321,6 +322,7 @@ library
Simplex.Chat.Store.SQLite.Migrations.M20260714_member_security_code
Simplex.Chat.Store.SQLite.Migrations.M20260715_profile_description
Simplex.Chat.Store.SQLite.Migrations.M20260716_signed_history
Simplex.Chat.Store.SQLite.Migrations.M20260813_auto_accept_group_invitations
Simplex.Chat.Store.SQLite.Migrations.M20260720_server_roles
Simplex.Chat.Store.SQLite.Migrations.M20260723_contact_request_rejection
other-modules:
+2
View File
@@ -339,6 +339,8 @@ data ChatCommand
| SetUserGroupReceipts UserMsgReceiptSettings
| APISetUserAutoAcceptMemberContacts {userId :: UserId, onOff :: Bool}
| SetUserAutoAcceptMemberContacts Bool
| APISetUserAutoAcceptGroupInvitations {userId :: UserId, onOff :: Bool}
| SetUserAutoAcceptGroupInvitations Bool
| APIHideUser UserId UserPwd
| APIUnhideUser UserId UserPwd
| APIMuteUser UserId
+8
View File
@@ -506,6 +506,12 @@ processChatCommand cxt nm = \case
withFastStore' $ \db -> updateUserAutoAcceptMemberContacts db user' onOff
ok user
SetUserAutoAcceptMemberContacts onOff -> withUser $ \User {userId} -> processChatCommand cxt nm $ APISetUserAutoAcceptMemberContacts userId onOff
APISetUserAutoAcceptGroupInvitations userId' onOff -> withUser $ \user -> do
user' <- privateGetUser userId'
validateUserPassword user user' Nothing
withFastStore' $ \db -> updateUserAutoAcceptGroupInvitations db user' onOff
ok user
SetUserAutoAcceptGroupInvitations onOff -> withUser $ \User {userId} -> processChatCommand cxt nm $ APISetUserAutoAcceptGroupInvitations userId onOff
APIHideUser userId' (UserPwd viewPwd) -> withUser $ \user -> do
user' <- privateGetUser userId'
case viewPwdHash user' of
@@ -5434,6 +5440,8 @@ chatCommandP =
"/set receipts groups " *> (SetUserGroupReceipts <$> receiptSettings),
"/_set accept member contacts " *> (APISetUserAutoAcceptMemberContacts <$> A.decimal <* A.space <*> onOffP),
"/set accept member contacts " *> (SetUserAutoAcceptMemberContacts <$> onOffP),
"/_set accept group invitations " *> (APISetUserAutoAcceptGroupInvitations <$> A.decimal <* A.space <*> onOffP),
"/set accept group invitations " *> (SetUserAutoAcceptGroupInvitations <$> onOffP),
"/_hide user " *> (APIHideUser <$> A.decimal <* A.space <*> jsonP),
"/_unhide user " *> (APIUnhideUser <$> A.decimal <* A.space <*> jsonP),
"/_mute user " *> (APIMuteUser <$> A.decimal),
+29 -18
View File
@@ -2618,24 +2618,35 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
(gInfo@GroupInfo {groupId, localDisplayName, groupProfile, membership}, hostId) <- withStore $ \db -> createGroupInvitation db cxt user ct inv customUserProfileId
void $ createChatItem user (CDGroupSnd gInfo Nothing) False CIChatBanner Nothing Nothing (Just epochStart)
let GroupMember {groupMemberId, memberId = membershipMemId} = membership
if sameGroupLinkId groupLinkId groupLinkId'
then do
subMode <- chatReadVar subscriptionMode
dm <- encodeConnInfo $ XGrpAcpt membershipMemId
connIds@(cmdId, acId) <- prepareAgentJoin user Nothing True connRequest
withStore' $ \db -> do
setViaGroupLinkUri db groupId connId
createMemberConnectionAsync db user hostId connIds connChatVersion peerChatVRange subMode
updateGroupMemberStatusById db userId hostId GSMemAccepted
updateGroupMemberStatus db userId membership GSMemAccepted
joinAgentConnectionAsync cmdId False acId True connRequest dm subMode
toView $ CEvtUserAcceptedGroupSent user gInfo {membership = membership {memberStatus = GSMemAccepted}} (Just ct)
else do
let content = CIRcvGroupInvitation (CIGroupInvitation {groupId, groupMemberId, localDisplayName, groupProfile, status = CIGISPending}) memRole
(ci, cInfo) <- saveRcvChatItemNoParse user (CDDirectRcv ct) msg brokerTs content
withStore' $ \db -> setGroupInvitationChatItemId db user groupId (chatItemId' ci)
toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDRcv cInfo ci]
toView $ CEvtReceivedGroupInvitation {user, groupInfo = gInfo, contact = ct, fromMemberRole = fromRole, memberRole = memRole}
-- hostContact is only reported for group links, where the client replaces
-- the transient host connection view with the group and removes its chat
joinGroupAsync hostContact_ = do
subMode <- chatReadVar subscriptionMode
dm <- encodeConnInfo $ XGrpAcpt membershipMemId
connIds@(cmdId, acId) <- prepareAgentJoin user Nothing True connRequest
withStore' $ \db -> do
createMemberConnectionAsync db user hostId connIds connChatVersion peerChatVRange subMode
updateGroupMemberStatusById db userId hostId GSMemAccepted
updateGroupMemberStatus db userId membership GSMemAccepted
joinAgentConnectionAsync cmdId False acId True connRequest dm subMode
toView $ CEvtUserAcceptedGroupSent user gInfo {membership = membership {memberStatus = GSMemAccepted}} hostContact_
createInvitationItem invStatus = do
let content = CIRcvGroupInvitation (CIGroupInvitation {groupId, groupMemberId, localDisplayName, groupProfile, status = invStatus}) memRole
(ci, cInfo) <- saveRcvChatItemNoParse user (CDDirectRcv ct) msg brokerTs content
withStore' $ \db -> setGroupInvitationChatItemId db user groupId (chatItemId' ci)
toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDRcv cInfo ci]
if
| sameGroupLinkId groupLinkId groupLinkId' -> do
withStore' $ \db -> setViaGroupLinkUri db groupId connId
joinGroupAsync (Just ct)
| autoAcceptGroupInvitations user ->
-- a resent invitation returns the existing group, so only join while still invited
when (memberStatus membership == GSMemInvited) $ do
joinGroupAsync Nothing
createInvitationItem CIGISAccepted
| otherwise -> do
createInvitationItem CIGISPending
toView $ CEvtReceivedGroupInvitation {user, groupInfo = gInfo, contact = ct, fromMemberRole = fromRole, memberRole = memRole}
where
GroupInvitation {groupProfile = GroupProfile {publicGroup}} = inv
brokerTs = metaBrokerTs msgMeta
@@ -45,6 +45,7 @@ import Simplex.Chat.Store.Postgres.Migrations.M20260714_member_security_code
import Simplex.Chat.Store.Postgres.Migrations.M20260715_profile_description
import Simplex.Chat.Store.Postgres.Migrations.M20260716_signed_history
import Simplex.Chat.Store.Postgres.Migrations.M20260720_server_roles
import Simplex.Chat.Store.Postgres.Migrations.M20260813_auto_accept_group_invitations
import Simplex.Chat.Store.Postgres.Migrations.M20260723_contact_request_rejection
import Simplex.Messaging.Agent.Store.Shared (Migration (..))
@@ -91,7 +92,8 @@ schemaMigrations =
("20260715_profile_description", m20260715_profile_description, Just down_m20260715_profile_description),
("20260716_signed_history", m20260716_signed_history, Just down_m20260716_signed_history),
("20260720_server_roles", m20260720_server_roles, Just down_m20260720_server_roles),
("20260723_contact_request_rejection", m20260723_contact_request_rejection, Just down_m20260723_contact_request_rejection)
("20260723_contact_request_rejection", m20260723_contact_request_rejection, Just down_m20260723_contact_request_rejection),
("20260813_auto_accept_group_invitations", m20260813_auto_accept_group_invitations, Just down_m20260813_auto_accept_group_invitations)
]
-- | The list of migrations in ascending order by date
@@ -0,0 +1,19 @@
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}
module Simplex.Chat.Store.Postgres.Migrations.M20260813_auto_accept_group_invitations where
import Data.Text (Text)
import Text.RawString.QQ (r)
m20260813_auto_accept_group_invitations :: Text
m20260813_auto_accept_group_invitations =
[r|
ALTER TABLE users ADD COLUMN auto_accept_group_invitations SMALLINT NOT NULL DEFAULT 0;
|]
down_m20260813_auto_accept_group_invitations :: Text
down_m20260813_auto_accept_group_invitations =
[r|
ALTER TABLE users DROP COLUMN auto_accept_group_invitations;
|]
@@ -1510,7 +1510,8 @@ CREATE TABLE test_chat_schema.users (
active_order bigint DEFAULT 0 NOT NULL,
auto_accept_member_contacts smallint DEFAULT 0 NOT NULL,
is_user_chat_relay smallint DEFAULT 0 NOT NULL,
client_service smallint DEFAULT 0 NOT NULL
client_service smallint DEFAULT 0 NOT NULL,
auto_accept_group_invitations smallint DEFAULT 0 NOT NULL
);
+9 -3
View File
@@ -42,6 +42,7 @@ module Simplex.Chat.Store.Profiles
updateUserContactReceipts,
updateUserGroupReceipts,
updateUserAutoAcceptMemberContacts,
updateUserAutoAcceptGroupInvitations,
updateUserProfile,
setUserBadge,
setUserProfileContactLink,
@@ -140,12 +141,13 @@ createUserRecordAt db (AgentUserId auId) userChatRelay clientService Profile {di
sendRcptsContacts = True
sendRcptsSmallGroups = True
autoAcceptMemberContacts = False
autoAcceptGroupInvitations = False
order <- getNextActiveOrder db
DB.execute
db
"INSERT INTO users (agent_user_id, local_display_name, active_user, is_user_chat_relay, active_order, contact_id, show_ntfs, send_rcpts_contacts, send_rcpts_small_groups, auto_accept_member_contacts, client_service, created_at, updated_at) VALUES (?,?,?,?,?,0,?,?,?,?,?,?,?)"
"INSERT INTO users (agent_user_id, local_display_name, active_user, is_user_chat_relay, active_order, contact_id, show_ntfs, send_rcpts_contacts, send_rcpts_small_groups, auto_accept_member_contacts, auto_accept_group_invitations, client_service, created_at, updated_at) VALUES (?,?,?,?,?,0,?,?,?,?,?,?,?,?)"
( (auId, displayName, BI activeUser, BI userChatRelay, order)
:. (BI showNtfs, BI sendRcptsContacts, BI sendRcptsSmallGroups, BI autoAcceptMemberContacts, BI clientService, currentTs, currentTs)
:. (BI showNtfs, BI sendRcptsContacts, BI sendRcptsSmallGroups, BI autoAcceptMemberContacts, BI autoAcceptGroupInvitations, BI clientService, currentTs, currentTs)
)
userId <- insertedRowId db
DB.execute
@@ -163,7 +165,7 @@ createUserRecordAt db (AgentUserId auId) userChatRelay clientService Profile {di
(profileId, displayName, userId, BI True, currentTs, currentTs, currentTs)
contactId <- insertedRowId db
DB.execute db "UPDATE users SET contact_id = ? WHERE user_id = ?" (contactId, userId)
pure $ toUser currentTs $ (userId, auId, contactId, profileId, BI activeUser, order) :. (displayName, fullName, shortDescr, description, image, Nothing, peerType, userPreferences) :. (BI showNtfs, BI sendRcptsContacts, BI sendRcptsSmallGroups, BI autoAcceptMemberContacts, Nothing, Nothing, Nothing, BI userChatRelay, BI clientService, Nothing) :. localBadgeToRow Nothing :. (Nothing, Nothing, Nothing)
pure $ toUser currentTs $ (userId, auId, contactId, profileId, BI activeUser, order) :. (displayName, fullName, shortDescr, description, image, Nothing, peerType, userPreferences) :. (BI showNtfs, BI sendRcptsContacts, BI sendRcptsSmallGroups, BI autoAcceptMemberContacts, BI autoAcceptGroupInvitations, Nothing, Nothing, Nothing, BI userChatRelay, BI clientService, Nothing) :. localBadgeToRow Nothing :. (Nothing, Nothing, Nothing)
-- TODO [mentions]
getUsersInfo :: DB.Connection -> IO [UserInfo]
@@ -328,6 +330,10 @@ updateUserAutoAcceptMemberContacts :: DB.Connection -> User -> Bool -> IO ()
updateUserAutoAcceptMemberContacts db User {userId} autoAccept =
DB.execute db "UPDATE users SET auto_accept_member_contacts = ? WHERE user_id = ?" (BI autoAccept, userId)
updateUserAutoAcceptGroupInvitations :: DB.Connection -> User -> Bool -> IO ()
updateUserAutoAcceptGroupInvitations db User {userId} autoAccept =
DB.execute db "UPDATE users SET auto_accept_group_invitations = ? WHERE user_id = ?" (BI autoAccept, userId)
updateUserProfile :: DB.Connection -> User -> Profile -> ExceptT StoreError IO User
updateUserProfile db user p'
| displayName == newName = liftIO $ do
+3 -1
View File
@@ -168,6 +168,7 @@ import Simplex.Chat.Store.SQLite.Migrations.M20260714_member_security_code
import Simplex.Chat.Store.SQLite.Migrations.M20260715_profile_description
import Simplex.Chat.Store.SQLite.Migrations.M20260716_signed_history
import Simplex.Chat.Store.SQLite.Migrations.M20260720_server_roles
import Simplex.Chat.Store.SQLite.Migrations.M20260813_auto_accept_group_invitations
import Simplex.Chat.Store.SQLite.Migrations.M20260723_contact_request_rejection
import Simplex.Messaging.Agent.Store.Shared (Migration (..))
@@ -337,7 +338,8 @@ schemaMigrations =
("20260715_profile_description", m20260715_profile_description, Just down_m20260715_profile_description),
("20260716_signed_history", m20260716_signed_history, Just down_m20260716_signed_history),
("20260720_server_roles", m20260720_server_roles, Just down_m20260720_server_roles),
("20260723_contact_request_rejection", m20260723_contact_request_rejection, Just down_m20260723_contact_request_rejection)
("20260723_contact_request_rejection", m20260723_contact_request_rejection, Just down_m20260723_contact_request_rejection),
("20260813_auto_accept_group_invitations", m20260813_auto_accept_group_invitations, Just down_m20260813_auto_accept_group_invitations)
]
-- | The list of migrations in ascending order by date
@@ -0,0 +1,18 @@
{-# LANGUAGE QuasiQuotes #-}
module Simplex.Chat.Store.SQLite.Migrations.M20260813_auto_accept_group_invitations where
import Database.SQLite.Simple (Query)
import Database.SQLite.Simple.QQ (sql)
m20260813_auto_accept_group_invitations :: Query
m20260813_auto_accept_group_invitations =
[sql|
ALTER TABLE users ADD COLUMN auto_accept_group_invitations INTEGER NOT NULL DEFAULT 0;
|]
down_m20260813_auto_accept_group_invitations :: Query
down_m20260813_auto_accept_group_invitations =
[sql|
ALTER TABLE users DROP COLUMN auto_accept_group_invitations;
|]
@@ -6185,7 +6185,7 @@ SEARCH server_operators USING INTEGER PRIMARY KEY (rowid=?)
Query:
SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.description, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences,
u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes,
u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.auto_accept_group_invitations, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes,
ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx, ucp.contact_domain, ucp.contact_domain_proof, ucp.contact_domain_verified
FROM users u
JOIN contacts uct ON uct.contact_id = u.contact_id
@@ -6198,7 +6198,7 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?)
Query:
SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.description, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences,
u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes,
u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.auto_accept_group_invitations, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes,
ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx, ucp.contact_domain, ucp.contact_domain_proof, ucp.contact_domain_verified
FROM users u
JOIN contacts uct ON uct.contact_id = u.contact_id
@@ -6212,7 +6212,7 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?)
Query:
SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.description, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences,
u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes,
u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.auto_accept_group_invitations, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes,
ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx, ucp.contact_domain, ucp.contact_domain_proof, ucp.contact_domain_verified
FROM users u
JOIN contacts uct ON uct.contact_id = u.contact_id
@@ -6226,7 +6226,7 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?)
Query:
SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.description, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences,
u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes,
u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.auto_accept_group_invitations, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes,
ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx, ucp.contact_domain, ucp.contact_domain_proof, ucp.contact_domain_verified
FROM users u
JOIN contacts uct ON uct.contact_id = u.contact_id
@@ -6241,7 +6241,7 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?)
Query:
SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.description, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences,
u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes,
u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.auto_accept_group_invitations, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes,
ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx, ucp.contact_domain, ucp.contact_domain_proof, ucp.contact_domain_verified
FROM users u
JOIN contacts uct ON uct.contact_id = u.contact_id
@@ -6255,7 +6255,7 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?)
Query:
SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.description, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences,
u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes,
u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.auto_accept_group_invitations, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes,
ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx, ucp.contact_domain, ucp.contact_domain_proof, ucp.contact_domain_verified
FROM users u
JOIN contacts uct ON uct.contact_id = u.contact_id
@@ -6269,7 +6269,7 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?)
Query:
SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.description, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences,
u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes,
u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.auto_accept_group_invitations, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes,
ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx, ucp.contact_domain, ucp.contact_domain_proof, ucp.contact_domain_verified
FROM users u
JOIN contacts uct ON uct.contact_id = u.contact_id
@@ -6283,7 +6283,7 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?)
Query:
SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.description, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences,
u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes,
u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.auto_accept_group_invitations, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes,
ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx, ucp.contact_domain, ucp.contact_domain_proof, ucp.contact_domain_verified
FROM users u
JOIN contacts uct ON uct.contact_id = u.contact_id
@@ -6297,7 +6297,7 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?)
Query:
SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.description, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences,
u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes,
u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.auto_accept_group_invitations, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes,
ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx, ucp.contact_domain, ucp.contact_domain_proof, ucp.contact_domain_verified
FROM users u
JOIN contacts uct ON uct.contact_id = u.contact_id
@@ -6310,7 +6310,7 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?)
Query:
SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.description, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences,
u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes,
u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.auto_accept_group_invitations, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes,
ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx, ucp.contact_domain, ucp.contact_domain_proof, ucp.contact_domain_verified
FROM users u
JOIN contacts uct ON uct.contact_id = u.contact_id
@@ -53,7 +53,8 @@ CREATE TABLE users(
active_order INTEGER NOT NULL DEFAULT 0,
auto_accept_member_contacts INTEGER NOT NULL DEFAULT 0,
is_user_chat_relay INTEGER NOT NULL DEFAULT 0,
client_service INTEGER NOT NULL DEFAULT 0, -- 1 for active user
client_service INTEGER NOT NULL DEFAULT 0,
auto_accept_group_invitations INTEGER NOT NULL DEFAULT 0, -- 1 for active user
FOREIGN KEY(user_id, local_display_name)
REFERENCES display_names(user_id, local_display_name)
ON DELETE RESTRICT
+4 -4
View File
@@ -557,16 +557,16 @@ userQuery :: Query
userQuery =
[sql|
SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.description, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences,
u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes,
u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.auto_accept_group_invitations, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes,
ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx, ucp.contact_domain, ucp.contact_domain_proof, ucp.contact_domain_verified
FROM users u
JOIN contacts uct ON uct.contact_id = u.contact_id
JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id
|]
toUser :: UTCTime -> (UserId, UserId, ContactId, ProfileId, BoolInt, Int64) :. (ContactName, Text, Maybe Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, Maybe Preferences) :. (BoolInt, BoolInt, BoolInt, BoolInt, Maybe B64UrlByteString, Maybe B64UrlByteString, Maybe UTCTime, BoolInt, BoolInt, Maybe UIThemeEntityOverrides) :. BadgeRow :. ContactDomainRow -> User
toUser now ((userId, auId, userContactId, profileId, BI activeUser, activeOrder) :. (displayName, fullName, shortDescr, description, image, contactLink, peerType, userPreferences) :. (BI showNtfs, BI sendRcptsContacts, BI sendRcptsSmallGroups, BI autoAcceptMemberContacts, viewPwdHash_, viewPwdSalt_, userMemberProfileUpdatedAt, BI userChatRelay, BI clientService, uiThemes) :. badgeRow :. domainRow) =
User {userId, agentUserId = AgentUserId auId, userContactId, localDisplayName = displayName, profile, activeUser, activeOrder, fullPreferences, showNtfs, sendRcptsContacts, sendRcptsSmallGroups, autoAcceptMemberContacts, viewPwdHash, userMemberProfileUpdatedAt, userChatRelay = BoolDef userChatRelay, clientService = BoolDef clientService, uiThemes}
toUser :: UTCTime -> (UserId, UserId, ContactId, ProfileId, BoolInt, Int64) :. (ContactName, Text, Maybe Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, Maybe Preferences) :. (BoolInt, BoolInt, BoolInt, BoolInt, BoolInt, Maybe B64UrlByteString, Maybe B64UrlByteString, Maybe UTCTime, BoolInt, BoolInt, Maybe UIThemeEntityOverrides) :. BadgeRow :. ContactDomainRow -> User
toUser now ((userId, auId, userContactId, profileId, BI activeUser, activeOrder) :. (displayName, fullName, shortDescr, description, image, contactLink, peerType, userPreferences) :. (BI showNtfs, BI sendRcptsContacts, BI sendRcptsSmallGroups, BI autoAcceptMemberContacts, BI autoAcceptGroupInvitations, viewPwdHash_, viewPwdSalt_, userMemberProfileUpdatedAt, BI userChatRelay, BI clientService, uiThemes) :. badgeRow :. domainRow) =
User {userId, agentUserId = AgentUserId auId, userContactId, localDisplayName = displayName, profile, activeUser, activeOrder, fullPreferences, showNtfs, sendRcptsContacts, sendRcptsSmallGroups, autoAcceptMemberContacts, autoAcceptGroupInvitations, viewPwdHash, userMemberProfileUpdatedAt, userChatRelay = BoolDef userChatRelay, clientService = BoolDef clientService, uiThemes}
where
profile = LocalProfile {profileId, displayName, fullName, shortDescr, description, image, contactLink, contactDomain = rowToContactDomain domainRow, contactDomainVerified = rowToDomainVerified domainRow, peerType, localBadge = rowToBadge now badgeRow, preferences = userPreferences, localAlias = ""}
fullPreferences = fullPreferences' userPreferences
+1
View File
@@ -143,6 +143,7 @@ data User = User
sendRcptsContacts :: Bool,
sendRcptsSmallGroups :: Bool,
autoAcceptMemberContacts :: Bool,
autoAcceptGroupInvitations :: Bool,
userMemberProfileUpdatedAt :: Maybe UTCTime,
userChatRelay :: BoolDef,
clientService :: BoolDef,
+58
View File
@@ -52,6 +52,9 @@ chatProfileTests = do
it "reject profile image that is too large" testSetProfileImageTooLarge
it "set profile image from file" testSetProfileImageFromFile
it "use multiword profile names" testMultiWordProfileNames
it "auto-accept group invitations" testAutoAcceptGroupInvitations
it "auto-accept group invitations on a second profile" testAutoAcceptGroupInvitationsSecondProfile
it "auto-accept group invitations on an inactive profile" testAutoAcceptGroupInvitationsInactiveProfile
it "present supporter badge to contacts" testUserBadgeBroadcast
it "supporter badge sent to contact connecting after attach" testUserBadgeOnConnect
it "supporter badge sent to member joining via group link" testUserBadgeGroupLink
@@ -539,6 +542,61 @@ testSetProfileImageFromFile ps = testChat aliceProfile test ps
alice ##> ("/set profile image file " <> emptyPath)
alice <##. "bad chat command: image file is empty"
testAutoAcceptGroupInvitations :: HasCallStack => TestParams -> IO ()
testAutoAcceptGroupInvitations =
testChat2 aliceProfile bobProfile $
\alice bob -> do
connectUsers alice bob
bob ##> "/set accept group invitations on"
bob <## "ok"
alice ##> "/g team"
alice <## "group #team is created"
alice <## "to add members use /a team <name> or /create link #team"
alice ##> "/a team bob admin"
alice <## "invitation to join the group #team sent to bob"
concurrently_
(alice <## "#team: bob joined the group")
(bob <## "#team: you joined the group")
testAutoAcceptGroupInvitationsSecondProfile :: HasCallStack => TestParams -> IO ()
testAutoAcceptGroupInvitationsSecondProfile =
testChat2 aliceProfile bobProfile $
\alice bob -> do
bob ##> "/create user bob2"
showActiveUser bob "bob2"
bob ##> "/set accept group invitations on"
bob <## "ok"
connectUsers alice bob
alice ##> "/g team"
alice <## "group #team is created"
alice <## "to add members use /a team <name> or /create link #team"
alice ##> "/a team bob2 admin"
alice <## "invitation to join the group #team sent to bob2"
concurrently_
(alice <## "#team: bob2 joined the group")
(bob <## "#team: you joined the group")
testAutoAcceptGroupInvitationsInactiveProfile :: HasCallStack => TestParams -> IO ()
testAutoAcceptGroupInvitationsInactiveProfile =
testChat2 aliceProfile bobProfile $
\alice bob -> do
bob ##> "/create user bob2"
showActiveUser bob "bob2"
bob ##> "/set accept group invitations on"
bob <## "ok"
connectUsers alice bob
-- switch away: bob2 now has auto-accept on but is NOT the active profile
bob ##> "/user bob"
showActiveUser bob "bob (Bob)"
alice ##> "/g team"
alice <## "group #team is created"
alice <## "to add members use /a team <name> or /create link #team"
alice ##> "/a team bob2 admin"
alice <## "invitation to join the group #team sent to bob2"
concurrently_
(alice <## "#team: bob2 joined the group")
(bob <## "[user: bob2] #team: you joined the group")
testMultiWordProfileNames :: HasCallStack => TestParams -> IO ()
testMultiWordProfileNames =
testChat3 aliceProfile' bobProfile' cathProfile' $
+3 -3
View File
@@ -17,10 +17,10 @@ activeUserExistsTagged :: LB.ByteString
activeUserExistsTagged = "{\"error\":{\"type\":\"error\",\"errorType\":{\"type\":\"userExists\",\"contactName\":\"alice\"}}}"
activeUserSwift :: LB.ByteString
activeUserSwift = "{\"result\":{\"_owsf\":true,\"activeUser\":{\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"\",\"shortDescr\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"files\":{\"allow\":\"always\"},\"calls\":{\"allow\":\"yes\"},\"sessions\":{\"allow\":\"no\"},\"commands\":[]},\"activeUser\":true,\"activeOrder\":1,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true,\"autoAcceptMemberContacts\":false,\"userChatRelay\":false,\"clientService\":false}}}}"
activeUserSwift = "{\"result\":{\"_owsf\":true,\"activeUser\":{\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"\",\"shortDescr\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"files\":{\"allow\":\"always\"},\"calls\":{\"allow\":\"yes\"},\"sessions\":{\"allow\":\"no\"},\"commands\":[]},\"activeUser\":true,\"activeOrder\":1,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true,\"autoAcceptMemberContacts\":false,\"autoAcceptGroupInvitations\":false,\"userChatRelay\":false,\"clientService\":false}}}}"
activeUserTagged :: LB.ByteString
activeUserTagged = "{\"result\":{\"type\":\"activeUser\",\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"\",\"shortDescr\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"files\":{\"allow\":\"always\"},\"calls\":{\"allow\":\"yes\"},\"sessions\":{\"allow\":\"no\"},\"commands\":[]},\"activeUser\":true,\"activeOrder\":1,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true,\"autoAcceptMemberContacts\":false,\"userChatRelay\":false,\"clientService\":false}}}"
activeUserTagged = "{\"result\":{\"type\":\"activeUser\",\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"\",\"shortDescr\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"files\":{\"allow\":\"always\"},\"calls\":{\"allow\":\"yes\"},\"sessions\":{\"allow\":\"no\"},\"commands\":[]},\"activeUser\":true,\"activeOrder\":1,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true,\"autoAcceptMemberContacts\":false,\"autoAcceptGroupInvitations\":false,\"userChatRelay\":false,\"clientService\":false}}}"
chatStartedSwift :: LB.ByteString
chatStartedSwift = "{\"result\":{\"_owsf\":true,\"chatStarted\":{}}}"
@@ -35,7 +35,7 @@ connectionsDiffTagged :: LB.ByteString
connectionsDiffTagged = "{\"result\":{\"type\":\"connectionsDiff\",\"userIds\":{\"missingIds\":[],\"extraIds\":[]},\"connIds\":{\"missingIds\":[],\"extraIds\":[]}}}"
userJSON :: LB.ByteString
userJSON = "{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"\",\"shortDescr\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"files\":{\"allow\":\"always\"},\"calls\":{\"allow\":\"yes\"},\"sessions\":{\"allow\":\"no\"},\"commands\":[]},\"activeUser\":true,\"activeOrder\":1,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true,\"autoAcceptMemberContacts\":false,\"userChatRelay\":false}"
userJSON = "{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"\",\"shortDescr\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"files\":{\"allow\":\"always\"},\"calls\":{\"allow\":\"yes\"},\"sessions\":{\"allow\":\"no\"},\"commands\":[]},\"activeUser\":true,\"activeOrder\":1,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true,\"autoAcceptMemberContacts\":false,\"autoAcceptGroupInvitations\":false,\"userChatRelay\":false}"
parsedMarkdownSwift :: LB.ByteString
parsedMarkdownSwift = "{\"formattedText\":[{\"format\":{\"_owsf\":true,\"bold\":{}},\"text\":\"hello\"}]}"