mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-04-27 10:45:54 +00:00
Merge branch 'master' into chat-relays
This commit is contained in:
@@ -512,7 +512,9 @@ struct GroupChatInfoView: View {
|
||||
}
|
||||
|
||||
private func memberConnStatus(_ member: GroupMember) -> LocalizedStringKey {
|
||||
if member.activeConn?.connDisabled ?? false {
|
||||
if case .failed = member.activeConn?.connStatus {
|
||||
return "failed"
|
||||
} else if member.activeConn?.connDisabled ?? false {
|
||||
return "disabled"
|
||||
} else if member.activeConn?.connInactive ?? false {
|
||||
return "inactive"
|
||||
|
||||
@@ -234,6 +234,12 @@ struct GroupMemberInfoView: View {
|
||||
}
|
||||
}
|
||||
|
||||
if let connFailedErr = member.activeConn?.connFailedErr {
|
||||
Section {
|
||||
infoRow("Connection failed", connFailedErr)
|
||||
}
|
||||
}
|
||||
|
||||
if groupInfo.membership.memberRole >= .moderator {
|
||||
adminDestructiveSection(member)
|
||||
} else if !groupInfo.useRelays {
|
||||
|
||||
@@ -196,7 +196,9 @@ struct MemberSupportView: View {
|
||||
}
|
||||
|
||||
private func memberStatus(_ member: GroupMember) -> LocalizedStringKey {
|
||||
if member.activeConn?.connDisabled ?? false {
|
||||
if case .failed = member.activeConn?.connStatus {
|
||||
return "failed"
|
||||
} else if member.activeConn?.connDisabled ?? false {
|
||||
return "disabled"
|
||||
} else if member.activeConn?.connInactive ?? false {
|
||||
return "inactive"
|
||||
|
||||
@@ -2117,6 +2117,11 @@ public struct Connection: Decodable, Hashable {
|
||||
|
||||
public var id: ChatId { get { ":\(connId)" } }
|
||||
|
||||
public var connFailedErr: String? {
|
||||
if case let .failed(err) = connStatus { return err }
|
||||
return nil
|
||||
}
|
||||
|
||||
public var connDisabled: Bool {
|
||||
authErrCounter >= 10 // authErrDisableCount in core
|
||||
}
|
||||
@@ -2302,15 +2307,16 @@ public struct PendingContactConnection: Decodable, NamedChat, Hashable {
|
||||
}
|
||||
}
|
||||
|
||||
public enum ConnStatus: String, Decodable, Hashable {
|
||||
case new = "new"
|
||||
case prepared = "prepared"
|
||||
case joined = "joined"
|
||||
case requested = "requested"
|
||||
case accepted = "accepted"
|
||||
case sndReady = "snd-ready"
|
||||
case ready = "ready"
|
||||
case deleted = "deleted"
|
||||
public enum ConnStatus: Decodable, Hashable {
|
||||
case new
|
||||
case prepared
|
||||
case joined
|
||||
case requested
|
||||
case accepted
|
||||
case sndReady
|
||||
case ready
|
||||
case deleted
|
||||
case failed(connError: String)
|
||||
|
||||
var initiated: Bool? {
|
||||
get {
|
||||
@@ -2323,6 +2329,7 @@ public enum ConnStatus: String, Decodable, Hashable {
|
||||
case .sndReady: return nil
|
||||
case .ready: return nil
|
||||
case .deleted: return nil
|
||||
case .failed: return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+25
-17
@@ -1904,6 +1904,12 @@ data class Connection(
|
||||
val connInactive: Boolean
|
||||
get() = quotaErrCounter >= 5 // quotaErrInactiveCount in core
|
||||
|
||||
val connFailedErr: String?
|
||||
get() = when (connStatus) {
|
||||
is ConnStatus.Failed -> connStatus.connError
|
||||
else -> null
|
||||
}
|
||||
|
||||
val connPQEnabled: Boolean
|
||||
get() = pqSndEnabled == true && pqRcvEnabled == true
|
||||
|
||||
@@ -2638,25 +2644,27 @@ class PendingContactConnection(
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class ConnStatus {
|
||||
@SerialName("new") New,
|
||||
@SerialName("prepared") Prepared,
|
||||
@SerialName("joined") Joined,
|
||||
@SerialName("requested") Requested,
|
||||
@SerialName("accepted") Accepted,
|
||||
@SerialName("snd-ready") SndReady,
|
||||
@SerialName("ready") Ready,
|
||||
@SerialName("deleted") Deleted;
|
||||
sealed class ConnStatus {
|
||||
@Serializable @SerialName("new") object New: ConnStatus()
|
||||
@Serializable @SerialName("prepared") object Prepared: ConnStatus()
|
||||
@Serializable @SerialName("joined") object Joined: ConnStatus()
|
||||
@Serializable @SerialName("requested") object Requested: ConnStatus()
|
||||
@Serializable @SerialName("accepted") object Accepted: ConnStatus()
|
||||
@Serializable @SerialName("sndReady") object SndReady: ConnStatus()
|
||||
@Serializable @SerialName("ready") object Ready: ConnStatus()
|
||||
@Serializable @SerialName("deleted") object Deleted: ConnStatus()
|
||||
@Serializable @SerialName("failed") class Failed(val connError: String): ConnStatus()
|
||||
|
||||
val initiated: Boolean? get() = when (this) {
|
||||
New -> true
|
||||
Prepared -> false
|
||||
Joined -> false
|
||||
Requested -> true
|
||||
Accepted -> true
|
||||
SndReady -> null
|
||||
Ready -> null
|
||||
Deleted -> null
|
||||
is New -> true
|
||||
is Prepared -> false
|
||||
is Joined -> false
|
||||
is Requested -> true
|
||||
is Accepted -> true
|
||||
is SndReady -> null
|
||||
is Ready -> null
|
||||
is Deleted -> null
|
||||
is Failed -> null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+4
-2
@@ -878,9 +878,11 @@ fun MemberRow(member: GroupMember, user: Boolean = false, infoPage: Boolean = tr
|
||||
}
|
||||
|
||||
fun memberConnStatus(): String {
|
||||
return if (member.activeConn?.connDisabled == true) {
|
||||
generalGetString(MR.strings.member_info_member_disabled)
|
||||
return if (member.activeConn?.connStatus is ConnStatus.Failed) {
|
||||
generalGetString(MR.strings.member_info_member_failed)
|
||||
} else if (member.activeConn?.connDisabled == true) {
|
||||
generalGetString(MR.strings.member_info_member_disabled)
|
||||
} else if (member.activeConn?.connInactive == true) {
|
||||
generalGetString(MR.strings.member_info_member_inactive)
|
||||
} else {
|
||||
member.memberStatus.shortText
|
||||
|
||||
+8
@@ -562,6 +562,14 @@ fun GroupMemberInfoLayout(
|
||||
}
|
||||
}
|
||||
|
||||
val connFailedErr = member.activeConn?.connFailedErr
|
||||
if (connFailedErr != null) {
|
||||
SectionDividerSpaced()
|
||||
SectionView {
|
||||
InfoRow(stringResource(MR.strings.info_row_connection_failed), connFailedErr)
|
||||
}
|
||||
}
|
||||
|
||||
if (groupInfo.membership.memberRole >= GroupMemberRole.Moderator) {
|
||||
ModeratorDestructiveSection()
|
||||
} else {
|
||||
|
||||
+3
-1
@@ -162,7 +162,9 @@ private fun ModalData.MemberSupportViewLayout(
|
||||
@Composable
|
||||
fun SupportChatRow(member: GroupMember) {
|
||||
fun memberStatus(): String {
|
||||
return if (member.activeConn?.connDisabled == true) {
|
||||
return if (member.activeConn?.connStatus is ConnStatus.Failed) {
|
||||
generalGetString(MR.strings.member_info_member_failed)
|
||||
} else if (member.activeConn?.connDisabled == true) {
|
||||
generalGetString(MR.strings.member_info_member_disabled)
|
||||
} else if (member.activeConn?.connInactive == true) {
|
||||
generalGetString(MR.strings.member_info_member_inactive)
|
||||
|
||||
@@ -1906,6 +1906,7 @@
|
||||
<string name="member_blocked_by_admin">Blocked by admin</string>
|
||||
<string name="member_info_member_blocked">blocked</string>
|
||||
<string name="member_info_member_disabled">disabled</string>
|
||||
<string name="member_info_member_failed">failed</string>
|
||||
<string name="member_info_member_inactive">inactive</string>
|
||||
<string name="member_info_section_title_member">MEMBER</string>
|
||||
<string name="role_in_group">Role</string>
|
||||
@@ -1924,6 +1925,7 @@
|
||||
<string name="info_row_group">Group</string>
|
||||
<string name="info_row_chat">Chat</string>
|
||||
<string name="info_row_connection">Connection</string>
|
||||
<string name="info_row_connection_failed">Connection failed</string>
|
||||
<string name="conn_level_desc_direct">direct</string>
|
||||
<string name="conn_level_desc_indirect">indirect (%1$s)</string>
|
||||
<string name="message_queue_info">Message queue info</string>
|
||||
|
||||
+29
-9
@@ -1485,15 +1485,35 @@ LARGE:
|
||||
|
||||
## ConnStatus
|
||||
|
||||
**Enum type**:
|
||||
- "new"
|
||||
- "prepared"
|
||||
- "joined"
|
||||
- "requested"
|
||||
- "accepted"
|
||||
- "snd-ready"
|
||||
- "ready"
|
||||
- "deleted"
|
||||
**Discriminated union type**:
|
||||
|
||||
New:
|
||||
- type: "new"
|
||||
|
||||
Prepared:
|
||||
- type: "prepared"
|
||||
|
||||
Joined:
|
||||
- type: "joined"
|
||||
|
||||
Requested:
|
||||
- type: "requested"
|
||||
|
||||
Accepted:
|
||||
- type: "accepted"
|
||||
|
||||
SndReady:
|
||||
- type: "sndReady"
|
||||
|
||||
Ready:
|
||||
- type: "ready"
|
||||
|
||||
Deleted:
|
||||
- type: "deleted"
|
||||
|
||||
Failed:
|
||||
- type: "failed"
|
||||
- connError: string
|
||||
|
||||
|
||||
---
|
||||
|
||||
@@ -243,7 +243,7 @@ chatTypesDocsData =
|
||||
(sti @ConnectionErrorType, STUnion, "", [], "", ""),
|
||||
(sti @ConnectionMode, (STEnum' $ take 3 . consLower "CM"), "", [], "", ""),
|
||||
(sti @ConnectionPlan, STUnion, "CP", [], "", ""),
|
||||
(sti @ConnStatus, (STEnum' $ consSep "Conn" '-'), "", [], "", ""),
|
||||
(sti @ConnStatus, STUnion, "Conn", [], "", ""),
|
||||
(sti @ConnType, (STEnum' $ consSep "Conn" '_'), "", [], "", ""),
|
||||
(sti @Contact, STRecord, "", [], "", ""),
|
||||
(sti @ContactAddressPlan, STUnion, "CAP", [], "", ""),
|
||||
|
||||
@@ -1713,15 +1713,69 @@ export interface ComposedMessage {
|
||||
mentions: {[key: string]: number} // string : int64
|
||||
}
|
||||
|
||||
export enum ConnStatus {
|
||||
New = "new",
|
||||
Prepared = "prepared",
|
||||
Joined = "joined",
|
||||
Requested = "requested",
|
||||
Accepted = "accepted",
|
||||
Snd_ready = "snd-ready",
|
||||
Ready = "ready",
|
||||
Deleted = "deleted",
|
||||
export type ConnStatus =
|
||||
| ConnStatus.New
|
||||
| ConnStatus.Prepared
|
||||
| ConnStatus.Joined
|
||||
| ConnStatus.Requested
|
||||
| ConnStatus.Accepted
|
||||
| ConnStatus.SndReady
|
||||
| ConnStatus.Ready
|
||||
| ConnStatus.Deleted
|
||||
| ConnStatus.Failed
|
||||
|
||||
export namespace ConnStatus {
|
||||
export type Tag =
|
||||
| "new"
|
||||
| "prepared"
|
||||
| "joined"
|
||||
| "requested"
|
||||
| "accepted"
|
||||
| "sndReady"
|
||||
| "ready"
|
||||
| "deleted"
|
||||
| "failed"
|
||||
|
||||
interface Interface {
|
||||
type: Tag
|
||||
}
|
||||
|
||||
export interface New extends Interface {
|
||||
type: "new"
|
||||
}
|
||||
|
||||
export interface Prepared extends Interface {
|
||||
type: "prepared"
|
||||
}
|
||||
|
||||
export interface Joined extends Interface {
|
||||
type: "joined"
|
||||
}
|
||||
|
||||
export interface Requested extends Interface {
|
||||
type: "requested"
|
||||
}
|
||||
|
||||
export interface Accepted extends Interface {
|
||||
type: "accepted"
|
||||
}
|
||||
|
||||
export interface SndReady extends Interface {
|
||||
type: "sndReady"
|
||||
}
|
||||
|
||||
export interface Ready extends Interface {
|
||||
type: "ready"
|
||||
}
|
||||
|
||||
export interface Deleted extends Interface {
|
||||
type: "deleted"
|
||||
}
|
||||
|
||||
export interface Failed extends Interface {
|
||||
type: "failed"
|
||||
connError: string
|
||||
}
|
||||
}
|
||||
|
||||
export enum ConnType {
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
# Save Permanent Connection Errors for Group Members
|
||||
|
||||
## Context
|
||||
|
||||
When a group member's connection handshake fails with a permanent error (e.g., `CONN NOT_ACCEPTED`, `SMP AUTH`, `AGENT A_VERSION`), the ERR event is logged to the UI event stream and discarded. The member record stays stuck in a "connecting" `GroupMemberStatus` (like `memIntroduced`, `memAccepted`) forever. Users see perpetual "connecting" with no explanation and no way to know whether to wait or re-invite.
|
||||
|
||||
**Root cause**: `agentMsgConnStatus` (Subscriber.hs:376) only maps success events (`CONF`, `INFO`, `JOINED`, `CON`) to status transitions. The ERR handler for group members (Subscriber.hs:1054-1056) only logs to UI and completes the command — no status or error is persisted.
|
||||
|
||||
## Solution Summary
|
||||
|
||||
Add `ConnError {connError :: Text}` constructor to `ConnStatus`. Error text is encoded in the `conn_status TEXT` column as `"error <text>"` via `TextEncoding`, and in JSON via `sumTypeJSON` (following `GSSError`/`CIFileStatus` pattern). No new DB column, no migration. When a non-temporary ERR arrives before connection is ready, transition to `ConnError` and notify UI. Messages are not queued for errored connections.
|
||||
|
||||
## Technical Design
|
||||
|
||||
### Error classification
|
||||
|
||||
Use `temporaryOrHostError` from `Simplex.Messaging.Agent.Client` (simplexmq Client.hs:1486, exported at line 60):
|
||||
- Returns `True` for NETWORK, TIMEOUT, HOST, TEVersion, INACTIVE, CRITICAL-with-restart → **do not save**
|
||||
- Returns `False` for AUTH, CONN errors, VERSION, INTERNAL, etc. → **save as permanent error**
|
||||
|
||||
Guard: only save when connection is not `ConnReady` and not already `ConnError`. Post-handshake errors (when `connStatus == ConnReady`) are handled by existing `processConnMERR` (AUTH counters, QUOTA counters).
|
||||
|
||||
### Data flow
|
||||
|
||||
```
|
||||
Agent ERR event
|
||||
→ Subscriber.hs processGroupMessage ERR handler
|
||||
→ guard: connStatus is not ConnReady, not ConnError, not temporaryOrHostError
|
||||
→ DB: UPDATE connections SET conn_status = 'error <tshow err>'
|
||||
→ emit: CEvtGroupMemberUpdated user gInfo m m'
|
||||
→ iOS: upsertGroupMember updates model → UI re-renders
|
||||
```
|
||||
|
||||
### DB encoding
|
||||
|
||||
`conn_status TEXT NOT NULL` already exists. `ConnError` encodes as `"error " <> errText` using `TextEncoding` (same as `GSSError`). No migration needed — new text values are valid in the existing column.
|
||||
|
||||
### JSON encoding
|
||||
|
||||
Replace manual `ToJSON`/`FromJSON` instances with `$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "Conn") ''ConnStatus)`. This follows the `GroupSndStatus`/`CIFileStatus` pattern — `sumTypeJSON` is already imported in Types.hs (line 60).
|
||||
|
||||
JSON format (platform-dependent via `sumTypeJSON`):
|
||||
- iOS: `{"error": {"connError": "SMP AUTH"}}` (ObjectWithSingleField)
|
||||
- Android/Desktop: `{"type": "error", "connError": "SMP AUTH"}` (TaggedObject)
|
||||
- Nullary cases: `{"ready": {}}` / `{"type": "ready"}` (not plain `"ready"` strings)
|
||||
|
||||
Note: `ConnSndReady` JSON tag changes from `"snd-ready"` to `"sndReady"` (`dropPrefix "Conn"` applies `fstToLower`). This is safe — JSON is core→UI within same build. Swift auto-synthesis matches on `sndReady` case name.
|
||||
|
||||
### Clear on recovery
|
||||
|
||||
When CON event arrives, `agentMsgConnStatus` returns `Just ConnReady`, `updateConnStatus` overwrites `conn_status` to `"ready"`. Error is implicitly cleared — no special cleanup needed.
|
||||
|
||||
### ConnStatus state machine update
|
||||
|
||||
```
|
||||
Existing transitions (unchanged):
|
||||
ConnNew → ConnRequested → ConnAccepted → ConnSndReady → ConnReady
|
||||
ConnNew → ConnJoined → ConnSndReady → ConnReady
|
||||
ConnPrepared → ConnJoined → ConnSndReady → ConnReady
|
||||
Any → ConnDeleted
|
||||
|
||||
New transitions:
|
||||
Any pre-ready state → ConnError (on permanent ERR)
|
||||
ConnError → ConnReady (on successful CON — recovery)
|
||||
ConnError → ConnDeleted (on connection deletion)
|
||||
```
|
||||
|
||||
### Pattern match safety audit
|
||||
|
||||
Traced every ConnStatus pattern match across Haskell (10 files), Swift (6 files), Kotlin (3 files).
|
||||
|
||||
**Must update (exhaustive matches):**
|
||||
|
||||
| Location | Change |
|
||||
|---|---|
|
||||
| Types.hs textEncode/textDecode (~1703) | Add ConnError encoding/decoding |
|
||||
| Types.hs ToJSON/FromJSON (~1696) | Replace with `sumTypeJSON` TH splice |
|
||||
| Swift ConnStatus.initiated | Add `case .error: return nil` |
|
||||
| Kotlin ConnStatus.initiated | Add `Error -> null` (follow-up) |
|
||||
|
||||
**Must update (behavioral):**
|
||||
|
||||
| Location | Current behavior | Fix |
|
||||
|---|---|---|
|
||||
| Internal.hs memberSendAction (line 2041) | ConnError falls to `otherwise -> pendingOrForwarded` — messages queued for permanently errored connections | Add pattern guard `ConnError {} <- connStatus -> Nothing` |
|
||||
|
||||
**Verified safe — no changes needed:**
|
||||
|
||||
| Pattern | Sites | Why safe |
|
||||
|---|---|---|
|
||||
| `== ConnReady` / `== ConnSndReady` | 12 sites (connReady, Contact.ready, GroupMember.ready, sndReady, readyMemberConn, xftpSndFileTransfer) | ConnError ≠ these → excluded from "ready" paths |
|
||||
| `== ConnPrepared` | 8 sites (joinPreparedConn, nextConnectPrepared, isContactCard, contactRequestPlan) | ConnError ≠ ConnPrepared → doesn't trigger join/prepare logic |
|
||||
| `== ConnNew` | 4 sites (contactConnInitiated, nextAcceptContactRequest, APIPrepareContact) | ConnError ≠ ConnNew → doesn't trigger new-connection logic |
|
||||
| `!= ConnDeleted` (DB WHERE) | 6 sites (getConnectionEntity, *ConnsToSub) | ConnError ≠ ConnDeleted → errored connections remain findable and subscribable (correct — enables recovery via CON). **Add TODO comments** at each site to consider whether ConnError connections should be excluded. |
|
||||
| `updateConnectionStatusFromTo` | 3 sites | Compares current to specific `fromStatus` — ConnError won't accidentally match |
|
||||
| `readyMemberConn` (Internal.hs:2078) | 1 site | `connStatus == ConnReady \|\| == ConnSndReady` — ConnError → `otherwise = Nothing` (correct) |
|
||||
| `connDisabled`/`connInactive` | 6 sites | Derived from error counters, not connStatus |
|
||||
| `agentMsgConnStatus` | 1 site | Only produces ConnSndReady/ConnRequested/ConnReady — no ConnError output |
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### 1. Haskell: ConnStatus type
|
||||
|
||||
**File: `src/Simplex/Chat/Types.hs`**
|
||||
|
||||
**ConnStatus** (~line 1673): Add constructor after `ConnDeleted`:
|
||||
```haskell
|
||||
| ConnError {connError :: Text}
|
||||
```
|
||||
Record syntax for `sumTypeJSON` field name in JSON. `deriving (Eq, Show, Read)` unchanged.
|
||||
|
||||
**TextEncoding instance** (~line 1703) — for DB storage:
|
||||
```haskell
|
||||
textEncode = \case
|
||||
...
|
||||
ConnError err -> "error " <> err
|
||||
textDecode s
|
||||
| Just err <- T.stripPrefix "error " s = Just (ConnError err)
|
||||
| otherwise = case s of
|
||||
"new" -> Just ConnNew
|
||||
... (existing cases unchanged)
|
||||
_ -> Nothing
|
||||
```
|
||||
|
||||
Note: `textDecode` changes from `\case` to named parameter `s` to support `stripPrefix` guard.
|
||||
|
||||
**JSON instances** (~lines 1696-1701): Replace manual instances with TH splice:
|
||||
```haskell
|
||||
-- Remove:
|
||||
-- instance FromJSON ConnStatus where parseJSON = textParseJSON "ConnStatus"
|
||||
-- instance ToJSON ConnStatus where toJSON = J.String . textEncode; toEncoding = JE.text . textEncode
|
||||
-- Add:
|
||||
$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "Conn") ''ConnStatus)
|
||||
```
|
||||
|
||||
`sumTypeJSON` and `dropPrefix` already imported (line 60). `FromField`/`ToField` instances unchanged — still use `TextEncoding` for DB.
|
||||
|
||||
**`connReady`** (line 1597): No change — `== ConnReady || == ConnSndReady`, `ConnError _` naturally returns `False`.
|
||||
|
||||
### 2. Haskell: Subscriber.hs — save error on permanent ERR
|
||||
|
||||
**File: `src/Simplex/Chat/Library/Subscriber.hs`**
|
||||
|
||||
Extend existing import (line 74):
|
||||
```haskell
|
||||
import Simplex.Messaging.Agent.Client (temporaryOrHostError, getAgentWorker, ...)
|
||||
```
|
||||
|
||||
Update ERR handler in `processGroupMessage` (line 1054-1056). Current:
|
||||
```haskell
|
||||
ERR err -> do
|
||||
eToView $ ChatErrorAgent err (AgentConnId agentConnId) (Just connEntity)
|
||||
when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> pure ()
|
||||
```
|
||||
|
||||
New:
|
||||
```haskell
|
||||
ERR err -> do
|
||||
eToView $ ChatErrorAgent err (AgentConnId agentConnId) (Just connEntity)
|
||||
when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> pure ()
|
||||
let Connection {connStatus = cs} = conn
|
||||
case cs of
|
||||
ConnReady -> pure ()
|
||||
ConnError _ -> pure ()
|
||||
_ | temporaryOrHostError err -> pure ()
|
||||
| otherwise -> do
|
||||
let errText = tshow err
|
||||
withStore' $ \db -> updateConnectionStatus db conn (ConnError errText)
|
||||
let conn' = conn {connStatus = ConnError errText}
|
||||
m' = m {activeConn = Just conn'}
|
||||
toView $ CEvtGroupMemberUpdated user gInfo m m'
|
||||
```
|
||||
|
||||
Note: `let Connection {connStatus = cs} = conn` destructures via pattern binding, avoiding ambiguous `connStatus conn` field selector under `DuplicateRecordFields`.
|
||||
|
||||
No new store function — reuses existing `updateConnectionStatus` (Direct.hs:937) which calls `updateConnectionStatus_` → `textEncode` → stores `"error SMP AUTH"` in `conn_status`.
|
||||
|
||||
### 3. Haskell: memberSendAction — don't queue for errored connections
|
||||
|
||||
**File: `src/Simplex/Chat/Library/Internal.hs`**
|
||||
|
||||
Update `memberSendAction` (line 2040-2044). Current:
|
||||
```haskell
|
||||
Just conn@Connection {connStatus}
|
||||
| connDisabled conn || connStatus == ConnDeleted || memberStatus == GSMemRejected -> Nothing
|
||||
| connInactive conn -> Just MSAPending
|
||||
| connStatus == ConnSndReady || connStatus == ConnReady -> sendBatchedOrSeparate conn
|
||||
| otherwise -> pendingOrForwarded
|
||||
```
|
||||
|
||||
Add pattern guard after first guard (can't use `==` with associated data):
|
||||
```haskell
|
||||
Just conn@Connection {connStatus}
|
||||
| connDisabled conn || connStatus == ConnDeleted || memberStatus == GSMemRejected -> Nothing
|
||||
| ConnError {} <- connStatus -> Nothing
|
||||
| connInactive conn -> Just MSAPending
|
||||
| connStatus == ConnSndReady || connStatus == ConnReady -> sendBatchedOrSeparate conn
|
||||
| otherwise -> pendingOrForwarded
|
||||
```
|
||||
|
||||
### 4. Swift: ConnStatus enum
|
||||
|
||||
**File: `apps/ios/SimpleXChat/ChatTypes.swift`** (~line 2301)
|
||||
|
||||
Change from `String`-backed raw value enum to enum with associated value. Auto-synthesized `Decodable` handles `sumTypeJSON` format (same as `GroupSndStatus`, `CIFileStatus`):
|
||||
|
||||
```swift
|
||||
public enum ConnStatus: Decodable, Hashable {
|
||||
case new
|
||||
case prepared
|
||||
case joined
|
||||
case requested
|
||||
case accepted
|
||||
case sndReady
|
||||
case ready
|
||||
case deleted
|
||||
case error(connError: String)
|
||||
|
||||
var initiated: Bool? {
|
||||
switch self {
|
||||
case .new: return true
|
||||
case .prepared: return false
|
||||
case .joined: return false
|
||||
case .requested: return true
|
||||
case .accepted: return true
|
||||
case .sndReady: return nil
|
||||
case .ready: return nil
|
||||
case .deleted: return nil
|
||||
case .error: return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
No custom `init(from:)` needed. `Hashable`/`Equatable` auto-synthesized. Existing equality checks like `connStatus == .ready` still compile (nullary cases).
|
||||
|
||||
### 5. Swift: Connection computed property
|
||||
|
||||
**File: `apps/ios/SimpleXChat/ChatTypes.swift`**
|
||||
|
||||
Add computed property to `Connection` struct (~line 2092, after `connStatus`):
|
||||
```swift
|
||||
public var connError: String? {
|
||||
if case let .error(err) = connStatus { return err }
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Swift: Member list status
|
||||
|
||||
**File: `apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift`**
|
||||
|
||||
Update `memberConnStatus` function (~line 457). Insert error check FIRST (before `connDisabled`/`connInactive`):
|
||||
```swift
|
||||
private func memberConnStatus(_ member: GroupMember) -> LocalizedStringKey {
|
||||
if case .error = member.activeConn?.connStatus {
|
||||
return "connection error"
|
||||
} else if member.activeConn?.connDisabled ?? false {
|
||||
return "disabled"
|
||||
} else if member.activeConn?.connInactive ?? false {
|
||||
return "inactive"
|
||||
} else {
|
||||
return member.memberStatus.shortText
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**File: `apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift`**
|
||||
|
||||
Update `memberStatus` function (line 198). Insert error check FIRST (before `connDisabled` at line 199):
|
||||
```swift
|
||||
if case .error = member.activeConn?.connStatus {
|
||||
return "connection error"
|
||||
} else if member.activeConn?.connDisabled ?? false {
|
||||
```
|
||||
|
||||
### 7. Swift: Member info error display
|
||||
|
||||
**File: `apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift`**
|
||||
|
||||
Add error display section after the `connStats` section (~line 190):
|
||||
```swift
|
||||
if let connError = member.activeConn?.connError {
|
||||
Section(header: Text("Connection error").foregroundColor(theme.colors.secondary)) {
|
||||
Text(connError)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
.font(.callout)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Files Changed Summary
|
||||
|
||||
| Layer | File | Change |
|
||||
|-------|------|--------|
|
||||
| Core | `Types.hs` | Add `ConnError {connError :: Text}` to ConnStatus, update TextEncoding, replace JSON with `sumTypeJSON` TH splice |
|
||||
| Logic | `Subscriber.hs` | Import `temporaryOrHostError`, handle permanent ERR for group members |
|
||||
| Logic | `Internal.hs` | Add `ConnError` guard to `memberSendAction` → return `Nothing` |
|
||||
| iOS | `ChatTypes.swift` | ConnStatus: auto-synthesized Decodable with `.error(connError:)`, Connection: `connError` computed property |
|
||||
| iOS | `GroupChatInfoView.swift` | Show "connection error" in `memberConnStatus` (first check) |
|
||||
| iOS | `MemberSupportView.swift` | Show "connection error" in `memberStatus` (first check) |
|
||||
| iOS | `GroupMemberInfoView.swift` | Show error description section |
|
||||
|
||||
## Verification
|
||||
|
||||
1. **Build Haskell**: `cabal build --ghc-options -O0`
|
||||
2. **Build iOS**: Verify Swift compiles — existing `connStatus == .ready` comparisons still work (nullary cases)
|
||||
3. **JSON format**: Verify `sumTypeJSON` output matches Swift auto-synthesis expectations (nullary: `{"ready": {}}`, error: `{"error": {"connError": "..."}}`)
|
||||
4. **Backward compat**: New `"error ..."` values in `conn_status` only appear after code update. Old code cannot parse them (downgrade risk, same as any new enum value).
|
||||
5. **Recovery**: CON event → `updateConnectionStatus_ ConnReady` → overwrites `"error ..."` with `"ready"` in DB
|
||||
6. **memberSendAction**: Verify messages are NOT queued for ConnError connections
|
||||
|
||||
## Out of Scope (immediate follow-up)
|
||||
|
||||
**Kotlin/Android/Desktop**: `ConnStatus` enum in `ChatModel.kt:2640` needs custom serializer for `sumTypeJSON` format (TaggedObject: `{"type": "error", "connError": "..."}`) + `Connection` needs `connError` computed property + member status UI. Must be updated before Android/Desktop builds from this commit. Existing bug at `GroupChatInfoView.kt:883` (`connDisabled` checked twice, should be `connInactive` on second check).
|
||||
@@ -2129,7 +2129,7 @@ memberSendAction gInfo@GroupInfo {membership} events members m@GroupMember {memb
|
||||
| otherwise = case memberConn m of
|
||||
Nothing -> pendingOrForwarded
|
||||
Just conn@Connection {connStatus}
|
||||
| connDisabled conn || connStatus == ConnDeleted || memberStatus == GSMemRejected -> Nothing
|
||||
| connDisabled conn || connStatus == ConnDeleted || isConnFailed connStatus || memberStatus == GSMemRejected -> Nothing
|
||||
| connInactive conn -> Just MSAPending
|
||||
| connStatus == ConnSndReady || connStatus == ConnReady -> sendBatchedOrSeparate conn
|
||||
| otherwise -> pendingOrForwarded
|
||||
|
||||
@@ -370,19 +370,20 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
|
||||
processContactConnMessage agentMessage entity conn uc
|
||||
where
|
||||
updateConnStatus :: ConnectionEntity -> CM ConnectionEntity
|
||||
updateConnStatus acEntity = case agentMsgConnStatus agentMessage of
|
||||
updateConnStatus acEntity = case agentMsgConnStatus (entityConnection acEntity) agentMessage of
|
||||
Just connStatus -> do
|
||||
let conn = (entityConnection acEntity) {connStatus}
|
||||
withStore' $ \db -> updateConnectionStatus db conn connStatus
|
||||
pure $ updateEntityConnStatus acEntity connStatus
|
||||
Nothing -> pure acEntity
|
||||
|
||||
agentMsgConnStatus :: AEvent e -> Maybe ConnStatus
|
||||
agentMsgConnStatus = \case
|
||||
agentMsgConnStatus :: Connection -> AEvent e -> Maybe ConnStatus
|
||||
agentMsgConnStatus Connection {connStatus = cs} = \case
|
||||
JOINED True _ -> Just ConnSndReady
|
||||
CONF {} -> Just ConnRequested
|
||||
INFO {} -> Just ConnSndReady
|
||||
CON _ -> Just ConnReady
|
||||
ERR err | cs /= ConnReady && not (temporaryOrHostError err) -> Just $ ConnFailed (tshow err)
|
||||
_ -> Nothing
|
||||
|
||||
processCONFpqSupport :: Connection -> PQSupport -> CM Connection
|
||||
@@ -1121,6 +1122,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
|
||||
ERR err -> do
|
||||
eToView $ ChatErrorAgent err (AgentConnId agentConnId) (Just connEntity)
|
||||
when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> pure ()
|
||||
when (isConnFailed $ connStatus conn) $
|
||||
toView $ CEvtGroupMemberUpdated user gInfo m m
|
||||
-- TODO add debugging output
|
||||
_ -> pure ()
|
||||
where
|
||||
|
||||
@@ -71,6 +71,9 @@ getChatLockEntity db agentConnId = do
|
||||
ExceptT . firstRow fromOnly (SEInternalError "group member connection group_id not found") $
|
||||
DB.query db "SELECT group_id FROM group_members WHERE group_member_id = ?" (Only groupMemberId)
|
||||
|
||||
-- TODO consider whether ConnFailed connections should be excluded:
|
||||
-- - from receiving: getConnectionEntity, getContactConnEntityByConnReqHash
|
||||
-- - from subscribing: getContactConnsToSub, getUCLConnsToSub, getMemberConnsToSub, getPendingConnsToSub
|
||||
getConnectionEntity :: DB.Connection -> VersionRangeChat -> User -> AgentConnId -> ExceptT StoreError IO ConnectionEntity
|
||||
getConnectionEntity db vr user@User {userId, userContactId} agentConnId = do
|
||||
c@Connection {connType, entityId} <- getConnection_
|
||||
|
||||
@@ -1741,19 +1741,14 @@ data ConnStatus
|
||||
ConnReady
|
||||
| -- | connection deleted
|
||||
ConnDeleted
|
||||
| -- | connection had a permanent error during handshake
|
||||
ConnFailed {connError :: Text}
|
||||
deriving (Eq, Show, Read)
|
||||
|
||||
instance FromField ConnStatus where fromField = fromTextField_ textDecode
|
||||
|
||||
instance ToField ConnStatus where toField = toField . textEncode
|
||||
|
||||
instance FromJSON ConnStatus where
|
||||
parseJSON = textParseJSON "ConnStatus"
|
||||
|
||||
instance ToJSON ConnStatus where
|
||||
toJSON = J.String . textEncode
|
||||
toEncoding = JE.text . textEncode
|
||||
|
||||
instance TextEncoding ConnStatus where
|
||||
textDecode = \case
|
||||
"new" -> Just ConnNew
|
||||
@@ -1764,6 +1759,7 @@ instance TextEncoding ConnStatus where
|
||||
"snd-ready" -> Just ConnSndReady
|
||||
"ready" -> Just ConnReady
|
||||
"deleted" -> Just ConnDeleted
|
||||
s | Just err <- T.stripPrefix "failed " s -> Just (ConnFailed err)
|
||||
_ -> Nothing
|
||||
textEncode = \case
|
||||
ConnNew -> "new"
|
||||
@@ -1774,6 +1770,12 @@ instance TextEncoding ConnStatus where
|
||||
ConnSndReady -> "snd-ready"
|
||||
ConnReady -> "ready"
|
||||
ConnDeleted -> "deleted"
|
||||
ConnFailed err -> "failed " <> err
|
||||
|
||||
isConnFailed :: ConnStatus -> Bool
|
||||
isConnFailed = \case
|
||||
ConnFailed {} -> True
|
||||
_ -> False
|
||||
|
||||
data ConnType = ConnContact | ConnMember | ConnUserContact
|
||||
deriving (Eq, Show)
|
||||
@@ -1997,6 +1999,8 @@ $(JQ.deriveJSON defaultJSON ''GroupMemberSettings)
|
||||
|
||||
$(JQ.deriveJSON defaultJSON ''SecurityCode)
|
||||
|
||||
$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "Conn") ''ConnStatus)
|
||||
|
||||
$(JQ.deriveJSON defaultJSON ''Connection)
|
||||
|
||||
$(JQ.deriveJSON defaultJSON ''PendingContactConnection)
|
||||
|
||||
@@ -260,7 +260,6 @@ active_directory: true
|
||||
app</a>.</p>
|
||||
<p>SimpleX Directory is also available as a <a href="https://smp4.simplex.im/a#lXUjJW5vHYQzoLYgmi8GbxkGP41_kjefFvBrdwg-0Ok" target="_blank">SimpleX chat bot</a>.</p>
|
||||
<p>Read about <a href="/docs/directory.html">how to add</a> your community</a>.</p>
|
||||
<p style="color:red;">Under maintenance — you can't join these groups until <a href="https://time.is/UTC" target="_blank" style="color:red;">17:00 UTC</a>, 01/09.</p>
|
||||
<div class="search-container">
|
||||
<input id="search" autocomplete="off">
|
||||
<div id="top-pagination" class="pagination">
|
||||
|
||||
@@ -223,7 +223,6 @@ active_home: true
|
||||
<p>{{ "index-directory-p1" | i18n({}, lang) }}</p>
|
||||
<p>{{ "index-directory-p2" | i18n({}, lang) }}</p>
|
||||
<a class="gradient-text" href="/directory">{{ "index-directory-cta" | i18n({}, lang) }}</a>
|
||||
<p class="gradient-text">Under maintenance — you can't join these groups until <a href="https://time.is/UTC" target="_blank">17:00 UTC</a>, 01/09.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user